embedded.html 108 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482
  1. <!DOCTYPE html>
  2. <meta name="apple-mobile-web-app-capable" content="yes" />
  3. <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
  4. <html>
  5. <head>
  6. <meta charset="UTF-8">
  7. <meta name="theme-color" content="#000000">
  8. <script src="../script/jquery.min.js"></script>
  9. <script src="../script/ao_module.js"></script>
  10. <!-- HLS via Media Source, so browsers without native HLS (Firefox,
  11. Chrome) can play the transcoded playlist without hls.js -->
  12. <script src="../script/hlsmse.js"></script>
  13. <script src="backend/common.js"></script>
  14. <!-- ASS/SSA subtitle parser + renderer (see script/ass.js) -->
  15. <script src="script/ass.js"></script>
  16. <style>
  17. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  18. :root {
  19. --accent: #0a84ff;
  20. --surface: #1c1c1e;
  21. --surface2: #2c2c2e;
  22. --text: #f5f5f7;
  23. --text-sub: #98989d;
  24. --transition: 0.2s ease;
  25. --radius: 0.4em;
  26. }
  27. html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
  28. #player-wrap {
  29. position: relative;
  30. width: 100%; height: 100%;
  31. display: flex; align-items: center; justify-content: center;
  32. background: #000;
  33. }
  34. #main-video {
  35. width: 100%; height: 100%;
  36. object-fit: contain; display: block; background: #000;
  37. }
  38. /* ── Controls overlay ──────────────────────────────────────────────── */
  39. #video-controls {
  40. position: absolute;
  41. bottom: 0; left: 0; right: 0;
  42. background: linear-gradient(transparent, rgba(0,0,0,0.85) 100%);
  43. padding: 40px 14px 12px;
  44. transition: opacity 0.3s;
  45. }
  46. #video-controls.hidden { opacity: 0; pointer-events: none; }
  47. #player-wrap:has(#video-controls.hidden) { cursor: none; }
  48. #progress-wrap {
  49. position: relative;
  50. height: 4px; background: rgba(255,255,255,0.25);
  51. border-radius: var(--radius); cursor: pointer; margin-bottom: 12px;
  52. }
  53. #progress-wrap:hover { height: 6px; }
  54. #progress-wrap::before {
  55. content: ''; position: absolute;
  56. top: -16px; left: 0; right: 0; height: 16px;
  57. }
  58. #progress-bar { height: 100%; border-radius: var(--radius); background: var(--accent); pointer-events: none; }
  59. #progress-thumb {
  60. position: absolute; top: 50%; transform: translateY(-50%);
  61. width: 14px; height: 14px;
  62. background: #fff; border-radius: 50%; pointer-events: none; display: none;
  63. }
  64. #progress-wrap:hover #progress-thumb { display: block; }
  65. #controls-row { display: flex; align-items: center; gap: 10px; }
  66. .ctrl-btn {
  67. background: none; border: none; cursor: pointer;
  68. color: #fff; padding: 4px; line-height: 1;
  69. transition: opacity var(--transition); outline: none;
  70. display: flex; align-items: center; justify-content: center;
  71. flex-shrink: 0;
  72. }
  73. .ctrl-btn:hover { opacity: 0.7; }
  74. .ctrl-btn img { width: 24px; height: 24px; display: block; }
  75. .ctrl-cc-icon {
  76. width: 24px; height: 24px;
  77. display: flex; align-items: center; justify-content: center;
  78. font-size: 10px; font-weight: 700; letter-spacing: 0.4px;
  79. border: 1.5px solid #fff; border-radius: 4px; line-height: 1;
  80. }
  81. #volume-wrap { display: flex; align-items: center; gap: 8px; }
  82. #volume-slider {
  83. -webkit-appearance: none;
  84. width: 80px; height: 4px;
  85. background: rgba(255,255,255,0.3); border-radius: 4px; outline: none; cursor: pointer;
  86. }
  87. #volume-slider::-webkit-slider-thumb {
  88. -webkit-appearance: none;
  89. width: 12px; height: 12px; background: #fff; border-radius: 50%;
  90. }
  91. /* Portrait phones/tablets drive playback volume with the hardware keys,
  92. so the in-page slider only eats toolbar width. Users who want it back
  93. can force it on from the settings popup ("Volume bar → Always show"). */
  94. @media (max-width: 899px) and (orientation: portrait) {
  95. #volume-slider { display: none; }
  96. }
  97. body.always-show-volume #volume-slider { display: block; }
  98. #time-display {
  99. font-size: 13px; color: rgba(255,255,255,0.8);
  100. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  101. margin-left: 4px; white-space: nowrap;
  102. }
  103. #spacer { flex: 1; }
  104. /* ─── ASS subtitle overlay ───────────────────────────────────────────────────── */
  105. /* Sized in JS to the video's displayed rect so script coordinates land correctly
  106. regardless of letterboxing. Children are absolutely positioned by the renderer. */
  107. #ass-overlay {
  108. display: none;
  109. position: absolute;
  110. left: 0; top: 0; width: 0; height: 0;
  111. pointer-events: none;
  112. overflow: hidden;
  113. /* No z-index on purpose: subtitles sit directly after <video> in the DOM,
  114. so they paint above the (unpositioned) video but below every later
  115. positioned sibling — scrub preview, controls, menus and modals all stay
  116. on top of the text instead of being covered by it. */
  117. /* ...but that only holds if the cues stay *inside* this box. The renderer
  118. gives each cue an inline z-index so simultaneous lines stack in event
  119. order (see ass.js boxCss), and a positioned element with z-index:auto is
  120. not a stacking context — so those cues would otherwise be promoted into
  121. the player's stacking context and outrank the controls, which are
  122. themselves z-index:auto. Isolating creates the stacking context that keeps
  123. cue ordering internal, without giving this layer a z-index of its own. */
  124. isolation: isolate;
  125. }
  126. #ass-overlay.active { display: block; }
  127. /* ── Scrub-bar hover preview ───────────────────────────────────────── */
  128. #scrub-preview {
  129. display: none;
  130. position: absolute;
  131. bottom: 100%;
  132. margin-bottom: 12px;
  133. transform: translateX(-50%);
  134. pointer-events: none;
  135. /* Above the centre play/pause flash, so scrubbing right after a click
  136. does not put the thumbnail behind it. (ASS cues no longer need
  137. outranking here — #ass-overlay isolates them.) */
  138. z-index: 20;
  139. flex-direction: column;
  140. align-items: center;
  141. gap: 6px;
  142. }
  143. #scrub-preview.active { display: flex; }
  144. #scrub-preview-thumb {
  145. display: none;
  146. border-radius: 8px;
  147. background-color: #000;
  148. background-repeat: no-repeat;
  149. box-shadow: 0 4px 18px rgba(0,0,0,0.8), 0 0 0 2px rgba(255,255,255,0.14);
  150. }
  151. #scrub-preview-thumb.ready { display: block; }
  152. #scrub-preview-time {
  153. background: rgba(0,0,0,0.82);
  154. border-radius: 6px;
  155. padding: 3px 9px;
  156. font-size: 12px; font-weight: 600;
  157. color: #fff; white-space: nowrap;
  158. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  159. font-variant-numeric: tabular-nums;
  160. }
  161. /* ── Transcode-seek freeze frame ───────────────────────────────────── */
  162. /* Deliberately no z-index: both sit immediately after <video> in the DOM,
  163. so they paint above the (unpositioned) video but below every later
  164. positioned sibling — controls and menus stay on top and clickable. */
  165. #seek-freeze {
  166. display: none;
  167. position: absolute; inset: 0;
  168. width: 100%; height: 100%;
  169. object-fit: contain;
  170. background: #000;
  171. pointer-events: none;
  172. }
  173. #seek-spinner {
  174. display: none;
  175. position: absolute; inset: 0;
  176. align-items: center; justify-content: center;
  177. pointer-events: none;
  178. }
  179. #seek-spinner.active { display: flex; }
  180. .seek-spinner-box {
  181. display: flex; flex-direction: column;
  182. align-items: center; gap: 10px;
  183. background: rgba(0,0,0,0.55);
  184. backdrop-filter: blur(4px);
  185. -webkit-backdrop-filter: blur(4px);
  186. border-radius: 14px;
  187. padding: 18px 24px;
  188. font-size: 12px; color: var(--text);
  189. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  190. }
  191. .seek-spinner-box .spinner {
  192. width: 38px; height: 38px;
  193. border: 3px solid rgba(255,255,255,0.18);
  194. border-top-color: var(--accent);
  195. border-radius: 50%;
  196. animation: seekSpin 0.8s linear infinite;
  197. }
  198. @keyframes seekSpin { to { transform: rotate(360deg); } }
  199. /* ── Subtitle display ──────────────────────────────────────────────── */
  200. #subtitle-display {
  201. display: none;
  202. position: absolute;
  203. bottom: 72px; left: 50%; transform: translateX(-50%);
  204. width: 90%; text-align: center;
  205. pointer-events: none; /* z-index deliberately unset, see #ass-overlay */
  206. color: #fff; font-size: 22px; font-weight: 500; line-height: 1.45;
  207. text-shadow:
  208. -1px -1px 0 #000, 1px -1px 0 #000,
  209. -1px 1px 0 #000, 1px 1px 0 #000,
  210. 0 2px 6px rgba(0,0,0,0.85);
  211. }
  212. /* ── Centre play/pause flash indicator ─────────────────────────────── */
  213. #play-flash {
  214. position: absolute;
  215. top: 50%; left: 50%;
  216. width: 88px; height: 88px;
  217. margin: -44px 0 0 -44px;
  218. border-radius: 50%;
  219. background: rgba(0,0,0,0.55);
  220. backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  221. display: flex; align-items: center; justify-content: center;
  222. pointer-events: none;
  223. opacity: 0; z-index: 18;
  224. }
  225. #play-flash img { width: 40px; height: 40px; display: block; }
  226. #play-flash.flash { animation: playFlash 0.62s ease-out; }
  227. @keyframes playFlash {
  228. 0% { opacity: 0; transform: scale(0.68); }
  229. 18% { opacity: 0.95; transform: scale(0.92); }
  230. 100% { opacity: 0; transform: scale(1.32); }
  231. }
  232. /* ── Resume popup ──────────────────────────────────────────────────── */
  233. #resume-popup {
  234. display: none;
  235. position: absolute; bottom: 90px; right: 20px; z-index: 25;
  236. background: rgba(22,22,24,0.97); backdrop-filter: blur(16px);
  237. border-radius: var(--radius); padding: 16px 18px; min-width: 240px;
  238. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1);
  239. animation: slideUpPopup 0.2s ease;
  240. color: var(--text);
  241. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  242. }
  243. #resume-popup.active { display: block; }
  244. @keyframes slideUpPopup {
  245. from { opacity: 0; transform: translateY(10px); }
  246. to { opacity: 1; transform: translateY(0); }
  247. }
  248. #resume-popup-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
  249. #resume-popup-sub { font-size: 12px; color: var(--text-sub); margin-bottom: 12px; }
  250. #resume-popup-btns { display: flex; gap: 8px; }
  251. .resume-btn {
  252. flex: 1; padding: 8px; border: none; cursor: pointer;
  253. border-radius: calc(var(--radius) / 1.6); font-size: 12px; font-weight: 600;
  254. outline: none; transition: opacity var(--transition);
  255. }
  256. .resume-btn:hover { opacity: 0.8; }
  257. #resume-btn-continue { background: var(--accent); color: #fff; }
  258. #resume-btn-restart { background: var(--surface2); color: var(--text); }
  259. /* ── Context menu ──────────────────────────────────────────────────── */
  260. #player-ctx {
  261. display: none;
  262. position: absolute; z-index: 30;
  263. background: rgba(28,28,30,0.97);
  264. backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  265. border-radius: var(--radius); padding: 4px 0; min-width: 192px;
  266. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
  267. user-select: none;
  268. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  269. }
  270. .ctx-item {
  271. padding: 9px 14px; font-size: 13px; cursor: pointer;
  272. display: flex; align-items: center; gap: 10px;
  273. color: var(--text); transition: background var(--transition);
  274. }
  275. .ctx-item:hover { background: rgba(255,255,255,0.08); }
  276. .ctx-item.ctx-active { color: var(--accent); }
  277. .ctx-item.ctx-disabled { opacity: 0.3; pointer-events: none; }
  278. .ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; }
  279. img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
  280. .ctx-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 3px 0; }
  281. .ctx-has-sub { justify-content: space-between; position: relative; }
  282. .ctx-sub-arrow { font-style: normal; opacity: 0.55; font-size: 15px; }
  283. #ctx-subtitle-sub {
  284. display: none;
  285. position: absolute; top: 0; left: 100%;
  286. background: rgba(28,28,30,0.97);
  287. backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  288. border-radius: calc(var(--radius) * 1.5); padding: 4px 0; min-width: 200px;
  289. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
  290. z-index: 32;
  291. overflow-y: auto;
  292. max-height: 320px;
  293. }
  294. #ctx-subtitle-sub::-webkit-scrollbar { width: 8px; }
  295. #ctx-subtitle-sub::-webkit-scrollbar-track { background: transparent; }
  296. #ctx-subtitle-sub::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
  297. #ctx-subtitle-sub::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
  298. /* ── Subtitle settings modal ───────────────────────────────────────── */
  299. #subtitle-settings-modal {
  300. display: none;
  301. position: absolute; top: 50%; left: 50%;
  302. transform: translate(-50%, -50%);
  303. z-index: 40;
  304. background: rgba(18,18,20,0.97);
  305. backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
  306. border-radius: calc(var(--radius) / 1.5); padding: 20px 22px 18px; width: 340px;
  307. box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
  308. color: var(--text);
  309. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  310. }
  311. #subtitle-settings-modal h3 {
  312. font-size: 14px; font-weight: 600; margin-bottom: 14px;
  313. display: flex; align-items: center; justify-content: space-between;
  314. }
  315. #sset-close {
  316. background: none; border: none; cursor: pointer;
  317. color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
  318. }
  319. #sset-close img { width: 14px; height: 14px; display: block; opacity: 0.55; transition: opacity var(--transition); }
  320. #sset-close:hover img { opacity: 1; }
  321. .sset-row {
  322. display: flex; align-items: center; gap: 10px;
  323. padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
  324. font-size: 12px;
  325. }
  326. .sset-row:last-child { border-bottom: none; }
  327. .sset-label { color: var(--text-sub); flex-shrink: 0; width: 56px; }
  328. .sset-seg {
  329. display: flex; background: var(--surface2);
  330. border-radius: calc(var(--radius) / 1.6); padding: 2px; gap: 2px; flex: 1;
  331. }
  332. .sset-seg-btn {
  333. flex: 1; padding: 4px 0; font-size: 12px; font-family: inherit;
  334. border: none; border-radius: calc(var(--radius) / 1.4); cursor: pointer;
  335. background: transparent; color: var(--text-sub);
  336. transition: background 0.15s, color 0.15s;
  337. }
  338. .sset-seg-btn.active { background: var(--surface); color: var(--text); }
  339. #sset-size { flex: 1; accent-color: var(--accent); cursor: pointer; }
  340. #sset-size-val { color: var(--text-sub); font-size: 11px; min-width: 32px; text-align: right; }
  341. #sset-font {
  342. flex: 1; background: var(--surface2); color: var(--text);
  343. border: 1px solid rgba(255,255,255,0.08); border-radius: calc(var(--radius) / 1.6);
  344. padding: 5px 8px; font-size: 12px; cursor: pointer; outline: none; appearance: auto;
  345. width: 100%;
  346. }
  347. .sset-colors { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex: 1; }
  348. .sset-color {
  349. width: 22px; height: 22px; border-radius: 50%;
  350. border: 2px solid transparent; outline: 2px solid transparent;
  351. cursor: pointer; padding: 0; flex-shrink: 0;
  352. transition: outline-color 0.15s, transform 0.1s;
  353. }
  354. .sset-color:hover { transform: scale(1.12); }
  355. .sset-color.active { outline-color: var(--text); transform: scale(1.15); }
  356. #sset-color-custom {
  357. width: 22px; height: 22px; border-radius: 50%;
  358. border: 2px solid rgba(255,255,255,0.2); padding: 0;
  359. cursor: pointer; background: transparent; flex-shrink: 0;
  360. }
  361. #sset-color-custom.active { border-color: var(--text); transform: scale(1.15); }
  362. #sset-preview {
  363. margin-top: 14px; background: rgba(0,0,0,0.65);
  364. border-radius: calc(var(--radius) / 1.25); padding: 14px 12px; text-align: center; line-height: 1.4;
  365. }
  366. /* ── Video info modal ──────────────────────────────────────────────── */
  367. #video-info-modal {
  368. display: none;
  369. position: absolute; top: 50%; left: 50%;
  370. transform: translate(-50%, -50%);
  371. z-index: 40;
  372. background: rgba(18,18,20,0.97);
  373. backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
  374. border-radius: calc(var(--radius) / 1.5); padding: 20px 22px 16px; width: 340px;
  375. box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
  376. color: var(--text);
  377. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  378. }
  379. #video-info-modal h3 {
  380. font-size: 14px; font-weight: 600; margin-bottom: 12px;
  381. display: flex; align-items: center; justify-content: space-between;
  382. }
  383. #video-info-close {
  384. background: none; border: none; cursor: pointer;
  385. color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
  386. }
  387. #video-info-close img { width: 14px; height: 14px; display: block; opacity: 0.55; transition: opacity var(--transition); }
  388. #video-info-close:hover img { opacity: 1; }
  389. #video-info-tabs {
  390. display: flex; gap: 3px;
  391. background: var(--surface2); border-radius: calc(var(--radius) / 1.25); padding: 3px; margin-bottom: 12px;
  392. }
  393. .info-tab {
  394. flex: 1; text-align: center; padding: 5px 0;
  395. font-size: 12px; font-weight: 500; border-radius: calc(var(--radius) / 1.6); cursor: pointer;
  396. color: var(--text-sub);
  397. transition: background var(--transition), color var(--transition);
  398. }
  399. .info-tab.active { background: var(--surface); color: var(--text); }
  400. .info-row {
  401. display: flex; gap: 8px;
  402. padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
  403. font-size: 12px; line-height: 1.4;
  404. }
  405. .info-row:last-child { border-bottom: none; }
  406. .info-label { color: var(--text-sub); flex-shrink: 0; width: 110px; }
  407. .info-value { color: var(--text); word-break: break-all; }
  408. /* ── Playback settings popup (gear button) ─────────────────────────── */
  409. #settings-popup {
  410. display: none;
  411. position: absolute;
  412. right: 14px; bottom: 62px;
  413. z-index: 35;
  414. background: rgba(28,28,30,0.97);
  415. backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  416. border-radius: calc(var(--radius) * 1.5);
  417. padding: 6px 0; width: 236px;
  418. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
  419. color: var(--text);
  420. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  421. user-select: none;
  422. overflow-y: auto;
  423. max-height: 320px;
  424. }
  425. #settings-popup.active { display: block; }
  426. #settings-popup::-webkit-scrollbar,
  427. #subtitle-popup::-webkit-scrollbar { width: 8px; }
  428. #settings-popup::-webkit-scrollbar-track,
  429. #subtitle-popup::-webkit-scrollbar-track { background: transparent; }
  430. #settings-popup::-webkit-scrollbar-thumb,
  431. #subtitle-popup::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
  432. #settings-popup::-webkit-scrollbar-thumb:hover,
  433. #subtitle-popup::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
  434. /* ── Subtitle popup (dedicated CC button) ────────────────────────────── */
  435. #subtitle-popup {
  436. display: none;
  437. position: absolute;
  438. right: 14px; bottom: 62px;
  439. z-index: 35;
  440. background: rgba(28,28,30,0.97);
  441. backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  442. border-radius: calc(var(--radius) * 1.5);
  443. padding: 4px 0; min-width: 220px;
  444. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
  445. color: var(--text);
  446. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  447. user-select: none;
  448. overflow-y: auto;
  449. max-height: 320px;
  450. }
  451. #subtitle-popup.active { display: block; }
  452. .set-section { padding: 6px 14px 8px; }
  453. .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
  454. .set-head {
  455. display: flex; align-items: center; gap: 8px;
  456. font-size: 11px; font-weight: 600; letter-spacing: 0.3px;
  457. text-transform: uppercase; color: var(--text-sub);
  458. margin-bottom: 7px;
  459. }
  460. .set-head img { width: 14px; height: 14px; opacity: 0.7; display: block; }
  461. .set-seg {
  462. display: flex; background: var(--surface2);
  463. border-radius: calc(var(--radius) * 1.2); padding: 2px; gap: 2px;
  464. }
  465. .set-seg-btn {
  466. flex: 1; padding: 5px 0; font-size: 12px; font-family: inherit;
  467. border: none; border-radius: var(--radius); cursor: pointer;
  468. background: transparent; color: var(--text-sub);
  469. transition: background 0.15s, color 0.15s; outline: none;
  470. }
  471. .set-seg-btn:hover { color: var(--text); }
  472. .set-seg-btn.active { background: var(--surface); color: var(--text); }
  473. .speed-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
  474. .speed-btn {
  475. padding: 5px 0; font-size: 12px; font-family: inherit;
  476. border: none; border-radius: var(--radius); cursor: pointer;
  477. background: var(--surface2); color: var(--text-sub);
  478. transition: background 0.15s, color 0.15s; outline: none;
  479. }
  480. .speed-btn:hover { color: var(--text); }
  481. .speed-btn.active { background: var(--accent); color: #fff; font-weight: 600; }
  482. .set-link {
  483. display: flex; align-items: center; gap: 9px;
  484. padding: 8px 14px; font-size: 13px; cursor: pointer;
  485. color: var(--text); transition: background var(--transition);
  486. }
  487. .set-link:hover { background: rgba(255,255,255,0.08); }
  488. .set-link img { width: 15px; height: 15px; opacity: 0.8; display: block; }
  489. /* ── Toast ─────────────────────────────────────────────────────────── */
  490. #toast {
  491. position: fixed; bottom: 30px; left: 50%;
  492. transform: translateX(-50%) translateY(20px);
  493. background: rgba(30,30,30,0.95); backdrop-filter: blur(8px);
  494. color: var(--text); padding: 10px 20px; border-radius: 20px;
  495. font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  496. opacity: 0; transition: opacity 0.3s, transform 0.3s;
  497. pointer-events: none; z-index: 1000; max-width: min(90vw, 30em);
  498. text-align: center; line-height: 1.45;
  499. }
  500. #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  501. </style>
  502. </head>
  503. <body>
  504. <div id="player-wrap">
  505. <video id="main-video" preload="metadata"></video>
  506. <!-- Frozen last frame + spinner, shown while a transcode seek re-buffers.
  507. Must stay directly after <video> — stacking relies on DOM order. -->
  508. <canvas id="seek-freeze"></canvas>
  509. <div id="seek-spinner">
  510. <div class="seek-spinner-box">
  511. <div class="spinner"></div>
  512. <span>Buffering…</span>
  513. </div>
  514. </div>
  515. <!-- Styled ASS/SSA subtitles are drawn here, sized to the video's
  516. displayed rectangle. The plain #subtitle-display below is used
  517. for SubRip tracks. -->
  518. <div id="ass-overlay"></div>
  519. <!-- Subtitle overlay -->
  520. <div id="subtitle-display"></div>
  521. <!-- Centre play/pause flash indicator -->
  522. <div id="play-flash"><img id="play-flash-icon" src="img/icons/play_white.svg" alt=""></div>
  523. <!-- Resume popup -->
  524. <div id="resume-popup">
  525. <div id="resume-popup-title">Resume playback?</div>
  526. <div id="resume-popup-sub"></div>
  527. <div id="resume-popup-btns">
  528. <button class="resume-btn" id="resume-btn-continue">Resume</button>
  529. <button class="resume-btn" id="resume-btn-restart">Start over</button>
  530. </div>
  531. </div>
  532. <!-- Context menu -->
  533. <div id="player-ctx">
  534. <div class="ctx-item" id="ctx-play"><img class="ctx-icon" src="img/icons/play_white.svg" alt="">Play</div>
  535. <div class="ctx-item" id="ctx-pause"><img class="ctx-icon" src="img/icons/pause_white.svg" alt="">Pause</div>
  536. <div class="ctx-divider"></div>
  537. <div class="ctx-item" id="ctx-repeat"><img class="ctx-icon" src="img/icons/repeat_white.svg" alt="">Repeat: Off</div>
  538. <div class="ctx-divider"></div>
  539. <div class="ctx-item ctx-has-sub" id="ctx-subtitle-parent">
  540. <span>Subtitles</span>
  541. <i class="ctx-sub-arrow">›</i>
  542. <div id="ctx-subtitle-sub">
  543. <div class="ctx-item ctx-active" id="ctx-sub-disable"><i class="ctx-icon">✓</i>Disable</div>
  544. <div class="ctx-divider"></div>
  545. <div class="ctx-item" id="ctx-sub-load"><i class="ctx-icon">+</i>Load SRT file…</div>
  546. </div>
  547. </div>
  548. <div class="ctx-divider"></div>
  549. <div class="ctx-item" id="ctx-subtitle-settings"><img class="ctx-icon" src="img/icons/settings_white.svg" alt="">Subtitle Settings</div>
  550. <div class="ctx-item" id="ctx-props">Video Properties</div>
  551. </div>
  552. <!-- Subtitle settings modal -->
  553. <div id="subtitle-settings-modal">
  554. <h3>Subtitle Settings<button id="sset-close" title="Close"><img src="img/icons/close_white.svg" alt=""></button></h3>
  555. <div class="sset-row">
  556. <span class="sset-label">Position</span>
  557. <div class="sset-seg">
  558. <button class="sset-seg-btn active" data-val="bottom">Bottom</button>
  559. <button class="sset-seg-btn" data-val="top">Top</button>
  560. </div>
  561. </div>
  562. <div class="sset-row">
  563. <span class="sset-label">Size</span>
  564. <input type="range" id="sset-size" min="14" max="52" step="1" value="22">
  565. <span id="sset-size-val">22px</span>
  566. </div>
  567. <div class="sset-row">
  568. <span class="sset-label">Font</span>
  569. <select id="sset-font">
  570. <option value="system-ui, sans-serif">System Default</option>
  571. <option value="Arial, sans-serif">Arial</option>
  572. <option value="'Helvetica Neue', Helvetica, sans-serif">Helvetica</option>
  573. <option value="Georgia, serif">Georgia</option>
  574. <option value="'Times New Roman', Times, serif">Times New Roman</option>
  575. <option value="'Courier New', Courier, monospace">Courier New</option>
  576. <option value="Verdana, sans-serif">Verdana</option>
  577. <option value="'Trebuchet MS', sans-serif">Trebuchet MS</option>
  578. <option value="Impact, fantasy">Impact</option>
  579. </select>
  580. </div>
  581. <div class="sset-row">
  582. <span class="sset-label">Color</span>
  583. <div class="sset-colors">
  584. <button class="sset-color active" data-color="#ffffff" style="background:#ffffff" title="White"></button>
  585. <button class="sset-color" data-color="#ffff00" style="background:#ffff00" title="Yellow"></button>
  586. <button class="sset-color" data-color="#00ffff" style="background:#00ffff" title="Cyan"></button>
  587. <button class="sset-color" data-color="#00ff88" style="background:#00ff88" title="Green"></button>
  588. <button class="sset-color" data-color="#ff8800" style="background:#ff8800" title="Orange"></button>
  589. <input type="color" id="sset-color-custom" value="#ffffff" title="Custom color">
  590. </div>
  591. </div>
  592. <div id="sset-preview">Sample subtitle text</div>
  593. </div>
  594. <!-- Video info / stats modal -->
  595. <div id="video-info-modal">
  596. <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>
  597. <div id="video-info-tabs">
  598. <div class="info-tab active" onclick="showInfoTab('props')">Properties</div>
  599. <div class="info-tab" onclick="showInfoTab('stats')">Stats</div>
  600. </div>
  601. <div id="video-info-body"></div>
  602. </div>
  603. <!-- Playback settings popup -->
  604. <div id="settings-popup">
  605. <div class="set-section">
  606. <div class="set-head"><img src="img/icons/repeat_white.svg" alt="">Repeat</div>
  607. <div class="set-seg" id="set-repeat-seg">
  608. <button class="set-seg-btn active" data-repeat="off">Off</button>
  609. <button class="set-seg-btn" data-repeat="one">Repeat one</button>
  610. </div>
  611. </div>
  612. <div class="set-section">
  613. <div class="set-head"><img src="img/icons/play_white.svg" alt="">Playback speed</div>
  614. <div class="speed-grid" id="set-speed-grid">
  615. <button class="speed-btn" data-speed="0.5">0.5&times;</button>
  616. <button class="speed-btn active" data-speed="1">1&times;</button>
  617. <button class="speed-btn" data-speed="1.2">1.2&times;</button>
  618. <button class="speed-btn" data-speed="1.5">1.5&times;</button>
  619. <button class="speed-btn" data-speed="2">2&times;</button>
  620. </div>
  621. </div>
  622. <div class="set-section">
  623. <div class="set-head"><img src="img/icons/volume_white.svg" alt="">Volume bar</div>
  624. <div class="set-seg" id="set-volbar-seg">
  625. <button class="set-seg-btn active" data-volbar="auto">Auto</button>
  626. <button class="set-seg-btn" data-volbar="always">Always show</button>
  627. </div>
  628. </div>
  629. <div class="set-section">
  630. <div class="set-head"><img src="img/icons/movie_white.svg" alt="">Streaming mode</div>
  631. <div class="set-seg" id="set-stream-seg">
  632. <button class="set-seg-btn active" data-stream="auto">Auto</button>
  633. <button class="set-seg-btn" data-stream="mp4">MP4</button>
  634. <button class="set-seg-btn" data-stream="hls">HLS</button>
  635. </div>
  636. </div>
  637. <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
  638. <div class="set-link" id="set-video-props">
  639. <img src="img/icons/menu_white.svg" alt="">Video properties
  640. </div>
  641. </div>
  642. </div>
  643. <!-- Subtitle popup (dedicated CC button) -->
  644. <div id="subtitle-popup">
  645. <div class="ctx-item ctx-active" id="sub-pop-disable"><i class="ctx-icon">✓</i>Disable</div>
  646. <div class="ctx-divider"></div>
  647. <div class="ctx-item" id="sub-pop-load"><i class="ctx-icon">+</i>Load SRT file…</div>
  648. <div class="ctx-divider"></div>
  649. <div class="set-link" id="sub-pop-settings">
  650. <img src="img/icons/settings_white.svg" alt="">Subtitle settings
  651. </div>
  652. </div>
  653. <!-- Player controls -->
  654. <div id="video-controls">
  655. <div id="progress-wrap">
  656. <div id="progress-bar" style="width:0%"></div>
  657. <div id="progress-thumb" style="left:0%"></div>
  658. <div id="scrub-preview">
  659. <div id="scrub-preview-thumb"></div>
  660. <div id="scrub-preview-time">0:00</div>
  661. </div>
  662. </div>
  663. <div id="controls-row">
  664. <button class="ctrl-btn" id="ctrl-play" title="Play / Pause (Space)">
  665. <img id="play-icon" src="img/icons/play_white.svg" alt="">
  666. </button>
  667. <div id="volume-wrap">
  668. <button class="ctrl-btn" id="ctrl-mute" title="Mute (M)">
  669. <img id="mute-icon" src="img/icons/volume_white.svg" alt="">
  670. </button>
  671. <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
  672. </div>
  673. <span id="time-display">0:00 / 0:00</span>
  674. <span id="spacer"></span>
  675. <button class="ctrl-btn" id="ctrl-subtitle" title="Subtitles">
  676. <span class="ctrl-cc-icon">CC</span>
  677. </button>
  678. <button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
  679. <img src="img/icons/settings_white.svg" alt="">
  680. </button>
  681. <button class="ctrl-btn" id="ctrl-fs" title="Fullscreen (F)">
  682. <img src="img/icons/fullscreen_white.svg" alt="">
  683. </button>
  684. </div>
  685. </div>
  686. </div><!-- #player-wrap -->
  687. <div id="toast"></div>
  688. <script>
  689. // ── State ─────────────────────────────────────────────────────────────────────
  690. var vid = document.getElementById('main-video');
  691. var currentFile = null; // {name, filepath, ext}
  692. var transcodeSeekOffset = 0; // seconds already streamed before current chunk
  693. var isTranscodedVideo = false;
  694. var transcodeDuration = 0; // total duration for transcoded video (from /media/duration/)
  695. var pendingResumePos = 0;
  696. var watchSaveInterval = null;
  697. var controlsTimer = null;
  698. var repeatSingle = (localStorage.getItem('movie_repeat_one') === '1');
  699. // Snapped to the nearest preset on load: an earlier build let this be dragged
  700. // to any value, and the popup highlights presets only.
  701. // The preset rates offered in the settings popup. Declared here rather than
  702. // beside setPlaybackSpeed because the initialiser below runs on load, and a
  703. // `var` further down the file would still be undefined at that point.
  704. var PLAYBACK_SPEEDS = [0.5, 1, 1.2, 1.5, 2];
  705. var playbackSpeed = nearestPlaybackSpeed(localStorage.getItem('movie_playback_speed') || '1');
  706. var alwaysShowVolumeBar = (localStorage.getItem('movie_always_volume_bar') === '1');
  707. var infoRefreshTimer = null;
  708. var currentInfoTab = 'props';
  709. // Subtitle state
  710. var loadedSubtitleFiles = []; // [{path, name, cues:[{start,end,text}]}]
  711. var activeSubtitleIndex = -1; // -1 = disabled
  712. // Subtitle settings (shared with main Movie window via localStorage)
  713. var subtitleSettings = { position: 'bottom', size: 22, font: 'system-ui, sans-serif', color: '#ffffff' };
  714. // ── Utilities ─────────────────────────────────────────────────────────────────
  715. function isWebPlayable(ext) {
  716. return ['mp4', 'webm', 'ogg'].indexOf(ext) !== -1;
  717. }
  718. function formatTime(s) {
  719. if (isNaN(s) || s < 0) { return '0:00'; }
  720. var h = Math.floor(s / 3600);
  721. var m = Math.floor((s % 3600) / 60);
  722. var sec = Math.floor(s % 60);
  723. var parts = [];
  724. if (h > 0) { parts.push(h); }
  725. parts.push(m);
  726. parts.push(sec < 10 ? '0' + sec : '' + sec);
  727. return parts.join(':');
  728. }
  729. function escapeHtml(str) {
  730. if (!str) { return ''; }
  731. return String(str)
  732. .replace(/&/g, '&amp;').replace(/</g, '&lt;')
  733. .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  734. }
  735. // Flash a large play/pause glyph in the centre of the video so the user gets
  736. // immediate feedback that their click / key press registered.
  737. function flashPlayState(isPlaying) {
  738. var $f = $('#play-flash');
  739. $('#play-flash-icon').attr('src',
  740. isPlaying ? 'img/icons/play_white.svg' : 'img/icons/pause_white.svg');
  741. $f.removeClass('flash');
  742. void $f[0].offsetWidth; // force reflow so the animation restarts
  743. $f.addClass('flash');
  744. }
  745. var toastTimer;
  746. function showToast(msg) {
  747. clearTimeout(toastTimer);
  748. $('#toast').text(msg).addClass('show');
  749. toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
  750. }
  751. // play() returns a promise that rejects when the browser will not start the
  752. // media — most commonly NotSupportedError on Safari, which refuses a transcoded
  753. // stream because the endpoint cannot answer byte-range requests. Left
  754. // unhandled it only shows up as an unhandled rejection in the console, so the
  755. // player just sits there with no explanation. Surface it instead.
  756. function playVideo(v) {
  757. var p = v.play();
  758. if (p && typeof p.catch === 'function') {
  759. p.catch(function (err) {
  760. if (err && err.name === 'AbortError') {
  761. // Fired whenever a load or a new src interrupts a pending
  762. // play() — routine during seek-by-reload, not a failure.
  763. return;
  764. }
  765. if (err && err.name === 'NotSupportedError') {
  766. showToast('This browser cannot play this transcoded format. Try Chrome or Firefox.');
  767. } else {
  768. showToast('Playback failed: ' + ((err && err.message) || 'unknown error'));
  769. }
  770. });
  771. }
  772. return p;
  773. }
  774. // ── File loading ──────────────────────────────────────────────────────────────
  775. var files = ao_module_loadInputFiles();
  776. if (files && files.length > 0) {
  777. currentFile = {
  778. name: files[0].filename,
  779. filepath: files[0].filepath,
  780. ext: files[0].filename.split('.').pop().toLowerCase()
  781. };
  782. ao_module_setWindowTitle('Movie – ' + currentFile.name);
  783. vid.autoplay = true;
  784. // The extension only says the container might be playable; the codec inside
  785. // decides. An .mp4 holding HEVC or 10-bit H.264 has to be transcoded.
  786. resolveDirectPlay(currentFile.filepath, currentFile.ext, function (directPlay) {
  787. beginFileStream(directPlay);
  788. });
  789. }
  790. // Bind the video element to the right stream and arm the resume prompt. Also
  791. // the retry target when direct playback turns out to fail anyway.
  792. function beginFileStream(directPlay) {
  793. if (!currentFile) { return; }
  794. clearDirectPlaybackWatch(vid);
  795. detachTranscodeStream(vid);
  796. isTranscodedVideo = !directPlay;
  797. transcodeSeekOffset = 0;
  798. transcodeDuration = 0;
  799. if (directPlay) {
  800. vid.src = MEDIA_API + '?file=' + encodeURIComponent(currentFile.filepath);
  801. vid.load();
  802. // Last resort: a browser without the platform decoder the probe assumed
  803. onDirectPlaybackFailure(vid, function () {
  804. showToast('This browser cannot decode that file — switching to a transcode');
  805. beginFileStream(false);
  806. });
  807. } else if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0),
  808. function (reason) { showToast(reason); })) {
  809. showToast('This browser cannot play the transcoded stream. Switch streaming mode to MP4 in settings.');
  810. return;
  811. }
  812. armResumePrompt();
  813. }
  814. // Offer to resume where the viewer left off. Direct playback learns its length
  815. // from the element; a transcode has to ask the server for it.
  816. function armResumePrompt() {
  817. if (!currentFile) { return; }
  818. if (!isTranscodedVideo) {
  819. $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
  820. if (vid.duration > 3600 && currentFile) {
  821. ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (data) {
  822. if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
  823. showResumePopup(data.position, vid.duration);
  824. }
  825. });
  826. }
  827. });
  828. return;
  829. }
  830. // Fetch total duration separately (transcode stream doesn't expose it)
  831. $(vid).off('loadedmetadata.resume');
  832. fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(currentFile.filepath))
  833. .then(function (r) { return r.json(); })
  834. .then(function (data) {
  835. if (data.duration > 0) {
  836. transcodeDuration = data.duration;
  837. if (transcodeDuration > 3600 && currentFile) {
  838. ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (wdata) {
  839. if (wdata && !wdata.error && wdata.position > 30 && wdata.position < transcodeDuration * 0.95) {
  840. showResumePopup(wdata.position, transcodeDuration);
  841. }
  842. });
  843. }
  844. }
  845. }).catch(function () {});
  846. }
  847. // ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
  848. // The server renders one sprite sheet per file holding a downscaled frame every
  849. // few seconds; we fetch it once and keep it in memory, so hovering the timeline
  850. // costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
  851. // software-decoded H.265 source, which is exactly where previews matter most.
  852. var storyboard = null; // layout from the server + the loaded sheet URL
  853. var storyboardToken = 0; // invalidates in-flight loads when the file changes
  854. var storyboardTimer = null;
  855. function resetStoryboard() {
  856. storyboardToken++;
  857. storyboard = null;
  858. clearTimeout(storyboardTimer);
  859. storyboardTimer = null;
  860. $('#scrub-preview-thumb').removeClass('ready');
  861. hideScrubPreview();
  862. }
  863. // Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
  864. // transcode) get established before adding more load.
  865. function scheduleStoryboardLoad(filepath) {
  866. resetStoryboard();
  867. if (!filepath) { return; }
  868. var token = storyboardToken;
  869. storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
  870. }
  871. function loadStoryboard(filepath, token) {
  872. var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
  873. fetch(base)
  874. .then(function (r) { return r.json(); })
  875. .then(function (meta) {
  876. if (token !== storyboardToken) { return; } // switched file while loading
  877. if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
  878. var img = new Image();
  879. img.onload = function () {
  880. if (token !== storyboardToken) { return; }
  881. meta.url = img.src;
  882. storyboard = meta;
  883. };
  884. img.src = base + '&image=1';
  885. })
  886. .catch(function () {}); // previews are optional — stay silent on failure
  887. }
  888. // Whole-file duration, whichever playback mode is active
  889. function scrubTotalDuration() {
  890. if (isTranscodedVideo) { return transcodeDuration; }
  891. return vid.duration || 0;
  892. }
  893. function initScrubPreview() {
  894. var $wrap = $('#progress-wrap');
  895. $wrap.on('mousemove', function (e) {
  896. var total = scrubTotalDuration();
  897. var rect = this.getBoundingClientRect();
  898. if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
  899. var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
  900. showScrubPreview(ratio * total, ratio, rect.width);
  901. });
  902. $wrap.on('mouseleave', hideScrubPreview);
  903. }
  904. function showScrubPreview(time, ratio, barWidth) {
  905. $('#scrub-preview-time').text(formatTime(time));
  906. var $thumb = $('#scrub-preview-thumb');
  907. var halfWidth;
  908. if (storyboard && storyboard.url) {
  909. var idx = Math.max(0, Math.min(storyboard.count - 1,
  910. Math.floor(time / storyboard.interval)));
  911. var col = idx % storyboard.cols;
  912. var row = Math.floor(idx / storyboard.cols);
  913. $thumb.addClass('ready').css({
  914. 'width': storyboard.tileWidth + 'px',
  915. 'height': storyboard.tileHeight + 'px',
  916. 'background-image': 'url("' + storyboard.url + '")',
  917. 'background-position': (-col * storyboard.tileWidth) + 'px '
  918. + (-row * storyboard.tileHeight) + 'px'
  919. });
  920. halfWidth = storyboard.tileWidth / 2;
  921. } else {
  922. // Sheet not ready (or unavailable) — the timestamp alone is still useful
  923. $thumb.removeClass('ready');
  924. halfWidth = 28;
  925. }
  926. // Keep the popup within the bar rather than letting it hang off an edge
  927. var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
  928. $('#scrub-preview').css('left', x + 'px').addClass('active');
  929. }
  930. function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
  931. // ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
  932. // Formats the browser can't decode are streamed through ffmpeg, so "seeking"
  933. // means restarting the transcode at a new offset. Replacing .src blanks the
  934. // <video> to black until the first frame of the new segment arrives, which can
  935. // take seconds. Freeze the last painted frame under a spinner instead, and only
  936. // reveal the live element once it is genuinely playing again.
  937. var seekFreezeTimer = null;
  938. function showSeekFreeze() {
  939. var canvas = document.getElementById('seek-freeze');
  940. if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
  941. canvas.width = vid.videoWidth;
  942. canvas.height = vid.videoHeight;
  943. try {
  944. canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
  945. $(canvas).show();
  946. } catch (e) {
  947. // No decodable frame right now (e.g. seeking again mid-buffer) —
  948. // leave whatever is already frozen on screen rather than flashing black.
  949. }
  950. }
  951. $('#seek-spinner').addClass('active');
  952. clearTimeout(seekFreezeTimer);
  953. // Safety net: a stalled transcode must never leave the overlay stuck on
  954. seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
  955. }
  956. function hideSeekFreeze() {
  957. clearTimeout(seekFreezeTimer);
  958. seekFreezeTimer = null;
  959. $('#seek-freeze').hide();
  960. $('#seek-spinner').removeClass('active');
  961. }
  962. // Seek to `seconds` (absolute, whole-file time), clamped to the known duration.
  963. //
  964. // An HLS stream keeps every segment produced so far in its playlist, so a seek
  965. // landing inside the transcoded window is an ordinary <video> seek — no reload,
  966. // no re-transcode, no black frame. Only a seek past that window (or an MP4
  967. // stream, which has no seekable history at all) has to restart the transcode at
  968. // the new offset.
  969. // Restarting the transcode this close to the end of the file would leave ffmpeg
  970. // with nothing to encode, and the request comes back with no playable stream at
  971. // all — so a scrub to the very end lands just short of it instead.
  972. var TRANSCODE_SEEK_TAIL = 5;
  973. function transcodeSeekTo(seconds) {
  974. if (!currentFile) { return; }
  975. var pos = Math.max(0, seconds);
  976. if (transcodeDuration > 0) {
  977. pos = Math.min(Math.max(0, transcodeDuration - TRANSCODE_SEEK_TAIL), pos);
  978. }
  979. if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
  980. setSubtitleSeekFloor(pos);
  981. vid.currentTime = pos - transcodeSeekOffset;
  982. playVideo(vid);
  983. return;
  984. }
  985. showSeekFreeze();
  986. setSubtitleSeekFloor(pos);
  987. transcodeSeekOffset = pos;
  988. // The freeze frame stays up until the new stream is bound: attaching a
  989. // playlist waits on the server to start the transcode, so playback resumes
  990. // from the onReady callback rather than immediately.
  991. if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, pos),
  992. function (reason) { hideSeekFreeze(); showToast(reason); },
  993. function () { playVideo(vid); })) {
  994. hideSeekFreeze();
  995. showToast('This browser cannot play the HLS stream. Switch streaming mode to MP4 in settings.');
  996. }
  997. }
  998. // Whether `pos` (absolute time) falls inside what the current stream has
  999. // already produced and can therefore be reached without reloading.
  1000. function seekWithinTranscodedWindow(videoEl, pos) {
  1001. var relative = pos - transcodeSeekOffset;
  1002. if (relative < 0) { return false; }
  1003. if (!videoEl.seekable || videoEl.seekable.length === 0) { return false; }
  1004. var available = videoEl.seekable.end(videoEl.seekable.length - 1);
  1005. // Stay a segment clear of the live edge; seeking right onto it stalls until
  1006. // the next segment lands.
  1007. return relative <= available - 4;
  1008. }
  1009. // ─── HLS stream recovery ──────────────────────────────────────────────────────
  1010. // An HLS transcode lives on the server, and it can disappear from under the
  1011. // player: reaped after a long pause, retired when the same player asked for a
  1012. // different offset, or lost with a restart. The next segment then 404s and the
  1013. // element reports a decode error, which leaves a black frame and no way back.
  1014. // Re-open the stream where it stopped instead.
  1015. var streamRecoveryAttempts = 0;
  1016. var STREAM_RECOVERY_LIMIT = 3; // consecutive tries before giving up
  1017. function recoverTranscodeStream() {
  1018. if (!isTranscodedVideo || !usingHLS() || !currentFile) { return; }
  1019. // A file that genuinely cannot be transcoded fails every time; retrying it
  1020. // forever would replace one dead player with a flickering one.
  1021. if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
  1022. streamRecoveryAttempts++;
  1023. var resumeAt = effectivePlaybackTime();
  1024. showSeekFreeze();
  1025. transcodeSeekOffset = resumeAt;
  1026. attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, resumeAt),
  1027. function (reason) { hideSeekFreeze(); showToast(reason); },
  1028. function () { playVideo(vid); });
  1029. }
  1030. // Current playback position in whole-file terms (transcode streams restart at 0)
  1031. function effectivePlaybackTime() {
  1032. return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
  1033. }
  1034. // ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
  1035. // The server renders one sprite sheet per file holding a downscaled frame every
  1036. // few seconds; we fetch it once and keep it in memory, so hovering the timeline
  1037. // costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
  1038. // software-decoded H.265 source, which is exactly where previews matter most.
  1039. var storyboard = null; // layout from the server + the loaded sheet URL
  1040. var storyboardToken = 0; // invalidates in-flight loads when the file changes
  1041. var storyboardTimer = null;
  1042. function resetStoryboard() {
  1043. storyboardToken++;
  1044. storyboard = null;
  1045. clearTimeout(storyboardTimer);
  1046. storyboardTimer = null;
  1047. $('#scrub-preview-thumb').removeClass('ready');
  1048. hideScrubPreview();
  1049. }
  1050. // Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
  1051. // transcode) get established before adding more load.
  1052. function scheduleStoryboardLoad(filepath) {
  1053. resetStoryboard();
  1054. if (!filepath) { return; }
  1055. var token = storyboardToken;
  1056. storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
  1057. }
  1058. function loadStoryboard(filepath, token) {
  1059. var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
  1060. fetch(base)
  1061. .then(function (r) { return r.json(); })
  1062. .then(function (meta) {
  1063. if (token !== storyboardToken) { return; } // switched file while loading
  1064. if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
  1065. var img = new Image();
  1066. img.onload = function () {
  1067. if (token !== storyboardToken) { return; }
  1068. meta.url = img.src;
  1069. storyboard = meta;
  1070. };
  1071. img.src = base + '&image=1';
  1072. })
  1073. .catch(function () {}); // previews are optional — stay silent on failure
  1074. }
  1075. // Whole-file duration, whichever playback mode is active
  1076. function scrubTotalDuration() {
  1077. if (isTranscodedVideo) { return transcodeDuration; }
  1078. return vid.duration || 0;
  1079. }
  1080. function initScrubPreview() {
  1081. var $wrap = $('#progress-wrap');
  1082. $wrap.on('mousemove', function (e) {
  1083. var total = scrubTotalDuration();
  1084. var rect = this.getBoundingClientRect();
  1085. if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
  1086. var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
  1087. showScrubPreview(ratio * total, ratio, rect.width);
  1088. });
  1089. $wrap.on('mouseleave', hideScrubPreview);
  1090. }
  1091. function showScrubPreview(time, ratio, barWidth) {
  1092. $('#scrub-preview-time').text(formatTime(time));
  1093. var $thumb = $('#scrub-preview-thumb');
  1094. var halfWidth;
  1095. if (storyboard && storyboard.url) {
  1096. var idx = Math.max(0, Math.min(storyboard.count - 1,
  1097. Math.floor(time / storyboard.interval)));
  1098. var col = idx % storyboard.cols;
  1099. var row = Math.floor(idx / storyboard.cols);
  1100. $thumb.addClass('ready').css({
  1101. 'width': storyboard.tileWidth + 'px',
  1102. 'height': storyboard.tileHeight + 'px',
  1103. 'background-image': 'url("' + storyboard.url + '")',
  1104. 'background-position': (-col * storyboard.tileWidth) + 'px '
  1105. + (-row * storyboard.tileHeight) + 'px'
  1106. });
  1107. halfWidth = storyboard.tileWidth / 2;
  1108. } else {
  1109. // Sheet not ready (or unavailable) — the timestamp alone is still useful
  1110. $thumb.removeClass('ready');
  1111. halfWidth = 28;
  1112. }
  1113. // Keep the popup within the bar rather than letting it hang off an edge
  1114. var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
  1115. $('#scrub-preview').css('left', x + 'px').addClass('active');
  1116. }
  1117. function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
  1118. // ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
  1119. // Formats the browser can't decode are streamed through ffmpeg, so "seeking"
  1120. // means restarting the transcode at a new offset. Replacing .src blanks the
  1121. // <video> to black until the first frame of the new segment arrives, which can
  1122. // take seconds. Freeze the last painted frame under a spinner instead, and only
  1123. // reveal the live element once it is genuinely playing again.
  1124. var seekFreezeTimer = null;
  1125. function showSeekFreeze() {
  1126. var canvas = document.getElementById('seek-freeze');
  1127. if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
  1128. canvas.width = vid.videoWidth;
  1129. canvas.height = vid.videoHeight;
  1130. try {
  1131. canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
  1132. $(canvas).show();
  1133. } catch (e) {
  1134. // No decodable frame right now (e.g. seeking again mid-buffer) —
  1135. // leave whatever is already frozen on screen rather than flashing black.
  1136. }
  1137. }
  1138. $('#seek-spinner').addClass('active');
  1139. clearTimeout(seekFreezeTimer);
  1140. // Safety net: a stalled transcode must never leave the overlay stuck on
  1141. seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
  1142. }
  1143. function hideSeekFreeze() {
  1144. clearTimeout(seekFreezeTimer);
  1145. seekFreezeTimer = null;
  1146. $('#seek-freeze').hide();
  1147. $('#seek-spinner').removeClass('active');
  1148. }
  1149. // Restart the transcode stream at `seconds`, holding the last frame until the
  1150. // new segment plays. Clamped to the known duration.
  1151. function transcodeSeekTo(seconds) {
  1152. if (!currentFile) { return; }
  1153. var pos = Math.max(0, seconds);
  1154. if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
  1155. showSeekFreeze();
  1156. setSubtitleSeekFloor(pos);
  1157. transcodeSeekOffset = pos;
  1158. vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
  1159. + '&start=' + pos.toFixed(3);
  1160. vid.load();
  1161. vid.play();
  1162. }
  1163. // Current playback position in whole-file terms (transcode streams restart at 0)
  1164. function effectivePlaybackTime() {
  1165. return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
  1166. }
  1167. // ── Video controls ────────────────────────────────────────────────────────────
  1168. function initVideoControls() {
  1169. var $prog = $('#progress-bar');
  1170. var $thumb = $('#progress-thumb');
  1171. var $time = $('#time-display');
  1172. // Restore saved volume
  1173. var savedVol = parseFloat(localStorage.getItem('movie_volume'));
  1174. if (!isNaN(savedVol) && savedVol >= 0 && savedVol <= 1) {
  1175. vid.volume = savedVol;
  1176. $('#volume-slider').val(savedVol);
  1177. }
  1178. if (localStorage.getItem('movie_muted') === '1') { vid.muted = true; }
  1179. $('#ctrl-play').on('click', togglePlay);
  1180. $(vid).on('click', togglePlay);
  1181. $('#ctrl-mute').on('click', function () { vid.muted = !vid.muted; updateMuteIcon(); });
  1182. $('#volume-slider').on('input', function () {
  1183. vid.volume = parseFloat($(this).val());
  1184. updateMuteIcon();
  1185. });
  1186. $('#ctrl-fs').on('click', toggleFullscreen);
  1187. // Progress bar — seek-by-reload for transcoded streams
  1188. $('#progress-wrap').on('click', function (e) {
  1189. if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
  1190. transcodeSeekTo((e.offsetX / $(this).width()) * transcodeDuration);
  1191. return;
  1192. }
  1193. if (vid.duration) {
  1194. vid.currentTime = (e.offsetX / $(this).width()) * vid.duration;
  1195. }
  1196. });
  1197. // Progress + subtitle update on timeupdate
  1198. $(vid).on('timeupdate', function () {
  1199. updateSubtitleDisplay();
  1200. if (isTranscodedVideo) {
  1201. var displayTime = vid.currentTime + transcodeSeekOffset;
  1202. if (transcodeDuration > 0) {
  1203. var pct = (displayTime / transcodeDuration) * 100;
  1204. $prog.css('width', pct + '%');
  1205. $thumb.css('left', 'calc(' + pct + '% - 7px)');
  1206. $time.text(formatTime(displayTime) + ' / ' + formatTime(transcodeDuration));
  1207. } else {
  1208. $time.text(formatTime(displayTime) + ' / --:--');
  1209. }
  1210. return;
  1211. }
  1212. if (!vid.duration) { return; }
  1213. var pct = (vid.currentTime / vid.duration) * 100;
  1214. $prog.css('width', pct + '%');
  1215. $thumb.css('left', 'calc(' + pct + '% - 7px)');
  1216. $time.text(formatTime(vid.currentTime) + ' / ' + formatTime(vid.duration));
  1217. });
  1218. $(vid).on('play', function () {
  1219. $('#play-icon').attr('src', 'img/icons/pause_white.svg');
  1220. if (watchSaveInterval) { clearInterval(watchSaveInterval); }
  1221. watchSaveInterval = setInterval(function () {
  1222. var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
  1223. if (!vid.paused && effectiveDuration > 3600) { saveWatchPosition(); }
  1224. }, 30000);
  1225. });
  1226. $(vid).on('pause', function () {
  1227. $('#play-icon').attr('src', 'img/icons/play_white.svg');
  1228. if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
  1229. var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
  1230. var effectiveTime = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
  1231. if (effectiveDuration > 3600 && effectiveTime > 30) { saveWatchPosition(); }
  1232. showControls();
  1233. });
  1234. $(vid).on('ended', function () {
  1235. clearWatchPosition();
  1236. if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
  1237. if (!repeatSingle) { return; }
  1238. if (isTranscodedVideo && transcodeSeekOffset > 0 && currentFile) {
  1239. // The stream started part-way in — restart it from the beginning
  1240. transcodeSeekTo(0);
  1241. } else {
  1242. vid.currentTime = 0;
  1243. playVideo(vid);
  1244. }
  1245. });
  1246. $(vid).on('volumechange', function () {
  1247. updateMuteIcon();
  1248. localStorage.setItem('movie_volume', vid.volume);
  1249. localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
  1250. });
  1251. // Drop the transcode-seek freeze frame the moment the new segment is live.
  1252. // 'playing' is the accurate signal; 'loadeddata' covers a seek made while
  1253. // paused (where 'playing' never fires), and 'error' avoids a stuck overlay.
  1254. // Any seek — native or transcode reload — re-bases the subtitle floor
  1255. $(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
  1256. $(vid).on('playing', function () {
  1257. hideSeekFreeze();
  1258. streamRecoveryAttempts = 0; // the stream is healthy again
  1259. });
  1260. $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
  1261. $(vid).on('error', function () {
  1262. hideSeekFreeze();
  1263. recoverTranscodeStream();
  1264. });
  1265. // Auto-hide controls on mouse movement
  1266. $('#player-wrap').on('mousemove touchstart', showControls);
  1267. document.addEventListener('fullscreenchange', function () {
  1268. if (!document.fullscreenElement) { showControls(); }
  1269. });
  1270. }
  1271. function updateMuteIcon() {
  1272. var isMuted = vid.muted || vid.volume === 0;
  1273. $('#mute-icon').attr('src', isMuted ? 'img/icons/mute_white.svg' : 'img/icons/volume_white.svg');
  1274. $('#volume-slider').val(vid.muted ? 0 : vid.volume);
  1275. }
  1276. function togglePlay() {
  1277. $('#resume-popup').removeClass('active');
  1278. var willPlay = vid.paused;
  1279. if (willPlay) { playVideo(vid); } else { vid.pause(); }
  1280. flashPlayState(willPlay);
  1281. }
  1282. function showControls() {
  1283. $('#video-controls').removeClass('hidden');
  1284. clearTimeout(controlsTimer);
  1285. controlsTimer = setTimeout(function () {
  1286. // Keep the bar up while a popup is open — both are anchored to it
  1287. if (!vid.paused && !$('#settings-popup').hasClass('active') && !$('#subtitle-popup').hasClass('active')) {
  1288. $('#video-controls').addClass('hidden');
  1289. }
  1290. }, 3000);
  1291. }
  1292. function toggleFullscreen() {
  1293. var el = document.getElementById('player-wrap');
  1294. if (!document.fullscreenElement) {
  1295. (el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen).call(el);
  1296. } else {
  1297. (document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen).call(document);
  1298. }
  1299. }
  1300. // ── Watch position (resume) ───────────────────────────────────────────────────
  1301. function saveWatchPosition() {
  1302. if (!currentFile) { return; }
  1303. var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
  1304. var effectiveTime = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
  1305. if (!effectiveDuration || effectiveDuration < 3600 || effectiveTime < 10) { return; }
  1306. ao_module_agirun(SCRIPT_SET_WATCHTIME, {
  1307. filepath: currentFile.filepath,
  1308. position: Math.floor(effectiveTime),
  1309. duration: Math.floor(effectiveDuration)
  1310. }, function () {}, function () {});
  1311. }
  1312. function clearWatchPosition() {
  1313. if (!currentFile) { return; }
  1314. ao_module_agirun(SCRIPT_SET_WATCHTIME,
  1315. { filepath: currentFile.filepath, position: 0, duration: 0 },
  1316. function () {}, function () {});
  1317. }
  1318. function showResumePopup(savedPos, duration) {
  1319. vid.pause();
  1320. pendingResumePos = savedPos;
  1321. $('#resume-popup-sub').text(
  1322. 'Last position: ' + formatTime(savedPos) + ' of ' + formatTime(duration)
  1323. );
  1324. $('#resume-popup').addClass('active');
  1325. showControls();
  1326. $('#resume-btn-continue').off('click').on('click', function () {
  1327. if (isTranscodedVideo && currentFile) {
  1328. transcodeSeekTo(pendingResumePos);
  1329. } else {
  1330. vid.currentTime = pendingResumePos;
  1331. playVideo(vid);
  1332. }
  1333. $('#resume-popup').removeClass('active');
  1334. });
  1335. $('#resume-btn-restart').off('click').on('click', function () {
  1336. playVideo(vid);
  1337. $('#resume-popup').removeClass('active');
  1338. });
  1339. }
  1340. // ── Context menu ──────────────────────────────────────────────────────────────
  1341. function initContextMenu() {
  1342. var $ctx = $('#player-ctx');
  1343. $('#player-wrap').on('contextmenu', function (e) {
  1344. e.preventDefault();
  1345. if (!currentFile) { return; }
  1346. $('#ctx-play').toggleClass('ctx-disabled', !vid.paused);
  1347. $('#ctx-pause').toggleClass('ctx-disabled', vid.paused);
  1348. $('#ctx-repeat')
  1349. .toggleClass('ctx-active', repeatSingle)
  1350. .html('<img class="ctx-icon" src="img/icons/'
  1351. + (repeatSingle ? 'repeat_one_white.svg' : 'repeat_white.svg')
  1352. + '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
  1353. $('#ctx-subtitle-sub').hide();
  1354. closeSettingsPopup();
  1355. closeSubtitlePopup();
  1356. var rect = this.getBoundingClientRect();
  1357. var x = e.clientX - rect.left;
  1358. var y = e.clientY - rect.top;
  1359. $ctx.css({ left: x, top: y, display: 'block' });
  1360. var mw = $ctx.outerWidth(), mh = $ctx.outerHeight();
  1361. if (x + mw > rect.width) { $ctx.css('left', Math.max(0, x - mw)); }
  1362. if (y + mh > rect.height) { $ctx.css('top', Math.max(0, y - mh)); }
  1363. showControls();
  1364. });
  1365. $(document).on('mousedown.ctx', function (e) {
  1366. if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
  1367. });
  1368. $('#ctx-play').on('click', function () { playVideo(vid); flashPlayState(true); $ctx.hide(); });
  1369. $('#ctx-pause').on('click', function () { vid.pause(); flashPlayState(false); $ctx.hide(); });
  1370. $('#ctx-repeat').on('click', function () { setRepeatSingle(!repeatSingle); $ctx.hide(); });
  1371. $('#ctx-subtitle-settings').on('click', function () { $ctx.hide(); openSubtitleSettings(); });
  1372. $('#ctx-props').on('click', function () { $ctx.hide(); openVideoInfo('props'); });
  1373. }
  1374. // ── Playback settings popup (gear button) ─────────────────────────────────────
  1375. function setAlwaysShowVolumeBar(on) {
  1376. alwaysShowVolumeBar = !!on;
  1377. localStorage.setItem('movie_always_volume_bar', alwaysShowVolumeBar ? '1' : '0');
  1378. $('body').toggleClass('always-show-volume', alwaysShowVolumeBar);
  1379. $('#set-volbar-seg .set-seg-btn').removeClass('active')
  1380. .filter('[data-volbar="' + (alwaysShowVolumeBar ? 'always' : 'auto') + '"]').addClass('active');
  1381. }
  1382. function setRepeatSingle(on) {
  1383. repeatSingle = !!on;
  1384. vid.loop = false; // handled manually in the 'ended' handler
  1385. localStorage.setItem('movie_repeat_one', repeatSingle ? '1' : '0');
  1386. $('#set-repeat-seg .set-seg-btn').removeClass('active')
  1387. .filter('[data-repeat="' + (repeatSingle ? 'one' : 'off') + '"]').addClass('active');
  1388. }
  1389. function setPlaybackSpeed(rate) {
  1390. playbackSpeed = parseFloat(rate) || 1;
  1391. vid.playbackRate = playbackSpeed;
  1392. localStorage.setItem('movie_playback_speed', String(playbackSpeed));
  1393. $('#set-speed-grid .speed-btn').removeClass('active')
  1394. .filter('[data-speed="' + playbackSpeed + '"]').addClass('active');
  1395. }
  1396. // Snap an arbitrary stored rate onto the nearest preset, so the popup always
  1397. // has exactly one button highlighted. Needed because an earlier build persisted
  1398. // free-form rates from a drag slider.
  1399. function nearestPlaybackSpeed(rate) {
  1400. var value = parseFloat(rate);
  1401. if (!value || value <= 0) { return 1; }
  1402. return PLAYBACK_SPEEDS.reduce(function (best, preset) {
  1403. return Math.abs(preset - value) < Math.abs(best - value) ? preset : best;
  1404. }, PLAYBACK_SPEEDS[0]);
  1405. }
  1406. // Apply a streaming mode. Changing it mid-playback re-opens the current file on
  1407. // the other transport, resuming where the viewer was.
  1408. function applyStreamMode(mode, reloadCurrent) {
  1409. var previous = getStreamMode();
  1410. setStreamMode(mode);
  1411. $('#set-stream-seg .set-seg-btn').removeClass('active')
  1412. .filter('[data-stream="' + getStreamMode() + '"]').addClass('active');
  1413. if (!reloadCurrent || previous === getStreamMode()) { return; }
  1414. // Warn only when the viewer actively picks a mode this browser cannot play
  1415. warnIfStreamModeUnplayable();
  1416. if (!isTranscodedVideo || !currentFile) { return; }
  1417. // Opening the file directly at the current position rather than at zero
  1418. // avoids starting a transcode from the beginning only to abandon it.
  1419. var resumeAt = effectivePlaybackTime();
  1420. var startAt = resumeAt > 1 ? resumeAt : 0;
  1421. transcodeSeekOffset = startAt;
  1422. if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, startAt),
  1423. function (reason) { showToast(reason); },
  1424. function () { playVideo(vid); })) {
  1425. showToast('This browser cannot play the HLS stream.');
  1426. }
  1427. }
  1428. function playerVideoElement() { return vid; }
  1429. // Surface the two streaming modes that cannot work on a given browser. Anything
  1430. // that is simply working says nothing at all.
  1431. function warnIfStreamModeUnplayable() {
  1432. var mode = getStreamMode();
  1433. if (mode === 'mp4' && isWebKitClient()) {
  1434. showToast('This browser cannot play the MP4 stream. Use Auto or HLS.');
  1435. } else if (mode === 'hls' && !hlsPlaybackSupported(playerVideoElement())) {
  1436. showToast('This browser has no HLS support (needs Media Source Extensions).');
  1437. }
  1438. }
  1439. // Cap a bottom-anchored popup's height so it never runs past the top edge of
  1440. // the player (it only ever opens upward) — the popup's own scrollbar takes
  1441. // over for whatever doesn't fit.
  1442. function clampBottomPopupHeight($popup) {
  1443. var container = document.getElementById('player-wrap');
  1444. if (!container) { return; }
  1445. var margin = 10;
  1446. var containerRect = container.getBoundingClientRect();
  1447. var popupRect = $popup[0].getBoundingClientRect();
  1448. var available = popupRect.bottom - containerRect.top - margin;
  1449. $popup.css('max-height', Math.max(120, available) + 'px');
  1450. }
  1451. function openSettingsPopup() {
  1452. setRepeatSingle(repeatSingle);
  1453. setPlaybackSpeed(playbackSpeed);
  1454. setAlwaysShowVolumeBar(alwaysShowVolumeBar);
  1455. applyStreamMode(getStreamMode(), false);
  1456. $('#player-ctx').hide();
  1457. closeSubtitlePopup();
  1458. $('#settings-popup').addClass('active');
  1459. clampBottomPopupHeight($('#settings-popup'));
  1460. showControls();
  1461. }
  1462. function closeSettingsPopup() { $('#settings-popup').removeClass('active'); }
  1463. function toggleSettingsPopup() {
  1464. if ($('#settings-popup').hasClass('active')) { closeSettingsPopup(); }
  1465. else { openSettingsPopup(); }
  1466. }
  1467. // ── Subtitle popup (dedicated CC button) ────────────────────────────────────
  1468. function openSubtitlePopup() {
  1469. updateSubtitleSubmenu();
  1470. $('#player-ctx').hide();
  1471. closeSettingsPopup();
  1472. $('#subtitle-popup').addClass('active');
  1473. clampBottomPopupHeight($('#subtitle-popup'));
  1474. showControls();
  1475. }
  1476. function closeSubtitlePopup() { $('#subtitle-popup').removeClass('active'); }
  1477. function toggleSubtitlePopup() {
  1478. if ($('#subtitle-popup').hasClass('active')) { closeSubtitlePopup(); }
  1479. else { openSubtitlePopup(); }
  1480. }
  1481. function initSubtitlePopup() {
  1482. $('#ctrl-subtitle').on('click', function (e) {
  1483. e.stopPropagation();
  1484. toggleSubtitlePopup();
  1485. });
  1486. $(document).on('mousedown.subpop', function (e) {
  1487. if (!$(e.target).closest('#subtitle-popup, #ctrl-subtitle').length) {
  1488. closeSubtitlePopup();
  1489. }
  1490. });
  1491. $('#sub-pop-disable').on('click', function () {
  1492. activeSubtitleIndex = -1;
  1493. stopAssTrack();
  1494. $('#subtitle-display').hide();
  1495. updateSubtitleSubmenu();
  1496. closeSubtitlePopup();
  1497. });
  1498. $('#sub-pop-load').on('click', function () { closeSubtitlePopup(); pickSubtitleFile(); });
  1499. $('#sub-pop-settings').on('click', function () { closeSubtitlePopup(); openSubtitleSettings(); });
  1500. }
  1501. function initSettingsPopup() {
  1502. $('#ctrl-settings').on('click', function (e) {
  1503. e.stopPropagation();
  1504. toggleSettingsPopup();
  1505. });
  1506. // Dismiss when clicking anywhere outside the popup or its button
  1507. $(document).on('mousedown.setpop', function (e) {
  1508. if (!$(e.target).closest('#settings-popup, #ctrl-settings').length) {
  1509. closeSettingsPopup();
  1510. }
  1511. });
  1512. $('#set-repeat-seg').on('click', '.set-seg-btn', function () {
  1513. setRepeatSingle($(this).data('repeat') === 'one');
  1514. });
  1515. $('#set-speed-grid').on('click', '.speed-btn', function () {
  1516. setPlaybackSpeed($(this).data('speed'));
  1517. });
  1518. $('#set-volbar-seg').on('click', '.set-seg-btn', function () {
  1519. setAlwaysShowVolumeBar($(this).data('volbar') === 'always');
  1520. });
  1521. $('#set-stream-seg').on('click', '.set-seg-btn', function () {
  1522. applyStreamMode(String($(this).data('stream')), true);
  1523. });
  1524. $('#set-video-props').on('click', function () { closeSettingsPopup(); openVideoInfo('props'); });
  1525. // Apply persisted values to the video element
  1526. setRepeatSingle(repeatSingle);
  1527. setPlaybackSpeed(playbackSpeed);
  1528. setAlwaysShowVolumeBar(alwaysShowVolumeBar);
  1529. applyStreamMode(getStreamMode(), false);
  1530. // A transcode seek replaces the media source, which resets playbackRate —
  1531. // reapply the chosen speed every time new media is loaded.
  1532. $(vid).on('loadedmetadata', function () { vid.playbackRate = playbackSpeed; });
  1533. }
  1534. // ── Video info / stats modal ──────────────────────────────────────────────────
  1535. function openVideoInfo(tab) {
  1536. currentInfoTab = tab || 'props';
  1537. $('#video-info-modal').show();
  1538. $('.info-tab').removeClass('active').eq(currentInfoTab === 'props' ? 0 : 1).addClass('active');
  1539. $('#video-info-title').text(currentInfoTab === 'props' ? 'Video Properties' : 'Streaming Stats');
  1540. renderInfoContent();
  1541. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  1542. if (currentInfoTab === 'stats') {
  1543. infoRefreshTimer = setInterval(renderInfoContent, 1000);
  1544. }
  1545. }
  1546. function closeVideoInfo() {
  1547. $('#video-info-modal').hide();
  1548. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  1549. }
  1550. function showInfoTab(tab) {
  1551. currentInfoTab = tab;
  1552. $('.info-tab').removeClass('active').eq(tab === 'props' ? 0 : 1).addClass('active');
  1553. $('#video-info-title').text(tab === 'props' ? 'Video Properties' : 'Streaming Stats');
  1554. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  1555. if (tab === 'stats') { infoRefreshTimer = setInterval(renderInfoContent, 1000); }
  1556. renderInfoContent();
  1557. }
  1558. function infoRow(label, value) {
  1559. return '<div class="info-row"><span class="info-label">' + escapeHtml(String(label)) + '</span>'
  1560. + '<span class="info-value">' + escapeHtml(String(value)) + '</span></div>';
  1561. }
  1562. function renderInfoContent() {
  1563. var html = '';
  1564. if (currentInfoTab === 'props') {
  1565. html += infoRow('Title', currentFile ? currentFile.name : '–');
  1566. html += infoRow('Resolution', (vid.videoWidth && vid.videoHeight)
  1567. ? vid.videoWidth + ' × ' + vid.videoHeight : '–');
  1568. var infoDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
  1569. var infoPosition = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
  1570. html += infoRow('Duration', infoDuration ? formatTime(infoDuration) : '–');
  1571. html += infoRow('Position', infoPosition ? formatTime(infoPosition) : '–');
  1572. html += infoRow('Playback speed', vid.playbackRate + '×');
  1573. html += infoRow('Volume', vid.muted ? 'Muted' : Math.round(vid.volume * 100) + '%');
  1574. if (currentFile) { html += infoRow('File path', currentFile.filepath || '–'); }
  1575. } else {
  1576. var rsLabels = ['No info', 'Metadata only', 'Have current data', 'Have future data', 'Enough data'];
  1577. var nsLabels = ['Empty', 'Idle', 'Loading', 'No source'];
  1578. html += infoRow('Ready state', rsLabels[vid.readyState] || vid.readyState);
  1579. html += infoRow('Network state', nsLabels[vid.networkState] || vid.networkState);
  1580. var bufEnd = 0;
  1581. if (vid.buffered && vid.buffered.length > 0) {
  1582. for (var i = 0; i < vid.buffered.length; i++) {
  1583. if (vid.buffered.start(i) <= vid.currentTime + 0.1) {
  1584. bufEnd = Math.max(bufEnd, vid.buffered.end(i));
  1585. }
  1586. }
  1587. }
  1588. html += infoRow('Buffer ahead', Math.max(0, bufEnd - vid.currentTime).toFixed(1) + 's');
  1589. html += infoRow('Total buffered', vid.buffered.length > 0
  1590. ? formatTime(vid.buffered.end(vid.buffered.length - 1)) : '–');
  1591. if (vid.getVideoPlaybackQuality) {
  1592. var q = vid.getVideoPlaybackQuality();
  1593. html += infoRow('Frames decoded', q.totalVideoFrames || 0);
  1594. html += infoRow('Frames dropped', q.droppedVideoFrames || 0);
  1595. }
  1596. html += infoRow('Stalled / ended', vid.ended ? 'Ended' : (vid.readyState < 3 ? 'Yes' : 'No'));
  1597. }
  1598. $('#video-info-body').html(html);
  1599. }
  1600. // ── Subtitle settings ─────────────────────────────────────────────────────────
  1601. function loadSubtitleSettings() {
  1602. try {
  1603. var s = JSON.parse(localStorage.getItem('movie_subtitle_settings') || 'null');
  1604. if (s) { Object.assign(subtitleSettings, s); }
  1605. } catch (e) {}
  1606. }
  1607. function saveSubtitleSettings() {
  1608. localStorage.setItem('movie_subtitle_settings', JSON.stringify(subtitleSettings));
  1609. }
  1610. function applySubtitleSettings() {
  1611. var s = subtitleSettings;
  1612. $('#subtitle-display').css({
  1613. 'font-size': s.size + 'px',
  1614. 'font-family': s.font,
  1615. 'color': s.color,
  1616. 'bottom': s.position === 'bottom' ? '72px' : 'auto',
  1617. 'top': s.position === 'top' ? '72px' : 'auto',
  1618. 'transform': 'translateX(-50%)'
  1619. });
  1620. }
  1621. function syncSettingsPreview() {
  1622. var s = subtitleSettings;
  1623. $('#sset-preview').css({
  1624. 'font-size': s.size + 'px',
  1625. 'font-family': s.font,
  1626. 'color': s.color,
  1627. '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)'
  1628. });
  1629. }
  1630. function openSubtitleSettings() {
  1631. var s = subtitleSettings;
  1632. $('.sset-seg-btn').removeClass('active').filter('[data-val="' + s.position + '"]').addClass('active');
  1633. $('#sset-size').val(s.size);
  1634. $('#sset-size-val').text(s.size + 'px');
  1635. $('#sset-font').val(s.font);
  1636. var swatchMatch = $('.sset-color[data-color="' + s.color.toLowerCase() + '"]');
  1637. $('.sset-color, #sset-color-custom').removeClass('active');
  1638. if (swatchMatch.length) { swatchMatch.addClass('active'); }
  1639. else { $('#sset-color-custom').addClass('active'); }
  1640. $('#sset-color-custom').val(s.color);
  1641. syncSettingsPreview();
  1642. $('#subtitle-settings-modal').show();
  1643. }
  1644. function closeSubtitleSettings() { $('#subtitle-settings-modal').hide(); }
  1645. function initSubtitleSettings() {
  1646. loadSubtitleSettings();
  1647. applySubtitleSettings();
  1648. $('#sset-close').on('click', closeSubtitleSettings);
  1649. $(document).on('click', '.sset-seg-btn', function () {
  1650. subtitleSettings.position = $(this).data('val');
  1651. $('.sset-seg-btn').removeClass('active');
  1652. $(this).addClass('active');
  1653. applySubtitleSettings();
  1654. saveSubtitleSettings();
  1655. });
  1656. $('#sset-size').on('input', function () {
  1657. subtitleSettings.size = parseInt($(this).val(), 10);
  1658. $('#sset-size-val').text(subtitleSettings.size + 'px');
  1659. applySubtitleSettings();
  1660. syncSettingsPreview();
  1661. saveSubtitleSettings();
  1662. });
  1663. $('#sset-font').on('change', function () {
  1664. subtitleSettings.font = $(this).val();
  1665. ensureEmbeddedFontLoaded(subtitleSettings.font); // no-op for built-in fonts
  1666. applySubtitleSettings();
  1667. syncSettingsPreview();
  1668. saveSubtitleSettings();
  1669. });
  1670. $(document).on('click', '.sset-color', function () {
  1671. subtitleSettings.color = $(this).data('color');
  1672. $('.sset-color, #sset-color-custom').removeClass('active');
  1673. $(this).addClass('active');
  1674. $('#sset-color-custom').val(subtitleSettings.color);
  1675. applySubtitleSettings();
  1676. syncSettingsPreview();
  1677. saveSubtitleSettings();
  1678. });
  1679. $('#sset-color-custom').on('input', function () {
  1680. subtitleSettings.color = $(this).val();
  1681. $('.sset-color').removeClass('active');
  1682. $('#sset-color-custom').addClass('active');
  1683. applySubtitleSettings();
  1684. syncSettingsPreview();
  1685. saveSubtitleSettings();
  1686. });
  1687. }
  1688. // ── Subtitle loading / parsing / display ──────────────────────────────────────
  1689. // Exposed on window so ao_module_openFileSelector can invoke it by name
  1690. window.movieEmbOnSubtitleFile = function (raw) {
  1691. var entry = Array.isArray(raw) ? raw[0] : raw;
  1692. var filePath = (entry && typeof entry === 'object') ? entry.filepath : entry;
  1693. if (!filePath || typeof filePath !== 'string') { return; }
  1694. $.ajax({
  1695. url: MEDIA_API + '?file=' + encodeURIComponent(filePath),
  1696. dataType: 'text',
  1697. success: function (content) {
  1698. var name = filePath.replace(/\\/g, '/').split('/').pop();
  1699. var cues = parseSrt(content);
  1700. var existing = -1;
  1701. loadedSubtitleFiles.forEach(function (sf, i) {
  1702. if (sf.path === filePath) { existing = i; }
  1703. });
  1704. if (existing >= 0) {
  1705. loadedSubtitleFiles[existing].cues = cues;
  1706. activeSubtitleIndex = existing;
  1707. } else {
  1708. loadedSubtitleFiles.push({ path: filePath, name: name, cues: cues });
  1709. activeSubtitleIndex = loadedSubtitleFiles.length - 1;
  1710. }
  1711. showToast('Subtitle loaded: ' + name);
  1712. applyActiveSubtitle(); // a sidecar file replaces any styled track
  1713. updateSubtitleSubmenu();
  1714. },
  1715. error: function () { showToast('Failed to load subtitle file'); }
  1716. });
  1717. };
  1718. function parseSrtTime(t) {
  1719. var m = t.match(/(\d+):(\d+):(\d+)[,.](\d+)/);
  1720. if (!m) { return 0; }
  1721. return parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) + parseInt(m[4]) / 1000;
  1722. }
  1723. function parseSrt(content) {
  1724. var cues = [];
  1725. var blocks = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim().split(/\n\n+/);
  1726. blocks.forEach(function (block) {
  1727. var lines = block.split('\n');
  1728. var tcIdx = -1;
  1729. for (var i = 0; i < lines.length; i++) {
  1730. if (lines[i].indexOf('-->') !== -1) { tcIdx = i; break; }
  1731. }
  1732. if (tcIdx < 0) { return; }
  1733. var parts = lines[tcIdx].split('-->');
  1734. if (parts.length < 2) { return; }
  1735. var start = parseSrtTime(parts[0].trim());
  1736. var end = parseSrtTime(parts[1].trim().split(' ')[0]);
  1737. // Strip inline SRT tags like <b>, <i>, <font>, plus any ASS override
  1738. // blocks such as {\an8} or {\pos(x,y)}. ffmpeg's ASS-to-SubRip
  1739. // conversion leaves positioning tags like those behind, so without this
  1740. // they would be shown to the viewer as literal text.
  1741. var text = lines.slice(tcIdx + 1).join('\n').trim()
  1742. .replace(/<[^>]+>/g, '')
  1743. .replace(/\{\\[^}]*\}/g, '')
  1744. .trim();
  1745. if (text) { cues.push({ start: start, end: end, text: text }); }
  1746. });
  1747. return cues;
  1748. }
  1749. function updateSubtitleDisplay() {
  1750. // Styled tracks are drawn by the ASS renderer on its own frame loop
  1751. if (assActive) { $('#subtitle-display').hide(); return; }
  1752. if (activeSubtitleIndex < 0 || !loadedSubtitleFiles[activeSubtitleIndex]) {
  1753. $('#subtitle-display').hide();
  1754. return;
  1755. }
  1756. var currentTime = isTranscodedVideo
  1757. ? (vid.currentTime + transcodeSeekOffset)
  1758. : vid.currentTime;
  1759. var cues = loadedSubtitleFiles[activeSubtitleIndex].cues;
  1760. var found = null;
  1761. for (var i = 0; i < cues.length; i++) {
  1762. if (cues[i].start < subtitleSeekFloor) { continue; } // in flight when we landed
  1763. if (currentTime >= cues[i].start && currentTime <= cues[i].end) { found = cues[i]; break; }
  1764. }
  1765. if (found) {
  1766. $('#subtitle-display').html(escapeHtml(found.text).replace(/\n/g, '<br>')).show();
  1767. } else {
  1768. $('#subtitle-display').hide();
  1769. }
  1770. }
  1771. // ── Embedded subtitle tracks and fonts ────────────────────────────────────────
  1772. // MKV releases usually mux their subtitles (and the fonts they were typeset in)
  1773. // into the container rather than shipping sidecar files. The server lists them
  1774. // and extracts on demand; tracks are converted to SubRip, which keeps the text
  1775. // and timing but drops ASS styling, positioning and karaoke.
  1776. var embeddedSubtitles = []; // [{index, codec, language, title, forced, textual}]
  1777. var embeddedFonts = []; // [{index, filename, mimetype}]
  1778. var embeddedMediaPath = null; // file the two lists above describe
  1779. var embeddedProbeToken = 0; // invalidates in-flight probes on file change
  1780. var embeddedFontFaces = []; // FontFace objects registered for this video
  1781. var embeddedFontsLoaded = {}; // family name -> already fetched
  1782. function resetEmbeddedTracks() {
  1783. embeddedProbeToken++;
  1784. stopAssTrack();
  1785. embeddedSubtitles = [];
  1786. embeddedFonts = [];
  1787. embeddedMediaPath = null;
  1788. // Font families are keyed by attachment ordinal, which means the same name
  1789. // refers to a different font in the next video — drop the old faces.
  1790. embeddedFontFaces.forEach(function (face) {
  1791. try { document.fonts.delete(face); } catch (e) {}
  1792. });
  1793. embeddedFontFaces = [];
  1794. embeddedFontsLoaded = {};
  1795. }
  1796. function loadEmbeddedTrackList(filepath) {
  1797. resetEmbeddedTracks();
  1798. if (!filepath) { return; }
  1799. embeddedMediaPath = filepath;
  1800. var token = embeddedProbeToken;
  1801. fetch(SUBTITLE_API + '?file=' + encodeURIComponent(filepath))
  1802. .then(function (r) { return r.json(); })
  1803. .then(function (info) {
  1804. if (token !== embeddedProbeToken) { return; }
  1805. if (!info || info.error) { return; }
  1806. embeddedSubtitles = info.subtitles || [];
  1807. embeddedFonts = info.fonts || [];
  1808. updateSubtitleSubmenu();
  1809. refreshEmbeddedFontOptions();
  1810. })
  1811. .catch(function () {}); // embedded tracks are a bonus — fail quietly
  1812. }
  1813. function embeddedTrackLabel(track, position) {
  1814. var label = track.title || '';
  1815. if (!label) { label = 'Track ' + (position + 1); }
  1816. if (track.language) { label += ' [' + track.language + ']'; }
  1817. if (track.forced) { label += ' (forced)'; }
  1818. if (!track.textual) { label += ' — image based'; }
  1819. return label;
  1820. }
  1821. // Extraction has to scan the whole container, so it takes a few seconds on a
  1822. // large file. Results are kept in loadedSubtitleFiles, making reselection free.
  1823. function selectEmbeddedSubtitle(track, position) {
  1824. if (!track.textual) {
  1825. showToast('That track is image based and cannot be displayed');
  1826. return;
  1827. }
  1828. var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
  1829. for (var i = 0; i < loadedSubtitleFiles.length; i++) {
  1830. if (loadedSubtitleFiles[i].path === cacheKey) {
  1831. activeSubtitleIndex = i;
  1832. applyActiveSubtitle();
  1833. updateSubtitleSubmenu();
  1834. return;
  1835. }
  1836. }
  1837. // ASS/SSA is fetched in its native form so the styling survives; everything
  1838. // else comes through as SubRip for the plain renderer.
  1839. var styled = /^(ass|ssa)$/i.test(track.codec) && typeof ASS !== 'undefined';
  1840. var mediaPath = embeddedMediaPath;
  1841. var url = SUBTITLE_API + '?file=' + encodeURIComponent(mediaPath) + '&track=' + track.index
  1842. + (styled ? '&format=ass' : '');
  1843. showToast('Extracting subtitle track…');
  1844. $.ajax({
  1845. url: url,
  1846. dataType: 'text',
  1847. success: function (content) {
  1848. if (mediaPath !== embeddedMediaPath) { return; } // episode changed
  1849. if (styled) {
  1850. var parsed = ASS.parse(content);
  1851. if (!parsed.events.length) {
  1852. showToast('That track contained no readable text');
  1853. return;
  1854. }
  1855. loadedSubtitleFiles.push({
  1856. path: cacheKey,
  1857. name: embeddedTrackLabel(track, position),
  1858. cues: [],
  1859. assTrack: parsed
  1860. });
  1861. activeSubtitleIndex = loadedSubtitleFiles.length - 1;
  1862. applyActiveSubtitle();
  1863. updateSubtitleSubmenu();
  1864. showToast('Subtitle loaded · ' + parsed.events.length + ' styled lines');
  1865. return;
  1866. }
  1867. var cues = parseSrt(content);
  1868. if (cues.length === 0) {
  1869. showToast('That track contained no readable text');
  1870. return;
  1871. }
  1872. loadedSubtitleFiles.push({
  1873. path: cacheKey,
  1874. name: embeddedTrackLabel(track, position),
  1875. cues: cues
  1876. });
  1877. activeSubtitleIndex = loadedSubtitleFiles.length - 1;
  1878. applyActiveSubtitle();
  1879. updateSubtitleSubmenu();
  1880. showToast('Subtitle loaded · ' + cues.length + ' lines');
  1881. },
  1882. error: function () { showToast('Could not extract that subtitle track'); }
  1883. });
  1884. }
  1885. // Switch the display between the styled ASS renderer and the plain SubRip
  1886. // overlay depending on what the newly selected track carries.
  1887. function applyActiveSubtitle() {
  1888. var entry = activeSubtitleIndex >= 0 ? loadedSubtitleFiles[activeSubtitleIndex] : null;
  1889. if (entry && entry.assTrack) {
  1890. startAssTrack(entry.assTrack);
  1891. } else {
  1892. stopAssTrack();
  1893. updateSubtitleDisplay();
  1894. }
  1895. }
  1896. // ── Subtitle seek floor ──────────────────────────────────────────────────────
  1897. // After a seek, any line that had already begun before the landing point is
  1898. // skipped entirely; only lines that start at or after it are shown. Seeking to
  1899. // 1:02 with lines at 1:00-1:03 and 1:05-1:10 therefore shows nothing until 1:05.
  1900. var subtitleSeekFloor = 0;
  1901. function setSubtitleSeekFloor(time) {
  1902. subtitleSeekFloor = Math.max(0, time || 0);
  1903. if (assRenderer) { assRenderer.setSuppressBefore(subtitleSeekFloor); }
  1904. updateSubtitleDisplay();
  1905. }
  1906. function resetSubtitleSeekFloor() { setSubtitleSeekFloor(0); }
  1907. // ── Styled ASS rendering ─────────────────────────────────────────────────────
  1908. // ASS tracks are fetched in their native form and drawn by script/ass.js, which
  1909. // preserves per-line styling and position. That is what makes dual-language
  1910. // releases work: the Japanese and Chinese lines are separate events shown at the
  1911. // same time, and a single-cue renderer can only ever display one of them.
  1912. var assRenderer = null;
  1913. var assActive = false;
  1914. var assFrameReq = null;
  1915. function initAssRenderer() {
  1916. var overlay = document.getElementById('ass-overlay');
  1917. if (overlay && typeof ASS !== 'undefined') { assRenderer = new ASS.Renderer(overlay); }
  1918. }
  1919. // Match the overlay to the letterboxed picture inside the video element, so
  1920. // script coordinates map onto what the viewer actually sees.
  1921. function syncAssOverlayGeometry() {
  1922. if (!assRenderer) { return; }
  1923. var vid = document.getElementById('main-video');
  1924. var overlay = document.getElementById('ass-overlay');
  1925. var cw = vid.clientWidth, ch = vid.clientHeight;
  1926. var vw = vid.videoWidth, vh = vid.videoHeight;
  1927. // Until the decoder reports the picture size there is no way to know where
  1928. // the letterbox bars are. Falling back to the raw element box would scale
  1929. // the script against the black bars too and push bottom-anchored lines down
  1930. // over the controls, so keep the last known good geometry instead.
  1931. if (!(vw > 0 && vh > 0 && cw > 0 && ch > 0)) { return; }
  1932. var scale = Math.min(cw / vw, ch / vh);
  1933. var width = vw * scale;
  1934. var height = vh * scale;
  1935. var left = (cw - width) / 2;
  1936. var top = (ch - height) / 2;
  1937. if (overlay._w !== width || overlay._h !== height || overlay._l !== left || overlay._t !== top) {
  1938. overlay._w = width; overlay._h = height; overlay._l = left; overlay._t = top;
  1939. overlay.style.left = left + 'px';
  1940. overlay.style.top = top + 'px';
  1941. overlay.style.width = width + 'px';
  1942. overlay.style.height = height + 'px';
  1943. assRenderer.resize(width, height);
  1944. }
  1945. }
  1946. function startAssTrack(track) {
  1947. if (!assRenderer) { return; }
  1948. stopAssTrack();
  1949. assRenderer.setTrack(track);
  1950. assActive = true;
  1951. $('#ass-overlay').addClass('active');
  1952. $('#subtitle-display').hide(); // the plain renderer must not double up
  1953. loadAssFonts(track);
  1954. var step = function () {
  1955. if (!assActive) { return; }
  1956. syncAssOverlayGeometry();
  1957. assRenderer.setTime(effectivePlaybackTime());
  1958. assFrameReq = requestAnimationFrame(step);
  1959. };
  1960. step();
  1961. }
  1962. function stopAssTrack() {
  1963. assActive = false;
  1964. if (assFrameReq) { cancelAnimationFrame(assFrameReq); assFrameReq = null; }
  1965. if (assRenderer) { assRenderer.clear(); }
  1966. $('#ass-overlay').removeClass('active');
  1967. }
  1968. // Only pull the fonts the track actually references — a release can attach a
  1969. // dozen and use half of them.
  1970. function loadAssFonts(track) {
  1971. if (typeof ASS === 'undefined') { return; }
  1972. ASS.referencedFonts(track).forEach(function (family) {
  1973. ensureEmbeddedFontLoaded(family);
  1974. });
  1975. }
  1976. // ── Embedded fonts ───────────────────────────────────────────────────────────
  1977. // Registered under the font's own internal family name, which is what ASS styles
  1978. // reference, and offered in Subtitle Settings for SubRip tracks. Fetched lazily:
  1979. // a release can carry a dozen fonts and only a few are ever used.
  1980. function embeddedFontFamily(font) {
  1981. return font.family || ('aroz-embedded-' + font.index);
  1982. }
  1983. function findEmbeddedFontByFamily(family) {
  1984. var wanted = String(family || '').replace(/^@/, '').toLowerCase();
  1985. var found = null;
  1986. embeddedFonts.forEach(function (f) {
  1987. if (embeddedFontFamily(f).toLowerCase() === wanted) { found = f; }
  1988. });
  1989. return found;
  1990. }
  1991. function refreshEmbeddedFontOptions() {
  1992. var $select = $('#sset-font');
  1993. $select.find('optgroup.embedded-fonts').remove();
  1994. if (embeddedFonts.length > 0) {
  1995. var $group = $('<optgroup label="Embedded in this video" class="embedded-fonts"></optgroup>');
  1996. embeddedFonts.forEach(function (font) {
  1997. $group.append($('<option></option>')
  1998. .attr('value', embeddedFontFamily(font))
  1999. .text(font.filename || ('Font ' + (font.index + 1))));
  2000. });
  2001. $select.append($group);
  2002. }
  2003. // A font picked for a previous video may not exist in this one
  2004. var stillOffered = $select.find('option').filter(function () {
  2005. return this.value === subtitleSettings.font;
  2006. }).length > 0;
  2007. if (!stillOffered) {
  2008. subtitleSettings.font = 'system-ui, sans-serif';
  2009. applySubtitleSettings();
  2010. saveSubtitleSettings();
  2011. }
  2012. $select.val(subtitleSettings.font);
  2013. ensureEmbeddedFontLoaded(subtitleSettings.font);
  2014. }
  2015. function ensureEmbeddedFontLoaded(family) {
  2016. if (!family || embeddedFontsLoaded[family] || !embeddedMediaPath) { return; }
  2017. if (typeof FontFace === 'undefined' || !document.fonts) { return; }
  2018. var meta = findEmbeddedFontByFamily(family);
  2019. if (!meta) { return; } // a built-in font, or one this video does not carry
  2020. embeddedFontsLoaded[family] = true; // claim it so we do not refetch in a loop
  2021. var url = SUBTITLE_API + '?file=' + encodeURIComponent(embeddedMediaPath) + '&font=' + meta.index;
  2022. var face = new FontFace(embeddedFontFamily(meta), 'url("' + url + '")');
  2023. face.load().then(function (loaded) {
  2024. document.fonts.add(loaded);
  2025. embeddedFontFaces.push(loaded);
  2026. applySubtitleSettings();
  2027. syncSettingsPreview();
  2028. // The renderer sizes every run off the face's own ascent/descent, so a
  2029. // face arriving late invalidates the measurements taken against the
  2030. // fallback it was standing in for.
  2031. if (typeof ASS !== 'undefined' && ASS.invalidateFontMetrics) { ASS.invalidateFontMetrics(); }
  2032. if (assRenderer) { assRenderer.clear(); } // redraw with the real face
  2033. }).catch(function () {
  2034. embeddedFontsLoaded[family] = false;
  2035. });
  2036. }
  2037. // Builds the track list (Disable / embedded tracks / loaded sidecar files) into
  2038. // one menu, given its "Disable" and "Load" anchor items — shared by the
  2039. // right-click context submenu and the dedicated CC button popup so both stay
  2040. // in sync from a single call to updateSubtitleSubmenu().
  2041. function populateSubtitleTrackList($disable, $load, onSelect) {
  2042. $disable.siblings('.ctx-sub-dynamic').remove();
  2043. var disabled = (activeSubtitleIndex < 0);
  2044. $disable.toggleClass('ctx-active', disabled)
  2045. .find('.ctx-icon').text(disabled ? '✓' : '');
  2046. // Tracks muxed into the video, listed whether or not they are loaded yet
  2047. if (embeddedSubtitles.length > 0) {
  2048. $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
  2049. embeddedSubtitles.forEach(function (track, position) {
  2050. var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
  2051. var isActive = activeSubtitleIndex >= 0 &&
  2052. loadedSubtitleFiles[activeSubtitleIndex] &&
  2053. loadedSubtitleFiles[activeSubtitleIndex].path === cacheKey;
  2054. $('<div class="ctx-item ctx-sub-dynamic'
  2055. + (isActive ? ' ctx-active' : '')
  2056. + (track.textual ? '' : ' ctx-disabled') + '">'
  2057. + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
  2058. + escapeHtml(embeddedTrackLabel(track, position))
  2059. + '</div>')
  2060. .on('click', function () {
  2061. selectEmbeddedSubtitle(track, position);
  2062. onSelect();
  2063. })
  2064. .insertBefore($load);
  2065. });
  2066. }
  2067. // Sidecar files the user loaded by hand
  2068. var external = [];
  2069. loadedSubtitleFiles.forEach(function (sf, idx) {
  2070. if (String(sf.path).indexOf('embedded#') !== 0) { external.push({ sf: sf, idx: idx }); }
  2071. });
  2072. if (external.length > 0) {
  2073. $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
  2074. external.forEach(function (entry) {
  2075. var isActive = (entry.idx === activeSubtitleIndex);
  2076. $('<div class="ctx-item ctx-sub-dynamic' + (isActive ? ' ctx-active' : '') + '" data-sub-idx="' + entry.idx + '">'
  2077. + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
  2078. + escapeHtml(entry.sf.name)
  2079. + '</div>')
  2080. .on('click', function () {
  2081. activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
  2082. applyActiveSubtitle();
  2083. updateSubtitleSubmenu();
  2084. onSelect();
  2085. })
  2086. .insertBefore($load);
  2087. });
  2088. }
  2089. }
  2090. function updateSubtitleSubmenu() {
  2091. populateSubtitleTrackList($('#ctx-sub-disable'), $('#ctx-sub-load'), function () {
  2092. $('#player-ctx').hide();
  2093. });
  2094. populateSubtitleTrackList($('#sub-pop-disable'), $('#sub-pop-load'), function () {
  2095. closeSubtitlePopup();
  2096. });
  2097. if ($('#subtitle-popup').hasClass('active')) { clampBottomPopupHeight($('#subtitle-popup')); }
  2098. }
  2099. function initSubtitleMenu() {
  2100. var $ctx = $('#player-ctx');
  2101. var $parent = $('#ctx-subtitle-parent');
  2102. var $sub = $('#ctx-subtitle-sub');
  2103. var subHideTimer;
  2104. function cancelSubHide() { clearTimeout(subHideTimer); }
  2105. function scheduleSubHide() {
  2106. subHideTimer = setTimeout(function () { $sub.hide(); }, 120);
  2107. }
  2108. $parent.on('mouseenter', function () {
  2109. cancelSubHide();
  2110. var ctxRight = $ctx.offset().left + $ctx.outerWidth();
  2111. var containerRight = $('#player-wrap').offset().left + $('#player-wrap').outerWidth();
  2112. if (ctxRight + 200 > containerRight) {
  2113. $sub.css({ left: 'auto', right: '100%' });
  2114. } else {
  2115. $sub.css({ left: '100%', right: 'auto' });
  2116. }
  2117. // Clamp vertically so the submenu never runs past the player edge —
  2118. // flip to open upward when there's more room above than below, and
  2119. // cap its height to whichever side it opens on (scrollbar handles the rest).
  2120. var margin = 10;
  2121. var containerRect = document.getElementById('player-wrap').getBoundingClientRect();
  2122. var parentRect = this.getBoundingClientRect();
  2123. var spaceBelow = containerRect.bottom - parentRect.top - margin;
  2124. var spaceAbove = parentRect.bottom - containerRect.top - margin;
  2125. if (spaceBelow < 160 && spaceAbove > spaceBelow) {
  2126. $sub.css({ top: 'auto', bottom: 0, maxHeight: Math.max(120, spaceAbove) + 'px' });
  2127. } else {
  2128. $sub.css({ top: 0, bottom: 'auto', maxHeight: Math.max(120, spaceBelow) + 'px' });
  2129. }
  2130. $sub.show();
  2131. });
  2132. $parent.on('mouseleave', scheduleSubHide);
  2133. $sub.on('mouseenter', cancelSubHide);
  2134. $sub.on('mouseleave', scheduleSubHide);
  2135. $('#ctx-sub-disable').on('click', function () {
  2136. activeSubtitleIndex = -1;
  2137. stopAssTrack();
  2138. $('#subtitle-display').hide();
  2139. updateSubtitleSubmenu();
  2140. $ctx.hide();
  2141. });
  2142. $('#ctx-sub-load').on('click', function () {
  2143. $ctx.hide();
  2144. pickSubtitleFile();
  2145. });
  2146. }
  2147. // Open the ArozOS file selector defaulted to the folder of the playing file
  2148. function pickSubtitleFile() {
  2149. var startDir = 'user:/';
  2150. if (currentFile) {
  2151. var fp = currentFile.filepath.replace(/\\/g, '/');
  2152. var slash = fp.lastIndexOf('/');
  2153. if (slash > 0) { startDir = fp.substring(0, slash); }
  2154. }
  2155. ao_module_openFileSelector(
  2156. window.movieEmbOnSubtitleFile,
  2157. startDir, 'file', false,
  2158. { fnameOverride: 'movieEmbOnSubtitleFile', extAllowed: '.srt' }
  2159. );
  2160. }
  2161. // ── Keyboard shortcuts ────────────────────────────────────────────────────────
  2162. function initKeyboard() {
  2163. $(document).on('keydown', function (e) {
  2164. if ($(e.target).is('input, select, textarea')) { return; }
  2165. switch (e.key) {
  2166. case ' ':
  2167. case 'k':
  2168. e.preventDefault(); togglePlay(); showControls(); break;
  2169. case 'ArrowRight':
  2170. e.preventDefault();
  2171. if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
  2172. transcodeSeekTo(effectivePlaybackTime() + 10);
  2173. } else {
  2174. vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10);
  2175. }
  2176. showControls(); break;
  2177. case 'ArrowLeft':
  2178. e.preventDefault();
  2179. if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
  2180. transcodeSeekTo(effectivePlaybackTime() - 10);
  2181. } else {
  2182. vid.currentTime = Math.max(0, vid.currentTime - 10);
  2183. }
  2184. showControls(); break;
  2185. case 'ArrowUp':
  2186. e.preventDefault();
  2187. vid.volume = Math.min(1, Math.round((vid.volume + 0.1) * 10) / 10);
  2188. showControls(); break;
  2189. case 'ArrowDown':
  2190. e.preventDefault();
  2191. vid.volume = Math.max(0, Math.round((vid.volume - 0.1) * 10) / 10);
  2192. showControls(); break;
  2193. case 'f':
  2194. case 'F':
  2195. e.preventDefault(); toggleFullscreen(); break;
  2196. case 'm':
  2197. case 'M':
  2198. e.preventDefault(); vid.muted = !vid.muted; break;
  2199. case 'r':
  2200. case 'R':
  2201. e.preventDefault();
  2202. setRepeatSingle(!repeatSingle);
  2203. showToast('Repeat: ' + (repeatSingle ? 'On' : 'Off')); break;
  2204. case 's':
  2205. case 'S':
  2206. e.preventDefault(); toggleSettingsPopup(); break;
  2207. case 'Escape':
  2208. e.preventDefault();
  2209. $('#player-ctx').hide();
  2210. closeSettingsPopup();
  2211. closeSubtitlePopup();
  2212. closeVideoInfo();
  2213. closeSubtitleSettings(); break;
  2214. case 'MediaPlayPause':
  2215. e.preventDefault(); togglePlay(); break;
  2216. }
  2217. });
  2218. }
  2219. // ── Init ──────────────────────────────────────────────────────────────────────
  2220. function initMain(){
  2221. initVideoControls();
  2222. initContextMenu();
  2223. initSettingsPopup();
  2224. initSubtitlePopup();
  2225. initScrubPreview();
  2226. initAssRenderer();
  2227. resetSubtitleSeekFloor(); // a freshly opened file starts unsuppressed
  2228. if (currentFile) {
  2229. scheduleStoryboardLoad(currentFile.filepath);
  2230. loadEmbeddedTrackList(currentFile.filepath);
  2231. }
  2232. initSubtitleMenu();
  2233. initSubtitleSettings();
  2234. initKeyboard();
  2235. }
  2236. $(document).ready(function () {
  2237. //Check if there are another instant running. If yes, replace that another instance URL
  2238. if (ao_module_virtualDesktop){
  2239. //If in ao_module mode, try to make this windows the only instance
  2240. if (!ao_module_makeSingleInstance()){
  2241. initMain();
  2242. }
  2243. }else{
  2244. initMain();
  2245. }
  2246. });
  2247. </script>
  2248. </body>
  2249. </html>