index.htm.bak 66 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  6. <meta name="apple-mobile-web-app-capable" content="yes">
  7. <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  8. <meta name="theme-color" content="#000000">
  9. <link rel="manifest" crossorigin="use-credentials" href="manifest.json">
  10. <title>Movie</title>
  11. <!-- ArozOS module helpers -->
  12. <script src="../script/jquery.min.js"></script>
  13. <script src="../script/ao_module.js"></script>
  14. <!-- App path config (single source of truth for all API paths) -->
  15. <script src="backend/common.js"></script>
  16. <style>
  17. /* ─── Reset & base ──────────────────────────────────────────────────────────── */
  18. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  19. :root {
  20. --bg: #0a0a0a;
  21. --surface: #1c1c1e;
  22. --surface2: #2c2c2e;
  23. --accent: #0a84ff;
  24. --accent2: #30d158;
  25. --text: #f5f5f7;
  26. --text-sub: #98989d;
  27. --radius: 12px;
  28. --card-w: 180px;
  29. --card-ratio: 1.5; /* height = width * ratio (poster aspect) */
  30. --header-h: 56px;
  31. --transition: 0.2s ease;
  32. }
  33. html, body {
  34. background: var(--bg);
  35. color: var(--text);
  36. font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", sans-serif;
  37. height: 100%;
  38. overflow: hidden;
  39. }
  40. /* ─── App shell ──────────────────────────────────────────────────────────────── */
  41. #app { width: 100vw; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
  42. /* ─── Top nav bar ────────────────────────────────────────────────────────────── */
  43. #topbar {
  44. flex-shrink: 0;
  45. height: var(--header-h);
  46. display: flex;
  47. align-items: center;
  48. padding: 0 20px;
  49. gap: 16px;
  50. background: linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, transparent 100%);
  51. position: relative;
  52. z-index: 10;
  53. }
  54. #topbar h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
  55. #topbar h1 span { color: var(--accent); }
  56. #search-wrap { margin-left: auto; display: flex; align-items: center; gap: 8px; }
  57. #search-input {
  58. background: var(--surface2);
  59. border: none;
  60. border-radius: 20px;
  61. color: var(--text);
  62. font-size: 14px;
  63. padding: 7px 14px;
  64. width: 200px;
  65. outline: none;
  66. transition: width var(--transition);
  67. }
  68. #search-input:focus { width: 280px; box-shadow: 0 0 0 2px var(--accent); }
  69. #search-input::placeholder { color: var(--text-sub); }
  70. /* ─── View containers ────────────────────────────────────────────────────────── */
  71. .view { display: none; flex: 1; overflow: hidden; flex-direction: column; }
  72. .view.active { display: flex; }
  73. /* ─── Library view ───────────────────────────────────────────────────────────── */
  74. #view-library { padding: 0; }
  75. #library-scroll {
  76. flex: 1;
  77. overflow-y: auto;
  78. overflow-x: hidden;
  79. padding: 8px 20px 40px;
  80. scroll-behavior: smooth;
  81. }
  82. #library-scroll::-webkit-scrollbar { width: 4px; }
  83. #library-scroll::-webkit-scrollbar-track { background: transparent; }
  84. #library-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
  85. #no-content {
  86. display: none;
  87. flex-direction: column;
  88. align-items: center;
  89. justify-content: center;
  90. height: 60%;
  91. gap: 12px;
  92. color: var(--text-sub);
  93. font-size: 16px;
  94. }
  95. #no-content .icon img { width: 80px; height: 80px; opacity: 0.3; }
  96. #loading-overlay {
  97. position: absolute; inset: 0;
  98. background: var(--bg);
  99. display: flex;
  100. flex-direction: column;
  101. align-items: center;
  102. justify-content: center;
  103. gap: 16px;
  104. z-index: 100;
  105. font-size: 15px;
  106. color: var(--text-sub);
  107. }
  108. .spinner {
  109. width: 40px; height: 40px;
  110. border: 3px solid var(--surface2);
  111. border-top-color: var(--accent);
  112. border-radius: 50%;
  113. animation: spin 0.8s linear infinite;
  114. }
  115. @keyframes spin { to { transform: rotate(360deg); } }
  116. /* ─── Section heading ────────────────────────────────────────────────────────── */
  117. .section-title {
  118. font-size: 20px;
  119. font-weight: 600;
  120. margin: 24px 0 12px;
  121. color: var(--text);
  122. }
  123. /* ─── Album grid ─────────────────────────────────────────────────────────────── */
  124. .album-grid {
  125. display: grid;
  126. grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr));
  127. gap: 16px;
  128. }
  129. .album-card {
  130. cursor: pointer;
  131. border-radius: var(--radius);
  132. overflow: hidden;
  133. background: var(--surface);
  134. transition: transform var(--transition), box-shadow var(--transition);
  135. outline: none;
  136. position: relative;
  137. }
  138. .album-card:hover,
  139. .album-card.focused {
  140. transform: scale(1.04);
  141. box-shadow: 0 8px 32px rgba(0,0,0,0.7), 0 0 0 2px var(--accent);
  142. z-index: 2;
  143. }
  144. .album-card .poster {
  145. width: 100%;
  146. aspect-ratio: 16 / 9;
  147. object-fit: cover;
  148. display: block;
  149. background: var(--surface2);
  150. }
  151. .poster-placeholder {
  152. width: 100%;
  153. aspect-ratio: 16 / 9;
  154. background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%);
  155. display: flex;
  156. align-items: center;
  157. justify-content: center;
  158. }
  159. .poster-placeholder img { width: 100%; opacity: 1; }
  160. .album-card .card-info {
  161. padding: 8px 10px 10px;
  162. }
  163. .album-card .card-title {
  164. font-size: 13px;
  165. font-weight: 600;
  166. white-space: nowrap;
  167. overflow: hidden;
  168. text-overflow: ellipsis;
  169. }
  170. .album-card .card-meta {
  171. font-size: 11px;
  172. color: var(--text-sub);
  173. margin-top: 2px;
  174. }
  175. .badge {
  176. position: absolute;
  177. top: 7px; right: 7px;
  178. background: rgba(0,0,0,0.65);
  179. backdrop-filter: blur(4px);
  180. border-radius: 6px;
  181. font-size: 10px;
  182. font-weight: 600;
  183. padding: 2px 6px;
  184. color: #fff;
  185. letter-spacing: 0.3px;
  186. text-transform: uppercase;
  187. }
  188. .badge.series { color: var(--accent2); }
  189. /* ─── Detail view ────────────────────────────────────────────────────────────── */
  190. #view-detail { position: relative; }
  191. #detail-hero {
  192. flex-shrink: 0;
  193. height: 38vh;
  194. min-height: 200px;
  195. position: relative;
  196. overflow: hidden;
  197. }
  198. #detail-hero-bg {
  199. position: absolute; inset: 0;
  200. background-size: cover;
  201. background-position: center top;
  202. filter: blur(28px) brightness(0.35);
  203. transform: scale(1.1);
  204. }
  205. #detail-hero-content {
  206. position: relative;
  207. display: flex;
  208. align-items: flex-end;
  209. height: 100%;
  210. padding: 0 28px 20px;
  211. gap: 20px;
  212. }
  213. #detail-poster {
  214. width: 120px;
  215. flex-shrink: 0;
  216. border-radius: 8px;
  217. overflow: hidden;
  218. box-shadow: 0 8px 24px rgba(0,0,0,0.6);
  219. }
  220. #detail-poster img, #detail-poster .poster-placeholder {
  221. width: 120px;
  222. aspect-ratio: 2 / 3;
  223. object-fit: cover;
  224. display: block;
  225. }
  226. #detail-meta { flex: 1; min-width: 0; }
  227. #detail-title { font-size: 26px; font-weight: 700; line-height: 1.15; }
  228. #detail-subtitle { font-size: 14px; color: var(--text-sub); margin-top: 4px; }
  229. #detail-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
  230. .btn {
  231. border: none; cursor: pointer; border-radius: 8px;
  232. font-size: 14px; font-weight: 600; padding: 10px 22px;
  233. transition: opacity var(--transition), transform var(--transition);
  234. outline: none;
  235. }
  236. .btn:hover, .btn.focused { opacity: 0.85; transform: scale(1.03); }
  237. .btn-primary { background: var(--text); color: #000; }
  238. .btn-secondary { background: var(--surface2); color: var(--text); }
  239. /* ─── Season tabs ────────────────────────────────────────────────────────────── */
  240. #season-tabs {
  241. flex-shrink: 0;
  242. display: flex;
  243. gap: 8px;
  244. padding: 12px 28px 0;
  245. overflow-x: auto;
  246. scrollbar-width: none;
  247. }
  248. #season-tabs::-webkit-scrollbar { display: none; }
  249. .season-tab {
  250. flex-shrink: 0;
  251. background: var(--surface2);
  252. border: none; cursor: pointer;
  253. border-radius: 20px;
  254. color: var(--text-sub);
  255. font-size: 13px; font-weight: 500;
  256. padding: 6px 16px;
  257. transition: background var(--transition), color var(--transition);
  258. outline: none;
  259. }
  260. .season-tab.active, .season-tab.focused {
  261. background: var(--text);
  262. color: #000;
  263. }
  264. /* ─── Episode list ───────────────────────────────────────────────────────────── */
  265. #episode-scroll {
  266. flex: 1;
  267. overflow-y: auto;
  268. padding: 12px 28px 40px;
  269. scroll-behavior: smooth;
  270. }
  271. #episode-scroll::-webkit-scrollbar { width: 4px; }
  272. #episode-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
  273. .episode-item {
  274. display: flex;
  275. align-items: center;
  276. gap: 14px;
  277. padding: 10px 12px;
  278. border-radius: var(--radius);
  279. cursor: pointer;
  280. transition: background var(--transition);
  281. outline: none;
  282. }
  283. .episode-item:hover, .episode-item.focused {
  284. background: var(--surface);
  285. box-shadow: 0 0 0 2px var(--accent);
  286. }
  287. .episode-item.playing { background: rgba(10,132,255,0.15); }
  288. .ep-thumb {
  289. width: 100px; flex-shrink: 0;
  290. aspect-ratio: 16 / 9;
  291. border-radius: 6px;
  292. overflow: hidden;
  293. background: var(--surface2);
  294. }
  295. .ep-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
  296. .ep-thumb-placeholder {
  297. width: 100%; height: 100%;
  298. display: flex; align-items: center; justify-content: center;
  299. }
  300. .ep-thumb-placeholder img { width: 22px; height: 22px; opacity: 0.5; }
  301. .ep-info { flex: 1; min-width: 0; }
  302. .ep-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  303. .ep-path { font-size: 11px; color: var(--text-sub); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  304. .ep-play-icon {
  305. flex-shrink: 0;
  306. width: 32px; height: 32px;
  307. background: var(--surface2);
  308. border-radius: 50%;
  309. display: flex; align-items: center; justify-content: center;
  310. transition: background var(--transition);
  311. }
  312. .ep-play-icon img { width: 16px; height: 16px; }
  313. .episode-item.focused .ep-play-icon,
  314. .episode-item:hover .ep-play-icon { background: var(--accent); }
  315. #detail-back {
  316. position: absolute;
  317. top: 12px; left: 16px;
  318. background: rgba(0,0,0,0.5);
  319. backdrop-filter: blur(8px);
  320. border: none; cursor: pointer;
  321. border-radius: 20px;
  322. color: var(--text);
  323. font-size: 13px; font-weight: 500;
  324. padding: 6px 16px;
  325. z-index: 5;
  326. transition: background var(--transition);
  327. outline: none;
  328. }
  329. #detail-back:hover, #detail-back.focused { background: var(--accent); }
  330. /* ─── Player view ────────────────────────────────────────────────────────────── */
  331. #view-player {
  332. position: fixed; inset: 0;
  333. background: #000;
  334. z-index: 200;
  335. flex-direction: row;
  336. }
  337. #view-player.active { display: flex; }
  338. #video-container {
  339. flex: 1;
  340. display: flex;
  341. flex-direction: column;
  342. position: relative;
  343. overflow: hidden;
  344. }
  345. #main-video {
  346. width: 100%; height: 100%;
  347. object-fit: contain;
  348. background: #000;
  349. display: block;
  350. }
  351. /* ─── Custom video controls ──────────────────────────────────────────────────── */
  352. #video-controls {
  353. position: absolute;
  354. bottom: 0; left: 0; right: 0;
  355. background: linear-gradient(transparent, rgba(0,0,0,0.85) 100%);
  356. padding: 40px 20px 16px;
  357. transition: opacity 0.3s;
  358. }
  359. #video-controls.hidden { opacity: 0; pointer-events: none; }
  360. /* Hide cursor when controls auto-hide */
  361. #video-container:has(#video-controls.hidden) { cursor: none; }
  362. #progress-wrap {
  363. position: relative;
  364. height: 4px;
  365. background: rgba(255,255,255,0.25);
  366. border-radius: 4px;
  367. cursor: pointer;
  368. margin-bottom: 12px;
  369. }
  370. #progress-bar {
  371. height: 100%; border-radius: 4px;
  372. background: var(--accent);
  373. pointer-events: none;
  374. }
  375. #progress-wrap:hover { height: 6px; }
  376. /* Transparent hit-zone 16px above the visual bar */
  377. #progress-wrap::before {
  378. content: '';
  379. position: absolute;
  380. top: -16px; left: 0; right: 0;
  381. height: 16px;
  382. }
  383. #progress-thumb {
  384. position: absolute;
  385. top: 50%; transform: translateY(-50%);
  386. width: 14px; height: 14px;
  387. background: #fff; border-radius: 50%;
  388. pointer-events: none;
  389. display: none;
  390. }
  391. #progress-wrap:hover #progress-thumb { display: block; }
  392. #controls-row {
  393. display: flex;
  394. align-items: center;
  395. gap: 10px;
  396. }
  397. .ctrl-btn {
  398. background: none; border: none; cursor: pointer;
  399. color: #fff; padding: 4px;
  400. line-height: 1;
  401. transition: opacity var(--transition);
  402. outline: none;
  403. display: flex; align-items: center; justify-content: center;
  404. }
  405. .ctrl-btn img { width: 24px; height: 24px; display: block; }
  406. .ctrl-btn:hover { opacity: 0.7; }
  407. .ctrl-btn.focused { background: rgba(10,132,255,0.18); border-radius: 6px; }
  408. #volume-wrap { display: flex; align-items: center; gap: 8px; }
  409. #volume-slider {
  410. -webkit-appearance: none;
  411. width: 80px; height: 4px;
  412. background: rgba(255,255,255,0.3);
  413. border-radius: 4px; outline: none;
  414. }
  415. #volume-slider::-webkit-slider-thumb {
  416. -webkit-appearance: none;
  417. width: 12px; height: 12px;
  418. background: #fff; border-radius: 50%;
  419. }
  420. #time-display { font-size: 13px; color: rgba(255,255,255,0.8); margin-left: 6px; }
  421. #now-playing-title {
  422. font-size: 15px; font-weight: 600;
  423. flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  424. margin-left: 10px;
  425. }
  426. /* ─── Playlist sidebar ───────────────────────────────────────────────────────── */
  427. #playlist-sidebar {
  428. width: 300px;
  429. flex-shrink: 0;
  430. background: rgba(20,20,20,0.95);
  431. backdrop-filter: blur(12px);
  432. display: flex;
  433. flex-direction: column;
  434. border-left: 1px solid rgba(255,255,255,0.08);
  435. transform: translateX(0);
  436. transition: transform var(--transition), width var(--transition);
  437. }
  438. #playlist-sidebar.collapsed { width: 0; overflow: hidden; }
  439. #sidebar-header {
  440. padding: 14px 16px 10px;
  441. font-size: 15px; font-weight: 600;
  442. border-bottom: 1px solid rgba(255,255,255,0.08);
  443. display: flex; align-items: center; gap: 8px;
  444. }
  445. #sidebar-close {
  446. margin-left: auto;
  447. background: none; border: none; cursor: pointer;
  448. color: var(--text-sub); font-size: 18px; padding: 2px;
  449. outline: none;
  450. }
  451. #sidebar-close:hover { color: var(--text); }
  452. #sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
  453. #sidebar-list::-webkit-scrollbar { width: 3px; }
  454. #sidebar-list::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
  455. .sidebar-ep {
  456. display: flex; align-items: center; gap: 10px;
  457. padding: 8px;
  458. border-radius: 8px; cursor: pointer;
  459. transition: background var(--transition);
  460. font-size: 13px; color: var(--text);
  461. outline: none;
  462. }
  463. .sidebar-ep:hover, .sidebar-ep.focused { background: var(--surface2); }
  464. .sidebar-ep.playing { background: rgba(10,132,255,0.2); color: var(--accent); }
  465. .sidebar-ep-num { flex-shrink: 0; width: 24px; text-align: center; color: var(--text-sub); font-size: 12px; }
  466. .sidebar-ep-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  467. #player-back {
  468. position: absolute;
  469. top: 14px; left: 14px;
  470. z-index: 10;
  471. background: rgba(0,0,0,0.55);
  472. backdrop-filter: blur(8px);
  473. border: none; cursor: pointer;
  474. border-radius: 20px;
  475. color: #fff;
  476. font-size: 13px; font-weight: 500;
  477. padding: 7px 16px;
  478. transition: background var(--transition);
  479. outline: none;
  480. }
  481. #player-back:hover { background: var(--accent); }
  482. /* ─── Toast notification ─────────────────────────────────────────────────────── */
  483. #toast {
  484. position: fixed;
  485. bottom: 30px; left: 50%;
  486. transform: translateX(-50%) translateY(20px);
  487. background: rgba(30,30,30,0.95);
  488. backdrop-filter: blur(8px);
  489. color: var(--text);
  490. padding: 10px 20px;
  491. border-radius: 20px;
  492. font-size: 13px;
  493. opacity: 0;
  494. transition: opacity 0.3s, transform 0.3s;
  495. pointer-events: none;
  496. z-index: 1000;
  497. white-space: nowrap;
  498. }
  499. #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  500. /* ─── Responsive ─────────────────────────────────────────────────────────────── */
  501. @media (max-width: 600px) {
  502. :root { --card-w: 130px; }
  503. #library-scroll { padding: 8px 12px 40px; }
  504. #detail-hero { height: 44vw; min-height: 160px; }
  505. #detail-poster { width: 80px; }
  506. #detail-title { font-size: 18px; }
  507. #playlist-sidebar { width: 100%; position: absolute; right: 0; top: 0; bottom: 0; z-index: 5; }
  508. #playlist-sidebar.collapsed { width: 0; }
  509. #episode-scroll { padding: 10px 12px 40px; }
  510. .ep-thumb { width: 72px; }
  511. }
  512. @media (max-width: 400px) {
  513. :root { --card-w: 110px; }
  514. }
  515. /* TV / large screen layout */
  516. @media (min-width: 1400px) {
  517. :root { --card-w: 200px; }
  518. }
  519. @media (min-width: 1800px) {
  520. :root { --card-w: 240px; }
  521. }
  522. /* Focus ring for TV remote navigation */
  523. .tv-focused {
  524. outline: 3px solid var(--accent) !important;
  525. outline-offset: 2px !important;
  526. }
  527. /* Hide back button when in fullscreen */
  528. #view-player:fullscreen #player-back,
  529. #view-player:-webkit-full-screen #player-back,
  530. #view-player:-moz-full-screen #player-back { display: none; }
  531. /* ─── Load More button ───────────────────────────────────────────────────────── */
  532. .load-more-wrap { text-align: center; padding: 16px 0 24px; }
  533. .load-more-btn {
  534. background: var(--surface2);
  535. border: 1px solid rgba(255,255,255,0.08); cursor: pointer;
  536. border-radius: 8px; color: var(--text);
  537. font-size: 13px; font-weight: 500; padding: 9px 28px;
  538. outline: none; transition: background var(--transition), box-shadow var(--transition);
  539. }
  540. .load-more-btn:hover { background: rgba(255,255,255,0.08); box-shadow: 0 0 0 1px var(--accent); }
  541. /* ─── Autoplay toggle (sidebar) ─────────────────────────────────────────────── */
  542. .autoplay-label {
  543. display: flex; align-items: center; gap: 5px;
  544. font-size: 11px; color: var(--text-sub);
  545. cursor: pointer; user-select: none;
  546. font-weight: 500;
  547. }
  548. .autoplay-label input[type="checkbox"] { display: none; }
  549. .autoplay-track {
  550. width: 30px; height: 17px;
  551. background: var(--surface2); border-radius: 9px;
  552. position: relative; flex-shrink: 0;
  553. transition: background 0.2s;
  554. }
  555. .autoplay-label input:checked + .autoplay-track { background: var(--accent2); }
  556. .autoplay-track::after {
  557. content: '';
  558. position: absolute; top: 2px; left: 2px;
  559. width: 13px; height: 13px;
  560. background: #fff; border-radius: 50%;
  561. transition: transform 0.2s;
  562. }
  563. .autoplay-label input:checked + .autoplay-track::after { transform: translateX(13px); }
  564. /* ─── Next-episode countdown ────────────────────────────────────────────────── */
  565. #next-countdown {
  566. display: none;
  567. position: absolute; bottom: 80px; right: 20px;
  568. background: rgba(20,20,20,0.92);
  569. backdrop-filter: blur(8px);
  570. border-radius: 10px; padding: 12px 16px;
  571. min-width: 210px; z-index: 20;
  572. }
  573. #next-countdown-text { font-size: 13px; color: var(--text-sub); margin-bottom: 8px; }
  574. #next-countdown-track {
  575. height: 4px; background: var(--surface2);
  576. border-radius: 4px; margin-bottom: 8px; overflow: hidden;
  577. }
  578. #next-countdown-bar { height: 100%; background: var(--accent); border-radius: 4px; transition: width 1s linear; }
  579. #next-countdown-cancel {
  580. display: block; width: 100%; background: var(--surface2);
  581. border: none; cursor: pointer; border-radius: 6px;
  582. color: var(--text); font-size: 12px; padding: 5px; outline: none;
  583. transition: background var(--transition);
  584. }
  585. #next-countdown-cancel:hover { background: rgba(255,59,48,0.28); }
  586. /* ─── Player context menu ────────────────────────────────────────────────────── */
  587. #player-ctx {
  588. display: none;
  589. position: absolute;
  590. z-index: 30;
  591. background: rgba(28,28,30,0.97);
  592. backdrop-filter: blur(16px);
  593. -webkit-backdrop-filter: blur(16px);
  594. border-radius: 10px;
  595. padding: 4px 0;
  596. min-width: 192px;
  597. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
  598. user-select: none;
  599. }
  600. .ctx-item {
  601. padding: 9px 14px;
  602. font-size: 13px;
  603. cursor: pointer;
  604. display: flex;
  605. align-items: center;
  606. gap: 10px;
  607. color: var(--text);
  608. transition: background var(--transition);
  609. }
  610. .ctx-item:hover { background: rgba(255,255,255,0.08); }
  611. .ctx-item.ctx-active { color: var(--accent); }
  612. .ctx-item.ctx-disabled { opacity: 0.3; pointer-events: none; }
  613. .ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; }
  614. .ctx-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 3px 0; }
  615. /* ─── Video info modal ───────────────────────────────────────────────────────── */
  616. #video-info-modal {
  617. display: none;
  618. position: absolute;
  619. top: 50%; left: 50%;
  620. transform: translate(-50%, -50%);
  621. z-index: 40;
  622. background: rgba(18,18,20,0.97);
  623. backdrop-filter: blur(20px);
  624. -webkit-backdrop-filter: blur(20px);
  625. border-radius: 14px;
  626. padding: 20px 22px 16px;
  627. width: 340px;
  628. box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
  629. }
  630. #video-info-modal h3 {
  631. font-size: 14px; font-weight: 600;
  632. margin-bottom: 12px;
  633. display: flex; align-items: center; justify-content: space-between;
  634. }
  635. #video-info-close {
  636. background: none; border: none; cursor: pointer;
  637. color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
  638. }
  639. #video-info-close:hover { color: var(--text); }
  640. #video-info-tabs {
  641. display: flex; gap: 3px;
  642. background: var(--surface2);
  643. border-radius: 8px;
  644. padding: 3px;
  645. margin-bottom: 12px;
  646. }
  647. .info-tab {
  648. flex: 1; text-align: center; padding: 5px 0;
  649. font-size: 12px; font-weight: 500;
  650. border-radius: 6px; cursor: pointer;
  651. color: var(--text-sub);
  652. transition: background var(--transition), color var(--transition);
  653. }
  654. .info-tab.active { background: var(--surface); color: var(--text); }
  655. .info-row {
  656. display: flex; gap: 8px;
  657. padding: 5px 0;
  658. border-bottom: 1px solid rgba(255,255,255,0.05);
  659. font-size: 12px;
  660. line-height: 1.4;
  661. }
  662. .info-row:last-child { border-bottom: none; }
  663. .info-label { color: var(--text-sub); flex-shrink: 0; width: 110px; }
  664. .info-value { color: var(--text); word-break: break-all; }
  665. </style>
  666. </head>
  667. <body>
  668. <div id="app">
  669. <!-- ─ Loading overlay ─────────────────────────────────────────────────── -->
  670. <div id="loading-overlay">
  671. <div class="spinner"></div>
  672. <span>Loading library…</span>
  673. </div>
  674. <!-- ─ Top bar ─────────────────────────────────────────────────────────── -->
  675. <div id="topbar">
  676. <h1><img src="img/icons/movie_white.svg" width="22" height="22" alt="" style="vertical-align:middle;margin-right:6px;"><span>Movie</span></h1>
  677. <div id="search-wrap">
  678. <input id="search-input" type="text" placeholder="Search…" autocomplete="off">
  679. </div>
  680. </div>
  681. <!-- ═══════════════ LIBRARY VIEW ═══════════════════════════════════════ -->
  682. <div id="view-library" class="view active">
  683. <div id="library-scroll">
  684. <div id="no-content">
  685. <div class="icon"><img src="img/icons/movie_white.svg" alt=""></div>
  686. <div>No videos found. Place your videos in a <strong>Video/</strong> folder on any storage.</div>
  687. </div>
  688. <div id="movies-section" style="display:none">
  689. <div class="section-title">Movies</div>
  690. <div id="movies-grid" class="album-grid"></div>
  691. <div class="load-more-wrap"><button class="load-more-btn" id="movies-load-more" style="display:none" onclick="loadMoreSection('movies')">Load more</button></div>
  692. </div>
  693. <div id="series-section" style="display:none">
  694. <div class="section-title">TV / Shows</div>
  695. <div id="series-grid" class="album-grid"></div>
  696. <div class="load-more-wrap"><button class="load-more-btn" id="shows-load-more" style="display:none" onclick="loadMoreSection('shows')">Load more</button></div>
  697. </div>
  698. <div id="anime-section" style="display:none">
  699. <div class="section-title">Anime</div>
  700. <div id="anime-grid" class="album-grid"></div>
  701. <div class="load-more-wrap"><button class="load-more-btn" id="anime-load-more" style="display:none" onclick="loadMoreSection('anime')">Load more</button></div>
  702. </div>
  703. <div id="shorts-section" style="display:none">
  704. <div class="section-title">Shorts</div>
  705. <div id="shorts-grid" class="album-grid"></div>
  706. <div class="load-more-wrap"><button class="load-more-btn" id="shorts-load-more" style="display:none" onclick="loadMoreSection('shorts')">Load more</button></div>
  707. </div>
  708. </div>
  709. </div>
  710. <!-- ═══════════════ DETAIL VIEW ════════════════════════════════════════ -->
  711. <div id="view-detail" class="view">
  712. <div id="detail-hero">
  713. <div id="detail-hero-bg"></div>
  714. <div id="detail-hero-content">
  715. <div id="detail-poster"><div class="poster-placeholder"><img src="img/icons/movie_white.svg" alt=""></div></div>
  716. <div id="detail-meta">
  717. <div id="detail-title">Album Title</div>
  718. <div id="detail-subtitle">0 episodes</div>
  719. <div id="detail-actions">
  720. <button class="btn btn-primary" id="btn-play-first"><img src="img/icons/play_black.svg" width="15" height="15" alt="" style="vertical-align:middle;margin-right:5px;">Play</button>
  721. <button class="btn btn-secondary" id="btn-shuffle"><img src="img/icons/shuffle_white.svg" width="15" height="15" alt="" style="vertical-align:middle;margin-right:5px;">Shuffle</button>
  722. </div>
  723. </div>
  724. </div>
  725. </div>
  726. <button id="detail-back" onclick="showLibrary()"><img src="img/icons/back_arrow_white.svg" width="14" height="14" alt="" style="vertical-align:middle;margin-right:4px;">Back</button>
  727. <div id="season-tabs"></div>
  728. <div id="episode-scroll">
  729. <div id="episode-list"></div>
  730. </div>
  731. </div>
  732. <!-- ═══════════════ PLAYER VIEW ════════════════════════════════════════ -->
  733. <div id="view-player" class="view">
  734. <div id="video-container">
  735. <button id="player-back" onclick="closePlayer()"><img src="img/icons/back_arrow_white.svg" width="14" height="14" alt="" style="vertical-align:middle;margin-right:4px;">Back</button>
  736. <video id="main-video" preload="metadata"></video>
  737. <!-- Auto-play countdown -->
  738. <div id="next-countdown">
  739. <div id="next-countdown-text">Next episode in <span id="countdown-num">5</span>s&hellip;</div>
  740. <div id="next-countdown-track"><div id="next-countdown-bar" style="width:100%"></div></div>
  741. <button id="next-countdown-cancel" onclick="cancelCountdown()">Cancel</button>
  742. </div>
  743. <!-- Player context menu -->
  744. <div id="player-ctx">
  745. <div class="ctx-item" id="ctx-play"><i class="ctx-icon">▶</i>Play</div>
  746. <div class="ctx-item" id="ctx-pause"><i class="ctx-icon">⏸</i>Pause</div>
  747. <div class="ctx-divider"></div>
  748. <div class="ctx-item" id="ctx-prev"><i class="ctx-icon">⏮</i>Previous</div>
  749. <div class="ctx-item" id="ctx-next"><i class="ctx-icon">⏭</i>Next</div>
  750. <div class="ctx-divider"></div>
  751. <div class="ctx-item" id="ctx-repeat"><i class="ctx-icon">↺</i>Repeat: Off</div>
  752. <div class="ctx-divider"></div>
  753. <div class="ctx-item" id="ctx-props"><i class="ctx-icon">ℹ</i>Video Properties</div>
  754. <!-- <div class="ctx-item" id="ctx-stats"><i class="ctx-icon">⧉</i>Streaming Stats</div> -->
  755. </div>
  756. <!-- Video info / stats modal -->
  757. <div id="video-info-modal">
  758. <h3><span id="video-info-title">Video Properties</span><button id="video-info-close" onclick="closeVideoInfo()">✕</button></h3>
  759. <div id="video-info-tabs">
  760. <div class="info-tab active" onclick="showInfoTab('props')">Properties</div>
  761. <div class="info-tab" onclick="showInfoTab('stats')">Stats</div>
  762. </div>
  763. <div id="video-info-body"></div>
  764. </div>
  765. <!-- Custom controls -->
  766. <div id="video-controls">
  767. <div id="progress-wrap">
  768. <div id="progress-bar" style="width:0%"></div>
  769. <div id="progress-thumb" style="left:0%"></div>
  770. </div>
  771. <div id="controls-row">
  772. <button class="ctrl-btn" id="ctrl-prev" title="Previous (←)"><img src="img/icons/skip_previous_white.svg" alt=""></button>
  773. <button class="ctrl-btn" id="ctrl-play" title="Play/Pause (Space)"><img id="play-icon" src="img/icons/play_white.svg" alt=""></button>
  774. <button class="ctrl-btn" id="ctrl-next" title="Next (→)"><img src="img/icons/skip_next_white.svg" alt=""></button>
  775. <div id="volume-wrap">
  776. <button class="ctrl-btn" id="ctrl-mute" title="Mute (M)"><img id="mute-icon" src="img/icons/volume_white.svg" alt=""></button>
  777. <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
  778. </div>
  779. <span id="time-display">0:00 / 0:00</span>
  780. <span id="now-playing-title"></span>
  781. <button class="ctrl-btn" id="ctrl-list" title="Episode list (L)"><img src="img/icons/menu_white.svg" alt=""></button>
  782. <button class="ctrl-btn" id="ctrl-fs" title="Fullscreen (F)"><img src="img/icons/fullscreen_white.svg" alt=""></button>
  783. </div>
  784. </div>
  785. </div>
  786. <!-- Sidebar playlist -->
  787. <div id="playlist-sidebar">
  788. <div id="sidebar-header">
  789. <span>Playlist</span>
  790. <label class="autoplay-label" title="Auto-play next episode">
  791. <input type="checkbox" id="autoplay-check">
  792. <span class="autoplay-track"></span>
  793. <span>Auto</span>
  794. </label>
  795. <button id="sidebar-close" onclick="toggleSidebar()"><img style="width: 16px;" src="img/icons/close_white.svg" alt=""></button>
  796. </div>
  797. <div id="sidebar-list"></div>
  798. </div>
  799. </div>
  800. </div>
  801. <!-- Toast -->
  802. <div id="toast"></div>
  803. <!-- ═══════════════ JAVASCRIPT ════════════════════════════════════════════════ -->
  804. <script>
  805. // ─── All configurable paths come from backend/common.js ──────────────────────
  806. // (SCRIPT_GET_LIBRARY, SCRIPT_GET_EPISODES, SCRIPT_GET_THUMBNAIL, MEDIA_API)
  807. // ─── App state ────────────────────────────────────────────────────────────────
  808. var library = []; // full album array from server
  809. var currentAlbum = null; // album object currently shown in detail view
  810. var currentSeason = null; // season object currently active
  811. var currentEpisodes = []; // flat episode array for current season/album
  812. var playingIndex = -1; // index in currentEpisodes being played
  813. // TV-remote focus management
  814. var focusMode = false; // set to true when arrow-key pressed
  815. var focusedEl = null;
  816. // Controls auto-hide timer
  817. var controlsTimer = null;
  818. // Library pagination
  819. var PAGE_SIZE = 24;
  820. var moviesData = [];
  821. var showsData = [];
  822. var shortsData = [];
  823. var moviesShown = 0;
  824. var showsShown = 0;
  825. var shortsShown = 0;
  826. // Auto-play between episodes
  827. var autoplayEnabled = localStorage.getItem('movie_autoplay') !== '0'; // on by default
  828. var countdownTimer = null;
  829. // Single-repeat
  830. var repeatSingle = false;
  831. // ─── Init ─────────────────────────────────────────────────────────────────────
  832. $(document).ready(function () {
  833. loadLibrary();
  834. initVideoControls();
  835. initKeyboard();
  836. initSearch();
  837. initContextMenu();
  838. // Autoplay toggle
  839. $('#autoplay-check').prop('checked', autoplayEnabled);
  840. $('#autoplay-check').on('change', function () {
  841. autoplayEnabled = $(this).is(':checked');
  842. localStorage.setItem('movie_autoplay', autoplayEnabled ? '1' : '0');
  843. });
  844. });
  845. // ─── Load library from backend ────────────────────────────────────────────────
  846. function loadLibrary() {
  847. ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
  848. $('#loading-overlay').fadeOut(300);
  849. if (!data || data.error) {
  850. showToast('Failed to load library');
  851. return;
  852. }
  853. library = data;
  854. renderLibrary(library);
  855. }, function () {
  856. $('#loading-overlay').fadeOut(300);
  857. showToast('Error loading library');
  858. });
  859. }
  860. // ─── Render library grid ──────────────────────────────────────────────────────
  861. function renderLibrary(albums) {
  862. // Movies: non-short, single-file | Shows: multi-episode | Shorts: type=short
  863. moviesData = albums.filter(function (a) { return a.type !== 'series' && a.type !== 'short' && a.episodeCount === 1; });
  864. showsData = albums.filter(function (a) { return a.episodeCount > 1; });
  865. shortsData = albums.filter(function (a) { return a.type === 'short'; });
  866. moviesShown = 0;
  867. showsShown = 0;
  868. shortsShown = 0;
  869. $('#movies-grid').empty();
  870. $('#series-grid').empty();
  871. $('#shorts-grid').empty();
  872. if (moviesData.length === 0 && showsData.length === 0 && shortsData.length === 0) {
  873. $('#no-content').show();
  874. return;
  875. }
  876. $('#no-content').hide();
  877. if (moviesData.length > 0) {
  878. $('#movies-section').show();
  879. loadMoreSection('movies');
  880. } else {
  881. $('#movies-section').hide();
  882. }
  883. if (showsData.length > 0) {
  884. $('#series-section').show();
  885. loadMoreSection('shows');
  886. } else {
  887. $('#series-section').hide();
  888. }
  889. if (shortsData.length > 0) {
  890. $('#shorts-section').show();
  891. loadMoreSection('shorts');
  892. } else {
  893. $('#shorts-section').hide();
  894. }
  895. }
  896. function loadMoreSection(which) {
  897. var data, $grid, $btn, start, end, i;
  898. if (which === 'movies') {
  899. data = moviesData;
  900. $grid = $('#movies-grid');
  901. $btn = $('#movies-load-more');
  902. start = moviesShown;
  903. end = Math.min(start + PAGE_SIZE, data.length);
  904. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  905. moviesShown = end;
  906. $btn.toggle(moviesShown < data.length);
  907. } else if (which === 'shows') {
  908. data = showsData;
  909. $grid = $('#series-grid');
  910. $btn = $('#shows-load-more');
  911. start = showsShown;
  912. end = Math.min(start + PAGE_SIZE, data.length);
  913. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  914. showsShown = end;
  915. $btn.toggle(showsShown < data.length);
  916. } else {
  917. data = shortsData;
  918. $grid = $('#shorts-grid');
  919. $btn = $('#shorts-load-more');
  920. start = shortsShown;
  921. end = Math.min(start + PAGE_SIZE, data.length);
  922. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  923. shortsShown = end;
  924. $btn.toggle(shortsShown < data.length);
  925. }
  926. loadCardThumbnails();
  927. }
  928. function buildCard(album, idx) {
  929. var thumb = album.thumbnail && album.thumbnail.length > 0
  930. ? '<img class="poster" src="data:image/jpeg;base64,' + album.thumbnail + '" alt="">'
  931. : '<div class="poster-placeholder"><img src="img/thumbnail.png" alt=""></div>';
  932. var badge = album.type === 'series' ? '<span class="badge series">Series</span>' : '';
  933. var ext = album._singleFile ? album._singleFile.split('.').pop().toUpperCase() : '';
  934. var meta = album.type === 'series'
  935. ? album.episodeCount + ' ep'
  936. : album.type === 'short'
  937. ? (ext || 'Short')
  938. : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
  939. var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(album.name) + '">'
  940. + thumb
  941. + badge
  942. + '<div class="card-info">'
  943. + '<div class="card-title">' + escapeHtml(album.name) + '</div>'
  944. + '<div class="card-meta">' + escapeHtml(meta) + '</div>'
  945. + '</div>'
  946. + '</div>');
  947. card.data('album', album);
  948. card.on('click', function () {
  949. if (album.type === 'short' && album._singleFile) {
  950. currentAlbum = album; currentSeason = null;
  951. currentEpisodes = [{ name: album.name, filepath: album._singleFile,
  952. ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
  953. startPlayback(0);
  954. } else { openDetail(album); }
  955. });
  956. card.on('keydown', function (e) {
  957. if (e.key === 'Enter' || e.key === ' ') {
  958. e.preventDefault();
  959. if (album.type === 'short' && album._singleFile) {
  960. currentAlbum = album; currentSeason = null;
  961. currentEpisodes = [{ name: album.name, filepath: album._singleFile,
  962. ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
  963. startPlayback(0);
  964. } else { openDetail(album); }
  965. }
  966. });
  967. return card;
  968. }
  969. // Load thumbnails in background (for cards that don't have one embedded)
  970. function loadCardThumbnails() {
  971. $('.album-card').each(function () {
  972. var $card = $(this);
  973. var album = $card.data('album');
  974. if (!album || album.thumbnail) { return; } // already has thumb
  975. ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: album.folderpath }, function (data) {
  976. if (data && !data.error && data.length > 20) {
  977. $card.find('.poster-placeholder')
  978. .replaceWith('<img class="poster" src="data:image/jpeg;base64,' + data + '" alt="">');
  979. }
  980. });
  981. });
  982. }
  983. // ─── Detail view ──────────────────────────────────────────────────────────────
  984. function openDetail(album) {
  985. currentAlbum = album;
  986. // Hero background
  987. var bg = album.thumbnail ? 'data:image/jpeg;base64,' + album.thumbnail : '';
  988. $('#detail-hero-bg').css('background-image', bg ? 'url(' + bg + ')' : 'none');
  989. // Poster
  990. var posterHtml = album.thumbnail
  991. ? '<img src="data:image/jpeg;base64,' + album.thumbnail + '" alt="" style="width:100%;aspect-ratio:2/3;object-fit:cover;">'
  992. : '<div class="poster-placeholder"><img src="img/icons/movie_white.svg" alt=""></div>';
  993. $('#detail-poster').html(posterHtml);
  994. // Title & subtitle
  995. $('#detail-title').text(album.name);
  996. var sub = album.type === 'series'
  997. ? album.seasons.length + ' season' + (album.seasons.length !== 1 ? 's' : '') + ' · ' + album.episodeCount + ' episodes'
  998. : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
  999. $('#detail-subtitle').text(sub);
  1000. // Season tabs
  1001. var $tabs = $('#season-tabs').empty();
  1002. if (album.type === 'series' && album.seasons.length > 0) {
  1003. album.seasons.forEach(function (s, i) {
  1004. var tab = $('<button class="season-tab" tabindex="0">' + escapeHtml(s.name) + '</button>');
  1005. tab.data('season', s);
  1006. tab.on('click', function () { selectSeason($(this).data('season')); activateTab(this); });
  1007. $tabs.append(tab);
  1008. });
  1009. $tabs.show();
  1010. selectSeason(album.seasons[0]);
  1011. $tabs.find('.season-tab').first().addClass('active');
  1012. } else {
  1013. $tabs.hide();
  1014. // Load the folder directly as episodes
  1015. loadEpisodes(album.folderpath);
  1016. }
  1017. // Play / shuffle button bindings
  1018. $('#btn-play-first').off('click').on('click', function () {
  1019. if (currentEpisodes.length > 0) { startPlayback(0); }
  1020. });
  1021. $('#btn-shuffle').off('click').on('click', function () {
  1022. if (currentEpisodes.length === 0) { return; }
  1023. var idx = Math.floor(Math.random() * currentEpisodes.length);
  1024. startPlayback(idx);
  1025. });
  1026. showView('detail');
  1027. }
  1028. function selectSeason(season) {
  1029. currentSeason = season;
  1030. loadEpisodes(season.folderpath);
  1031. }
  1032. function activateTab(el) {
  1033. $('#season-tabs .season-tab').removeClass('active');
  1034. $(el).addClass('active');
  1035. }
  1036. function loadEpisodes(folderpath) {
  1037. $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">Loading…</div>');
  1038. ao_module_agirun(SCRIPT_GET_EPISODES, { folder: folderpath }, function (data) {
  1039. if (!data || data.error) {
  1040. $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">No episodes found.</div>');
  1041. currentEpisodes = [];
  1042. return;
  1043. }
  1044. currentEpisodes = data;
  1045. renderEpisodes(data);
  1046. }, function () {
  1047. $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">Error loading episodes.</div>');
  1048. });
  1049. }
  1050. function renderEpisodes(episodes) {
  1051. var $list = $('#episode-list').empty();
  1052. episodes.forEach(function (ep, i) {
  1053. var row = $('<div class="episode-item" tabindex="0" role="button">'
  1054. + '<div class="ep-thumb"><div class="ep-thumb-placeholder"><img src="img/icons/play_white.svg" alt=""></div></div>'
  1055. + '<div class="ep-info">'
  1056. + '<div class="ep-name">' + escapeHtml(ep.name) + '</div>'
  1057. + '<div class="ep-path">' + escapeHtml(ep.ext.replace('.', '').toUpperCase()) + '</div>'
  1058. + '</div>'
  1059. + '<div class="ep-play-icon"><img src="img/icons/play_white.svg" alt=""></div>'
  1060. + '</div>');
  1061. row.data('ep', ep);
  1062. row.data('idx', i);
  1063. row.on('click', function () { startPlayback($(this).data('idx')); });
  1064. row.on('keydown', function (e) {
  1065. if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startPlayback($(this).data('idx')); }
  1066. });
  1067. $list.append(row);
  1068. // Lazy-load thumbnail
  1069. (function (epObj, rowEl) {
  1070. ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: epObj.filepath }, function (data) {
  1071. if (data && !data.error && data.length > 20) {
  1072. rowEl.find('.ep-thumb-placeholder')
  1073. .replaceWith('<img src="data:image/jpeg;base64,' + data + '" alt="">');
  1074. }
  1075. });
  1076. })(ep, row);
  1077. });
  1078. }
  1079. // ─── Player ───────────────────────────────────────────────────────────────────
  1080. function isWebPlayable(ext) {
  1081. return ['mp4', 'webm', 'ogg'].includes(ext);
  1082. }
  1083. function startPlayback(index) {
  1084. cancelCountdown();
  1085. if (!currentEpisodes || currentEpisodes.length === 0) { return; }
  1086. playingIndex = index;
  1087. var ep = currentEpisodes[index];
  1088. // Choose endpoint based on file extension
  1089. var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
  1090. var src = '';
  1091. if (isWebPlayable(ext)) {
  1092. src = MEDIA_API + '?file=' + encodeURIComponent(ep.filepath);
  1093. } else {
  1094. src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
  1095. }
  1096. var vid = document.getElementById('main-video');
  1097. vid.src = src;
  1098. vid.play();
  1099. $('#now-playing-title').text(ep.name);
  1100. ao_module_setWindowTitle('Movie – ' + ep.name);
  1101. // Build sidebar list
  1102. renderSidebar(currentEpisodes, index);
  1103. // Highlight in episode list
  1104. highlightPlayingEpisode(index);
  1105. showView('player');
  1106. showControls();
  1107. }
  1108. function renderSidebar(episodes, playing) {
  1109. var $list = $('#sidebar-list').empty();
  1110. episodes.forEach(function (ep, i) {
  1111. var row = $('<div class="sidebar-ep' + (i === playing ? ' playing' : '') + '" tabindex="0" role="button">'
  1112. + '<span class="sidebar-ep-num">' + (i + 1) + '</span>'
  1113. + '<span class="sidebar-ep-name">' + escapeHtml(ep.name) + '</span>'
  1114. + '</div>');
  1115. row.data('idx', i);
  1116. row.on('click', function () {
  1117. cancelCountdown();
  1118. var idx = $(this).data('idx');
  1119. playingIndex = idx;
  1120. var e2 = currentEpisodes[idx];
  1121. var ext = e2.ext ? e2.ext.toLowerCase().replace(/^\./, '') : '';
  1122. var src = '';
  1123. if (isWebPlayable(ext)) {
  1124. src = MEDIA_API + '?file=' + encodeURIComponent(e2.filepath);
  1125. } else {
  1126. src = TRANSCODE_API + '?file=' + encodeURIComponent(e2.filepath);
  1127. }
  1128. var vid = document.getElementById('main-video');
  1129. vid.src = src;
  1130. vid.play();
  1131. $('#now-playing-title').text(e2.name);
  1132. ao_module_setWindowTitle('Movie – ' + e2.name);
  1133. highlightPlayingEpisode(idx);
  1134. renderSidebar(currentEpisodes, idx);
  1135. });
  1136. $list.append(row);
  1137. });
  1138. // Scroll to playing
  1139. var $playing = $list.find('.playing');
  1140. if ($playing.length) {
  1141. setTimeout(function () { $playing[0].scrollIntoView({ block: 'center' }); }, 50);
  1142. }
  1143. }
  1144. function highlightPlayingEpisode(idx) {
  1145. $('#episode-list .episode-item').removeClass('playing');
  1146. $('#episode-list .episode-item').eq(idx).addClass('playing');
  1147. }
  1148. function closePlayer() {
  1149. cancelCountdown();
  1150. var vid = document.getElementById('main-video');
  1151. vid.pause();
  1152. vid.src = '';
  1153. // Return to detail only if user was watching a series with a detail page;
  1154. // for movies/shorts go straight back to library.
  1155. showView(currentAlbum && currentAlbum.type === 'series' ? 'detail' : 'library');
  1156. }
  1157. function showLibrary() {
  1158. showView('library');
  1159. currentAlbum = null;
  1160. }
  1161. // ─── View switching ───────────────────────────────────────────────────────────
  1162. function showView(name) {
  1163. $('.view').removeClass('active');
  1164. $('#view-' + name).addClass('active');
  1165. }
  1166. // ─── Video controls ───────────────────────────────────────────────────────────
  1167. function initVideoControls() {
  1168. var vid = document.getElementById('main-video');
  1169. var $ctrl = $('#video-controls');
  1170. var $prog = $('#progress-bar');
  1171. var $thumb = $('#progress-thumb');
  1172. var $time = $('#time-display');
  1173. var $play = $('#ctrl-play');
  1174. var $mute = $('#ctrl-mute');
  1175. // Restore saved volume from last session
  1176. var savedVol = parseFloat(localStorage.getItem('movie_volume'));
  1177. if (!isNaN(savedVol) && savedVol >= 0 && savedVol <= 1) {
  1178. vid.volume = savedVol;
  1179. $('#volume-slider').val(savedVol);
  1180. }
  1181. if (localStorage.getItem('movie_muted') === '1') { vid.muted = true; }
  1182. // Play/Pause
  1183. $('#ctrl-play').on('click', function () { togglePlay(); });
  1184. $('#ctrl-mute').on('click', function () { vid.muted = !vid.muted; updateMuteIcon(); });
  1185. $('#ctrl-prev').on('click', function () { playOffset(-1); });
  1186. $('#ctrl-next').on('click', function () { playOffset(1); });
  1187. $('#ctrl-fs').on('click', function () { toggleFullscreen(); });
  1188. $('#ctrl-list').on('click', function () { toggleSidebar(); });
  1189. $('#volume-slider').on('input', function () {
  1190. vid.volume = parseFloat($(this).val());
  1191. updateMuteIcon();
  1192. });
  1193. // Progress bar click / drag
  1194. $('#progress-wrap').on('click', function (e) {
  1195. if (vid.duration) {
  1196. var pct = e.offsetX / $(this).width();
  1197. vid.currentTime = pct * vid.duration;
  1198. }
  1199. });
  1200. // Video events
  1201. $(vid).on('timeupdate', function () {
  1202. if (!vid.duration) { return; }
  1203. var pct = (vid.currentTime / vid.duration) * 100;
  1204. $prog.css('width', pct + '%');
  1205. $thumb.css('left', 'calc(' + pct + '% - 7px)');
  1206. $time.text(formatTime(vid.currentTime) + ' / ' + formatTime(vid.duration));
  1207. });
  1208. $(vid).on('play', function () { $('#play-icon').attr('src', 'img/icons/pause_white.svg'); });
  1209. $(vid).on('pause', function () { $('#play-icon').attr('src', 'img/icons/play_white.svg'); });
  1210. $(vid).on('ended', function () {
  1211. cancelCountdown();
  1212. if (repeatSingle) {
  1213. vid.currentTime = 0;
  1214. vid.play();
  1215. } else if (playingIndex < currentEpisodes.length - 1 && autoplayEnabled) {
  1216. startNextCountdown();
  1217. }
  1218. });
  1219. $(vid).on('volumechange', function () {
  1220. updateMuteIcon();
  1221. localStorage.setItem('movie_volume', vid.volume);
  1222. localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
  1223. });
  1224. // Auto-hide controls
  1225. $('#video-container').on('mousemove touchstart', function () { showControls(); });
  1226. // Click on video = play/pause
  1227. $(vid).on('click', function () { togglePlay(); });
  1228. // Hide back button when in fullscreen (JS reinforcement)
  1229. document.addEventListener('fullscreenchange', function () {
  1230. $('#player-back').toggle(!document.fullscreenElement);
  1231. });
  1232. document.addEventListener('webkitfullscreenchange', function () {
  1233. $('#player-back').toggle(!document.webkitFullscreenElement);
  1234. });
  1235. function updateMuteIcon() {
  1236. var isMuted = vid.muted || vid.volume === 0;
  1237. $('#mute-icon').attr('src', isMuted ? 'img/icons/mute_white.svg' : 'img/icons/volume_white.svg');
  1238. $('#volume-slider').val(vid.muted ? 0 : vid.volume);
  1239. }
  1240. }
  1241. function togglePlay() {
  1242. var vid = document.getElementById('main-video');
  1243. if (vid.paused) { vid.play(); } else { vid.pause(); }
  1244. }
  1245. function playOffset(offset) {
  1246. var next = playingIndex + offset;
  1247. if (next < 0) { next = 0; }
  1248. if (next >= currentEpisodes.length) { next = currentEpisodes.length - 1; }
  1249. if (next !== playingIndex) { startPlayback(next); }
  1250. }
  1251. function showControls() {
  1252. $('#video-controls').removeClass('hidden');
  1253. clearTimeout(controlsTimer);
  1254. controlsTimer = setTimeout(function () {
  1255. var vid = document.getElementById('main-video');
  1256. if (!vid.paused) { $('#video-controls').addClass('hidden'); }
  1257. }, 3000);
  1258. }
  1259. function toggleFullscreen() {
  1260. var el = document.getElementById('view-player');
  1261. if (!document.fullscreenElement) {
  1262. el.requestFullscreen && el.requestFullscreen();
  1263. } else {
  1264. document.exitFullscreen && document.exitFullscreen();
  1265. }
  1266. }
  1267. function toggleSidebar() {
  1268. $('#playlist-sidebar').toggleClass('collapsed');
  1269. }
  1270. function startNextCountdown() {
  1271. var total = 5;
  1272. var remaining = total;
  1273. var $bar = $('#next-countdown-bar');
  1274. var $num = $('#countdown-num');
  1275. $bar.css('transition', 'none').css('width', '100%');
  1276. $num.text(remaining);
  1277. $('#next-countdown').show();
  1278. countdownTimer = setInterval(function () {
  1279. remaining--;
  1280. $num.text(remaining);
  1281. $bar.css('transition', 'width 1s linear').css('width', (remaining / total * 100) + '%');
  1282. if (remaining <= 0) { cancelCountdown(); playOffset(1); }
  1283. }, 1000);
  1284. }
  1285. function cancelCountdown() {
  1286. if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
  1287. $('#next-countdown').hide();
  1288. }
  1289. // ─── Player context menu ──────────────────────────────────────────────────────
  1290. var infoRefreshTimer = null;
  1291. var currentInfoTab = 'props';
  1292. function initContextMenu() {
  1293. var vid = document.getElementById('main-video');
  1294. var $ctx = $('#player-ctx');
  1295. // Right-click on the video container
  1296. $('#video-container').on('contextmenu', function (e) {
  1297. e.preventDefault();
  1298. if (playingIndex < 0) { return; }
  1299. // Update item disabled / active states
  1300. var paused = vid.paused;
  1301. $('#ctx-play').toggleClass('ctx-disabled', !paused);
  1302. $('#ctx-pause').toggleClass('ctx-disabled', paused);
  1303. $('#ctx-prev').toggleClass('ctx-disabled', playingIndex <= 0);
  1304. $('#ctx-next').toggleClass('ctx-disabled', playingIndex >= currentEpisodes.length - 1);
  1305. $('#ctx-repeat').toggleClass('ctx-active', repeatSingle)
  1306. .html('<i class="ctx-icon">' + (repeatSingle ? '✓' : '↺') + '</i>Repeat: ' + (repeatSingle ? 'On' : 'Off'));
  1307. // Position menu, clamp inside container
  1308. var rect = this.getBoundingClientRect();
  1309. var x = e.clientX - rect.left;
  1310. var y = e.clientY - rect.top;
  1311. $ctx.css({ left: x, top: y, display: 'block' });
  1312. var mw = $ctx.outerWidth(), mh = $ctx.outerHeight();
  1313. if (x + mw > rect.width) { $ctx.css('left', Math.max(0, x - mw)); }
  1314. if (y + mh > rect.height) { $ctx.css('top', Math.max(0, y - mh)); }
  1315. showControls();
  1316. });
  1317. // Dismiss on any click outside the menu
  1318. $(document).on('mousedown.ctx', function (e) {
  1319. if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
  1320. });
  1321. // Dismiss on Escape
  1322. $(document).on('keydown.ctx', function (e) {
  1323. if (e.key === 'Escape') { $ctx.hide(); closeVideoInfo(); }
  1324. });
  1325. $('#ctx-play').on('click', function () { vid.play(); $ctx.hide(); });
  1326. $('#ctx-pause').on('click', function () { vid.pause(); $ctx.hide(); });
  1327. $('#ctx-prev').on('click', function () { cancelCountdown(); playOffset(-1); $ctx.hide(); });
  1328. $('#ctx-next').on('click', function () { cancelCountdown(); playOffset(1); $ctx.hide(); });
  1329. $('#ctx-repeat').on('click', function () { repeatSingle = !repeatSingle; $ctx.hide(); });
  1330. $('#ctx-props').on('click', function () { $ctx.hide(); openVideoInfo('props'); });
  1331. $('#ctx-stats').on('click', function () { $ctx.hide(); openVideoInfo('stats'); });
  1332. }
  1333. function openVideoInfo(tab) {
  1334. currentInfoTab = tab || 'props';
  1335. $('#video-info-modal').show();
  1336. $('.info-tab').removeClass('active').eq(currentInfoTab === 'props' ? 0 : 1).addClass('active');
  1337. $('#video-info-title').text(currentInfoTab === 'props' ? 'Video Properties' : 'Streaming Stats');
  1338. renderInfoContent();
  1339. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  1340. if (currentInfoTab === 'stats') {
  1341. infoRefreshTimer = setInterval(renderInfoContent, 1000);
  1342. }
  1343. }
  1344. function closeVideoInfo() {
  1345. $('#video-info-modal').hide();
  1346. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  1347. }
  1348. function showInfoTab(tab) {
  1349. currentInfoTab = tab;
  1350. $('.info-tab').removeClass('active').eq(tab === 'props' ? 0 : 1).addClass('active');
  1351. $('#video-info-title').text(tab === 'props' ? 'Video Properties' : 'Streaming Stats');
  1352. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  1353. if (tab === 'stats') { infoRefreshTimer = setInterval(renderInfoContent, 1000); }
  1354. renderInfoContent();
  1355. }
  1356. function infoRow(label, value) {
  1357. return '<div class="info-row"><span class="info-label">' + escapeHtml(String(label)) + '</span>'
  1358. + '<span class="info-value">' + escapeHtml(String(value)) + '</span></div>';
  1359. }
  1360. function renderInfoContent() {
  1361. var vid = document.getElementById('main-video');
  1362. var ep = (playingIndex >= 0 && currentEpisodes[playingIndex]) ? currentEpisodes[playingIndex] : null;
  1363. var html = '';
  1364. if (currentInfoTab === 'props') {
  1365. html += infoRow('Title', ep ? ep.name : '–');
  1366. html += infoRow('Resolution', (vid.videoWidth && vid.videoHeight)
  1367. ? vid.videoWidth + ' × ' + vid.videoHeight : '–');
  1368. html += infoRow('Duration', vid.duration ? formatTime(vid.duration) : '–');
  1369. html += infoRow('Position', vid.currentTime ? formatTime(vid.currentTime) : '–');
  1370. html += infoRow('Playback speed', vid.playbackRate + '×');
  1371. html += infoRow('Volume', vid.muted ? 'Muted' : Math.round(vid.volume * 100) + '%');
  1372. if (ep) { html += infoRow('File path', ep.filepath || '–'); }
  1373. } else {
  1374. var rsLabels = ['No info', 'Metadata only', 'Have current data', 'Have future data', 'Enough data'];
  1375. var nsLabels = ['Empty', 'Idle', 'Loading', 'No source'];
  1376. html += infoRow('Ready state', rsLabels[vid.readyState] || vid.readyState);
  1377. html += infoRow('Network state', nsLabels[vid.networkState] || vid.networkState);
  1378. // Buffer ahead
  1379. var bufEnd = 0;
  1380. if (vid.buffered && vid.buffered.length > 0) {
  1381. for (var i = 0; i < vid.buffered.length; i++) {
  1382. if (vid.buffered.start(i) <= vid.currentTime + 0.1) {
  1383. bufEnd = Math.max(bufEnd, vid.buffered.end(i));
  1384. }
  1385. }
  1386. }
  1387. html += infoRow('Buffer ahead', Math.max(0, bufEnd - vid.currentTime).toFixed(1) + 's');
  1388. html += infoRow('Total buffered', vid.buffered.length > 0
  1389. ? formatTime(vid.buffered.end(vid.buffered.length - 1)) : '–');
  1390. if (vid.getVideoPlaybackQuality) {
  1391. var q = vid.getVideoPlaybackQuality();
  1392. html += infoRow('Frames decoded', q.totalVideoFrames || 0);
  1393. html += infoRow('Frames dropped', q.droppedVideoFrames || 0);
  1394. }
  1395. html += infoRow('Stalled / ended', vid.ended ? 'Ended' : (vid.readyState < 3 ? 'Yes' : 'No'));
  1396. }
  1397. $('#video-info-body').html(html);
  1398. }
  1399. function formatTime(s) {
  1400. if (isNaN(s)) { return '0:00'; }
  1401. var h = Math.floor(s / 3600);
  1402. var m = Math.floor((s % 3600) / 60);
  1403. var sec = Math.floor(s % 60);
  1404. var parts = [];
  1405. if (h > 0) { parts.push(h); }
  1406. parts.push(m);
  1407. parts.push(sec < 10 ? '0' + sec : sec);
  1408. return parts.join(':');
  1409. }
  1410. // ─── Keyboard / TV-remote navigation ─────────────────────────────────────────
  1411. function initKeyboard() {
  1412. $(document).on('keydown', function (e) {
  1413. var activeView = $('.view.active').attr('id');
  1414. // ── Player shortcuts ──────────────────────────────────────────────────
  1415. if (activeView === 'view-player') {
  1416. var vid = document.getElementById('main-video');
  1417. switch (e.key) {
  1418. case ' ':
  1419. case 'k':
  1420. e.preventDefault(); togglePlay(); showControls(); break;
  1421. case 'ArrowRight':
  1422. e.preventDefault(); vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10); showControls(); break;
  1423. case 'ArrowLeft':
  1424. e.preventDefault(); vid.currentTime = Math.max(0, vid.currentTime - 10); showControls(); break;
  1425. case 'ArrowUp':
  1426. e.preventDefault(); vid.volume = Math.min(1, vid.volume + 0.1); showControls(); break;
  1427. case 'ArrowDown':
  1428. e.preventDefault(); vid.volume = Math.max(0, vid.volume - 0.1); showControls(); break;
  1429. case 'f':
  1430. case 'F':
  1431. e.preventDefault(); toggleFullscreen(); break;
  1432. case 'm':
  1433. case 'M':
  1434. e.preventDefault(); vid.muted = !vid.muted; break;
  1435. case 'l':
  1436. case 'L':
  1437. e.preventDefault(); toggleSidebar(); break;
  1438. case 'n':
  1439. e.preventDefault(); playOffset(1); break;
  1440. case 'p':
  1441. e.preventDefault(); playOffset(-1); break;
  1442. case 'Escape':
  1443. e.preventDefault(); closePlayer(); break;
  1444. case 'MediaPlayPause':
  1445. e.preventDefault(); togglePlay(); break;
  1446. case 'MediaTrackNext':
  1447. e.preventDefault(); playOffset(1); break;
  1448. case 'MediaTrackPrevious':
  1449. e.preventDefault(); playOffset(-1); break;
  1450. }
  1451. return;
  1452. }
  1453. // ── Grid / detail navigation (TV remote) ──────────────────────────────
  1454. if (e.key === 'Escape') {
  1455. e.preventDefault();
  1456. if (activeView === 'view-detail') { showLibrary(); }
  1457. return;
  1458. }
  1459. if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Enter'].indexOf(e.key) === -1) { return; }
  1460. e.preventDefault();
  1461. focusMode = true;
  1462. var $focusables = $('.view.active [tabindex="0"]:visible');
  1463. if ($focusables.length === 0) { return; }
  1464. var $cur = $(document.activeElement);
  1465. var curIdx = $focusables.index($cur);
  1466. if (curIdx < 0) {
  1467. // Nothing focused yet – focus first element
  1468. $focusables.first().focus();
  1469. return;
  1470. }
  1471. if (e.key === 'Enter') {
  1472. $cur.trigger('click');
  1473. return;
  1474. }
  1475. // Compute next focus index based on grid layout
  1476. var nextIdx = computeNextFocus($focusables, curIdx, e.key);
  1477. if (nextIdx >= 0 && nextIdx < $focusables.length) {
  1478. $focusables.eq(nextIdx).focus();
  1479. $focusables.eq(nextIdx)[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
  1480. }
  1481. });
  1482. }
  1483. function computeNextFocus($els, curIdx, key) {
  1484. if (key === 'ArrowDown') { return Math.min($els.length - 1, curIdx + 1); }
  1485. if (key === 'ArrowUp') { return Math.max(0, curIdx - 1); }
  1486. // For left/right in a grid, figure out how many columns there are
  1487. var $cur = $els.eq(curIdx);
  1488. var $parent = $cur.parent();
  1489. if ($parent.hasClass('album-grid')) {
  1490. var colCount = Math.round($parent.width() / ($cur.outerWidth(true) || 1)) || 1;
  1491. if (key === 'ArrowRight') { return Math.min($els.length - 1, curIdx + 1); }
  1492. if (key === 'ArrowLeft') { return Math.max(0, curIdx - 1); }
  1493. }
  1494. if (key === 'ArrowRight') { return Math.min($els.length - 1, curIdx + 1); }
  1495. if (key === 'ArrowLeft') { return Math.max(0, curIdx - 1); }
  1496. return curIdx;
  1497. }
  1498. // ─── Search ───────────────────────────────────────────────────────────────────
  1499. function initSearch() {
  1500. $('#search-input').on('input', function () {
  1501. var q = $(this).val().trim().toLowerCase();
  1502. if (q.length === 0) {
  1503. renderLibrary(library);
  1504. return;
  1505. }
  1506. var filtered = library.filter(function (a) {
  1507. return a.name.toLowerCase().indexOf(q) > -1;
  1508. });
  1509. renderLibrary(filtered);
  1510. });
  1511. // Prevent keyboard nav from hijacking search input
  1512. $('#search-input').on('keydown', function (e) {
  1513. e.stopPropagation();
  1514. if (e.key === 'Escape') { $(this).val(''); renderLibrary(library); $(this).blur(); }
  1515. });
  1516. }
  1517. // ─── Utilities ────────────────────────────────────────────────────────────────
  1518. function escapeHtml(str) {
  1519. if (!str) { return ''; }
  1520. return String(str)
  1521. .replace(/&/g, '&amp;')
  1522. .replace(/</g, '&lt;')
  1523. .replace(/>/g, '&gt;')
  1524. .replace(/"/g, '&quot;');
  1525. }
  1526. function escapeAttr(str) { return escapeHtml(str); }
  1527. var toastTimer;
  1528. function showToast(msg) {
  1529. clearTimeout(toastTimer);
  1530. $('#toast').text(msg).addClass('show');
  1531. toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
  1532. }
  1533. </script>
  1534. </body>
  1535. </html>