index.html 106 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613
  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. /* Status bar ─────────────────────────────────────────────────────────────────── */
  76. #library-status-bar {
  77. flex-shrink: 0;
  78. display: flex;
  79. align-items: center;
  80. gap: 7px;
  81. padding: 3px 22px;
  82. font-size: 11px;
  83. color: var(--text-sub);
  84. border-bottom: 1px solid rgba(255,255,255,0.04);
  85. min-height: 26px;
  86. }
  87. .spinner-sm {
  88. width: 11px; height: 11px; flex-shrink: 0;
  89. border: 2px solid rgba(255,255,255,0.15);
  90. border-top-color: var(--accent);
  91. border-radius: 50%;
  92. animation: spin 0.8s linear infinite;
  93. }
  94. #library-refresh-btn {
  95. background: none; border: none; cursor: pointer;
  96. color: var(--accent); font-size: 11px; font-weight: 500;
  97. padding: 0; outline: none; transition: opacity var(--transition);
  98. }
  99. #library-refresh-btn:hover { opacity: 0.7; }
  100. #library-refresh-btn:disabled { opacity: 0.35; cursor: default; }
  101. #library-scroll {
  102. flex: 1;
  103. overflow-y: auto;
  104. overflow-x: hidden;
  105. padding: 8px 20px 40px;
  106. scroll-behavior: smooth;
  107. }
  108. #library-scroll::-webkit-scrollbar { width: 4px; }
  109. #library-scroll::-webkit-scrollbar-track { background: transparent; }
  110. #library-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
  111. #no-content {
  112. display: none;
  113. flex-direction: column;
  114. align-items: center;
  115. justify-content: center;
  116. height: 60%;
  117. gap: 12px;
  118. color: var(--text-sub);
  119. font-size: 16px;
  120. }
  121. #no-content .icon img { width: 80px; height: 80px; opacity: 0.3; }
  122. #loading-overlay {
  123. position: absolute; inset: 0;
  124. background: var(--bg);
  125. display: flex;
  126. flex-direction: column;
  127. align-items: center;
  128. justify-content: center;
  129. gap: 16px;
  130. z-index: 100;
  131. font-size: 15px;
  132. color: var(--text-sub);
  133. }
  134. .spinner {
  135. width: 40px; height: 40px;
  136. border: 3px solid var(--surface2);
  137. border-top-color: var(--accent);
  138. border-radius: 50%;
  139. animation: spin 0.8s linear infinite;
  140. }
  141. @keyframes spin { to { transform: rotate(360deg); } }
  142. /* ─── Section heading ────────────────────────────────────────────────────────── */
  143. .section-title {
  144. font-size: 20px;
  145. font-weight: 600;
  146. margin: 24px 0 12px;
  147. color: var(--text);
  148. }
  149. /* ─── Album grid ─────────────────────────────────────────────────────────────── */
  150. .album-grid {
  151. display: grid;
  152. grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr));
  153. gap: 16px;
  154. }
  155. .album-card {
  156. cursor: pointer;
  157. border-radius: var(--radius);
  158. overflow: hidden;
  159. background: var(--surface);
  160. transition: transform var(--transition), box-shadow var(--transition);
  161. outline: none;
  162. position: relative;
  163. }
  164. .album-card:hover,
  165. .album-card.focused {
  166. transform: scale(1.04);
  167. box-shadow: 0 8px 32px rgba(0,0,0,0.7), 0 0 0 2px var(--accent);
  168. z-index: 2;
  169. }
  170. .album-card .poster {
  171. width: 100%;
  172. aspect-ratio: 16 / 9;
  173. object-fit: cover;
  174. display: block;
  175. background: var(--surface2);
  176. }
  177. .poster-placeholder {
  178. width: 100%;
  179. aspect-ratio: 16 / 9;
  180. background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%);
  181. display: flex;
  182. align-items: center;
  183. justify-content: center;
  184. }
  185. .poster-placeholder img { width: 100%; opacity: 1; }
  186. .album-card .card-info {
  187. padding: 8px 10px 10px;
  188. }
  189. .album-card .card-title {
  190. font-size: 13px;
  191. font-weight: 600;
  192. white-space: nowrap;
  193. overflow: hidden;
  194. text-overflow: ellipsis;
  195. }
  196. .album-card .card-meta {
  197. font-size: 11px;
  198. color: var(--text-sub);
  199. margin-top: 2px;
  200. }
  201. .badge {
  202. position: absolute;
  203. top: 7px; right: 7px;
  204. background: rgba(0,0,0,0.65);
  205. backdrop-filter: blur(4px);
  206. border-radius: 6px;
  207. font-size: 10px;
  208. font-weight: 600;
  209. padding: 2px 6px;
  210. color: #fff;
  211. letter-spacing: 0.3px;
  212. text-transform: uppercase;
  213. }
  214. .badge.series { color: var(--accent2); }
  215. .badge.anime { color: #ff9f0a; } /* warm orange for anime */
  216. /* ─── Detail view ────────────────────────────────────────────────────────────── */
  217. #view-detail { position: relative; }
  218. #detail-hero {
  219. flex-shrink: 0;
  220. height: 38vh;
  221. min-height: 200px;
  222. position: relative;
  223. overflow: hidden;
  224. }
  225. #detail-hero-bg {
  226. position: absolute; inset: 0;
  227. background-size: cover;
  228. background-position: center top;
  229. filter: blur(4px) brightness(0.55);
  230. transform: scale(1.1);
  231. }
  232. #detail-hero-content {
  233. position: relative;
  234. display: flex;
  235. align-items: flex-end;
  236. height: 100%;
  237. padding: 0 28px 20px;
  238. gap: 20px;
  239. }
  240. #detail-poster {
  241. width: 120px;
  242. flex-shrink: 0;
  243. border-radius: 8px;
  244. overflow: hidden;
  245. box-shadow: 0 8px 24px rgba(0,0,0,0.6);
  246. }
  247. #detail-poster img, #detail-poster .poster-placeholder {
  248. width: 120px;
  249. aspect-ratio: 2 / 3;
  250. object-fit: cover;
  251. display: block;
  252. }
  253. #detail-meta { flex: 1; min-width: 0; }
  254. #detail-title { font-size: 26px; font-weight: 700; line-height: 1.15; }
  255. #detail-subtitle { font-size: 14px; color: var(--text-sub); margin-top: 4px; }
  256. #detail-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
  257. .btn {
  258. border: none; cursor: pointer; border-radius: 8px;
  259. font-size: 14px; font-weight: 600; padding: 10px 22px;
  260. transition: opacity var(--transition), transform var(--transition);
  261. outline: none;
  262. }
  263. .btn:hover, .btn.focused { opacity: 0.85; transform: scale(1.03); }
  264. .btn-primary { background: var(--text); color: #000; }
  265. .btn-secondary { background: var(--surface2); color: var(--text); }
  266. /* ─── Season tabs ────────────────────────────────────────────────────────────── */
  267. #season-tabs {
  268. flex-shrink: 0;
  269. display: flex;
  270. flex-wrap: wrap;
  271. gap: 6px 8px;
  272. padding: 10px 28px 8px;
  273. max-height: 120px; /* ~3 rows before scrolling */
  274. overflow-y: auto;
  275. scrollbar-width: thin;
  276. scrollbar-color: var(--surface2) transparent;
  277. }
  278. #season-tabs::-webkit-scrollbar { width: 3px; height: 3px; }
  279. #season-tabs::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
  280. .season-tab {
  281. flex-shrink: 0;
  282. background: var(--surface2);
  283. border: none; cursor: pointer;
  284. border-radius: 20px;
  285. color: var(--text-sub);
  286. font-size: 13px; font-weight: 500;
  287. padding: 6px 16px;
  288. transition: background var(--transition), color var(--transition);
  289. outline: none;
  290. }
  291. .season-tab.active, .season-tab.focused {
  292. background: var(--text);
  293. color: #000;
  294. }
  295. /* ─── Episode list ───────────────────────────────────────────────────────────── */
  296. #episode-scroll {
  297. flex: 1;
  298. overflow-y: auto;
  299. padding: 12px 28px 40px;
  300. scroll-behavior: smooth;
  301. }
  302. #episode-scroll::-webkit-scrollbar { width: 4px; }
  303. #episode-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
  304. .episode-item {
  305. display: flex;
  306. align-items: center;
  307. gap: 14px;
  308. padding: 10px 12px;
  309. border-radius: var(--radius);
  310. cursor: pointer;
  311. transition: background var(--transition);
  312. outline: none;
  313. }
  314. .episode-item:hover, .episode-item.focused {
  315. background: var(--surface);
  316. box-shadow: 0 0 0 2px var(--accent);
  317. }
  318. .episode-item.playing { background: rgba(10,132,255,0.15); }
  319. .ep-thumb {
  320. width: 100px; flex-shrink: 0;
  321. aspect-ratio: 16 / 9;
  322. border-radius: 6px;
  323. overflow: hidden;
  324. background: var(--surface2);
  325. }
  326. .ep-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
  327. .ep-thumb-placeholder {
  328. width: 100%; height: 100%;
  329. display: flex; align-items: center; justify-content: center;
  330. }
  331. .ep-thumb-placeholder img { width: 22px; height: 22px; opacity: 0.5; }
  332. .ep-info { flex: 1; min-width: 0; }
  333. .ep-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  334. .ep-path { font-size: 11px; color: var(--text-sub); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  335. .ep-play-icon {
  336. flex-shrink: 0;
  337. width: 32px; height: 32px;
  338. background: var(--surface2);
  339. border-radius: 50%;
  340. display: flex; align-items: center; justify-content: center;
  341. transition: background var(--transition);
  342. }
  343. .ep-play-icon img { width: 16px; height: 16px; }
  344. .episode-item.focused .ep-play-icon,
  345. .episode-item:hover .ep-play-icon { background: var(--accent); }
  346. #detail-back {
  347. position: absolute;
  348. top: 12px; left: 16px;
  349. background: rgba(0,0,0,0.5);
  350. backdrop-filter: blur(8px);
  351. border: none; cursor: pointer;
  352. border-radius: 20px;
  353. color: var(--text);
  354. font-size: 13px; font-weight: 500;
  355. padding: 6px 16px;
  356. z-index: 5;
  357. transition: background var(--transition);
  358. outline: none;
  359. }
  360. #detail-back:hover, #detail-back.focused { background: var(--accent); }
  361. /* ─── Player view ────────────────────────────────────────────────────────────── */
  362. #view-player {
  363. position: fixed; inset: 0;
  364. background: #000;
  365. z-index: 200;
  366. flex-direction: row;
  367. }
  368. #view-player.active { display: flex; }
  369. #video-container {
  370. flex: 1;
  371. display: flex;
  372. flex-direction: column;
  373. position: relative;
  374. overflow: hidden;
  375. }
  376. #main-video {
  377. width: 100%; height: 100%;
  378. object-fit: contain;
  379. background: #000;
  380. display: block;
  381. }
  382. /* ─── Custom video controls ──────────────────────────────────────────────────── */
  383. #video-controls {
  384. position: absolute;
  385. bottom: 0; left: 0; right: 0;
  386. background: linear-gradient(transparent, rgba(0,0,0,0.85) 100%);
  387. padding: 40px 20px 16px;
  388. transition: opacity 0.3s;
  389. }
  390. #video-controls.hidden { opacity: 0; pointer-events: none; }
  391. /* Hide cursor when controls auto-hide */
  392. #video-container:has(#video-controls.hidden) { cursor: none; }
  393. #progress-wrap {
  394. position: relative;
  395. height: 4px;
  396. background: rgba(255,255,255,0.25);
  397. border-radius: 4px;
  398. cursor: pointer;
  399. margin-bottom: 12px;
  400. }
  401. #progress-bar {
  402. height: 100%; border-radius: 4px;
  403. background: var(--accent);
  404. pointer-events: none;
  405. }
  406. #progress-wrap:hover { height: 6px; }
  407. /* Transparent hit-zone 16px above the visual bar */
  408. #progress-wrap::before {
  409. content: '';
  410. position: absolute;
  411. top: -16px; left: 0; right: 0;
  412. height: 16px;
  413. }
  414. #progress-thumb {
  415. position: absolute;
  416. top: 50%; transform: translateY(-50%);
  417. width: 14px; height: 14px;
  418. background: #fff; border-radius: 50%;
  419. pointer-events: none;
  420. display: none;
  421. }
  422. #progress-wrap:hover #progress-thumb { display: block; }
  423. #controls-row {
  424. display: flex;
  425. align-items: center;
  426. gap: 10px;
  427. }
  428. .ctrl-btn {
  429. background: none; border: none; cursor: pointer;
  430. color: #fff; padding: 4px;
  431. line-height: 1;
  432. transition: opacity var(--transition);
  433. outline: none;
  434. display: flex; align-items: center; justify-content: center;
  435. }
  436. .ctrl-btn img { width: 24px; height: 24px; display: block; }
  437. .ctrl-btn:hover { opacity: 0.7; }
  438. .ctrl-btn.focused { background: rgba(10,132,255,0.18); border-radius: 6px; }
  439. #volume-wrap { display: flex; align-items: center; gap: 8px; }
  440. #volume-slider {
  441. -webkit-appearance: none;
  442. width: 80px; height: 4px;
  443. background: rgba(255,255,255,0.3);
  444. border-radius: 4px; outline: none;
  445. }
  446. #volume-slider::-webkit-slider-thumb {
  447. -webkit-appearance: none;
  448. width: 12px; height: 12px;
  449. background: #fff; border-radius: 50%;
  450. }
  451. #time-display { font-size: 13px; color: rgba(255,255,255,0.8); margin-left: 6px; }
  452. #now-playing-title {
  453. font-size: 15px; font-weight: 600;
  454. flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  455. margin-left: 10px;
  456. }
  457. /* ─── Playlist sidebar ───────────────────────────────────────────────────────── */
  458. #playlist-sidebar {
  459. width: 300px;
  460. flex-shrink: 0;
  461. background: rgba(20,20,20,0.95);
  462. backdrop-filter: blur(12px);
  463. display: flex;
  464. flex-direction: column;
  465. border-left: 1px solid rgba(255,255,255,0.08);
  466. transform: translateX(0);
  467. transition: transform var(--transition), width var(--transition);
  468. }
  469. #playlist-sidebar.collapsed { width: 0; overflow: hidden; }
  470. #sidebar-header {
  471. padding: 14px 16px 10px;
  472. font-size: 15px; font-weight: 600;
  473. border-bottom: 1px solid rgba(255,255,255,0.08);
  474. display: flex; align-items: center; gap: 8px;
  475. }
  476. #sidebar-close {
  477. margin-left: auto;
  478. background: none; border: none; cursor: pointer;
  479. color: var(--text-sub); font-size: 18px; padding: 2px;
  480. outline: none;
  481. }
  482. #sidebar-close:hover { color: var(--text); }
  483. #sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
  484. #sidebar-list::-webkit-scrollbar { width: 3px; }
  485. #sidebar-list::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
  486. .sidebar-ep {
  487. display: flex; align-items: center; gap: 10px;
  488. padding: 8px;
  489. border-radius: 8px; cursor: pointer;
  490. transition: background var(--transition);
  491. font-size: 13px; color: var(--text);
  492. outline: none;
  493. }
  494. .sidebar-ep:hover, .sidebar-ep.focused { background: var(--surface2); }
  495. .sidebar-ep.playing { background: rgba(10,132,255,0.2); color: var(--accent); }
  496. .sidebar-ep-num { flex-shrink: 0; width: 24px; text-align: center; color: var(--text-sub); font-size: 12px; }
  497. .sidebar-ep-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  498. #player-back {
  499. position: absolute;
  500. top: 14px; left: 14px;
  501. z-index: 10;
  502. background: rgba(0,0,0,0.55);
  503. backdrop-filter: blur(8px);
  504. border: none; cursor: pointer;
  505. border-radius: 20px;
  506. color: #fff;
  507. font-size: 13px; font-weight: 500;
  508. padding: 7px 16px;
  509. transition: background var(--transition);
  510. outline: none;
  511. }
  512. #player-back:hover { background: var(--accent); }
  513. /* ─── Toast notification ─────────────────────────────────────────────────────── */
  514. #toast {
  515. position: fixed;
  516. bottom: 30px; left: 50%;
  517. transform: translateX(-50%) translateY(20px);
  518. background: rgba(30,30,30,0.95);
  519. backdrop-filter: blur(8px);
  520. color: var(--text);
  521. padding: 10px 20px;
  522. border-radius: 20px;
  523. font-size: 13px;
  524. opacity: 0;
  525. transition: opacity 0.3s, transform 0.3s;
  526. pointer-events: none;
  527. z-index: 1000;
  528. white-space: nowrap;
  529. }
  530. #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  531. /* ─── Responsive ─────────────────────────────────────────────────────────────── */
  532. @media (max-width: 600px) {
  533. :root { --card-w: 130px; }
  534. #library-scroll { padding: 8px 12px 40px; }
  535. #detail-hero { height: 44vw; min-height: 160px; }
  536. #detail-poster { width: 80px; }
  537. #detail-title { font-size: 18px; }
  538. #playlist-sidebar { width: 100%; position: absolute; right: 0; top: 0; bottom: 0; z-index: 5; }
  539. #playlist-sidebar.collapsed { width: 0; }
  540. #episode-scroll { padding: 10px 12px 40px; }
  541. .ep-thumb { width: 72px; }
  542. }
  543. /* ─── Phone: wrap topbar so the search bar never overflows ───────────────────── */
  544. @media (max-width: 540px) {
  545. #topbar {
  546. flex-wrap: wrap;
  547. height: auto;
  548. min-height: var(--header-h);
  549. padding: 6px 14px;
  550. row-gap: 4px;
  551. align-items: center;
  552. }
  553. #topbar h1 { font-size: 18px; flex-shrink: 0; }
  554. #mode-tabs { flex-shrink: 0; }
  555. /* Force search onto its own row, full width */
  556. #search-wrap {
  557. flex-basis: 100%;
  558. margin-left: 0;
  559. order: 10;
  560. }
  561. #search-input { width: 100%; max-width: none; }
  562. #search-input:focus { width: 100%; }
  563. }
  564. @media (max-width: 400px) {
  565. :root { --card-w: 110px; }
  566. .mode-tab { padding: 5px 8px; font-size: 12px; }
  567. }
  568. /* TV / large screen layout */
  569. @media (min-width: 1400px) {
  570. :root { --card-w: 200px; }
  571. }
  572. @media (min-width: 1800px) {
  573. :root { --card-w: 240px; }
  574. }
  575. /* ─── Mode tabs ──────────────────────────────────────────────────────────────── */
  576. #mode-tabs { display: flex; gap: 2px; }
  577. .mode-tab {
  578. background: none; border: none; cursor: pointer;
  579. border-radius: 6px; color: var(--text-sub);
  580. font-size: 13px; font-weight: 500; padding: 5px 12px;
  581. transition: background var(--transition), color var(--transition);
  582. outline: none;
  583. }
  584. .mode-tab.active { background: var(--surface2); color: var(--text); }
  585. .mode-tab:hover { color: var(--text); }
  586. /* ─── Folder view ────────────────────────────────────────────────────────────── */
  587. #folder-nav-bar {
  588. flex-shrink: 0;
  589. padding: 6px 20px;
  590. display: flex;
  591. align-items: center;
  592. overflow-x: auto;
  593. scrollbar-width: none;
  594. border-bottom: 1px solid rgba(255,255,255,0.06);
  595. min-height: 40px;
  596. }
  597. #folder-nav-bar::-webkit-scrollbar { display: none; }
  598. #folder-breadcrumb { display: flex; align-items: center; gap: 2px; }
  599. .crumb-btn {
  600. background: none; border: none; cursor: pointer;
  601. color: var(--text-sub); font-size: 13px;
  602. padding: 3px 7px; border-radius: 5px;
  603. transition: color var(--transition), background var(--transition);
  604. outline: none; white-space: nowrap;
  605. }
  606. .crumb-btn:hover { color: var(--text); background: var(--surface2); }
  607. .crumb-btn.crumb-current { color: var(--text); font-weight: 600; pointer-events: none; }
  608. .crumb-sep { color: var(--text-sub); font-size: 12px; opacity: 0.4; padding: 0 2px; }
  609. #folder-scroll {
  610. flex: 1; overflow-y: auto; overflow-x: hidden;
  611. padding: 8px 20px 40px; scroll-behavior: smooth;
  612. }
  613. #folder-scroll::-webkit-scrollbar { width: 4px; }
  614. #folder-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
  615. #folder-loading {
  616. display: none; flex-direction: column; align-items: center;
  617. justify-content: center; padding: 60px 0; gap: 12px; color: var(--text-sub);
  618. }
  619. #folder-loading.active { display: flex; }
  620. #folder-empty { display: none; color: var(--text-sub); padding: 60px 0; text-align: center; }
  621. .folder-thumb {
  622. width: 100%; aspect-ratio: 16/9;
  623. background: linear-gradient(135deg, #1c2640 0%, #2a3d6e 100%);
  624. display: flex; align-items: center; justify-content: center;
  625. font-size: 32px; user-select: none;
  626. }
  627. /* ─── Movie info panel ───────────────────────────────────────────────────────── */
  628. #view-movie-info { position: relative; }
  629. #movie-info-backdrop {
  630. position: absolute; inset: 0;
  631. background-size: cover; background-position: center 20%;
  632. filter: blur(4px) brightness(0.38);
  633. transform: scale(1.12); z-index: 0;
  634. }
  635. #movie-info-back {
  636. position: absolute; top: 14px; left: 16px; z-index: 10;
  637. background: rgba(0,0,0,0.5); backdrop-filter: blur(8px);
  638. border: none; cursor: pointer; border-radius: 20px;
  639. color: var(--text); font-size: 13px; font-weight: 500;
  640. padding: 6px 16px; transition: background var(--transition); outline: none;
  641. }
  642. #movie-info-back:hover { background: var(--accent); }
  643. #movie-info-scroll {
  644. flex: 1; overflow-y: auto; overflow-x: hidden;
  645. scroll-behavior: smooth; position: relative; z-index: 1;
  646. }
  647. #movie-info-scroll::-webkit-scrollbar { width: 4px; }
  648. #movie-info-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
  649. #movie-info-hero {
  650. min-height: 280px; display: flex; align-items: flex-end; gap: 24px;
  651. padding: 70px 32px 28px;
  652. }
  653. #movie-info-poster-wrap {
  654. width: 130px; flex-shrink: 0; border-radius: 10px; overflow: hidden;
  655. box-shadow: 0 12px 32px rgba(0,0,0,0.7);
  656. aspect-ratio: 2/3; background: var(--surface2);
  657. }
  658. #movie-info-poster-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
  659. #movie-info-meta { flex: 1; min-width: 0; }
  660. #movie-info-title { font-size: 26px; font-weight: 700; line-height: 1.15; margin-bottom: 2px; }
  661. #movie-info-filename {
  662. font-size: 11px; color: var(--text-sub); opacity: 0.6;
  663. font-style: italic; margin-bottom: 6px;
  664. overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  665. display: none; /* shown only after IMDB title replaces the album name */
  666. }
  667. #movie-info-year { font-size: 14px; color: var(--text-sub); margin-bottom: 8px; }
  668. #movie-info-cast { font-size: 13px; color: var(--text-sub); line-height: 1.5; margin-bottom: 14px; }
  669. #movie-info-actions { display: flex; gap: 10px; flex-wrap: wrap; }
  670. .yt-badge {
  671. display: inline-flex; align-items: center;
  672. background: #ff0000; color: #fff;
  673. border-radius: 5px; font-size: 12px; font-weight: 800;
  674. padding: 5px 9px; cursor: pointer; border: none; outline: none;
  675. transition: opacity var(--transition);
  676. }
  677. .yt-badge:hover { opacity: 0.8; }
  678. .imdb-badge {
  679. display: inline-flex; align-items: center;
  680. background: #f5c518; color: #000;
  681. border-radius: 5px; font-size: 12px; font-weight: 800;
  682. padding: 5px 9px; cursor: pointer; border: none; outline: none;
  683. transition: opacity var(--transition);
  684. }
  685. .imdb-badge:hover { opacity: 0.8; }
  686. #movie-info-loading {
  687. display: none; flex-direction: column; align-items: center;
  688. justify-content: center; padding: 28px 0; gap: 10px; color: var(--text-sub);
  689. }
  690. #movie-info-loading.active { display: flex; }
  691. #movie-info-files { padding: 0 20px 48px; }
  692. #movie-info-wrong-wrap {
  693. display: none;
  694. padding: 10px 32px 0;
  695. }
  696. .reset-info-btn {
  697. background: none;
  698. border: 1px solid rgba(255,255,255,0.12);
  699. color: var(--text-sub);
  700. font-size: 11px; font-weight: 500;
  701. padding: 4px 12px;
  702. border-radius: 6px;
  703. cursor: pointer; outline: none;
  704. transition: border-color var(--transition), color var(--transition);
  705. }
  706. .reset-info-btn:hover { border-color: rgba(255,69,58,0.55); color: #ff453a; }
  707. @media (max-width: 600px) {
  708. #movie-info-hero { flex-direction: column; align-items: flex-start; padding: 56px 16px 20px; }
  709. #movie-info-poster-wrap { width: 90px; }
  710. #movie-info-title { font-size: 20px; }
  711. #movie-info-files { padding: 0 12px 48px; }
  712. }
  713. /* ─── Resume popup ───────────────────────────────────────────────────────────── */
  714. #resume-popup {
  715. display: none;
  716. position: absolute; bottom: 90px; right: 20px; z-index: 25;
  717. background: rgba(22,22,24,0.97); backdrop-filter: blur(16px);
  718. border-radius: 12px; padding: 16px 18px; min-width: 240px;
  719. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1);
  720. animation: slideUpPopup 0.2s ease;
  721. }
  722. #resume-popup.active { display: block; }
  723. @keyframes slideUpPopup {
  724. from { opacity: 0; transform: translateY(10px); }
  725. to { opacity: 1; transform: translateY(0); }
  726. }
  727. #resume-popup-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
  728. #resume-popup-sub { font-size: 12px; color: var(--text-sub); margin-bottom: 12px; }
  729. #resume-popup-btns { display: flex; gap: 8px; }
  730. .resume-btn {
  731. flex: 1; padding: 8px; border: none; cursor: pointer;
  732. border-radius: 7px; font-size: 12px; font-weight: 600;
  733. outline: none; transition: opacity var(--transition);
  734. }
  735. .resume-btn:hover { opacity: 0.8; }
  736. #resume-btn-continue { background: var(--accent); color: #fff; }
  737. #resume-btn-restart { background: var(--surface2); color: var(--text); }
  738. /* Focus ring for TV remote navigation */
  739. .tv-focused {
  740. outline: 3px solid var(--accent) !important;
  741. outline-offset: 2px !important;
  742. }
  743. /* Hide back button when in fullscreen */
  744. #view-player:fullscreen #player-back,
  745. #view-player:-webkit-full-screen #player-back,
  746. #view-player:-moz-full-screen #player-back { display: none; }
  747. /* ─── Load More button ───────────────────────────────────────────────────────── */
  748. .load-more-wrap { text-align: center; padding: 16px 0 24px; }
  749. .load-more-btn {
  750. background: var(--surface2);
  751. border: 1px solid rgba(255,255,255,0.08); cursor: pointer;
  752. border-radius: 8px; color: var(--text);
  753. font-size: 13px; font-weight: 500; padding: 9px 28px;
  754. outline: none; transition: background var(--transition), box-shadow var(--transition);
  755. }
  756. .load-more-btn:hover { background: rgba(255,255,255,0.08); box-shadow: 0 0 0 1px var(--accent); }
  757. /* ─── Autoplay toggle (sidebar) ─────────────────────────────────────────────── */
  758. .autoplay-label {
  759. display: flex; align-items: center; gap: 5px;
  760. font-size: 11px; color: var(--text-sub);
  761. cursor: pointer; user-select: none;
  762. font-weight: 500;
  763. }
  764. .autoplay-label input[type="checkbox"] { display: none; }
  765. .autoplay-track {
  766. width: 30px; height: 17px;
  767. background: var(--surface2); border-radius: 9px;
  768. position: relative; flex-shrink: 0;
  769. transition: background 0.2s;
  770. }
  771. .autoplay-label input:checked + .autoplay-track { background: var(--accent2); }
  772. .autoplay-track::after {
  773. content: '';
  774. position: absolute; top: 2px; left: 2px;
  775. width: 13px; height: 13px;
  776. background: #fff; border-radius: 50%;
  777. transition: transform 0.2s;
  778. }
  779. .autoplay-label input:checked + .autoplay-track::after { transform: translateX(13px); }
  780. /* ─── Next-episode countdown ────────────────────────────────────────────────── */
  781. #next-countdown {
  782. display: none;
  783. position: absolute; bottom: 80px; right: 20px;
  784. background: rgba(20,20,20,0.92);
  785. backdrop-filter: blur(8px);
  786. border-radius: 10px; padding: 12px 16px;
  787. min-width: 210px; z-index: 20;
  788. }
  789. #next-countdown-text { font-size: 13px; color: var(--text-sub); margin-bottom: 8px; }
  790. #next-countdown-track {
  791. height: 4px; background: var(--surface2);
  792. border-radius: 4px; margin-bottom: 8px; overflow: hidden;
  793. }
  794. #next-countdown-bar { height: 100%; background: var(--accent); border-radius: 4px; transition: width 1s linear; }
  795. #next-countdown-cancel {
  796. display: block; width: 100%; background: var(--surface2);
  797. border: none; cursor: pointer; border-radius: 6px;
  798. color: var(--text); font-size: 12px; padding: 5px; outline: none;
  799. transition: background var(--transition);
  800. }
  801. #next-countdown-cancel:hover { background: rgba(255,59,48,0.28); }
  802. /* ─── Player context menu ────────────────────────────────────────────────────── */
  803. #player-ctx {
  804. display: none;
  805. position: absolute;
  806. z-index: 30;
  807. background: rgba(28,28,30,0.97);
  808. backdrop-filter: blur(16px);
  809. -webkit-backdrop-filter: blur(16px);
  810. border-radius: 10px;
  811. padding: 4px 0;
  812. min-width: 192px;
  813. box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
  814. user-select: none;
  815. }
  816. .ctx-item {
  817. padding: 9px 14px;
  818. font-size: 13px;
  819. cursor: pointer;
  820. display: flex;
  821. align-items: center;
  822. gap: 10px;
  823. color: var(--text);
  824. transition: background var(--transition);
  825. }
  826. .ctx-item:hover { background: rgba(255,255,255,0.08); }
  827. .ctx-item.ctx-active { color: var(--accent); }
  828. .ctx-item.ctx-disabled { opacity: 0.3; pointer-events: none; }
  829. .ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; }
  830. .ctx-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 3px 0; }
  831. /* ─── Video info modal ───────────────────────────────────────────────────────── */
  832. #video-info-modal {
  833. display: none;
  834. position: absolute;
  835. top: 50%; left: 50%;
  836. transform: translate(-50%, -50%);
  837. z-index: 40;
  838. background: rgba(18,18,20,0.97);
  839. backdrop-filter: blur(20px);
  840. -webkit-backdrop-filter: blur(20px);
  841. border-radius: 14px;
  842. padding: 20px 22px 16px;
  843. width: 340px;
  844. box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
  845. }
  846. #video-info-modal h3 {
  847. font-size: 14px; font-weight: 600;
  848. margin-bottom: 12px;
  849. display: flex; align-items: center; justify-content: space-between;
  850. }
  851. #video-info-close {
  852. background: none; border: none; cursor: pointer;
  853. color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
  854. }
  855. #video-info-close:hover { color: var(--text); }
  856. #video-info-tabs {
  857. display: flex; gap: 3px;
  858. background: var(--surface2);
  859. border-radius: 8px;
  860. padding: 3px;
  861. margin-bottom: 12px;
  862. }
  863. .info-tab {
  864. flex: 1; text-align: center; padding: 5px 0;
  865. font-size: 12px; font-weight: 500;
  866. border-radius: 6px; cursor: pointer;
  867. color: var(--text-sub);
  868. transition: background var(--transition), color var(--transition);
  869. }
  870. .info-tab.active { background: var(--surface); color: var(--text); }
  871. .info-row {
  872. display: flex; gap: 8px;
  873. padding: 5px 0;
  874. border-bottom: 1px solid rgba(255,255,255,0.05);
  875. font-size: 12px;
  876. line-height: 1.4;
  877. }
  878. .info-row:last-child { border-bottom: none; }
  879. .info-label { color: var(--text-sub); flex-shrink: 0; width: 110px; }
  880. .info-value { color: var(--text); word-break: break-all; }
  881. </style>
  882. </head>
  883. <body>
  884. <div id="app">
  885. <!-- ─ Loading overlay ─────────────────────────────────────────────────── -->
  886. <div id="loading-overlay">
  887. <div class="spinner"></div>
  888. <span>Loading library…</span>
  889. </div>
  890. <!-- ─ Top bar ─────────────────────────────────────────────────────────── -->
  891. <div id="topbar">
  892. <h1><img src="img/module_icon.png" width="22" height="22" alt="" style="vertical-align:middle;margin-right:6px;margin-top:-4px;"><span>Movie</span></h1>
  893. <div id="mode-tabs">
  894. <button class="mode-tab active" id="tab-library" onclick="switchTab('library')">Library</button>
  895. <button class="mode-tab" id="tab-folder" onclick="switchTab('folder')">Browse</button>
  896. </div>
  897. <div id="search-wrap">
  898. <input id="search-input" type="text" placeholder="Search…" autocomplete="off">
  899. </div>
  900. </div>
  901. <!-- ═══════════════ LIBRARY VIEW ═══════════════════════════════════════ -->
  902. <div id="view-library" class="view active">
  903. <div id="library-status-bar">
  904. <div class="spinner-sm" id="library-spinner"></div>
  905. <span id="library-status-text"></span>
  906. <button id="library-refresh-btn" onclick="refreshLibrary()" style="display:none">Refresh</button>
  907. </div>
  908. <div id="library-scroll">
  909. <div id="no-content">
  910. <div class="icon"><img src="img/icons/movie_white.svg" alt=""></div>
  911. <div>No videos found. Place your videos in a <strong>Video/</strong> folder on any storage.</div>
  912. </div>
  913. <div id="movies-section" style="display:none">
  914. <div class="section-title">Movies</div>
  915. <div id="movies-grid" class="album-grid"></div>
  916. <div class="load-more-wrap"><button class="load-more-btn" id="movies-load-more" style="display:none" onclick="loadMoreSection('movies')">Load more</button></div>
  917. </div>
  918. <div id="collections-section" style="display:none">
  919. <div class="section-title">Collections</div>
  920. <div id="collections-grid" class="album-grid"></div>
  921. <div class="load-more-wrap"><button class="load-more-btn" id="collections-load-more" style="display:none" onclick="loadMoreSection('collections')">Load more</button></div>
  922. </div>
  923. <div id="series-section" style="display:none">
  924. <div class="section-title">TV / Shows</div>
  925. <div id="series-grid" class="album-grid"></div>
  926. <div class="load-more-wrap"><button class="load-more-btn" id="shows-load-more" style="display:none" onclick="loadMoreSection('shows')">Load more</button></div>
  927. </div>
  928. <div id="anime-section" style="display:none">
  929. <div class="section-title">Anime</div>
  930. <div id="anime-grid" class="album-grid"></div>
  931. <div class="load-more-wrap"><button class="load-more-btn" id="anime-load-more" style="display:none" onclick="loadMoreSection('anime')">Load more</button></div>
  932. </div>
  933. <div id="shorts-section" style="display:none">
  934. <div class="section-title">Shorts</div>
  935. <div id="shorts-grid" class="album-grid"></div>
  936. <div class="load-more-wrap"><button class="load-more-btn" id="shorts-load-more" style="display:none" onclick="loadMoreSection('shorts')">Load more</button></div>
  937. </div>
  938. </div>
  939. </div>
  940. <!-- ═══════════════ DETAIL VIEW ════════════════════════════════════════ -->
  941. <div id="view-detail" class="view">
  942. <div id="detail-hero">
  943. <div id="detail-hero-bg"></div>
  944. <div id="detail-hero-content">
  945. <div id="detail-poster"><div class="poster-placeholder"><img src="img/icons/movie_white.svg" alt=""></div></div>
  946. <div id="detail-meta">
  947. <div id="detail-title">Album Title</div>
  948. <div id="detail-subtitle">0 episodes</div>
  949. <div id="detail-actions">
  950. <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>
  951. <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>
  952. </div>
  953. </div>
  954. </div>
  955. </div>
  956. <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>
  957. <div id="season-tabs"></div>
  958. <div id="episode-scroll">
  959. <div id="episode-list"></div>
  960. </div>
  961. </div>
  962. <!-- ═══════════════ MOVIE INFO VIEW ══════════════════════════════════════ -->
  963. <div id="view-movie-info" class="view">
  964. <div id="movie-info-backdrop"></div>
  965. <button id="movie-info-back" onclick="closeMovieInfo()"><img src="img/icons/back_arrow_white.svg" width="14" height="14" alt="" style="vertical-align:middle;margin-right:4px;">Back</button>
  966. <div id="movie-info-scroll">
  967. <div id="movie-info-hero">
  968. <div id="movie-info-poster-wrap">
  969. <img id="movie-info-poster-img" src="img/thumbnail.png" alt="">
  970. </div>
  971. <div id="movie-info-meta">
  972. <div id="movie-info-title"></div>
  973. <div id="movie-info-filename"></div>
  974. <div id="movie-info-year"></div>
  975. <div id="movie-info-cast"></div>
  976. <div id="movie-info-actions">
  977. <button class="btn btn-primary" id="movie-info-play-btn"><img src="img/icons/play_black.svg" width="15" height="15" alt="" style="vertical-align:middle;margin-right:5px;">Play</button>
  978. <button class="btn btn-secondary" id="movie-info-fm-btn">Open in Files</button>
  979. <button class="yt-badge" id="movie-info-yt-btn">▶ YouTube</button>
  980. <button class="imdb-badge" id="movie-info-imdb-btn" style="display:none">IMDb</button>
  981. </div>
  982. </div>
  983. </div>
  984. <div id="movie-info-wrong-wrap">
  985. <button class="reset-info-btn" id="movie-info-reset-btn">✕ Wrong movie? Disable IMDB info</button>
  986. </div>
  987. <div id="movie-info-loading"><div class="spinner"></div><span>Loading info…</span></div>
  988. <div id="movie-info-files" style="display:none">
  989. <div class="section-title" style="font-size:16px;margin:16px 20px 10px;">Files</div>
  990. <div id="movie-info-episode-list"></div>
  991. </div>
  992. </div>
  993. </div>
  994. <!-- ═══════════════ FOLDER VIEW ════════════════════════════════════════ -->
  995. <div id="view-folder" class="view">
  996. <div id="folder-nav-bar">
  997. <div id="folder-breadcrumb"></div>
  998. </div>
  999. <div id="folder-scroll">
  1000. <div id="folder-loading"><div class="spinner"></div><span>Loading…</span></div>
  1001. <div id="folder-empty"></div>
  1002. <div id="folder-section-dirs" style="display:none">
  1003. <div class="section-title">Folders</div>
  1004. <div id="folder-dirs-grid" class="album-grid"></div>
  1005. </div>
  1006. <div id="folder-section-vids" style="display:none">
  1007. <div class="section-title">Videos</div>
  1008. <div id="folder-vids-grid" class="album-grid"></div>
  1009. </div>
  1010. </div>
  1011. </div>
  1012. <!-- ═══════════════ PLAYER VIEW ════════════════════════════════════════ -->
  1013. <div id="view-player" class="view">
  1014. <div id="video-container">
  1015. <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>
  1016. <video id="main-video" preload="metadata"></video>
  1017. <!-- Resume-from-last-position popup -->
  1018. <div id="resume-popup">
  1019. <div id="resume-popup-title">Resume playback?</div>
  1020. <div id="resume-popup-sub"></div>
  1021. <div id="resume-popup-btns">
  1022. <button class="resume-btn" id="resume-btn-continue">Resume</button>
  1023. <button class="resume-btn" id="resume-btn-restart">Start over</button>
  1024. </div>
  1025. </div>
  1026. <!-- Auto-play countdown -->
  1027. <div id="next-countdown">
  1028. <div id="next-countdown-text">Next episode in <span id="countdown-num">5</span>s&hellip;</div>
  1029. <div id="next-countdown-track"><div id="next-countdown-bar" style="width:100%"></div></div>
  1030. <button id="next-countdown-cancel" onclick="cancelCountdown()">Cancel</button>
  1031. </div>
  1032. <!-- Player context menu -->
  1033. <div id="player-ctx">
  1034. <div class="ctx-item" id="ctx-play"><i class="ctx-icon">▶</i>Play</div>
  1035. <div class="ctx-item" id="ctx-pause"><i class="ctx-icon">⏸</i>Pause</div>
  1036. <div class="ctx-divider"></div>
  1037. <div class="ctx-item" id="ctx-prev"><i class="ctx-icon">⏮</i>Previous</div>
  1038. <div class="ctx-item" id="ctx-next"><i class="ctx-icon">⏭</i>Next</div>
  1039. <div class="ctx-divider"></div>
  1040. <div class="ctx-item" id="ctx-repeat"><i class="ctx-icon">↺</i>Repeat: Off</div>
  1041. <div class="ctx-divider"></div>
  1042. <div class="ctx-item" id="ctx-props">Video Properties</div>
  1043. <!-- <div class="ctx-item" id="ctx-stats"><i class="ctx-icon">⧉</i>Streaming Stats</div> -->
  1044. </div>
  1045. <!-- Video info / stats modal -->
  1046. <div id="video-info-modal">
  1047. <h3><span id="video-info-title">Video Properties</span><button id="video-info-close" onclick="closeVideoInfo()">✕</button></h3>
  1048. <div id="video-info-tabs">
  1049. <div class="info-tab active" onclick="showInfoTab('props')">Properties</div>
  1050. <div class="info-tab" onclick="showInfoTab('stats')">Stats</div>
  1051. </div>
  1052. <div id="video-info-body"></div>
  1053. </div>
  1054. <!-- Custom controls -->
  1055. <div id="video-controls">
  1056. <div id="progress-wrap">
  1057. <div id="progress-bar" style="width:0%"></div>
  1058. <div id="progress-thumb" style="left:0%"></div>
  1059. </div>
  1060. <div id="controls-row">
  1061. <button class="ctrl-btn" id="ctrl-prev" title="Previous (←)"><img src="img/icons/skip_previous_white.svg" alt=""></button>
  1062. <button class="ctrl-btn" id="ctrl-play" title="Play/Pause (Space)"><img id="play-icon" src="img/icons/play_white.svg" alt=""></button>
  1063. <button class="ctrl-btn" id="ctrl-next" title="Next (→)"><img src="img/icons/skip_next_white.svg" alt=""></button>
  1064. <div id="volume-wrap">
  1065. <button class="ctrl-btn" id="ctrl-mute" title="Mute (M)"><img id="mute-icon" src="img/icons/volume_white.svg" alt=""></button>
  1066. <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
  1067. </div>
  1068. <span id="time-display">0:00 / 0:00</span>
  1069. <span id="now-playing-title"></span>
  1070. <button class="ctrl-btn" id="ctrl-list" title="Episode list (L)"><img src="img/icons/menu_white.svg" alt=""></button>
  1071. <button class="ctrl-btn" id="ctrl-fs" title="Fullscreen (F)"><img src="img/icons/fullscreen_white.svg" alt=""></button>
  1072. </div>
  1073. </div>
  1074. </div>
  1075. <!-- Sidebar playlist -->
  1076. <div id="playlist-sidebar">
  1077. <div id="sidebar-header">
  1078. <span>Playlist</span>
  1079. <label class="autoplay-label" title="Auto-play next episode">
  1080. <input type="checkbox" id="autoplay-check">
  1081. <span class="autoplay-track"></span>
  1082. <span>Auto</span>
  1083. </label>
  1084. <button id="sidebar-close" onclick="toggleSidebar()"><img style="width: 16px;" src="img/icons/close_white.svg" alt=""></button>
  1085. </div>
  1086. <div id="sidebar-list"></div>
  1087. </div>
  1088. </div>
  1089. </div>
  1090. <!-- Toast -->
  1091. <div id="toast"></div>
  1092. <!-- ═══════════════ JAVASCRIPT ════════════════════════════════════════════════ -->
  1093. <script>
  1094. // ─── All configurable paths come from backend/common.js ──────────────────────
  1095. // (SCRIPT_GET_LIBRARY, SCRIPT_GET_EPISODES, SCRIPT_GET_THUMBNAIL, MEDIA_API)
  1096. // ─── Library cache ────────────────────────────────────────────────────────────
  1097. // Cache is stored server-side (user:/Document/Appdata/Movie/library_cache.json)
  1098. // so it is shared across devices for the same user account.
  1099. var libraryNeedsRedraw = false; // set when bg-refresh finishes outside the library view
  1100. // ─── App state ────────────────────────────────────────────────────────────────
  1101. var library = []; // full album array from server
  1102. var currentAlbum = null; // album object currently shown in detail view
  1103. var currentSeason = null; // season object currently active
  1104. var currentEpisodes = []; // flat episode array for current season/album
  1105. var playingIndex = -1; // index in currentEpisodes being played
  1106. // TV-remote focus management
  1107. var focusMode = false; // set to true when arrow-key pressed
  1108. var focusedEl = null;
  1109. // Controls auto-hide timer
  1110. var controlsTimer = null;
  1111. // Library pagination
  1112. var PAGE_SIZE = 24;
  1113. var moviesData = [];
  1114. var collectionsData = [];
  1115. var showsData = [];
  1116. var shortsData = [];
  1117. var animeData = [];
  1118. var moviesShown = 0;
  1119. var collectionsShown = 0;
  1120. var showsShown = 0;
  1121. var shortsShown = 0;
  1122. var animeShown = 0;
  1123. // Auto-play between episodes
  1124. var autoplayEnabled = localStorage.getItem('movie_autoplay') !== '0'; // on by default
  1125. var countdownTimer = null;
  1126. // Single-repeat
  1127. var repeatSingle = false;
  1128. // Movie info panel
  1129. var currentMovieAlbum = null;
  1130. var movieInfoEpisodes = [];
  1131. var currentMovieImdbTitle = null; // IMDB title once fetched; null until then
  1132. // Player return-destination and watch-position tracking
  1133. var playerReturnView = 'library';
  1134. var pendingResumePos = 0;
  1135. var watchSaveInterval = null;
  1136. // Folder browse state
  1137. var folderViewPath = '/';
  1138. var folderViewVideos = [];
  1139. // ─── Movie info panel ─────────────────────────────────────────────────────────
  1140. function openMovieInfo(album) {
  1141. currentMovieAlbum = album;
  1142. currentAlbum = album;
  1143. movieInfoEpisodes = [];
  1144. currentMovieImdbTitle = null;
  1145. // Seed with local data immediately
  1146. $('#movie-info-title').text(album.name);
  1147. $('#movie-info-filename').hide().text('');
  1148. $('#movie-info-year').text('');
  1149. $('#movie-info-cast').text('');
  1150. $('#movie-info-imdb-btn').hide();
  1151. $('#movie-info-wrong-wrap').hide();
  1152. $('#movie-info-files').hide();
  1153. $('#movie-info-loading').addClass('active');
  1154. var localThumb = album.thumbnail
  1155. ? 'data:image/jpeg;base64,' + album.thumbnail
  1156. : 'img/thumbnail.png';
  1157. $('#movie-info-poster-img').attr('src', localThumb);
  1158. $('#movie-info-backdrop').css('background-image', 'url(' + localThumb + ')');
  1159. // Bind Play button
  1160. $('#movie-info-play-btn').off('click').on('click', function () {
  1161. if (movieInfoEpisodes.length > 0) {
  1162. currentAlbum = album; currentSeason = null;
  1163. currentEpisodes = movieInfoEpisodes;
  1164. startPlayback(0);
  1165. }
  1166. });
  1167. // Bind Open in Files — opens file manager at the movie's location
  1168. $('#movie-info-fm-btn').off('click').on('click', function () {
  1169. if (!currentMovieAlbum) { return; }
  1170. if (currentMovieAlbum._singleFile) {
  1171. var fp = currentMovieAlbum._singleFile;
  1172. var dir = fp.substring(0, fp.lastIndexOf('/'));
  1173. var fname = fp.split('/').pop();
  1174. ao_module_openPath(dir, fname);
  1175. } else {
  1176. ao_module_openPath(currentMovieAlbum.folderpath);
  1177. }
  1178. });
  1179. // Bind YouTube search — uses IMDB title once available, raw name otherwise
  1180. $('#movie-info-yt-btn').off('click').on('click', function () {
  1181. var term = currentMovieImdbTitle || (currentMovieAlbum ? currentMovieAlbum.name : '');
  1182. window.open('https://www.youtube.com/results?search_query=' + encodeURIComponent(term + ' trailer'), '_blank');
  1183. });
  1184. showView('movie-info');
  1185. // Fetch IMDB metadata
  1186. ao_module_agirun(SCRIPT_GET_MOVIE_INFO, { movie: album.name }, function (data) {
  1187. $('#movie-info-loading').removeClass('active');
  1188. if (data && !data.error) { applyMovieInfo(data); }
  1189. }, function () {
  1190. $('#movie-info-loading').removeClass('active');
  1191. });
  1192. // Build file list
  1193. if (album._singleFile) {
  1194. var ext = '.' + album._singleFile.split('.').pop().toLowerCase();
  1195. movieInfoEpisodes = [{ name: album.name, filepath: album._singleFile, ext: ext, index: 0 }];
  1196. // Single file — file list not needed (Play button is enough)
  1197. } else {
  1198. ao_module_agirun(SCRIPT_GET_EPISODES, { folder: album.folderpath }, function (data) {
  1199. if (data && !data.error && data.length > 0) {
  1200. movieInfoEpisodes = data;
  1201. if (data.length > 1) { renderMovieFileList(data); }
  1202. }
  1203. });
  1204. }
  1205. }
  1206. function applyMovieInfo(info) {
  1207. var imdbTitle = info['#TITLE'] || '';
  1208. var year = info['#YEAR'] ? String(info['#YEAR']) : '';
  1209. var actors = info['#ACTORS'] || '';
  1210. var imdbUrl = info['#IMDB_URL'] || '';
  1211. var poster = info['#IMG_POSTER'] || '';
  1212. // Replace the displayed title with the canonical IMDB title and show the
  1213. // original filename as a small italic subtitle underneath.
  1214. if (imdbTitle) {
  1215. currentMovieImdbTitle = imdbTitle;
  1216. $('#movie-info-title').text(imdbTitle);
  1217. if (currentMovieAlbum && currentMovieAlbum.name !== imdbTitle) {
  1218. $('#movie-info-filename').text(currentMovieAlbum.name).show();
  1219. }
  1220. }
  1221. if (year) { $('#movie-info-year').text(year); }
  1222. if (actors) {
  1223. $('#movie-info-cast').html(
  1224. '<span style="color:var(--text-sub);font-weight:600">Cast</span> ' + escapeHtml(actors)
  1225. );
  1226. }
  1227. if (poster) {
  1228. var img = document.getElementById('movie-info-poster-img');
  1229. img.onerror = function () {
  1230. img.src = (currentMovieAlbum && currentMovieAlbum.thumbnail)
  1231. ? 'data:image/jpeg;base64,' + currentMovieAlbum.thumbnail
  1232. : 'img/thumbnail.png';
  1233. img.onerror = null;
  1234. };
  1235. img.onload = function () {
  1236. $('#movie-info-backdrop').css('background-image', 'url(' + poster + ')');
  1237. img.onload = null;
  1238. };
  1239. img.src = poster;
  1240. }
  1241. if (imdbUrl) {
  1242. $('#movie-info-imdb-btn').show().off('click').on('click', function () {
  1243. window.open(imdbUrl, '_blank');
  1244. });
  1245. }
  1246. // Show the "wrong movie" reset button now that IMDB data is displayed
  1247. $('#movie-info-wrong-wrap').show();
  1248. $('#movie-info-reset-btn').off('click').on('click', function () {
  1249. if (!currentMovieAlbum) { return; }
  1250. ao_module_agirun(SCRIPT_DISABLE_MOVIE_INFO, { movie: currentMovieAlbum.name },
  1251. function () { resetMovieInfo(); },
  1252. function () { resetMovieInfo(); } // reset UI even if backend call fails
  1253. );
  1254. });
  1255. }
  1256. // Reset the movie info panel to local-only state (used after disabling IMDB info)
  1257. function resetMovieInfo() {
  1258. if (!currentMovieAlbum) { return; }
  1259. currentMovieImdbTitle = null;
  1260. var album = currentMovieAlbum;
  1261. $('#movie-info-title').text(album.name);
  1262. $('#movie-info-filename').hide().text('');
  1263. $('#movie-info-year').text('');
  1264. $('#movie-info-cast').text('');
  1265. $('#movie-info-imdb-btn').hide();
  1266. $('#movie-info-wrong-wrap').hide();
  1267. // Restore local thumbnail as poster and backdrop
  1268. var localThumb = album.thumbnail
  1269. ? 'data:image/jpeg;base64,' + album.thumbnail
  1270. : 'img/thumbnail.png';
  1271. document.getElementById('movie-info-poster-img').src = localThumb;
  1272. $('#movie-info-backdrop').css('background-image', 'url(' + localThumb + ')');
  1273. }
  1274. function renderMovieFileList(episodes) {
  1275. var $list = $('#movie-info-episode-list').empty();
  1276. episodes.forEach(function (ep, i) {
  1277. var row = $('<div class="episode-item" tabindex="0" role="button">'
  1278. + '<div class="ep-thumb"><div class="ep-thumb-placeholder"><img src="img/icons/play_white.svg" alt=""></div></div>'
  1279. + '<div class="ep-info">'
  1280. + '<div class="ep-name">' + escapeHtml(ep.name) + '</div>'
  1281. + '<div class="ep-path">' + escapeHtml((ep.ext || '').replace('.', '').toUpperCase()) + '</div>'
  1282. + '</div>'
  1283. + '<div class="ep-play-icon"><img src="img/icons/play_white.svg" alt=""></div>'
  1284. + '</div>');
  1285. row.data('idx', i);
  1286. row.on('click', function () {
  1287. currentAlbum = currentMovieAlbum; currentSeason = null;
  1288. currentEpisodes = movieInfoEpisodes;
  1289. startPlayback($(this).data('idx'));
  1290. });
  1291. row.on('keydown', function (e) {
  1292. if (e.key === 'Enter' || e.key === ' ') {
  1293. e.preventDefault();
  1294. currentAlbum = currentMovieAlbum; currentSeason = null;
  1295. currentEpisodes = movieInfoEpisodes;
  1296. startPlayback($(this).data('idx'));
  1297. }
  1298. });
  1299. $list.append(row);
  1300. // Lazy thumbnail
  1301. (function (epObj, rowEl) {
  1302. ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: epObj.filepath }, function (data) {
  1303. if (data && !data.error && data.length > 20) {
  1304. rowEl.find('.ep-thumb-placeholder')
  1305. .replaceWith('<img src="data:image/jpeg;base64,' + data + '" alt="">');
  1306. }
  1307. });
  1308. })(ep, row);
  1309. });
  1310. $('#movie-info-files').show();
  1311. }
  1312. function closeMovieInfo() {
  1313. currentMovieAlbum = null;
  1314. movieInfoEpisodes = [];
  1315. showView('library');
  1316. }
  1317. // ─── Watch position (resume) ──────────────────────────────────────────────────
  1318. function saveWatchPosition() {
  1319. var vid = document.getElementById('main-video');
  1320. if (playingIndex < 0 || !currentEpisodes || !vid.duration || vid.duration < 3600) { return; }
  1321. var ep = currentEpisodes[playingIndex];
  1322. if (!ep || vid.currentTime < 10) { return; }
  1323. ao_module_agirun(SCRIPT_SET_WATCHTIME, {
  1324. filepath: ep.filepath,
  1325. position: Math.floor(vid.currentTime),
  1326. duration: Math.floor(vid.duration)
  1327. }, function () {}, function () {});
  1328. }
  1329. function clearWatchPosition() {
  1330. if (playingIndex < 0 || !currentEpisodes) { return; }
  1331. var ep = currentEpisodes[playingIndex];
  1332. if (!ep) { return; }
  1333. ao_module_agirun(SCRIPT_SET_WATCHTIME, { filepath: ep.filepath, position: 0, duration: 0 },
  1334. function () {}, function () {});
  1335. }
  1336. function showResumePopup(savedPos, duration) {
  1337. var vid = document.getElementById('main-video');
  1338. vid.pause();
  1339. pendingResumePos = savedPos;
  1340. $('#resume-popup-sub').text(
  1341. 'Last position: ' + formatTime(savedPos) + ' of ' + formatTime(duration)
  1342. );
  1343. $('#resume-popup').addClass('active');
  1344. showControls();
  1345. $('#resume-btn-continue').off('click').on('click', function () {
  1346. vid.currentTime = pendingResumePos;
  1347. vid.play();
  1348. $('#resume-popup').removeClass('active');
  1349. });
  1350. $('#resume-btn-restart').off('click').on('click', function () {
  1351. vid.play();
  1352. $('#resume-popup').removeClass('active');
  1353. });
  1354. }
  1355. // ─── Tab switching ────────────────────────────────────────────────────────────
  1356. function switchTab(tab) {
  1357. if (tab === 'folder') {
  1358. var wasAlreadyFolder = $('#view-folder').hasClass('active');
  1359. showView('folder');
  1360. if (!wasAlreadyFolder) { navigateFolder(folderViewPath); }
  1361. } else {
  1362. showView('library');
  1363. }
  1364. }
  1365. // ─── Folder navigation ────────────────────────────────────────────────────────
  1366. function buildBreadcrumbs(path) {
  1367. var crumbs = [{ name: 'Home', path: '/' }];
  1368. if (!path || path === '/') { return crumbs; }
  1369. var colonIdx = path.indexOf(':/');
  1370. if (colonIdx < 0) { return crumbs; }
  1371. var root = path.substring(0, colonIdx + 2);
  1372. crumbs.push({ name: root, path: root });
  1373. var rest = path.substring(colonIdx + 2);
  1374. if (!rest) { return crumbs; }
  1375. var parts = rest.split('/').filter(function (s) { return s.length > 0; });
  1376. var cumPath = root;
  1377. for (var i = 0; i < parts.length; i++) {
  1378. cumPath += parts[i] + '/';
  1379. crumbs.push({ name: parts[i], path: cumPath });
  1380. }
  1381. return crumbs;
  1382. }
  1383. function renderBreadcrumb(path) {
  1384. var crumbs = buildBreadcrumbs(path);
  1385. var $bc = $('#folder-breadcrumb').empty();
  1386. for (var i = 0; i < crumbs.length; i++) {
  1387. var crumb = crumbs[i];
  1388. var isLast = (i === crumbs.length - 1);
  1389. if (i > 0) { $bc.append('<span class="crumb-sep">›</span>'); }
  1390. var btn = $('<button class="crumb-btn' + (isLast ? ' crumb-current' : '') + '">'
  1391. + escapeHtml(crumb.name) + '</button>');
  1392. if (!isLast) {
  1393. (function (p) { btn.on('click', function () { navigateFolder(p); }); })(crumb.path);
  1394. }
  1395. $bc.append(btn);
  1396. }
  1397. }
  1398. function navigateFolder(path) {
  1399. folderViewPath = path || '/';
  1400. renderBreadcrumb(folderViewPath);
  1401. loadFolderContents(folderViewPath);
  1402. }
  1403. function loadFolderContents(path) {
  1404. $('#folder-loading').addClass('active');
  1405. $('#folder-empty').hide();
  1406. $('#folder-section-dirs').hide();
  1407. $('#folder-section-vids').hide();
  1408. var params = (path && path !== '/') ? { folder: path } : { folder: '' };
  1409. ao_module_agirun(SCRIPT_LIST_FOLDER, params, function (data) {
  1410. $('#folder-loading').removeClass('active');
  1411. if (!data || data.error) {
  1412. $('#folder-empty').text(data && data.error ? data.error : 'Failed to load folder.').show();
  1413. return;
  1414. }
  1415. renderFolderContents(data);
  1416. }, function () {
  1417. $('#folder-loading').removeClass('active');
  1418. $('#folder-empty').text('Error loading folder.').show();
  1419. });
  1420. }
  1421. function renderFolderContents(data) {
  1422. folderViewVideos = data.videos || [];
  1423. var hasDirs = data.folders && data.folders.length > 0;
  1424. var hasVids = data.videos && data.videos.length > 0;
  1425. if (!hasDirs && !hasVids) {
  1426. $('#folder-empty').text('This folder is empty.').show();
  1427. return;
  1428. }
  1429. if (hasDirs) {
  1430. var $dirs = $('#folder-dirs-grid').empty();
  1431. $('#folder-section-dirs').show();
  1432. data.folders.forEach(function (f) {
  1433. var thumbHtml;
  1434. if (f.isRoot) {
  1435. thumbHtml = '<div class="poster-placeholder"><img src="img/root_placeholder.png" alt=""></div>';
  1436. } else if (f.hasVideos && f.thumbnail && f.thumbnail.length > 20) {
  1437. thumbHtml = '<img class="poster" src="data:image/jpeg;base64,' + f.thumbnail + '" alt="">';
  1438. } else if (f.hasVideos) {
  1439. thumbHtml = '<div class="poster-placeholder"><img src="img/playlist_placeholder.png" alt=""></div>';
  1440. } else {
  1441. thumbHtml = '<div class="poster-placeholder"><img src="img/folder_placeholder.png" alt=""></div>';
  1442. }
  1443. var meta = f.isRoot ? 'Storage root' : (f.hasVideos ? 'Playlist' : 'Folder');
  1444. var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(f.name) + '">'
  1445. + thumbHtml
  1446. + '<div class="card-info">'
  1447. + '<div class="card-title">' + escapeHtml(f.name) + '</div>'
  1448. + '<div class="card-meta">' + escapeHtml(meta) + '</div>'
  1449. + '</div>'
  1450. + '</div>');
  1451. card.on('click', function () { navigateFolder(f.path); });
  1452. card.on('keydown', function (e) {
  1453. if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigateFolder(f.path); }
  1454. });
  1455. $dirs.append(card);
  1456. });
  1457. }
  1458. if (hasVids) {
  1459. var $vids = $('#folder-vids-grid').empty();
  1460. $('#folder-section-vids').show();
  1461. data.videos.forEach(function (v, i) {
  1462. var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(v.name) + '">'
  1463. + '<div class="poster-placeholder"><img src="img/thumbnail.png" alt=""></div>'
  1464. + '<div class="card-info">'
  1465. + '<div class="card-title">' + escapeHtml(v.name) + '</div>'
  1466. + '<div class="card-meta">' + escapeHtml((v.ext || '').replace('.', '').toUpperCase()) + '</div>'
  1467. + '</div>'
  1468. + '</div>');
  1469. card.data('vidIdx', i);
  1470. card.data('vidInfo', v);
  1471. card.on('click', function () { playFolderVideos(folderViewVideos, $(this).data('vidIdx')); });
  1472. card.on('keydown', function (e) {
  1473. if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); playFolderVideos(folderViewVideos, $(this).data('vidIdx')); }
  1474. });
  1475. $vids.append(card);
  1476. });
  1477. // Lazy-load video thumbnails
  1478. $('#folder-vids-grid .album-card').each(function () {
  1479. var $card = $(this);
  1480. var v = $card.data('vidInfo');
  1481. if (!v) { return; }
  1482. ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: v.filepath }, function (data) {
  1483. if (data && !data.error && data.length > 20) {
  1484. $card.find('.poster-placeholder')
  1485. .replaceWith('<img class="poster" src="data:image/jpeg;base64,' + data + '" alt="">');
  1486. }
  1487. });
  1488. });
  1489. }
  1490. }
  1491. function playFolderVideos(videos, startIndex) {
  1492. if (!videos || videos.length === 0) { return; }
  1493. var segments = folderViewPath.split('/').filter(function (s) { return s.length > 0; });
  1494. var folderName = segments.length > 0 ? segments[segments.length - 1] : 'Videos';
  1495. currentAlbum = { name: folderName, type: 'folder', folderpath: folderViewPath };
  1496. currentSeason = null;
  1497. currentEpisodes = videos.map(function (v, i) {
  1498. return { name: v.name, filepath: v.filepath, ext: v.ext, index: i };
  1499. });
  1500. startPlayback(startIndex);
  1501. }
  1502. // ─── Init ─────────────────────────────────────────────────────────────────────
  1503. $(document).ready(function () {
  1504. loadLibrary();
  1505. initVideoControls();
  1506. initKeyboard();
  1507. initSearch();
  1508. initContextMenu();
  1509. // Autoplay toggle
  1510. $('#autoplay-check').prop('checked', autoplayEnabled);
  1511. $('#autoplay-check').on('change', function () {
  1512. autoplayEnabled = $(this).is(':checked');
  1513. localStorage.setItem('movie_autoplay', autoplayEnabled ? '1' : '0');
  1514. });
  1515. });
  1516. // ─── Library status bar helpers ────────────────────────────────────────────────
  1517. // spinning=true shows the spinner and hides the Refresh button, and vice-versa.
  1518. function setLibraryStatus(text, spinning) {
  1519. $('#library-status-text').text(text);
  1520. $('#library-spinner').toggle(spinning);
  1521. $('#library-refresh-btn').toggle(!spinning);
  1522. }
  1523. function timeAgo(ts) {
  1524. var d = Math.floor((Date.now() - ts) / 1000);
  1525. if (d < 60) { return 'just now'; }
  1526. if (d < 3600) { return Math.floor(d / 60) + 'm ago'; }
  1527. if (d < 86400) { return Math.floor(d / 3600) + 'h ago'; }
  1528. return Math.floor(d / 86400) + 'd ago';
  1529. }
  1530. // Re-render the library grid, preserving an active search filter if present.
  1531. function renderCurrentLibrary() {
  1532. var q = $('#search-input').val().trim().toLowerCase();
  1533. renderLibrary(q ? library.filter(function (a) { return a.name.toLowerCase().indexOf(q) > -1; }) : library);
  1534. }
  1535. // Run a background full scan. getLibrary.js writes the cache file server-side
  1536. // before it sends its response, so the cache is updated even if the tab closes
  1537. // partway through — the browser just won't receive the response in that case.
  1538. function backgroundScanLibrary() {
  1539. ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
  1540. if (!data || data.error) {
  1541. setLibraryStatus('Refresh failed · showing cached data', false);
  1542. return;
  1543. }
  1544. library = data;
  1545. if ($('#view-library').hasClass('active')) {
  1546. renderCurrentLibrary();
  1547. libraryNeedsRedraw = false;
  1548. } else {
  1549. libraryNeedsRedraw = true;
  1550. }
  1551. var n = library.length;
  1552. setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
  1553. }, function () {
  1554. setLibraryStatus('Refresh failed · showing cached data', false);
  1555. });
  1556. }
  1557. // ─── Load library (server-side cache-first) ────────────────────────────────────
  1558. // 1. Ask getLibraryCache.js for the last saved scan result (milliseconds, no scan).
  1559. // 2a. Cache hit → render immediately, kick off backgroundScanLibrary().
  1560. // 2b. Cache miss → show loading overlay, run full scan, render when done.
  1561. function loadLibrary() {
  1562. ao_module_agirun(SCRIPT_GET_LIBRARY_CACHE, {}, function (cached) {
  1563. if (cached && !cached.error && Array.isArray(cached.data)) {
  1564. // Fast path: paint the UI from cache right away
  1565. $('#loading-overlay').hide();
  1566. library = cached.data;
  1567. renderLibrary(library);
  1568. setLibraryStatus('Cached · ' + timeAgo(cached.ts) + ' · refreshing…', true);
  1569. backgroundScanLibrary();
  1570. } else {
  1571. // Cold start: no cache yet — full scan with loading overlay
  1572. setLibraryStatus('Loading…', true);
  1573. ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
  1574. $('#loading-overlay').fadeOut(300);
  1575. if (!data || data.error) {
  1576. showToast('Failed to load library');
  1577. setLibraryStatus('Failed to load library', false);
  1578. return;
  1579. }
  1580. library = data;
  1581. renderLibrary(library);
  1582. var n = library.length;
  1583. setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
  1584. }, function () {
  1585. $('#loading-overlay').fadeOut(300);
  1586. showToast('Error loading library');
  1587. setLibraryStatus('Error loading library', false);
  1588. });
  1589. }
  1590. }, function () {
  1591. // getLibraryCache.js itself failed — treat as cold start
  1592. setLibraryStatus('Loading…', true);
  1593. ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
  1594. $('#loading-overlay').fadeOut(300);
  1595. if (!data || data.error) {
  1596. showToast('Failed to load library');
  1597. setLibraryStatus('Failed to load library', false);
  1598. return;
  1599. }
  1600. library = data;
  1601. renderLibrary(library);
  1602. var n = library.length;
  1603. setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
  1604. }, function () {
  1605. $('#loading-overlay').fadeOut(300);
  1606. showToast('Error loading library');
  1607. setLibraryStatus('Error loading library', false);
  1608. });
  1609. });
  1610. }
  1611. // ─── Manual refresh (Refresh button) ──────────────────────────────────────────
  1612. // Triggers a full scan; getLibrary.js saves the cache file automatically.
  1613. function refreshLibrary() {
  1614. $('#library-refresh-btn').prop('disabled', true);
  1615. setLibraryStatus('Refreshing…', true);
  1616. ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
  1617. $('#library-refresh-btn').prop('disabled', false);
  1618. if (!data || data.error) {
  1619. showToast('Failed to refresh library');
  1620. setLibraryStatus('Refresh failed', false);
  1621. return;
  1622. }
  1623. library = data;
  1624. renderCurrentLibrary();
  1625. libraryNeedsRedraw = false;
  1626. var n = library.length;
  1627. setLibraryStatus(n + ' item' + (n !== 1 ? 's' : '') + ' · just refreshed', false);
  1628. }, function () {
  1629. $('#library-refresh-btn').prop('disabled', false);
  1630. showToast('Error refreshing library');
  1631. setLibraryStatus('Refresh failed', false);
  1632. });
  1633. }
  1634. // ─── Render library grid ──────────────────────────────────────────────────────
  1635. function renderLibrary(albums) {
  1636. moviesData = albums.filter(function (a) { return a.type === 'movie'; });
  1637. collectionsData = albums.filter(function (a) { return a.type === 'collection'; });
  1638. showsData = albums.filter(function (a) { return a.type === 'series'; });
  1639. animeData = albums.filter(function (a) { return a.type === 'anime'; });
  1640. shortsData = albums.filter(function (a) { return a.type === 'short'; });
  1641. moviesShown = 0;
  1642. collectionsShown = 0;
  1643. showsShown = 0;
  1644. animeShown = 0;
  1645. shortsShown = 0;
  1646. $('#movies-grid').empty();
  1647. $('#collections-grid').empty();
  1648. $('#series-grid').empty();
  1649. $('#shorts-grid').empty();
  1650. $('#anime-grid').empty();
  1651. if (!moviesData.length && !collectionsData.length && !showsData.length && !animeData.length && !shortsData.length) {
  1652. $('#no-content').show();
  1653. return;
  1654. }
  1655. $('#no-content').hide();
  1656. if (moviesData.length > 0) { $('#movies-section').show(); loadMoreSection('movies'); }
  1657. else { $('#movies-section').hide(); }
  1658. if (collectionsData.length > 0) { $('#collections-section').show(); loadMoreSection('collections'); }
  1659. else { $('#collections-section').hide(); }
  1660. if (showsData.length > 0) { $('#series-section').show(); loadMoreSection('shows'); }
  1661. else { $('#series-section').hide(); }
  1662. if (animeData.length > 0) { $('#anime-section').show(); loadMoreSection('anime'); }
  1663. else { $('#anime-section').hide(); }
  1664. if (shortsData.length > 0) { $('#shorts-section').show(); loadMoreSection('shorts'); }
  1665. else { $('#shorts-section').hide(); }
  1666. }
  1667. function loadMoreSection(which) {
  1668. var data, $grid, $btn, start, end, i;
  1669. if (which === 'movies') {
  1670. data = moviesData;
  1671. $grid = $('#movies-grid');
  1672. $btn = $('#movies-load-more');
  1673. start = moviesShown;
  1674. end = Math.min(start + PAGE_SIZE, data.length);
  1675. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  1676. moviesShown = end;
  1677. $btn.toggle(moviesShown < data.length);
  1678. } else if (which === 'shows') {
  1679. data = showsData;
  1680. $grid = $('#series-grid');
  1681. $btn = $('#shows-load-more');
  1682. start = showsShown;
  1683. end = Math.min(start + PAGE_SIZE, data.length);
  1684. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  1685. showsShown = end;
  1686. $btn.toggle(showsShown < data.length);
  1687. } else if (which === 'collections') {
  1688. data = collectionsData;
  1689. $grid = $('#collections-grid');
  1690. $btn = $('#collections-load-more');
  1691. start = collectionsShown;
  1692. end = Math.min(start + PAGE_SIZE, data.length);
  1693. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  1694. collectionsShown = end;
  1695. $btn.toggle(collectionsShown < data.length);
  1696. } else if (which === 'anime') {
  1697. data = animeData;
  1698. $grid = $('#anime-grid');
  1699. $btn = $('#anime-load-more');
  1700. start = animeShown;
  1701. end = Math.min(start + PAGE_SIZE, data.length);
  1702. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  1703. animeShown = end;
  1704. $btn.toggle(animeShown < data.length);
  1705. } else {
  1706. data = shortsData;
  1707. $grid = $('#shorts-grid');
  1708. $btn = $('#shorts-load-more');
  1709. start = shortsShown;
  1710. end = Math.min(start + PAGE_SIZE, data.length);
  1711. for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
  1712. shortsShown = end;
  1713. $btn.toggle(shortsShown < data.length);
  1714. }
  1715. loadCardThumbnails();
  1716. }
  1717. function buildCard(album, idx) {
  1718. var thumb = album.thumbnail && album.thumbnail.length > 0
  1719. ? '<img class="poster" src="data:image/jpeg;base64,' + album.thumbnail + '" alt="">'
  1720. : '<div class="poster-placeholder"><img src="img/thumbnail.png" alt=""></div>';
  1721. var badge = album.type === 'series' ? '<span class="badge series">Series</span>'
  1722. : album.type === 'anime' ? '<span class="badge anime">Anime</span>'
  1723. : '';
  1724. var ext = album._singleFile ? album._singleFile.split('.').pop().toUpperCase() : '';
  1725. var meta = (album.type === 'series' || album.type === 'anime')
  1726. ? album.episodeCount + ' ep'
  1727. : album.type === 'short'
  1728. ? (ext || 'Short')
  1729. : album.type === 'collection'
  1730. ? album.episodeCount + (album.episodeCount > 1 ? ' videos' : ' video')
  1731. : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
  1732. var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(album.name) + '">'
  1733. + thumb
  1734. + badge
  1735. + '<div class="card-info">'
  1736. + '<div class="card-title">' + escapeHtml(album.name) + '</div>'
  1737. + '<div class="card-meta">' + escapeHtml(meta) + '</div>'
  1738. + '</div>'
  1739. + '</div>');
  1740. card.data('album', album);
  1741. card.on('click', function () {
  1742. if (album.type === 'short' && album._singleFile) {
  1743. currentAlbum = album; currentSeason = null;
  1744. currentEpisodes = [{ name: album.name, filepath: album._singleFile,
  1745. ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
  1746. startPlayback(0);
  1747. } else if (album.type === 'movie') {
  1748. openMovieInfo(album);
  1749. } else { openDetail(album); }
  1750. });
  1751. card.on('keydown', function (e) {
  1752. if (e.key === 'Enter' || e.key === ' ') {
  1753. e.preventDefault();
  1754. if (album.type === 'short' && album._singleFile) {
  1755. currentAlbum = album; currentSeason = null;
  1756. currentEpisodes = [{ name: album.name, filepath: album._singleFile,
  1757. ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
  1758. startPlayback(0);
  1759. } else if (album.type === 'movie') {
  1760. openMovieInfo(album);
  1761. } else { openDetail(album); }
  1762. }
  1763. });
  1764. return card;
  1765. }
  1766. // Load thumbnails in background (for cards that don't have one embedded)
  1767. function loadCardThumbnails() {
  1768. $('.album-card').each(function () {
  1769. var $card = $(this);
  1770. var album = $card.data('album');
  1771. if (!album || album.thumbnail) { return; } // already has thumb
  1772. // For shorts use the actual video file; folder path would yield the generic folder thumbnail
  1773. var fileParam = (album.type === 'short' && album._singleFile) ? album._singleFile : album.folderpath;
  1774. ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: fileParam }, function (data) {
  1775. if (data && !data.error && data.length > 20) {
  1776. $card.find('.poster-placeholder')
  1777. .replaceWith('<img class="poster" src="data:image/jpeg;base64,' + data + '" alt="">');
  1778. }
  1779. });
  1780. });
  1781. }
  1782. // ─── Detail view ──────────────────────────────────────────────────────────────
  1783. function openDetail(album) {
  1784. currentAlbum = album;
  1785. // Hero background
  1786. var bg = album.thumbnail ? 'data:image/jpeg;base64,' + album.thumbnail : '';
  1787. $('#detail-hero-bg').css('background-image', bg ? 'url(' + bg + ')' : 'none');
  1788. // Poster
  1789. var posterHtml = album.thumbnail
  1790. ? '<img src="data:image/jpeg;base64,' + album.thumbnail + '" alt="" style="width:100%;aspect-ratio:2/3;object-fit:cover;">'
  1791. : '<div class="poster-placeholder"><img src="img/icons/movie_white.svg" alt=""></div>';
  1792. $('#detail-poster').html(posterHtml);
  1793. // Title & subtitle
  1794. $('#detail-title').text(album.name);
  1795. var sub = (album.type === 'series' || album.type === 'anime')
  1796. ? album.seasons.length + ' season' + (album.seasons.length !== 1 ? 's' : '') + ' · ' + album.episodeCount + ' episodes'
  1797. : album.type === 'collection'
  1798. ? album.episodeCount + (album.episodeCount > 1 ? ' videos' : ' video')
  1799. : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
  1800. $('#detail-subtitle').text(sub);
  1801. // Season tabs
  1802. var $tabs = $('#season-tabs').empty();
  1803. if ((album.type === 'series' || album.type === 'anime') && album.seasons.length > 0) {
  1804. album.seasons.forEach(function (s, i) {
  1805. var tab = $('<button class="season-tab" tabindex="0">' + escapeHtml(s.name) + '</button>');
  1806. tab.data('season', s);
  1807. tab.on('click', function () { selectSeason($(this).data('season')); activateTab(this); });
  1808. $tabs.append(tab);
  1809. });
  1810. $tabs.show();
  1811. selectSeason(album.seasons[0]);
  1812. $tabs.find('.season-tab').first().addClass('active');
  1813. } else {
  1814. $tabs.hide();
  1815. // Load the folder directly as episodes
  1816. loadEpisodes(album.folderpath);
  1817. }
  1818. // Play / shuffle button bindings
  1819. $('#btn-play-first').off('click').on('click', function () {
  1820. if (currentEpisodes.length > 0) { startPlayback(0); }
  1821. });
  1822. $('#btn-shuffle').off('click').on('click', function () {
  1823. if (currentEpisodes.length === 0) { return; }
  1824. var idx = Math.floor(Math.random() * currentEpisodes.length);
  1825. startPlayback(idx);
  1826. });
  1827. showView('detail');
  1828. }
  1829. function selectSeason(season) {
  1830. currentSeason = season;
  1831. loadEpisodes(season.folderpath);
  1832. }
  1833. function activateTab(el) {
  1834. $('#season-tabs .season-tab').removeClass('active');
  1835. $(el).addClass('active');
  1836. }
  1837. function loadEpisodes(folderpath) {
  1838. $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">Loading…</div>');
  1839. ao_module_agirun(SCRIPT_GET_EPISODES, { folder: folderpath }, function (data) {
  1840. if (!data || data.error) {
  1841. $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">No episodes found.</div>');
  1842. currentEpisodes = [];
  1843. return;
  1844. }
  1845. currentEpisodes = data;
  1846. renderEpisodes(data);
  1847. }, function () {
  1848. $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">Error loading episodes.</div>');
  1849. });
  1850. }
  1851. function renderEpisodes(episodes) {
  1852. var $list = $('#episode-list').empty();
  1853. episodes.forEach(function (ep, i) {
  1854. var row = $('<div class="episode-item" tabindex="0" role="button">'
  1855. + '<div class="ep-thumb"><div class="ep-thumb-placeholder"><img src="img/icons/play_white.svg" alt=""></div></div>'
  1856. + '<div class="ep-info">'
  1857. + '<div class="ep-name">' + escapeHtml(ep.name) + '</div>'
  1858. + '<div class="ep-path">' + escapeHtml(ep.ext.replace('.', '').toUpperCase()) + '</div>'
  1859. + '</div>'
  1860. + '<div class="ep-play-icon"><img src="img/icons/play_white.svg" alt=""></div>'
  1861. + '</div>');
  1862. row.data('ep', ep);
  1863. row.data('idx', i);
  1864. row.on('click', function () { startPlayback($(this).data('idx')); });
  1865. row.on('keydown', function (e) {
  1866. if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startPlayback($(this).data('idx')); }
  1867. });
  1868. $list.append(row);
  1869. // Lazy-load thumbnail
  1870. (function (epObj, rowEl) {
  1871. ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: epObj.filepath }, function (data) {
  1872. if (data && !data.error && data.length > 20) {
  1873. rowEl.find('.ep-thumb-placeholder')
  1874. .replaceWith('<img src="data:image/jpeg;base64,' + data + '" alt="">');
  1875. }
  1876. });
  1877. })(ep, row);
  1878. });
  1879. }
  1880. // ─── Player ───────────────────────────────────────────────────────────────────
  1881. function isWebPlayable(ext) {
  1882. return ['mp4', 'webm', 'ogg'].includes(ext);
  1883. }
  1884. function startPlayback(index) {
  1885. cancelCountdown();
  1886. $('#resume-popup').removeClass('active');
  1887. if (!currentEpisodes || currentEpisodes.length === 0) { return; }
  1888. playingIndex = index;
  1889. var ep = currentEpisodes[index];
  1890. var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
  1891. var src = isWebPlayable(ext)
  1892. ? MEDIA_API + '?file=' + encodeURIComponent(ep.filepath)
  1893. : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
  1894. var vid = document.getElementById('main-video');
  1895. vid.src = src;
  1896. vid.play();
  1897. $('#now-playing-title').text(ep.name);
  1898. ao_module_setWindowTitle('Movie – ' + ep.name);
  1899. renderSidebar(currentEpisodes, index);
  1900. if (currentEpisodes.length <= 1) {
  1901. $('#playlist-sidebar').addClass('collapsed');
  1902. } else {
  1903. $('#playlist-sidebar').removeClass('collapsed');
  1904. }
  1905. highlightPlayingEpisode(index);
  1906. // Remember where we came from so closePlayer() can return there
  1907. var curView = $('.view.active').attr('id');
  1908. if (curView && curView !== 'view-player') {
  1909. playerReturnView = curView.replace('view-', '');
  1910. }
  1911. showView('player');
  1912. showControls();
  1913. // After metadata loads, offer to resume if the video is >1 hr and has a saved position
  1914. (function (epFilepath) {
  1915. $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
  1916. if (vid.duration > 3600) {
  1917. ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: epFilepath }, function (data) {
  1918. if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
  1919. showResumePopup(data.position, vid.duration);
  1920. }
  1921. });
  1922. }
  1923. });
  1924. })(ep.filepath);
  1925. }
  1926. function renderSidebar(episodes, playing) {
  1927. var $list = $('#sidebar-list').empty();
  1928. episodes.forEach(function (ep, i) {
  1929. var row = $('<div class="sidebar-ep' + (i === playing ? ' playing' : '') + '" tabindex="0" role="button">'
  1930. + '<span class="sidebar-ep-num">' + (i + 1) + '</span>'
  1931. + '<span class="sidebar-ep-name">' + escapeHtml(ep.name) + '</span>'
  1932. + '</div>');
  1933. row.data('idx', i);
  1934. row.on('click', function () {
  1935. cancelCountdown();
  1936. var idx = $(this).data('idx');
  1937. playingIndex = idx;
  1938. var e2 = currentEpisodes[idx];
  1939. var ext = e2.ext ? e2.ext.toLowerCase().replace(/^\./, '') : '';
  1940. var src = '';
  1941. if (isWebPlayable(ext)) {
  1942. src = MEDIA_API + '?file=' + encodeURIComponent(e2.filepath);
  1943. } else {
  1944. src = TRANSCODE_API + '?file=' + encodeURIComponent(e2.filepath);
  1945. }
  1946. var vid = document.getElementById('main-video');
  1947. vid.src = src;
  1948. vid.play();
  1949. $('#now-playing-title').text(e2.name);
  1950. ao_module_setWindowTitle('Movie – ' + e2.name);
  1951. highlightPlayingEpisode(idx);
  1952. renderSidebar(currentEpisodes, idx);
  1953. });
  1954. $list.append(row);
  1955. });
  1956. // Scroll to playing
  1957. var $playing = $list.find('.playing');
  1958. if ($playing.length) {
  1959. setTimeout(function () { $playing[0].scrollIntoView({ block: 'center' }); }, 50);
  1960. }
  1961. }
  1962. function highlightPlayingEpisode(idx) {
  1963. $('#episode-list .episode-item').removeClass('playing');
  1964. $('#episode-list .episode-item').eq(idx).addClass('playing');
  1965. }
  1966. function closePlayer() {
  1967. saveWatchPosition();
  1968. cancelCountdown();
  1969. if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
  1970. var vid = document.getElementById('main-video');
  1971. vid.pause();
  1972. vid.src = '';
  1973. $('#resume-popup').removeClass('active');
  1974. var returnTo = (currentAlbum && (currentAlbum.type === 'series' || currentAlbum.type === 'anime' || currentAlbum.type === 'collection'))
  1975. ? 'detail'
  1976. : playerReturnView;
  1977. showView(returnTo);
  1978. }
  1979. function showLibrary() {
  1980. showView('library');
  1981. currentAlbum = null;
  1982. // If a background refresh completed while the user was away, apply it now
  1983. if (libraryNeedsRedraw) {
  1984. libraryNeedsRedraw = false;
  1985. renderCurrentLibrary();
  1986. }
  1987. }
  1988. // ─── View switching ───────────────────────────────────────────────────────────
  1989. function showView(name) {
  1990. $('.view').removeClass('active');
  1991. $('#view-' + name).addClass('active');
  1992. if (name === 'library' || name === 'detail') {
  1993. $('.mode-tab').removeClass('active');
  1994. $('#tab-library').addClass('active');
  1995. $('#search-wrap').show();
  1996. } else if (name === 'folder') {
  1997. $('.mode-tab').removeClass('active');
  1998. $('#tab-folder').addClass('active');
  1999. $('#search-wrap').hide();
  2000. } else if (name === 'movie-info') {
  2001. $('.mode-tab').removeClass('active');
  2002. $('#tab-library').addClass('active');
  2003. $('#search-wrap').hide();
  2004. }
  2005. // player view leaves tab state unchanged
  2006. }
  2007. // ─── Video controls ───────────────────────────────────────────────────────────
  2008. function initVideoControls() {
  2009. var vid = document.getElementById('main-video');
  2010. var $ctrl = $('#video-controls');
  2011. var $prog = $('#progress-bar');
  2012. var $thumb = $('#progress-thumb');
  2013. var $time = $('#time-display');
  2014. var $play = $('#ctrl-play');
  2015. var $mute = $('#ctrl-mute');
  2016. // Restore saved volume from last session
  2017. var savedVol = parseFloat(localStorage.getItem('movie_volume'));
  2018. if (!isNaN(savedVol) && savedVol >= 0 && savedVol <= 1) {
  2019. vid.volume = savedVol;
  2020. $('#volume-slider').val(savedVol);
  2021. }
  2022. if (localStorage.getItem('movie_muted') === '1') { vid.muted = true; }
  2023. // Play/Pause
  2024. $('#ctrl-play').on('click', function () { togglePlay(); });
  2025. $('#ctrl-mute').on('click', function () { vid.muted = !vid.muted; updateMuteIcon(); });
  2026. $('#ctrl-prev').on('click', function () { playOffset(-1); });
  2027. $('#ctrl-next').on('click', function () { playOffset(1); });
  2028. $('#ctrl-fs').on('click', function () { toggleFullscreen(); });
  2029. $('#ctrl-list').on('click', function () { toggleSidebar(); });
  2030. $('#volume-slider').on('input', function () {
  2031. vid.volume = parseFloat($(this).val());
  2032. updateMuteIcon();
  2033. });
  2034. // Progress bar click / drag
  2035. $('#progress-wrap').on('click', function (e) {
  2036. if (vid.duration) {
  2037. var pct = e.offsetX / $(this).width();
  2038. vid.currentTime = pct * vid.duration;
  2039. }
  2040. });
  2041. // Video events
  2042. $(vid).on('timeupdate', function () {
  2043. if (!vid.duration) { return; }
  2044. var pct = (vid.currentTime / vid.duration) * 100;
  2045. $prog.css('width', pct + '%');
  2046. $thumb.css('left', 'calc(' + pct + '% - 7px)');
  2047. $time.text(formatTime(vid.currentTime) + ' / ' + formatTime(vid.duration));
  2048. });
  2049. $(vid).on('play', function () {
  2050. $('#play-icon').attr('src', 'img/icons/pause_white.svg');
  2051. // Periodically save position for videos longer than 1 hr
  2052. if (watchSaveInterval) { clearInterval(watchSaveInterval); }
  2053. watchSaveInterval = setInterval(function () {
  2054. if (!vid.paused && vid.duration > 3600) { saveWatchPosition(); }
  2055. }, 30000);
  2056. });
  2057. $(vid).on('pause', function () {
  2058. $('#play-icon').attr('src', 'img/icons/play_white.svg');
  2059. if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
  2060. if (vid.duration > 3600 && vid.currentTime > 30) { saveWatchPosition(); }
  2061. });
  2062. $(vid).on('ended', function () {
  2063. clearWatchPosition(); // video finished naturally — remove resume point
  2064. if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
  2065. cancelCountdown();
  2066. if (repeatSingle) {
  2067. vid.currentTime = 0;
  2068. vid.play();
  2069. } else if (playingIndex < currentEpisodes.length - 1 && autoplayEnabled) {
  2070. startNextCountdown();
  2071. }
  2072. });
  2073. $(vid).on('volumechange', function () {
  2074. updateMuteIcon();
  2075. localStorage.setItem('movie_volume', vid.volume);
  2076. localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
  2077. });
  2078. // Auto-hide controls
  2079. $('#video-container').on('mousemove touchstart', function () { showControls(); });
  2080. // Click on video = play/pause
  2081. $(vid).on('click', function () { togglePlay(); });
  2082. // Hide back button when in fullscreen (JS reinforcement)
  2083. document.addEventListener('fullscreenchange', function () {
  2084. $('#player-back').toggle(!document.fullscreenElement);
  2085. });
  2086. document.addEventListener('webkitfullscreenchange', function () {
  2087. $('#player-back').toggle(!document.webkitFullscreenElement);
  2088. });
  2089. function updateMuteIcon() {
  2090. var isMuted = vid.muted || vid.volume === 0;
  2091. $('#mute-icon').attr('src', isMuted ? 'img/icons/mute_white.svg' : 'img/icons/volume_white.svg');
  2092. $('#volume-slider').val(vid.muted ? 0 : vid.volume);
  2093. }
  2094. }
  2095. function togglePlay() {
  2096. $('#resume-popup').removeClass('active');
  2097. var vid = document.getElementById('main-video');
  2098. if (vid.paused) { vid.play(); } else { vid.pause(); }
  2099. }
  2100. function playOffset(offset) {
  2101. var next = playingIndex + offset;
  2102. if (next < 0) { next = 0; }
  2103. if (next >= currentEpisodes.length) { next = currentEpisodes.length - 1; }
  2104. if (next !== playingIndex) { startPlayback(next); }
  2105. }
  2106. function showControls() {
  2107. $('#video-controls').removeClass('hidden');
  2108. clearTimeout(controlsTimer);
  2109. controlsTimer = setTimeout(function () {
  2110. var vid = document.getElementById('main-video');
  2111. if (!vid.paused) { $('#video-controls').addClass('hidden'); }
  2112. }, 3000);
  2113. }
  2114. function toggleFullscreen() {
  2115. var el = document.getElementById('view-player');
  2116. if (!document.fullscreenElement) {
  2117. el.requestFullscreen && el.requestFullscreen();
  2118. } else {
  2119. document.exitFullscreen && document.exitFullscreen();
  2120. }
  2121. }
  2122. function toggleSidebar() {
  2123. $('#playlist-sidebar').toggleClass('collapsed');
  2124. }
  2125. function startNextCountdown() {
  2126. var total = 5;
  2127. var remaining = total;
  2128. var $bar = $('#next-countdown-bar');
  2129. var $num = $('#countdown-num');
  2130. $bar.css('transition', 'none').css('width', '100%');
  2131. $num.text(remaining);
  2132. $('#next-countdown').show();
  2133. countdownTimer = setInterval(function () {
  2134. remaining--;
  2135. $num.text(remaining);
  2136. $bar.css('transition', 'width 1s linear').css('width', (remaining / total * 100) + '%');
  2137. if (remaining <= 0) { cancelCountdown(); playOffset(1); }
  2138. }, 1000);
  2139. }
  2140. function cancelCountdown() {
  2141. if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
  2142. $('#next-countdown').hide();
  2143. }
  2144. // ─── Player context menu ──────────────────────────────────────────────────────
  2145. var infoRefreshTimer = null;
  2146. var currentInfoTab = 'props';
  2147. function initContextMenu() {
  2148. var vid = document.getElementById('main-video');
  2149. var $ctx = $('#player-ctx');
  2150. // Right-click on the video container
  2151. $('#video-container').on('contextmenu', function (e) {
  2152. e.preventDefault();
  2153. if (playingIndex < 0) { return; }
  2154. // Update item disabled / active states
  2155. var paused = vid.paused;
  2156. $('#ctx-play').toggleClass('ctx-disabled', !paused);
  2157. $('#ctx-pause').toggleClass('ctx-disabled', paused);
  2158. $('#ctx-prev').toggleClass('ctx-disabled', playingIndex <= 0);
  2159. $('#ctx-next').toggleClass('ctx-disabled', playingIndex >= currentEpisodes.length - 1);
  2160. $('#ctx-repeat').toggleClass('ctx-active', repeatSingle)
  2161. .html('<i class="ctx-icon">' + (repeatSingle ? '✓' : '↺') + '</i>Repeat: ' + (repeatSingle ? 'On' : 'Off'));
  2162. // Position menu, clamp inside container
  2163. var rect = this.getBoundingClientRect();
  2164. var x = e.clientX - rect.left;
  2165. var y = e.clientY - rect.top;
  2166. $ctx.css({ left: x, top: y, display: 'block' });
  2167. var mw = $ctx.outerWidth(), mh = $ctx.outerHeight();
  2168. if (x + mw > rect.width) { $ctx.css('left', Math.max(0, x - mw)); }
  2169. if (y + mh > rect.height) { $ctx.css('top', Math.max(0, y - mh)); }
  2170. showControls();
  2171. });
  2172. // Dismiss on any click outside the menu
  2173. $(document).on('mousedown.ctx', function (e) {
  2174. if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
  2175. });
  2176. // Dismiss on Escape
  2177. $(document).on('keydown.ctx', function (e) {
  2178. if (e.key === 'Escape') { $ctx.hide(); closeVideoInfo(); }
  2179. });
  2180. $('#ctx-play').on('click', function () { vid.play(); $ctx.hide(); });
  2181. $('#ctx-pause').on('click', function () { vid.pause(); $ctx.hide(); });
  2182. $('#ctx-prev').on('click', function () { cancelCountdown(); playOffset(-1); $ctx.hide(); });
  2183. $('#ctx-next').on('click', function () { cancelCountdown(); playOffset(1); $ctx.hide(); });
  2184. $('#ctx-repeat').on('click', function () { repeatSingle = !repeatSingle; $ctx.hide(); });
  2185. $('#ctx-props').on('click', function () { $ctx.hide(); openVideoInfo('props'); });
  2186. $('#ctx-stats').on('click', function () { $ctx.hide(); openVideoInfo('stats'); });
  2187. }
  2188. function openVideoInfo(tab) {
  2189. currentInfoTab = tab || 'props';
  2190. $('#video-info-modal').show();
  2191. $('.info-tab').removeClass('active').eq(currentInfoTab === 'props' ? 0 : 1).addClass('active');
  2192. $('#video-info-title').text(currentInfoTab === 'props' ? 'Video Properties' : 'Streaming Stats');
  2193. renderInfoContent();
  2194. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  2195. if (currentInfoTab === 'stats') {
  2196. infoRefreshTimer = setInterval(renderInfoContent, 1000);
  2197. }
  2198. }
  2199. function closeVideoInfo() {
  2200. $('#video-info-modal').hide();
  2201. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  2202. }
  2203. function showInfoTab(tab) {
  2204. currentInfoTab = tab;
  2205. $('.info-tab').removeClass('active').eq(tab === 'props' ? 0 : 1).addClass('active');
  2206. $('#video-info-title').text(tab === 'props' ? 'Video Properties' : 'Streaming Stats');
  2207. if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
  2208. if (tab === 'stats') { infoRefreshTimer = setInterval(renderInfoContent, 1000); }
  2209. renderInfoContent();
  2210. }
  2211. function infoRow(label, value) {
  2212. return '<div class="info-row"><span class="info-label">' + escapeHtml(String(label)) + '</span>'
  2213. + '<span class="info-value">' + escapeHtml(String(value)) + '</span></div>';
  2214. }
  2215. function renderInfoContent() {
  2216. var vid = document.getElementById('main-video');
  2217. var ep = (playingIndex >= 0 && currentEpisodes[playingIndex]) ? currentEpisodes[playingIndex] : null;
  2218. var html = '';
  2219. if (currentInfoTab === 'props') {
  2220. html += infoRow('Title', ep ? ep.name : '–');
  2221. html += infoRow('Resolution', (vid.videoWidth && vid.videoHeight)
  2222. ? vid.videoWidth + ' × ' + vid.videoHeight : '–');
  2223. html += infoRow('Duration', vid.duration ? formatTime(vid.duration) : '–');
  2224. html += infoRow('Position', vid.currentTime ? formatTime(vid.currentTime) : '–');
  2225. html += infoRow('Playback speed', vid.playbackRate + '×');
  2226. html += infoRow('Volume', vid.muted ? 'Muted' : Math.round(vid.volume * 100) + '%');
  2227. if (ep) { html += infoRow('File path', ep.filepath || '–'); }
  2228. } else {
  2229. var rsLabels = ['No info', 'Metadata only', 'Have current data', 'Have future data', 'Enough data'];
  2230. var nsLabels = ['Empty', 'Idle', 'Loading', 'No source'];
  2231. html += infoRow('Ready state', rsLabels[vid.readyState] || vid.readyState);
  2232. html += infoRow('Network state', nsLabels[vid.networkState] || vid.networkState);
  2233. // Buffer ahead
  2234. var bufEnd = 0;
  2235. if (vid.buffered && vid.buffered.length > 0) {
  2236. for (var i = 0; i < vid.buffered.length; i++) {
  2237. if (vid.buffered.start(i) <= vid.currentTime + 0.1) {
  2238. bufEnd = Math.max(bufEnd, vid.buffered.end(i));
  2239. }
  2240. }
  2241. }
  2242. html += infoRow('Buffer ahead', Math.max(0, bufEnd - vid.currentTime).toFixed(1) + 's');
  2243. html += infoRow('Total buffered', vid.buffered.length > 0
  2244. ? formatTime(vid.buffered.end(vid.buffered.length - 1)) : '–');
  2245. if (vid.getVideoPlaybackQuality) {
  2246. var q = vid.getVideoPlaybackQuality();
  2247. html += infoRow('Frames decoded', q.totalVideoFrames || 0);
  2248. html += infoRow('Frames dropped', q.droppedVideoFrames || 0);
  2249. }
  2250. html += infoRow('Stalled / ended', vid.ended ? 'Ended' : (vid.readyState < 3 ? 'Yes' : 'No'));
  2251. }
  2252. $('#video-info-body').html(html);
  2253. }
  2254. function formatTime(s) {
  2255. if (isNaN(s)) { return '0:00'; }
  2256. var h = Math.floor(s / 3600);
  2257. var m = Math.floor((s % 3600) / 60);
  2258. var sec = Math.floor(s % 60);
  2259. var parts = [];
  2260. if (h > 0) { parts.push(h); }
  2261. parts.push(m);
  2262. parts.push(sec < 10 ? '0' + sec : sec);
  2263. return parts.join(':');
  2264. }
  2265. // ─── Keyboard / TV-remote navigation ─────────────────────────────────────────
  2266. function initKeyboard() {
  2267. $(document).on('keydown', function (e) {
  2268. var activeView = $('.view.active').attr('id');
  2269. // ── Player shortcuts ──────────────────────────────────────────────────
  2270. if (activeView === 'view-player') {
  2271. var vid = document.getElementById('main-video');
  2272. switch (e.key) {
  2273. case ' ':
  2274. case 'k':
  2275. e.preventDefault(); togglePlay(); showControls(); break;
  2276. case 'ArrowRight':
  2277. e.preventDefault(); vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10); showControls(); break;
  2278. case 'ArrowLeft':
  2279. e.preventDefault(); vid.currentTime = Math.max(0, vid.currentTime - 10); showControls(); break;
  2280. case 'ArrowUp':
  2281. e.preventDefault(); vid.volume = Math.min(1, vid.volume + 0.1); showControls(); break;
  2282. case 'ArrowDown':
  2283. e.preventDefault(); vid.volume = Math.max(0, vid.volume - 0.1); showControls(); break;
  2284. case 'f':
  2285. case 'F':
  2286. e.preventDefault(); toggleFullscreen(); break;
  2287. case 'm':
  2288. case 'M':
  2289. e.preventDefault(); vid.muted = !vid.muted; break;
  2290. case 'l':
  2291. case 'L':
  2292. e.preventDefault(); toggleSidebar(); break;
  2293. case 'n':
  2294. e.preventDefault(); playOffset(1); break;
  2295. case 'p':
  2296. e.preventDefault(); playOffset(-1); break;
  2297. case 'Escape':
  2298. e.preventDefault(); closePlayer(); break;
  2299. case 'MediaPlayPause':
  2300. e.preventDefault(); togglePlay(); break;
  2301. case 'MediaTrackNext':
  2302. e.preventDefault(); playOffset(1); break;
  2303. case 'MediaTrackPrevious':
  2304. e.preventDefault(); playOffset(-1); break;
  2305. }
  2306. return;
  2307. }
  2308. // ── Grid / detail navigation (TV remote) ──────────────────────────────
  2309. if (e.key === 'Escape') {
  2310. e.preventDefault();
  2311. if (activeView === 'view-detail') { showLibrary(); }
  2312. return;
  2313. }
  2314. if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Enter'].indexOf(e.key) === -1) { return; }
  2315. e.preventDefault();
  2316. focusMode = true;
  2317. var $focusables = $('.view.active [tabindex="0"]:visible');
  2318. if ($focusables.length === 0) { return; }
  2319. var $cur = $(document.activeElement);
  2320. var curIdx = $focusables.index($cur);
  2321. if (curIdx < 0) {
  2322. // Nothing focused yet – focus first element
  2323. $focusables.first().focus();
  2324. return;
  2325. }
  2326. if (e.key === 'Enter') {
  2327. $cur.trigger('click');
  2328. return;
  2329. }
  2330. // Compute next focus index based on grid layout
  2331. var nextIdx = computeNextFocus($focusables, curIdx, e.key);
  2332. if (nextIdx >= 0 && nextIdx < $focusables.length) {
  2333. $focusables.eq(nextIdx).focus();
  2334. $focusables.eq(nextIdx)[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
  2335. }
  2336. });
  2337. }
  2338. function computeNextFocus($els, curIdx, key) {
  2339. if (key === 'ArrowDown') { return Math.min($els.length - 1, curIdx + 1); }
  2340. if (key === 'ArrowUp') { return Math.max(0, curIdx - 1); }
  2341. // For left/right in a grid, figure out how many columns there are
  2342. var $cur = $els.eq(curIdx);
  2343. var $parent = $cur.parent();
  2344. if ($parent.hasClass('album-grid')) {
  2345. var colCount = Math.round($parent.width() / ($cur.outerWidth(true) || 1)) || 1;
  2346. if (key === 'ArrowRight') { return Math.min($els.length - 1, curIdx + 1); }
  2347. if (key === 'ArrowLeft') { return Math.max(0, curIdx - 1); }
  2348. }
  2349. if (key === 'ArrowRight') { return Math.min($els.length - 1, curIdx + 1); }
  2350. if (key === 'ArrowLeft') { return Math.max(0, curIdx - 1); }
  2351. return curIdx;
  2352. }
  2353. // ─── Search ───────────────────────────────────────────────────────────────────
  2354. function initSearch() {
  2355. $('#search-input').on('input', function () {
  2356. var q = $(this).val().trim().toLowerCase();
  2357. if (q.length === 0) {
  2358. renderLibrary(library);
  2359. return;
  2360. }
  2361. var filtered = library.filter(function (a) {
  2362. return a.name.toLowerCase().indexOf(q) > -1;
  2363. });
  2364. renderLibrary(filtered);
  2365. });
  2366. // Prevent keyboard nav from hijacking search input
  2367. $('#search-input').on('keydown', function (e) {
  2368. e.stopPropagation();
  2369. if (e.key === 'Escape') { $(this).val(''); renderLibrary(library); $(this).blur(); }
  2370. });
  2371. }
  2372. // ─── Utilities ────────────────────────────────────────────────────────────────
  2373. function escapeHtml(str) {
  2374. if (!str) { return ''; }
  2375. return String(str)
  2376. .replace(/&/g, '&amp;')
  2377. .replace(/</g, '&lt;')
  2378. .replace(/>/g, '&gt;')
  2379. .replace(/"/g, '&quot;');
  2380. }
  2381. function escapeAttr(str) { return escapeHtml(str); }
  2382. var toastTimer;
  2383. function showToast(msg) {
  2384. clearTimeout(toastTimer);
  2385. $('#toast').text(msg).addClass('show');
  2386. toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
  2387. }
  2388. </script>
  2389. </body>
  2390. </html>