embedded.html 92 KB

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