| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
- <meta name="apple-mobile-web-app-capable" content="yes">
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
- <meta name="theme-color" content="#000000">
- <link rel="manifest" crossorigin="use-credentials" href="manifest.json">
- <title>Movie</title>
- <!-- ArozOS module helpers -->
- <script src="../script/jquery.min.js"></script>
- <script src="../script/ao_module.js"></script>
- <!-- App path config (single source of truth for all API paths) -->
- <script src="backend/common.js"></script>
- <style>
- /* ─── Reset & base ──────────────────────────────────────────────────────────── */
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- :root {
- --bg: #0a0a0a;
- --surface: #1c1c1e;
- --surface2: #2c2c2e;
- --accent: #0a84ff;
- --accent2: #30d158;
- --text: #f5f5f7;
- --text-sub: #98989d;
- --radius: 12px;
- --card-w: 180px;
- --card-ratio: 1.5; /* height = width * ratio (poster aspect) */
- --header-h: 56px;
- --transition: 0.2s ease;
- }
- html, body {
- background: var(--bg);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", sans-serif;
- height: 100%;
- overflow: hidden;
- }
- /* ─── App shell ──────────────────────────────────────────────────────────────── */
- #app { width: 100vw; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
- /* ─── Top nav bar ────────────────────────────────────────────────────────────── */
- #topbar {
- flex-shrink: 0;
- height: var(--header-h);
- display: flex;
- align-items: center;
- padding: 0 20px;
- gap: 16px;
- background: linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, transparent 100%);
- position: relative;
- z-index: 10;
- }
- #topbar h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
- #topbar h1 span { color: var(--accent); }
- #search-wrap { margin-left: auto; display: flex; align-items: center; gap: 8px; }
- #search-input {
- background: var(--surface2);
- border: none;
- border-radius: 20px;
- color: var(--text);
- font-size: 14px;
- padding: 7px 14px;
- width: 200px;
- outline: none;
- transition: width var(--transition);
- }
- #search-input:focus { width: 280px; box-shadow: 0 0 0 2px var(--accent); }
- #search-input::placeholder { color: var(--text-sub); }
- /* ─── View containers ────────────────────────────────────────────────────────── */
- .view { display: none; flex: 1; overflow: hidden; flex-direction: column; }
- .view.active { display: flex; }
- /* ─── Library view ───────────────────────────────────────────────────────────── */
- #view-library { padding: 0; }
- /* Status bar ─────────────────────────────────────────────────────────────────── */
- #library-status-bar {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 3px 22px;
- font-size: 11px;
- color: var(--text-sub);
- border-bottom: 1px solid rgba(255,255,255,0.04);
- min-height: 26px;
- }
- .spinner-sm {
- width: 11px; height: 11px; flex-shrink: 0;
- border: 2px solid rgba(255,255,255,0.15);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
- }
- #library-refresh-btn {
- background: none; border: none; cursor: pointer;
- color: var(--accent); font-size: 11px; font-weight: 500;
- padding: 0; outline: none; transition: opacity var(--transition);
- }
- #library-refresh-btn:hover { opacity: 0.7; }
- #library-refresh-btn:disabled { opacity: 0.35; cursor: default; }
- #library-scroll {
- flex: 1;
- overflow-y: auto;
- overflow-x: hidden;
- padding: 8px 20px 40px;
- scroll-behavior: smooth;
- }
- #library-scroll::-webkit-scrollbar { width: 4px; }
- #library-scroll::-webkit-scrollbar-track { background: transparent; }
- #library-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
- #no-content {
- display: none;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 60%;
- gap: 12px;
- color: var(--text-sub);
- font-size: 16px;
- }
- #no-content .icon img { width: 80px; height: 80px; opacity: 0.3; }
- #loading-overlay {
- position: absolute; inset: 0;
- background: var(--bg);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 16px;
- z-index: 100;
- font-size: 15px;
- color: var(--text-sub);
- }
- .spinner {
- width: 40px; height: 40px;
- border: 3px solid var(--surface2);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- /* ─── Section heading ────────────────────────────────────────────────────────── */
- .section-title {
- font-size: 20px;
- font-weight: 600;
- margin: 24px 0 12px;
- color: var(--text);
- }
- /* ─── Album grid ─────────────────────────────────────────────────────────────── */
- .album-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr));
- gap: 16px;
- }
- .album-card {
- cursor: pointer;
- border-radius: var(--radius);
- overflow: hidden;
- background: var(--surface);
- transition: transform var(--transition), box-shadow var(--transition);
- outline: none;
- position: relative;
- }
- .album-card:hover,
- .album-card.focused {
- transform: scale(1.04);
- box-shadow: 0 8px 32px rgba(0,0,0,0.7), 0 0 0 2px var(--accent);
- z-index: 2;
- }
- .album-card .poster {
- width: 100%;
- aspect-ratio: 16 / 9;
- object-fit: cover;
- display: block;
- background: var(--surface2);
- }
- .poster-placeholder {
- width: 100%;
- aspect-ratio: 16 / 9;
- background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .poster-placeholder img { width: 100%; opacity: 1; }
- .album-card .card-info {
- padding: 8px 10px 10px;
- }
- .album-card .card-title {
- font-size: 13px;
- font-weight: 600;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .album-card .card-meta {
- font-size: 11px;
- color: var(--text-sub);
- margin-top: 2px;
- }
- .badge {
- position: absolute;
- top: 7px; right: 7px;
- background: rgba(0,0,0,0.65);
- backdrop-filter: blur(4px);
- border-radius: 6px;
- font-size: 10px;
- font-weight: 600;
- padding: 2px 6px;
- color: #fff;
- letter-spacing: 0.3px;
- text-transform: uppercase;
- }
- .badge.series { color: var(--accent2); }
- .badge.anime { color: #ff9f0a; } /* warm orange for anime */
- /* ─── Detail view ────────────────────────────────────────────────────────────── */
- #view-detail { position: relative; }
- #detail-hero {
- flex-shrink: 0;
- height: 38vh;
- min-height: 200px;
- position: relative;
- overflow: hidden;
- }
- #detail-hero-bg {
- position: absolute; inset: 0;
- background-size: cover;
- background-position: center top;
- filter: blur(4px) brightness(0.55);
- transform: scale(1.1);
- }
- #detail-hero-content {
- position: relative;
- display: flex;
- align-items: flex-end;
- height: 100%;
- padding: 0 28px 20px;
- gap: 20px;
- }
- #detail-poster {
- width: 120px;
- flex-shrink: 0;
- border-radius: 8px;
- overflow: hidden;
- box-shadow: 0 8px 24px rgba(0,0,0,0.6);
- }
- #detail-poster img, #detail-poster .poster-placeholder {
- width: 120px;
- aspect-ratio: 2 / 3;
- object-fit: cover;
- display: block;
- }
- #detail-meta { flex: 1; min-width: 0; }
- #detail-title { font-size: 26px; font-weight: 700; line-height: 1.15; }
- #detail-subtitle { font-size: 14px; color: var(--text-sub); margin-top: 4px; }
- #detail-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
- .btn {
- border: none; cursor: pointer; border-radius: 8px;
- font-size: 14px; font-weight: 600; padding: 10px 22px;
- transition: opacity var(--transition), transform var(--transition);
- outline: none;
- }
- .btn:hover, .btn.focused { opacity: 0.85; transform: scale(1.03); }
- .btn-primary { background: var(--text); color: #000; }
- .btn-secondary { background: var(--surface2); color: var(--text); }
- /* ─── Season tabs ────────────────────────────────────────────────────────────── */
- #season-tabs {
- flex-shrink: 0;
- display: flex;
- flex-wrap: wrap;
- gap: 6px 8px;
- padding: 10px 28px 8px;
- max-height: 120px; /* ~3 rows before scrolling */
- overflow-y: auto;
- scrollbar-width: thin;
- scrollbar-color: var(--surface2) transparent;
- }
- #season-tabs::-webkit-scrollbar { width: 3px; height: 3px; }
- #season-tabs::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
- .season-tab {
- flex-shrink: 0;
- background: var(--surface2);
- border: none; cursor: pointer;
- border-radius: 20px;
- color: var(--text-sub);
- font-size: 13px; font-weight: 500;
- padding: 6px 16px;
- transition: background var(--transition), color var(--transition);
- outline: none;
- }
- .season-tab.active, .season-tab.focused {
- background: var(--text);
- color: #000;
- }
- /* ─── Episode list ───────────────────────────────────────────────────────────── */
- #episode-scroll {
- flex: 1;
- overflow-y: auto;
- padding: 12px 28px 40px;
- scroll-behavior: smooth;
- }
- #episode-scroll::-webkit-scrollbar { width: 4px; }
- #episode-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
- .episode-item {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 10px 12px;
- border-radius: var(--radius);
- cursor: pointer;
- transition: background var(--transition);
- outline: none;
- }
- .episode-item:hover, .episode-item.focused {
- background: var(--surface);
- box-shadow: 0 0 0 2px var(--accent);
- }
- .episode-item.playing { background: rgba(10,132,255,0.15); }
- .ep-thumb {
- width: 100px; flex-shrink: 0;
- aspect-ratio: 16 / 9;
- border-radius: 6px;
- overflow: hidden;
- background: var(--surface2);
- }
- .ep-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
- .ep-thumb-placeholder {
- width: 100%; height: 100%;
- display: flex; align-items: center; justify-content: center;
- }
- .ep-thumb-placeholder img { width: 22px; height: 22px; opacity: 0.5; }
- .ep-info { flex: 1; min-width: 0; }
- .ep-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
- .ep-path { font-size: 11px; color: var(--text-sub); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .ep-play-icon {
- flex-shrink: 0;
- width: 32px; height: 32px;
- background: var(--surface2);
- border-radius: 50%;
- display: flex; align-items: center; justify-content: center;
- transition: background var(--transition);
- }
- .ep-play-icon img { width: 16px; height: 16px; }
- .episode-item.focused .ep-play-icon,
- .episode-item:hover .ep-play-icon { background: var(--accent); }
- #detail-back {
- position: absolute;
- top: 12px; left: 16px;
- background: rgba(0,0,0,0.5);
- backdrop-filter: blur(8px);
- border: none; cursor: pointer;
- border-radius: 20px;
- color: var(--text);
- font-size: 13px; font-weight: 500;
- padding: 6px 16px;
- z-index: 5;
- transition: background var(--transition);
- outline: none;
- }
- #detail-back:hover, #detail-back.focused { background: var(--accent); }
- /* ─── Player view ────────────────────────────────────────────────────────────── */
- #view-player {
- position: fixed; inset: 0;
- background: #000;
- z-index: 200;
- flex-direction: row;
- }
- #view-player.active { display: flex; }
- #video-container {
- flex: 1;
- display: flex;
- flex-direction: column;
- position: relative;
- overflow: hidden;
- }
- #main-video {
- width: 100%; height: 100%;
- object-fit: contain;
- background: #000;
- display: block;
- }
- /* ─── Custom video controls ──────────────────────────────────────────────────── */
- #video-controls {
- position: absolute;
- bottom: 0; left: 0; right: 0;
- background: linear-gradient(transparent, rgba(0,0,0,0.85) 100%);
- padding: 40px 20px 16px;
- transition: opacity 0.3s;
- }
- #video-controls.hidden { opacity: 0; pointer-events: none; }
- /* Hide cursor when controls auto-hide */
- #video-container:has(#video-controls.hidden) { cursor: none; }
- #progress-wrap {
- position: relative;
- height: 4px;
- background: rgba(255,255,255,0.25);
- border-radius: 4px;
- cursor: pointer;
- margin-bottom: 12px;
- }
- #progress-bar {
- height: 100%; border-radius: 4px;
- background: var(--accent);
- pointer-events: none;
- }
- #progress-wrap:hover { height: 6px; }
- /* Transparent hit-zone 16px above the visual bar */
- #progress-wrap::before {
- content: '';
- position: absolute;
- top: -16px; left: 0; right: 0;
- height: 16px;
- }
- #progress-thumb {
- position: absolute;
- top: 50%; transform: translateY(-50%);
- width: 14px; height: 14px;
- background: #fff; border-radius: 50%;
- pointer-events: none;
- display: none;
- }
- #progress-wrap:hover #progress-thumb { display: block; }
- #controls-row {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .ctrl-btn {
- background: none; border: none; cursor: pointer;
- color: #fff; padding: 4px;
- line-height: 1;
- transition: opacity var(--transition);
- outline: none;
- display: flex; align-items: center; justify-content: center;
- }
- .ctrl-btn img { width: 24px; height: 24px; display: block; }
- .ctrl-btn:hover { opacity: 0.7; }
- .ctrl-btn.focused { background: rgba(10,132,255,0.18); border-radius: 6px; }
- #volume-wrap { display: flex; align-items: center; gap: 8px; }
- #volume-slider {
- -webkit-appearance: none;
- width: 80px; height: 4px;
- background: rgba(255,255,255,0.3);
- border-radius: 4px; outline: none;
- }
- #volume-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 12px; height: 12px;
- background: #fff; border-radius: 50%;
- }
- #time-display { font-size: 13px; color: rgba(255,255,255,0.8); margin-left: 6px; }
- #now-playing-title {
- font-size: 15px; font-weight: 600;
- flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- margin-left: 10px;
- }
- /* ─── Playlist sidebar ───────────────────────────────────────────────────────── */
- #playlist-sidebar {
- width: 300px;
- flex-shrink: 0;
- background: rgba(20,20,20,0.95);
- backdrop-filter: blur(12px);
- display: flex;
- flex-direction: column;
- border-left: 1px solid rgba(255,255,255,0.08);
- transform: translateX(0);
- transition: transform var(--transition), width var(--transition);
- }
- #playlist-sidebar.collapsed { width: 0; overflow: hidden; }
- #sidebar-header {
- padding: 14px 16px 10px;
- font-size: 15px; font-weight: 600;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- display: flex; align-items: center; gap: 8px;
- }
- #sidebar-close {
- margin-left: auto;
- background: none; border: none; cursor: pointer;
- color: var(--text-sub); font-size: 18px; padding: 2px;
- outline: none;
- }
- #sidebar-close:hover { color: var(--text); }
- #sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
- #sidebar-list::-webkit-scrollbar { width: 3px; }
- #sidebar-list::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
- .sidebar-ep {
- display: flex; align-items: center; gap: 10px;
- padding: 8px;
- border-radius: 8px; cursor: pointer;
- transition: background var(--transition);
- font-size: 13px; color: var(--text);
- outline: none;
- }
- .sidebar-ep:hover, .sidebar-ep.focused { background: var(--surface2); }
- .sidebar-ep.playing { background: rgba(10,132,255,0.2); color: var(--accent); }
- .sidebar-ep-num { flex-shrink: 0; width: 24px; text-align: center; color: var(--text-sub); font-size: 12px; }
- .sidebar-ep-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- #player-back {
- position: absolute;
- top: 14px; left: 14px;
- z-index: 10;
- background: rgba(0,0,0,0.55);
- backdrop-filter: blur(8px);
- border: none; cursor: pointer;
- border-radius: 20px;
- color: #fff;
- font-size: 13px; font-weight: 500;
- padding: 7px 16px;
- transition: background var(--transition);
- outline: none;
- }
- #player-back:hover { background: var(--accent); }
- /* ─── Toast notification ─────────────────────────────────────────────────────── */
- #toast {
- position: fixed;
- bottom: 30px; left: 50%;
- transform: translateX(-50%) translateY(20px);
- background: rgba(30,30,30,0.95);
- backdrop-filter: blur(8px);
- color: var(--text);
- padding: 10px 20px;
- border-radius: 20px;
- font-size: 13px;
- opacity: 0;
- transition: opacity 0.3s, transform 0.3s;
- pointer-events: none;
- z-index: 1000;
- white-space: nowrap;
- }
- #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
- /* ─── Responsive ─────────────────────────────────────────────────────────────── */
- @media (max-width: 600px) {
- :root { --card-w: 130px; }
- #library-scroll { padding: 8px 12px 40px; }
- #detail-hero { height: 44vw; min-height: 160px; }
- #detail-poster { width: 80px; }
- #detail-title { font-size: 18px; }
- #playlist-sidebar { width: 100%; position: absolute; right: 0; top: 0; bottom: 0; z-index: 5; }
- #playlist-sidebar.collapsed { width: 0; }
- #episode-scroll { padding: 10px 12px 40px; }
- .ep-thumb { width: 72px; }
- }
- /* ─── Phone: wrap topbar so the search bar never overflows ───────────────────── */
- @media (max-width: 540px) {
- #topbar {
- flex-wrap: wrap;
- height: auto;
- min-height: var(--header-h);
- padding: 6px 14px;
- row-gap: 4px;
- align-items: center;
- }
- #topbar h1 { font-size: 18px; flex-shrink: 0; }
- #mode-tabs { flex-shrink: 0; }
- /* Force search onto its own row, full width */
- #search-wrap {
- flex-basis: 100%;
- margin-left: 0;
- order: 10;
- }
- #search-input { width: 100%; max-width: none; }
- #search-input:focus { width: 100%; }
- }
- @media (max-width: 400px) {
- :root { --card-w: 110px; }
- .mode-tab { padding: 5px 8px; font-size: 12px; }
- }
- /* TV / large screen layout */
- @media (min-width: 1400px) {
- :root { --card-w: 200px; }
- }
- @media (min-width: 1800px) {
- :root { --card-w: 240px; }
- }
- /* ─── Mode tabs ──────────────────────────────────────────────────────────────── */
- #mode-tabs { display: flex; gap: 2px; }
- .mode-tab {
- background: none; border: none; cursor: pointer;
- border-radius: 6px; color: var(--text-sub);
- font-size: 13px; font-weight: 500; padding: 5px 12px;
- transition: background var(--transition), color var(--transition);
- outline: none;
- }
- .mode-tab.active { background: var(--surface2); color: var(--text); }
- .mode-tab:hover { color: var(--text); }
- /* ─── Folder view ────────────────────────────────────────────────────────────── */
- #folder-nav-bar {
- flex-shrink: 0;
- padding: 6px 20px;
- display: flex;
- align-items: center;
- overflow-x: auto;
- scrollbar-width: none;
- border-bottom: 1px solid rgba(255,255,255,0.06);
- min-height: 40px;
- }
- #folder-nav-bar::-webkit-scrollbar { display: none; }
- #folder-breadcrumb { display: flex; align-items: center; gap: 2px; }
- .crumb-btn {
- background: none; border: none; cursor: pointer;
- color: var(--text-sub); font-size: 13px;
- padding: 3px 7px; border-radius: 5px;
- transition: color var(--transition), background var(--transition);
- outline: none; white-space: nowrap;
- }
- .crumb-btn:hover { color: var(--text); background: var(--surface2); }
- .crumb-btn.crumb-current { color: var(--text); font-weight: 600; pointer-events: none; }
- .crumb-sep { color: var(--text-sub); font-size: 12px; opacity: 0.4; padding: 0 2px; }
- #folder-scroll {
- flex: 1; overflow-y: auto; overflow-x: hidden;
- padding: 8px 20px 40px; scroll-behavior: smooth;
- }
- #folder-scroll::-webkit-scrollbar { width: 4px; }
- #folder-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
- #folder-loading {
- display: none; flex-direction: column; align-items: center;
- justify-content: center; padding: 60px 0; gap: 12px; color: var(--text-sub);
- }
- #folder-loading.active { display: flex; }
- #folder-empty { display: none; color: var(--text-sub); padding: 60px 0; text-align: center; }
- .folder-thumb {
- width: 100%; aspect-ratio: 16/9;
- background: linear-gradient(135deg, #1c2640 0%, #2a3d6e 100%);
- display: flex; align-items: center; justify-content: center;
- font-size: 32px; user-select: none;
- }
- /* ─── Movie info panel ───────────────────────────────────────────────────────── */
- #view-movie-info { position: relative; }
- #movie-info-backdrop {
- position: absolute; inset: 0;
- background-size: cover; background-position: center 20%;
- filter: blur(4px) brightness(0.38);
- transform: scale(1.12); z-index: 0;
- }
- #movie-info-back {
- position: absolute; top: 14px; left: 16px; z-index: 10;
- background: rgba(0,0,0,0.5); backdrop-filter: blur(8px);
- border: none; cursor: pointer; border-radius: 20px;
- color: var(--text); font-size: 13px; font-weight: 500;
- padding: 6px 16px; transition: background var(--transition); outline: none;
- }
- #movie-info-back:hover { background: var(--accent); }
- #movie-info-scroll {
- flex: 1; overflow-y: auto; overflow-x: hidden;
- scroll-behavior: smooth; position: relative; z-index: 1;
- }
- #movie-info-scroll::-webkit-scrollbar { width: 4px; }
- #movie-info-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
- #movie-info-hero {
- min-height: 280px; display: flex; align-items: flex-end; gap: 24px;
- padding: 70px 32px 28px;
- }
- #movie-info-poster-wrap {
- width: 130px; flex-shrink: 0; border-radius: 10px; overflow: hidden;
- box-shadow: 0 12px 32px rgba(0,0,0,0.7);
- aspect-ratio: 2/3; background: var(--surface2);
- }
- #movie-info-poster-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
- #movie-info-meta { flex: 1; min-width: 0; }
- #movie-info-title { font-size: 26px; font-weight: 700; line-height: 1.15; margin-bottom: 2px; }
- #movie-info-filename {
- font-size: 11px; color: var(--text-sub); opacity: 0.6;
- font-style: italic; margin-bottom: 6px;
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- display: none; /* shown only after IMDB title replaces the album name */
- }
- #movie-info-year { font-size: 14px; color: var(--text-sub); margin-bottom: 8px; }
- #movie-info-cast { font-size: 13px; color: var(--text-sub); line-height: 1.5; margin-bottom: 14px; }
- #movie-info-actions { display: flex; gap: 10px; flex-wrap: wrap; }
- .yt-badge {
- display: inline-flex; align-items: center;
- background: #ff0000; color: #fff;
- border-radius: 5px; font-size: 12px; font-weight: 800;
- padding: 5px 9px; cursor: pointer; border: none; outline: none;
- transition: opacity var(--transition);
- }
- .yt-badge:hover { opacity: 0.8; }
- .imdb-badge {
- display: inline-flex; align-items: center;
- background: #f5c518; color: #000;
- border-radius: 5px; font-size: 12px; font-weight: 800;
- padding: 5px 9px; cursor: pointer; border: none; outline: none;
- transition: opacity var(--transition);
- }
- .imdb-badge:hover { opacity: 0.8; }
- #movie-info-loading {
- display: none; flex-direction: column; align-items: center;
- justify-content: center; padding: 28px 0; gap: 10px; color: var(--text-sub);
- }
- #movie-info-loading.active { display: flex; }
- #movie-info-files { padding: 0 20px 48px; }
- #movie-info-wrong-wrap {
- display: none;
- padding: 10px 32px 0;
- }
- .reset-info-btn {
- background: none;
- border: 1px solid rgba(255,255,255,0.12);
- color: var(--text-sub);
- font-size: 11px; font-weight: 500;
- padding: 4px 12px;
- border-radius: 6px;
- cursor: pointer; outline: none;
- transition: border-color var(--transition), color var(--transition);
- }
- .reset-info-btn:hover { border-color: rgba(255,69,58,0.55); color: #ff453a; }
- @media (max-width: 600px) {
- #movie-info-hero { flex-direction: column; align-items: flex-start; padding: 56px 16px 20px; }
- #movie-info-poster-wrap { width: 90px; }
- #movie-info-title { font-size: 20px; }
- #movie-info-files { padding: 0 12px 48px; }
- }
- /* ─── Resume popup ───────────────────────────────────────────────────────────── */
- #resume-popup {
- display: none;
- position: absolute; bottom: 90px; right: 20px; z-index: 25;
- background: rgba(22,22,24,0.97); backdrop-filter: blur(16px);
- border-radius: 12px; padding: 16px 18px; min-width: 240px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1);
- animation: slideUpPopup 0.2s ease;
- }
- #resume-popup.active { display: block; }
- @keyframes slideUpPopup {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
- }
- #resume-popup-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
- #resume-popup-sub { font-size: 12px; color: var(--text-sub); margin-bottom: 12px; }
- #resume-popup-btns { display: flex; gap: 8px; }
- .resume-btn {
- flex: 1; padding: 8px; border: none; cursor: pointer;
- border-radius: 7px; font-size: 12px; font-weight: 600;
- outline: none; transition: opacity var(--transition);
- }
- .resume-btn:hover { opacity: 0.8; }
- #resume-btn-continue { background: var(--accent); color: #fff; }
- #resume-btn-restart { background: var(--surface2); color: var(--text); }
- /* Focus ring for TV remote navigation */
- .tv-focused {
- outline: 3px solid var(--accent) !important;
- outline-offset: 2px !important;
- }
- /* Hide back button when in fullscreen */
- #view-player:fullscreen #player-back,
- #view-player:-webkit-full-screen #player-back,
- #view-player:-moz-full-screen #player-back { display: none; }
- /* ─── Load More button ───────────────────────────────────────────────────────── */
- .load-more-wrap { text-align: center; padding: 16px 0 24px; }
- .load-more-btn {
- background: var(--surface2);
- border: 1px solid rgba(255,255,255,0.08); cursor: pointer;
- border-radius: 8px; color: var(--text);
- font-size: 13px; font-weight: 500; padding: 9px 28px;
- outline: none; transition: background var(--transition), box-shadow var(--transition);
- }
- .load-more-btn:hover { background: rgba(255,255,255,0.08); box-shadow: 0 0 0 1px var(--accent); }
- /* ─── Autoplay toggle (sidebar) ─────────────────────────────────────────────── */
- .autoplay-label {
- display: flex; align-items: center; gap: 5px;
- font-size: 11px; color: var(--text-sub);
- cursor: pointer; user-select: none;
- font-weight: 500;
- }
- .autoplay-label input[type="checkbox"] { display: none; }
- .autoplay-track {
- width: 30px; height: 17px;
- background: var(--surface2); border-radius: 9px;
- position: relative; flex-shrink: 0;
- transition: background 0.2s;
- }
- .autoplay-label input:checked + .autoplay-track { background: var(--accent2); }
- .autoplay-track::after {
- content: '';
- position: absolute; top: 2px; left: 2px;
- width: 13px; height: 13px;
- background: #fff; border-radius: 50%;
- transition: transform 0.2s;
- }
- .autoplay-label input:checked + .autoplay-track::after { transform: translateX(13px); }
- /* ─── Next-episode countdown ────────────────────────────────────────────────── */
- #next-countdown {
- display: none;
- position: absolute; bottom: 80px; right: 20px;
- background: rgba(20,20,20,0.92);
- backdrop-filter: blur(8px);
- border-radius: 10px; padding: 12px 16px;
- min-width: 210px; z-index: 20;
- }
- #next-countdown-text { font-size: 13px; color: var(--text-sub); margin-bottom: 8px; }
- #next-countdown-track {
- height: 4px; background: var(--surface2);
- border-radius: 4px; margin-bottom: 8px; overflow: hidden;
- }
- #next-countdown-bar { height: 100%; background: var(--accent); border-radius: 4px; transition: width 1s linear; }
- #next-countdown-cancel {
- display: block; width: 100%; background: var(--surface2);
- border: none; cursor: pointer; border-radius: 6px;
- color: var(--text); font-size: 12px; padding: 5px; outline: none;
- transition: background var(--transition);
- }
- #next-countdown-cancel:hover { background: rgba(255,59,48,0.28); }
- /* ─── Player context menu ────────────────────────────────────────────────────── */
- #player-ctx {
- display: none;
- position: absolute;
- z-index: 30;
- background: rgba(28,28,30,0.97);
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
- border-radius: 10px;
- padding: 4px 0;
- min-width: 192px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
- user-select: none;
- }
- .ctx-item {
- padding: 9px 14px;
- font-size: 13px;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 10px;
- color: var(--text);
- transition: background var(--transition);
- }
- .ctx-item:hover { background: rgba(255,255,255,0.08); }
- .ctx-item.ctx-active { color: var(--accent); }
- .ctx-item.ctx-disabled { opacity: 0.3; pointer-events: none; }
- .ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; }
- .ctx-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 3px 0; }
- /* ─── Video info modal ───────────────────────────────────────────────────────── */
- #video-info-modal {
- display: none;
- position: absolute;
- top: 50%; left: 50%;
- transform: translate(-50%, -50%);
- z-index: 40;
- background: rgba(18,18,20,0.97);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 14px;
- padding: 20px 22px 16px;
- width: 340px;
- box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
- }
- #video-info-modal h3 {
- font-size: 14px; font-weight: 600;
- margin-bottom: 12px;
- display: flex; align-items: center; justify-content: space-between;
- }
- #video-info-close {
- background: none; border: none; cursor: pointer;
- color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
- }
- #video-info-close:hover { color: var(--text); }
- #video-info-tabs {
- display: flex; gap: 3px;
- background: var(--surface2);
- border-radius: 8px;
- padding: 3px;
- margin-bottom: 12px;
- }
- .info-tab {
- flex: 1; text-align: center; padding: 5px 0;
- font-size: 12px; font-weight: 500;
- border-radius: 6px; cursor: pointer;
- color: var(--text-sub);
- transition: background var(--transition), color var(--transition);
- }
- .info-tab.active { background: var(--surface); color: var(--text); }
- .info-row {
- display: flex; gap: 8px;
- padding: 5px 0;
- border-bottom: 1px solid rgba(255,255,255,0.05);
- font-size: 12px;
- line-height: 1.4;
- }
- .info-row:last-child { border-bottom: none; }
- .info-label { color: var(--text-sub); flex-shrink: 0; width: 110px; }
- .info-value { color: var(--text); word-break: break-all; }
- </style>
- </head>
- <body>
- <div id="app">
- <!-- ─ Loading overlay ─────────────────────────────────────────────────── -->
- <div id="loading-overlay">
- <div class="spinner"></div>
- <span>Loading library…</span>
- </div>
- <!-- ─ Top bar ─────────────────────────────────────────────────────────── -->
- <div id="topbar">
- <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>
- <div id="mode-tabs">
- <button class="mode-tab active" id="tab-library" onclick="switchTab('library')">Library</button>
- <button class="mode-tab" id="tab-folder" onclick="switchTab('folder')">Browse</button>
- </div>
- <div id="search-wrap">
- <input id="search-input" type="text" placeholder="Search…" autocomplete="off">
- </div>
- </div>
- <!-- ═══════════════ LIBRARY VIEW ═══════════════════════════════════════ -->
- <div id="view-library" class="view active">
- <div id="library-status-bar">
- <div class="spinner-sm" id="library-spinner"></div>
- <span id="library-status-text"></span>
- <button id="library-refresh-btn" onclick="refreshLibrary()" style="display:none">Refresh</button>
- </div>
- <div id="library-scroll">
- <div id="no-content">
- <div class="icon"><img src="img/icons/movie_white.svg" alt=""></div>
- <div>No videos found. Place your videos in a <strong>Video/</strong> folder on any storage.</div>
- </div>
- <div id="movies-section" style="display:none">
- <div class="section-title">Movies</div>
- <div id="movies-grid" class="album-grid"></div>
- <div class="load-more-wrap"><button class="load-more-btn" id="movies-load-more" style="display:none" onclick="loadMoreSection('movies')">Load more</button></div>
- </div>
- <div id="collections-section" style="display:none">
- <div class="section-title">Collections</div>
- <div id="collections-grid" class="album-grid"></div>
- <div class="load-more-wrap"><button class="load-more-btn" id="collections-load-more" style="display:none" onclick="loadMoreSection('collections')">Load more</button></div>
- </div>
- <div id="series-section" style="display:none">
- <div class="section-title">TV / Shows</div>
- <div id="series-grid" class="album-grid"></div>
- <div class="load-more-wrap"><button class="load-more-btn" id="shows-load-more" style="display:none" onclick="loadMoreSection('shows')">Load more</button></div>
- </div>
- <div id="anime-section" style="display:none">
- <div class="section-title">Anime</div>
- <div id="anime-grid" class="album-grid"></div>
- <div class="load-more-wrap"><button class="load-more-btn" id="anime-load-more" style="display:none" onclick="loadMoreSection('anime')">Load more</button></div>
- </div>
- <div id="shorts-section" style="display:none">
- <div class="section-title">Shorts</div>
- <div id="shorts-grid" class="album-grid"></div>
- <div class="load-more-wrap"><button class="load-more-btn" id="shorts-load-more" style="display:none" onclick="loadMoreSection('shorts')">Load more</button></div>
- </div>
- </div>
- </div>
- <!-- ═══════════════ DETAIL VIEW ════════════════════════════════════════ -->
- <div id="view-detail" class="view">
- <div id="detail-hero">
- <div id="detail-hero-bg"></div>
- <div id="detail-hero-content">
- <div id="detail-poster"><div class="poster-placeholder"><img src="img/icons/movie_white.svg" alt=""></div></div>
- <div id="detail-meta">
- <div id="detail-title">Album Title</div>
- <div id="detail-subtitle">0 episodes</div>
- <div id="detail-actions">
- <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>
- <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>
- </div>
- </div>
- </div>
- </div>
- <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>
- <div id="season-tabs"></div>
- <div id="episode-scroll">
- <div id="episode-list"></div>
- </div>
- </div>
- <!-- ═══════════════ MOVIE INFO VIEW ══════════════════════════════════════ -->
- <div id="view-movie-info" class="view">
- <div id="movie-info-backdrop"></div>
- <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>
- <div id="movie-info-scroll">
- <div id="movie-info-hero">
- <div id="movie-info-poster-wrap">
- <img id="movie-info-poster-img" src="img/thumbnail.png" alt="">
- </div>
- <div id="movie-info-meta">
- <div id="movie-info-title"></div>
- <div id="movie-info-filename"></div>
- <div id="movie-info-year"></div>
- <div id="movie-info-cast"></div>
- <div id="movie-info-actions">
- <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>
- <button class="btn btn-secondary" id="movie-info-fm-btn">Open in Files</button>
- <button class="yt-badge" id="movie-info-yt-btn">▶ YouTube</button>
- <button class="imdb-badge" id="movie-info-imdb-btn" style="display:none">IMDb</button>
- </div>
- </div>
- </div>
- <div id="movie-info-wrong-wrap">
- <button class="reset-info-btn" id="movie-info-reset-btn">✕ Wrong movie? Disable IMDB info</button>
- </div>
- <div id="movie-info-loading"><div class="spinner"></div><span>Loading info…</span></div>
- <div id="movie-info-files" style="display:none">
- <div class="section-title" style="font-size:16px;margin:16px 20px 10px;">Files</div>
- <div id="movie-info-episode-list"></div>
- </div>
- </div>
- </div>
- <!-- ═══════════════ FOLDER VIEW ════════════════════════════════════════ -->
- <div id="view-folder" class="view">
- <div id="folder-nav-bar">
- <div id="folder-breadcrumb"></div>
- </div>
- <div id="folder-scroll">
- <div id="folder-loading"><div class="spinner"></div><span>Loading…</span></div>
- <div id="folder-empty"></div>
- <div id="folder-section-dirs" style="display:none">
- <div class="section-title">Folders</div>
- <div id="folder-dirs-grid" class="album-grid"></div>
- </div>
- <div id="folder-section-vids" style="display:none">
- <div class="section-title">Videos</div>
- <div id="folder-vids-grid" class="album-grid"></div>
- </div>
- </div>
- </div>
- <!-- ═══════════════ PLAYER VIEW ════════════════════════════════════════ -->
- <div id="view-player" class="view">
- <div id="video-container">
- <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>
- <video id="main-video" preload="metadata"></video>
- <!-- Resume-from-last-position popup -->
- <div id="resume-popup">
- <div id="resume-popup-title">Resume playback?</div>
- <div id="resume-popup-sub"></div>
- <div id="resume-popup-btns">
- <button class="resume-btn" id="resume-btn-continue">Resume</button>
- <button class="resume-btn" id="resume-btn-restart">Start over</button>
- </div>
- </div>
- <!-- Auto-play countdown -->
- <div id="next-countdown">
- <div id="next-countdown-text">Next episode in <span id="countdown-num">5</span>s…</div>
- <div id="next-countdown-track"><div id="next-countdown-bar" style="width:100%"></div></div>
- <button id="next-countdown-cancel" onclick="cancelCountdown()">Cancel</button>
- </div>
- <!-- Player context menu -->
- <div id="player-ctx">
- <div class="ctx-item" id="ctx-play"><i class="ctx-icon">▶</i>Play</div>
- <div class="ctx-item" id="ctx-pause"><i class="ctx-icon">⏸</i>Pause</div>
- <div class="ctx-divider"></div>
- <div class="ctx-item" id="ctx-prev"><i class="ctx-icon">⏮</i>Previous</div>
- <div class="ctx-item" id="ctx-next"><i class="ctx-icon">⏭</i>Next</div>
- <div class="ctx-divider"></div>
- <div class="ctx-item" id="ctx-repeat"><i class="ctx-icon">↺</i>Repeat: Off</div>
- <div class="ctx-divider"></div>
- <div class="ctx-item" id="ctx-props">Video Properties</div>
- <!-- <div class="ctx-item" id="ctx-stats"><i class="ctx-icon">⧉</i>Streaming Stats</div> -->
- </div>
- <!-- Video info / stats modal -->
- <div id="video-info-modal">
- <h3><span id="video-info-title">Video Properties</span><button id="video-info-close" onclick="closeVideoInfo()">✕</button></h3>
- <div id="video-info-tabs">
- <div class="info-tab active" onclick="showInfoTab('props')">Properties</div>
- <div class="info-tab" onclick="showInfoTab('stats')">Stats</div>
- </div>
- <div id="video-info-body"></div>
- </div>
- <!-- Custom controls -->
- <div id="video-controls">
- <div id="progress-wrap">
- <div id="progress-bar" style="width:0%"></div>
- <div id="progress-thumb" style="left:0%"></div>
- </div>
- <div id="controls-row">
- <button class="ctrl-btn" id="ctrl-prev" title="Previous (←)"><img src="img/icons/skip_previous_white.svg" alt=""></button>
- <button class="ctrl-btn" id="ctrl-play" title="Play/Pause (Space)"><img id="play-icon" src="img/icons/play_white.svg" alt=""></button>
- <button class="ctrl-btn" id="ctrl-next" title="Next (→)"><img src="img/icons/skip_next_white.svg" alt=""></button>
- <div id="volume-wrap">
- <button class="ctrl-btn" id="ctrl-mute" title="Mute (M)"><img id="mute-icon" src="img/icons/volume_white.svg" alt=""></button>
- <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
- </div>
- <span id="time-display">0:00 / 0:00</span>
- <span id="now-playing-title"></span>
- <button class="ctrl-btn" id="ctrl-list" title="Episode list (L)"><img src="img/icons/menu_white.svg" alt=""></button>
- <button class="ctrl-btn" id="ctrl-fs" title="Fullscreen (F)"><img src="img/icons/fullscreen_white.svg" alt=""></button>
- </div>
- </div>
- </div>
- <!-- Sidebar playlist -->
- <div id="playlist-sidebar">
- <div id="sidebar-header">
- <span>Playlist</span>
- <label class="autoplay-label" title="Auto-play next episode">
- <input type="checkbox" id="autoplay-check">
- <span class="autoplay-track"></span>
- <span>Auto</span>
- </label>
- <button id="sidebar-close" onclick="toggleSidebar()"><img style="width: 16px;" src="img/icons/close_white.svg" alt=""></button>
- </div>
- <div id="sidebar-list"></div>
- </div>
- </div>
- </div>
- <!-- Toast -->
- <div id="toast"></div>
- <!-- ═══════════════ JAVASCRIPT ════════════════════════════════════════════════ -->
- <script>
- // ─── All configurable paths come from backend/common.js ──────────────────────
- // (SCRIPT_GET_LIBRARY, SCRIPT_GET_EPISODES, SCRIPT_GET_THUMBNAIL, MEDIA_API)
- // ─── Library cache ────────────────────────────────────────────────────────────
- // Cache is stored server-side (user:/Document/Appdata/Movie/library_cache.json)
- // so it is shared across devices for the same user account.
- var libraryNeedsRedraw = false; // set when bg-refresh finishes outside the library view
- // ─── App state ────────────────────────────────────────────────────────────────
- var library = []; // full album array from server
- var currentAlbum = null; // album object currently shown in detail view
- var currentSeason = null; // season object currently active
- var currentEpisodes = []; // flat episode array for current season/album
- var playingIndex = -1; // index in currentEpisodes being played
- // TV-remote focus management
- var focusMode = false; // set to true when arrow-key pressed
- var focusedEl = null;
- // Controls auto-hide timer
- var controlsTimer = null;
- // Library pagination
- var PAGE_SIZE = 24;
- var moviesData = [];
- var collectionsData = [];
- var showsData = [];
- var shortsData = [];
- var animeData = [];
- var moviesShown = 0;
- var collectionsShown = 0;
- var showsShown = 0;
- var shortsShown = 0;
- var animeShown = 0;
- // Auto-play between episodes
- var autoplayEnabled = localStorage.getItem('movie_autoplay') !== '0'; // on by default
- var countdownTimer = null;
- // Single-repeat
- var repeatSingle = false;
- // Movie info panel
- var currentMovieAlbum = null;
- var movieInfoEpisodes = [];
- var currentMovieImdbTitle = null; // IMDB title once fetched; null until then
- // Player return-destination and watch-position tracking
- var playerReturnView = 'library';
- var pendingResumePos = 0;
- var watchSaveInterval = null;
- // Folder browse state
- var folderViewPath = '/';
- var folderViewVideos = [];
- // ─── Movie info panel ─────────────────────────────────────────────────────────
- function openMovieInfo(album) {
- currentMovieAlbum = album;
- currentAlbum = album;
- movieInfoEpisodes = [];
- currentMovieImdbTitle = null;
- // Seed with local data immediately
- $('#movie-info-title').text(album.name);
- $('#movie-info-filename').hide().text('');
- $('#movie-info-year').text('');
- $('#movie-info-cast').text('');
- $('#movie-info-imdb-btn').hide();
- $('#movie-info-wrong-wrap').hide();
- $('#movie-info-files').hide();
- $('#movie-info-loading').addClass('active');
- var localThumb = album.thumbnail
- ? 'data:image/jpeg;base64,' + album.thumbnail
- : 'img/thumbnail.png';
- $('#movie-info-poster-img').attr('src', localThumb);
- $('#movie-info-backdrop').css('background-image', 'url(' + localThumb + ')');
- // Bind Play button
- $('#movie-info-play-btn').off('click').on('click', function () {
- if (movieInfoEpisodes.length > 0) {
- currentAlbum = album; currentSeason = null;
- currentEpisodes = movieInfoEpisodes;
- startPlayback(0);
- }
- });
- // Bind Open in Files — opens file manager at the movie's location
- $('#movie-info-fm-btn').off('click').on('click', function () {
- if (!currentMovieAlbum) { return; }
- if (currentMovieAlbum._singleFile) {
- var fp = currentMovieAlbum._singleFile;
- var dir = fp.substring(0, fp.lastIndexOf('/'));
- var fname = fp.split('/').pop();
- ao_module_openPath(dir, fname);
- } else {
- ao_module_openPath(currentMovieAlbum.folderpath);
- }
- });
- // Bind YouTube search — uses IMDB title once available, raw name otherwise
- $('#movie-info-yt-btn').off('click').on('click', function () {
- var term = currentMovieImdbTitle || (currentMovieAlbum ? currentMovieAlbum.name : '');
- window.open('https://www.youtube.com/results?search_query=' + encodeURIComponent(term + ' trailer'), '_blank');
- });
- showView('movie-info');
- // Fetch IMDB metadata
- ao_module_agirun(SCRIPT_GET_MOVIE_INFO, { movie: album.name }, function (data) {
- $('#movie-info-loading').removeClass('active');
- if (data && !data.error) { applyMovieInfo(data); }
- }, function () {
- $('#movie-info-loading').removeClass('active');
- });
- // Build file list
- if (album._singleFile) {
- var ext = '.' + album._singleFile.split('.').pop().toLowerCase();
- movieInfoEpisodes = [{ name: album.name, filepath: album._singleFile, ext: ext, index: 0 }];
- // Single file — file list not needed (Play button is enough)
- } else {
- ao_module_agirun(SCRIPT_GET_EPISODES, { folder: album.folderpath }, function (data) {
- if (data && !data.error && data.length > 0) {
- movieInfoEpisodes = data;
- if (data.length > 1) { renderMovieFileList(data); }
- }
- });
- }
- }
- function applyMovieInfo(info) {
- var imdbTitle = info['#TITLE'] || '';
- var year = info['#YEAR'] ? String(info['#YEAR']) : '';
- var actors = info['#ACTORS'] || '';
- var imdbUrl = info['#IMDB_URL'] || '';
- var poster = info['#IMG_POSTER'] || '';
- // Replace the displayed title with the canonical IMDB title and show the
- // original filename as a small italic subtitle underneath.
- if (imdbTitle) {
- currentMovieImdbTitle = imdbTitle;
- $('#movie-info-title').text(imdbTitle);
- if (currentMovieAlbum && currentMovieAlbum.name !== imdbTitle) {
- $('#movie-info-filename').text(currentMovieAlbum.name).show();
- }
- }
- if (year) { $('#movie-info-year').text(year); }
- if (actors) {
- $('#movie-info-cast').html(
- '<span style="color:var(--text-sub);font-weight:600">Cast</span> ' + escapeHtml(actors)
- );
- }
- if (poster) {
- var img = document.getElementById('movie-info-poster-img');
- img.onerror = function () {
- img.src = (currentMovieAlbum && currentMovieAlbum.thumbnail)
- ? 'data:image/jpeg;base64,' + currentMovieAlbum.thumbnail
- : 'img/thumbnail.png';
- img.onerror = null;
- };
- img.onload = function () {
- $('#movie-info-backdrop').css('background-image', 'url(' + poster + ')');
- img.onload = null;
- };
- img.src = poster;
- }
- if (imdbUrl) {
- $('#movie-info-imdb-btn').show().off('click').on('click', function () {
- window.open(imdbUrl, '_blank');
- });
- }
- // Show the "wrong movie" reset button now that IMDB data is displayed
- $('#movie-info-wrong-wrap').show();
- $('#movie-info-reset-btn').off('click').on('click', function () {
- if (!currentMovieAlbum) { return; }
- ao_module_agirun(SCRIPT_DISABLE_MOVIE_INFO, { movie: currentMovieAlbum.name },
- function () { resetMovieInfo(); },
- function () { resetMovieInfo(); } // reset UI even if backend call fails
- );
- });
- }
- // Reset the movie info panel to local-only state (used after disabling IMDB info)
- function resetMovieInfo() {
- if (!currentMovieAlbum) { return; }
- currentMovieImdbTitle = null;
- var album = currentMovieAlbum;
- $('#movie-info-title').text(album.name);
- $('#movie-info-filename').hide().text('');
- $('#movie-info-year').text('');
- $('#movie-info-cast').text('');
- $('#movie-info-imdb-btn').hide();
- $('#movie-info-wrong-wrap').hide();
- // Restore local thumbnail as poster and backdrop
- var localThumb = album.thumbnail
- ? 'data:image/jpeg;base64,' + album.thumbnail
- : 'img/thumbnail.png';
- document.getElementById('movie-info-poster-img').src = localThumb;
- $('#movie-info-backdrop').css('background-image', 'url(' + localThumb + ')');
- }
- function renderMovieFileList(episodes) {
- var $list = $('#movie-info-episode-list').empty();
- episodes.forEach(function (ep, i) {
- var row = $('<div class="episode-item" tabindex="0" role="button">'
- + '<div class="ep-thumb"><div class="ep-thumb-placeholder"><img src="img/icons/play_white.svg" alt=""></div></div>'
- + '<div class="ep-info">'
- + '<div class="ep-name">' + escapeHtml(ep.name) + '</div>'
- + '<div class="ep-path">' + escapeHtml((ep.ext || '').replace('.', '').toUpperCase()) + '</div>'
- + '</div>'
- + '<div class="ep-play-icon"><img src="img/icons/play_white.svg" alt=""></div>'
- + '</div>');
- row.data('idx', i);
- row.on('click', function () {
- currentAlbum = currentMovieAlbum; currentSeason = null;
- currentEpisodes = movieInfoEpisodes;
- startPlayback($(this).data('idx'));
- });
- row.on('keydown', function (e) {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- currentAlbum = currentMovieAlbum; currentSeason = null;
- currentEpisodes = movieInfoEpisodes;
- startPlayback($(this).data('idx'));
- }
- });
- $list.append(row);
- // Lazy thumbnail
- (function (epObj, rowEl) {
- ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: epObj.filepath }, function (data) {
- if (data && !data.error && data.length > 20) {
- rowEl.find('.ep-thumb-placeholder')
- .replaceWith('<img src="data:image/jpeg;base64,' + data + '" alt="">');
- }
- });
- })(ep, row);
- });
- $('#movie-info-files').show();
- }
- function closeMovieInfo() {
- currentMovieAlbum = null;
- movieInfoEpisodes = [];
- showView('library');
- }
- // ─── Watch position (resume) ──────────────────────────────────────────────────
- function saveWatchPosition() {
- var vid = document.getElementById('main-video');
- if (playingIndex < 0 || !currentEpisodes || !vid.duration || vid.duration < 3600) { return; }
- var ep = currentEpisodes[playingIndex];
- if (!ep || vid.currentTime < 10) { return; }
- ao_module_agirun(SCRIPT_SET_WATCHTIME, {
- filepath: ep.filepath,
- position: Math.floor(vid.currentTime),
- duration: Math.floor(vid.duration)
- }, function () {}, function () {});
- }
- function clearWatchPosition() {
- if (playingIndex < 0 || !currentEpisodes) { return; }
- var ep = currentEpisodes[playingIndex];
- if (!ep) { return; }
- ao_module_agirun(SCRIPT_SET_WATCHTIME, { filepath: ep.filepath, position: 0, duration: 0 },
- function () {}, function () {});
- }
- function showResumePopup(savedPos, duration) {
- var vid = document.getElementById('main-video');
- vid.pause();
- pendingResumePos = savedPos;
- $('#resume-popup-sub').text(
- 'Last position: ' + formatTime(savedPos) + ' of ' + formatTime(duration)
- );
- $('#resume-popup').addClass('active');
- showControls();
- $('#resume-btn-continue').off('click').on('click', function () {
- vid.currentTime = pendingResumePos;
- vid.play();
- $('#resume-popup').removeClass('active');
- });
- $('#resume-btn-restart').off('click').on('click', function () {
- vid.play();
- $('#resume-popup').removeClass('active');
- });
- }
- // ─── Tab switching ────────────────────────────────────────────────────────────
- function switchTab(tab) {
- if (tab === 'folder') {
- var wasAlreadyFolder = $('#view-folder').hasClass('active');
- showView('folder');
- if (!wasAlreadyFolder) { navigateFolder(folderViewPath); }
- } else {
- showView('library');
- }
- }
- // ─── Folder navigation ────────────────────────────────────────────────────────
- function buildBreadcrumbs(path) {
- var crumbs = [{ name: 'Home', path: '/' }];
- if (!path || path === '/') { return crumbs; }
- var colonIdx = path.indexOf(':/');
- if (colonIdx < 0) { return crumbs; }
- var root = path.substring(0, colonIdx + 2);
- crumbs.push({ name: root, path: root });
- var rest = path.substring(colonIdx + 2);
- if (!rest) { return crumbs; }
- var parts = rest.split('/').filter(function (s) { return s.length > 0; });
- var cumPath = root;
- for (var i = 0; i < parts.length; i++) {
- cumPath += parts[i] + '/';
- crumbs.push({ name: parts[i], path: cumPath });
- }
- return crumbs;
- }
- function renderBreadcrumb(path) {
- var crumbs = buildBreadcrumbs(path);
- var $bc = $('#folder-breadcrumb').empty();
- for (var i = 0; i < crumbs.length; i++) {
- var crumb = crumbs[i];
- var isLast = (i === crumbs.length - 1);
- if (i > 0) { $bc.append('<span class="crumb-sep">›</span>'); }
- var btn = $('<button class="crumb-btn' + (isLast ? ' crumb-current' : '') + '">'
- + escapeHtml(crumb.name) + '</button>');
- if (!isLast) {
- (function (p) { btn.on('click', function () { navigateFolder(p); }); })(crumb.path);
- }
- $bc.append(btn);
- }
- }
- function navigateFolder(path) {
- folderViewPath = path || '/';
- renderBreadcrumb(folderViewPath);
- loadFolderContents(folderViewPath);
- }
- function loadFolderContents(path) {
- $('#folder-loading').addClass('active');
- $('#folder-empty').hide();
- $('#folder-section-dirs').hide();
- $('#folder-section-vids').hide();
- var params = (path && path !== '/') ? { folder: path } : { folder: '' };
- ao_module_agirun(SCRIPT_LIST_FOLDER, params, function (data) {
- $('#folder-loading').removeClass('active');
- if (!data || data.error) {
- $('#folder-empty').text(data && data.error ? data.error : 'Failed to load folder.').show();
- return;
- }
- renderFolderContents(data);
- }, function () {
- $('#folder-loading').removeClass('active');
- $('#folder-empty').text('Error loading folder.').show();
- });
- }
- function renderFolderContents(data) {
- folderViewVideos = data.videos || [];
- var hasDirs = data.folders && data.folders.length > 0;
- var hasVids = data.videos && data.videos.length > 0;
- if (!hasDirs && !hasVids) {
- $('#folder-empty').text('This folder is empty.').show();
- return;
- }
- if (hasDirs) {
- var $dirs = $('#folder-dirs-grid').empty();
- $('#folder-section-dirs').show();
- data.folders.forEach(function (f) {
- var thumbHtml;
- if (f.isRoot) {
- thumbHtml = '<div class="poster-placeholder"><img src="img/root_placeholder.png" alt=""></div>';
- } else if (f.hasVideos && f.thumbnail && f.thumbnail.length > 20) {
- thumbHtml = '<img class="poster" src="data:image/jpeg;base64,' + f.thumbnail + '" alt="">';
- } else if (f.hasVideos) {
- thumbHtml = '<div class="poster-placeholder"><img src="img/playlist_placeholder.png" alt=""></div>';
- } else {
- thumbHtml = '<div class="poster-placeholder"><img src="img/folder_placeholder.png" alt=""></div>';
- }
- var meta = f.isRoot ? 'Storage root' : (f.hasVideos ? 'Playlist' : 'Folder');
- var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(f.name) + '">'
- + thumbHtml
- + '<div class="card-info">'
- + '<div class="card-title">' + escapeHtml(f.name) + '</div>'
- + '<div class="card-meta">' + escapeHtml(meta) + '</div>'
- + '</div>'
- + '</div>');
- card.on('click', function () { navigateFolder(f.path); });
- card.on('keydown', function (e) {
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigateFolder(f.path); }
- });
- $dirs.append(card);
- });
- }
- if (hasVids) {
- var $vids = $('#folder-vids-grid').empty();
- $('#folder-section-vids').show();
- data.videos.forEach(function (v, i) {
- var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(v.name) + '">'
- + '<div class="poster-placeholder"><img src="img/thumbnail.png" alt=""></div>'
- + '<div class="card-info">'
- + '<div class="card-title">' + escapeHtml(v.name) + '</div>'
- + '<div class="card-meta">' + escapeHtml((v.ext || '').replace('.', '').toUpperCase()) + '</div>'
- + '</div>'
- + '</div>');
- card.data('vidIdx', i);
- card.data('vidInfo', v);
- card.on('click', function () { playFolderVideos(folderViewVideos, $(this).data('vidIdx')); });
- card.on('keydown', function (e) {
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); playFolderVideos(folderViewVideos, $(this).data('vidIdx')); }
- });
- $vids.append(card);
- });
- // Lazy-load video thumbnails
- $('#folder-vids-grid .album-card').each(function () {
- var $card = $(this);
- var v = $card.data('vidInfo');
- if (!v) { return; }
- ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: v.filepath }, function (data) {
- if (data && !data.error && data.length > 20) {
- $card.find('.poster-placeholder')
- .replaceWith('<img class="poster" src="data:image/jpeg;base64,' + data + '" alt="">');
- }
- });
- });
- }
- }
- function playFolderVideos(videos, startIndex) {
- if (!videos || videos.length === 0) { return; }
- var segments = folderViewPath.split('/').filter(function (s) { return s.length > 0; });
- var folderName = segments.length > 0 ? segments[segments.length - 1] : 'Videos';
- currentAlbum = { name: folderName, type: 'folder', folderpath: folderViewPath };
- currentSeason = null;
- currentEpisodes = videos.map(function (v, i) {
- return { name: v.name, filepath: v.filepath, ext: v.ext, index: i };
- });
- startPlayback(startIndex);
- }
- // ─── Init ─────────────────────────────────────────────────────────────────────
- $(document).ready(function () {
- loadLibrary();
- initVideoControls();
- initKeyboard();
- initSearch();
- initContextMenu();
- // Autoplay toggle
- $('#autoplay-check').prop('checked', autoplayEnabled);
- $('#autoplay-check').on('change', function () {
- autoplayEnabled = $(this).is(':checked');
- localStorage.setItem('movie_autoplay', autoplayEnabled ? '1' : '0');
- });
- });
- // ─── Library status bar helpers ────────────────────────────────────────────────
- // spinning=true shows the spinner and hides the Refresh button, and vice-versa.
- function setLibraryStatus(text, spinning) {
- $('#library-status-text').text(text);
- $('#library-spinner').toggle(spinning);
- $('#library-refresh-btn').toggle(!spinning);
- }
- function timeAgo(ts) {
- var d = Math.floor((Date.now() - ts) / 1000);
- if (d < 60) { return 'just now'; }
- if (d < 3600) { return Math.floor(d / 60) + 'm ago'; }
- if (d < 86400) { return Math.floor(d / 3600) + 'h ago'; }
- return Math.floor(d / 86400) + 'd ago';
- }
- // Re-render the library grid, preserving an active search filter if present.
- function renderCurrentLibrary() {
- var q = $('#search-input').val().trim().toLowerCase();
- renderLibrary(q ? library.filter(function (a) { return a.name.toLowerCase().indexOf(q) > -1; }) : library);
- }
- // Run a background full scan. getLibrary.js writes the cache file server-side
- // before it sends its response, so the cache is updated even if the tab closes
- // partway through — the browser just won't receive the response in that case.
- function backgroundScanLibrary() {
- ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
- if (!data || data.error) {
- setLibraryStatus('Refresh failed · showing cached data', false);
- return;
- }
- library = data;
- if ($('#view-library').hasClass('active')) {
- renderCurrentLibrary();
- libraryNeedsRedraw = false;
- } else {
- libraryNeedsRedraw = true;
- }
- var n = library.length;
- setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
- }, function () {
- setLibraryStatus('Refresh failed · showing cached data', false);
- });
- }
- // ─── Load library (server-side cache-first) ────────────────────────────────────
- // 1. Ask getLibraryCache.js for the last saved scan result (milliseconds, no scan).
- // 2a. Cache hit → render immediately, kick off backgroundScanLibrary().
- // 2b. Cache miss → show loading overlay, run full scan, render when done.
- function loadLibrary() {
- ao_module_agirun(SCRIPT_GET_LIBRARY_CACHE, {}, function (cached) {
- if (cached && !cached.error && Array.isArray(cached.data)) {
- // Fast path: paint the UI from cache right away
- $('#loading-overlay').hide();
- library = cached.data;
- renderLibrary(library);
- setLibraryStatus('Cached · ' + timeAgo(cached.ts) + ' · refreshing…', true);
- backgroundScanLibrary();
- } else {
- // Cold start: no cache yet — full scan with loading overlay
- setLibraryStatus('Loading…', true);
- ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
- $('#loading-overlay').fadeOut(300);
- if (!data || data.error) {
- showToast('Failed to load library');
- setLibraryStatus('Failed to load library', false);
- return;
- }
- library = data;
- renderLibrary(library);
- var n = library.length;
- setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
- }, function () {
- $('#loading-overlay').fadeOut(300);
- showToast('Error loading library');
- setLibraryStatus('Error loading library', false);
- });
- }
- }, function () {
- // getLibraryCache.js itself failed — treat as cold start
- setLibraryStatus('Loading…', true);
- ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
- $('#loading-overlay').fadeOut(300);
- if (!data || data.error) {
- showToast('Failed to load library');
- setLibraryStatus('Failed to load library', false);
- return;
- }
- library = data;
- renderLibrary(library);
- var n = library.length;
- setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
- }, function () {
- $('#loading-overlay').fadeOut(300);
- showToast('Error loading library');
- setLibraryStatus('Error loading library', false);
- });
- });
- }
- // ─── Manual refresh (Refresh button) ──────────────────────────────────────────
- // Triggers a full scan; getLibrary.js saves the cache file automatically.
- function refreshLibrary() {
- $('#library-refresh-btn').prop('disabled', true);
- setLibraryStatus('Refreshing…', true);
- ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
- $('#library-refresh-btn').prop('disabled', false);
- if (!data || data.error) {
- showToast('Failed to refresh library');
- setLibraryStatus('Refresh failed', false);
- return;
- }
- library = data;
- renderCurrentLibrary();
- libraryNeedsRedraw = false;
- var n = library.length;
- setLibraryStatus(n + ' item' + (n !== 1 ? 's' : '') + ' · just refreshed', false);
- }, function () {
- $('#library-refresh-btn').prop('disabled', false);
- showToast('Error refreshing library');
- setLibraryStatus('Refresh failed', false);
- });
- }
- // ─── Render library grid ──────────────────────────────────────────────────────
- function renderLibrary(albums) {
- moviesData = albums.filter(function (a) { return a.type === 'movie'; });
- collectionsData = albums.filter(function (a) { return a.type === 'collection'; });
- showsData = albums.filter(function (a) { return a.type === 'series'; });
- animeData = albums.filter(function (a) { return a.type === 'anime'; });
- shortsData = albums.filter(function (a) { return a.type === 'short'; });
- moviesShown = 0;
- collectionsShown = 0;
- showsShown = 0;
- animeShown = 0;
- shortsShown = 0;
- $('#movies-grid').empty();
- $('#collections-grid').empty();
- $('#series-grid').empty();
- $('#shorts-grid').empty();
- $('#anime-grid').empty();
- if (!moviesData.length && !collectionsData.length && !showsData.length && !animeData.length && !shortsData.length) {
- $('#no-content').show();
- return;
- }
- $('#no-content').hide();
- if (moviesData.length > 0) { $('#movies-section').show(); loadMoreSection('movies'); }
- else { $('#movies-section').hide(); }
- if (collectionsData.length > 0) { $('#collections-section').show(); loadMoreSection('collections'); }
- else { $('#collections-section').hide(); }
- if (showsData.length > 0) { $('#series-section').show(); loadMoreSection('shows'); }
- else { $('#series-section').hide(); }
- if (animeData.length > 0) { $('#anime-section').show(); loadMoreSection('anime'); }
- else { $('#anime-section').hide(); }
- if (shortsData.length > 0) { $('#shorts-section').show(); loadMoreSection('shorts'); }
- else { $('#shorts-section').hide(); }
- }
- function loadMoreSection(which) {
- var data, $grid, $btn, start, end, i;
- if (which === 'movies') {
- data = moviesData;
- $grid = $('#movies-grid');
- $btn = $('#movies-load-more');
- start = moviesShown;
- end = Math.min(start + PAGE_SIZE, data.length);
- for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
- moviesShown = end;
- $btn.toggle(moviesShown < data.length);
- } else if (which === 'shows') {
- data = showsData;
- $grid = $('#series-grid');
- $btn = $('#shows-load-more');
- start = showsShown;
- end = Math.min(start + PAGE_SIZE, data.length);
- for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
- showsShown = end;
- $btn.toggle(showsShown < data.length);
- } else if (which === 'collections') {
- data = collectionsData;
- $grid = $('#collections-grid');
- $btn = $('#collections-load-more');
- start = collectionsShown;
- end = Math.min(start + PAGE_SIZE, data.length);
- for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
- collectionsShown = end;
- $btn.toggle(collectionsShown < data.length);
- } else if (which === 'anime') {
- data = animeData;
- $grid = $('#anime-grid');
- $btn = $('#anime-load-more');
- start = animeShown;
- end = Math.min(start + PAGE_SIZE, data.length);
- for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
- animeShown = end;
- $btn.toggle(animeShown < data.length);
- } else {
- data = shortsData;
- $grid = $('#shorts-grid');
- $btn = $('#shorts-load-more');
- start = shortsShown;
- end = Math.min(start + PAGE_SIZE, data.length);
- for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
- shortsShown = end;
- $btn.toggle(shortsShown < data.length);
- }
- loadCardThumbnails();
- }
- function buildCard(album, idx) {
- var thumb = album.thumbnail && album.thumbnail.length > 0
- ? '<img class="poster" src="data:image/jpeg;base64,' + album.thumbnail + '" alt="">'
- : '<div class="poster-placeholder"><img src="img/thumbnail.png" alt=""></div>';
- var badge = album.type === 'series' ? '<span class="badge series">Series</span>'
- : album.type === 'anime' ? '<span class="badge anime">Anime</span>'
- : '';
- var ext = album._singleFile ? album._singleFile.split('.').pop().toUpperCase() : '';
- var meta = (album.type === 'series' || album.type === 'anime')
- ? album.episodeCount + ' ep'
- : album.type === 'short'
- ? (ext || 'Short')
- : album.type === 'collection'
- ? album.episodeCount + (album.episodeCount > 1 ? ' videos' : ' video')
- : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
- var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(album.name) + '">'
- + thumb
- + badge
- + '<div class="card-info">'
- + '<div class="card-title">' + escapeHtml(album.name) + '</div>'
- + '<div class="card-meta">' + escapeHtml(meta) + '</div>'
- + '</div>'
- + '</div>');
- card.data('album', album);
- card.on('click', function () {
- if (album.type === 'short' && album._singleFile) {
- currentAlbum = album; currentSeason = null;
- currentEpisodes = [{ name: album.name, filepath: album._singleFile,
- ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
- startPlayback(0);
- } else if (album.type === 'movie') {
- openMovieInfo(album);
- } else { openDetail(album); }
- });
- card.on('keydown', function (e) {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- if (album.type === 'short' && album._singleFile) {
- currentAlbum = album; currentSeason = null;
- currentEpisodes = [{ name: album.name, filepath: album._singleFile,
- ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
- startPlayback(0);
- } else if (album.type === 'movie') {
- openMovieInfo(album);
- } else { openDetail(album); }
- }
- });
- return card;
- }
- // Load thumbnails in background (for cards that don't have one embedded)
- function loadCardThumbnails() {
- $('.album-card').each(function () {
- var $card = $(this);
- var album = $card.data('album');
- if (!album || album.thumbnail) { return; } // already has thumb
- // For shorts use the actual video file; folder path would yield the generic folder thumbnail
- var fileParam = (album.type === 'short' && album._singleFile) ? album._singleFile : album.folderpath;
- ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: fileParam }, function (data) {
- if (data && !data.error && data.length > 20) {
- $card.find('.poster-placeholder')
- .replaceWith('<img class="poster" src="data:image/jpeg;base64,' + data + '" alt="">');
- }
- });
- });
- }
- // ─── Detail view ──────────────────────────────────────────────────────────────
- function openDetail(album) {
- currentAlbum = album;
- // Hero background
- var bg = album.thumbnail ? 'data:image/jpeg;base64,' + album.thumbnail : '';
- $('#detail-hero-bg').css('background-image', bg ? 'url(' + bg + ')' : 'none');
- // Poster
- var posterHtml = album.thumbnail
- ? '<img src="data:image/jpeg;base64,' + album.thumbnail + '" alt="" style="width:100%;aspect-ratio:2/3;object-fit:cover;">'
- : '<div class="poster-placeholder"><img src="img/icons/movie_white.svg" alt=""></div>';
- $('#detail-poster').html(posterHtml);
- // Title & subtitle
- $('#detail-title').text(album.name);
- var sub = (album.type === 'series' || album.type === 'anime')
- ? album.seasons.length + ' season' + (album.seasons.length !== 1 ? 's' : '') + ' · ' + album.episodeCount + ' episodes'
- : album.type === 'collection'
- ? album.episodeCount + (album.episodeCount > 1 ? ' videos' : ' video')
- : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
- $('#detail-subtitle').text(sub);
- // Season tabs
- var $tabs = $('#season-tabs').empty();
- if ((album.type === 'series' || album.type === 'anime') && album.seasons.length > 0) {
- album.seasons.forEach(function (s, i) {
- var tab = $('<button class="season-tab" tabindex="0">' + escapeHtml(s.name) + '</button>');
- tab.data('season', s);
- tab.on('click', function () { selectSeason($(this).data('season')); activateTab(this); });
- $tabs.append(tab);
- });
- $tabs.show();
- selectSeason(album.seasons[0]);
- $tabs.find('.season-tab').first().addClass('active');
- } else {
- $tabs.hide();
- // Load the folder directly as episodes
- loadEpisodes(album.folderpath);
- }
- // Play / shuffle button bindings
- $('#btn-play-first').off('click').on('click', function () {
- if (currentEpisodes.length > 0) { startPlayback(0); }
- });
- $('#btn-shuffle').off('click').on('click', function () {
- if (currentEpisodes.length === 0) { return; }
- var idx = Math.floor(Math.random() * currentEpisodes.length);
- startPlayback(idx);
- });
- showView('detail');
- }
- function selectSeason(season) {
- currentSeason = season;
- loadEpisodes(season.folderpath);
- }
- function activateTab(el) {
- $('#season-tabs .season-tab').removeClass('active');
- $(el).addClass('active');
- }
- function loadEpisodes(folderpath) {
- $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">Loading…</div>');
- ao_module_agirun(SCRIPT_GET_EPISODES, { folder: folderpath }, function (data) {
- if (!data || data.error) {
- $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">No episodes found.</div>');
- currentEpisodes = [];
- return;
- }
- currentEpisodes = data;
- renderEpisodes(data);
- }, function () {
- $('#episode-list').html('<div style="color:var(--text-sub);padding:20px 0;">Error loading episodes.</div>');
- });
- }
- function renderEpisodes(episodes) {
- var $list = $('#episode-list').empty();
- episodes.forEach(function (ep, i) {
- var row = $('<div class="episode-item" tabindex="0" role="button">'
- + '<div class="ep-thumb"><div class="ep-thumb-placeholder"><img src="img/icons/play_white.svg" alt=""></div></div>'
- + '<div class="ep-info">'
- + '<div class="ep-name">' + escapeHtml(ep.name) + '</div>'
- + '<div class="ep-path">' + escapeHtml(ep.ext.replace('.', '').toUpperCase()) + '</div>'
- + '</div>'
- + '<div class="ep-play-icon"><img src="img/icons/play_white.svg" alt=""></div>'
- + '</div>');
- row.data('ep', ep);
- row.data('idx', i);
- row.on('click', function () { startPlayback($(this).data('idx')); });
- row.on('keydown', function (e) {
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startPlayback($(this).data('idx')); }
- });
- $list.append(row);
- // Lazy-load thumbnail
- (function (epObj, rowEl) {
- ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: epObj.filepath }, function (data) {
- if (data && !data.error && data.length > 20) {
- rowEl.find('.ep-thumb-placeholder')
- .replaceWith('<img src="data:image/jpeg;base64,' + data + '" alt="">');
- }
- });
- })(ep, row);
- });
- }
- // ─── Player ───────────────────────────────────────────────────────────────────
- function isWebPlayable(ext) {
- return ['mp4', 'webm', 'ogg'].includes(ext);
- }
- function startPlayback(index) {
- cancelCountdown();
- $('#resume-popup').removeClass('active');
- if (!currentEpisodes || currentEpisodes.length === 0) { return; }
- playingIndex = index;
- var ep = currentEpisodes[index];
- var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
- var src = isWebPlayable(ext)
- ? MEDIA_API + '?file=' + encodeURIComponent(ep.filepath)
- : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
- var vid = document.getElementById('main-video');
- vid.src = src;
- vid.play();
- $('#now-playing-title').text(ep.name);
- ao_module_setWindowTitle('Movie – ' + ep.name);
- renderSidebar(currentEpisodes, index);
- if (currentEpisodes.length <= 1) {
- $('#playlist-sidebar').addClass('collapsed');
- } else {
- $('#playlist-sidebar').removeClass('collapsed');
- }
- highlightPlayingEpisode(index);
- // Remember where we came from so closePlayer() can return there
- var curView = $('.view.active').attr('id');
- if (curView && curView !== 'view-player') {
- playerReturnView = curView.replace('view-', '');
- }
- showView('player');
- showControls();
- // After metadata loads, offer to resume if the video is >1 hr and has a saved position
- (function (epFilepath) {
- $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
- if (vid.duration > 3600) {
- ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: epFilepath }, function (data) {
- if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
- showResumePopup(data.position, vid.duration);
- }
- });
- }
- });
- })(ep.filepath);
- }
- function renderSidebar(episodes, playing) {
- var $list = $('#sidebar-list').empty();
- episodes.forEach(function (ep, i) {
- var row = $('<div class="sidebar-ep' + (i === playing ? ' playing' : '') + '" tabindex="0" role="button">'
- + '<span class="sidebar-ep-num">' + (i + 1) + '</span>'
- + '<span class="sidebar-ep-name">' + escapeHtml(ep.name) + '</span>'
- + '</div>');
- row.data('idx', i);
- row.on('click', function () {
- cancelCountdown();
- var idx = $(this).data('idx');
- playingIndex = idx;
- var e2 = currentEpisodes[idx];
- var ext = e2.ext ? e2.ext.toLowerCase().replace(/^\./, '') : '';
- var src = '';
- if (isWebPlayable(ext)) {
- src = MEDIA_API + '?file=' + encodeURIComponent(e2.filepath);
- } else {
- src = TRANSCODE_API + '?file=' + encodeURIComponent(e2.filepath);
- }
- var vid = document.getElementById('main-video');
- vid.src = src;
- vid.play();
- $('#now-playing-title').text(e2.name);
- ao_module_setWindowTitle('Movie – ' + e2.name);
- highlightPlayingEpisode(idx);
- renderSidebar(currentEpisodes, idx);
- });
- $list.append(row);
- });
- // Scroll to playing
- var $playing = $list.find('.playing');
- if ($playing.length) {
- setTimeout(function () { $playing[0].scrollIntoView({ block: 'center' }); }, 50);
- }
- }
- function highlightPlayingEpisode(idx) {
- $('#episode-list .episode-item').removeClass('playing');
- $('#episode-list .episode-item').eq(idx).addClass('playing');
- }
- function closePlayer() {
- saveWatchPosition();
- cancelCountdown();
- if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
- var vid = document.getElementById('main-video');
- vid.pause();
- vid.src = '';
- $('#resume-popup').removeClass('active');
- var returnTo = (currentAlbum && (currentAlbum.type === 'series' || currentAlbum.type === 'anime' || currentAlbum.type === 'collection'))
- ? 'detail'
- : playerReturnView;
- showView(returnTo);
- }
- function showLibrary() {
- showView('library');
- currentAlbum = null;
- // If a background refresh completed while the user was away, apply it now
- if (libraryNeedsRedraw) {
- libraryNeedsRedraw = false;
- renderCurrentLibrary();
- }
- }
- // ─── View switching ───────────────────────────────────────────────────────────
- function showView(name) {
- $('.view').removeClass('active');
- $('#view-' + name).addClass('active');
- if (name === 'library' || name === 'detail') {
- $('.mode-tab').removeClass('active');
- $('#tab-library').addClass('active');
- $('#search-wrap').show();
- } else if (name === 'folder') {
- $('.mode-tab').removeClass('active');
- $('#tab-folder').addClass('active');
- $('#search-wrap').hide();
- } else if (name === 'movie-info') {
- $('.mode-tab').removeClass('active');
- $('#tab-library').addClass('active');
- $('#search-wrap').hide();
- }
- // player view leaves tab state unchanged
- }
- // ─── Video controls ───────────────────────────────────────────────────────────
- function initVideoControls() {
- var vid = document.getElementById('main-video');
- var $ctrl = $('#video-controls');
- var $prog = $('#progress-bar');
- var $thumb = $('#progress-thumb');
- var $time = $('#time-display');
- var $play = $('#ctrl-play');
- var $mute = $('#ctrl-mute');
- // Restore saved volume from last session
- var savedVol = parseFloat(localStorage.getItem('movie_volume'));
- if (!isNaN(savedVol) && savedVol >= 0 && savedVol <= 1) {
- vid.volume = savedVol;
- $('#volume-slider').val(savedVol);
- }
- if (localStorage.getItem('movie_muted') === '1') { vid.muted = true; }
- // Play/Pause
- $('#ctrl-play').on('click', function () { togglePlay(); });
- $('#ctrl-mute').on('click', function () { vid.muted = !vid.muted; updateMuteIcon(); });
- $('#ctrl-prev').on('click', function () { playOffset(-1); });
- $('#ctrl-next').on('click', function () { playOffset(1); });
- $('#ctrl-fs').on('click', function () { toggleFullscreen(); });
- $('#ctrl-list').on('click', function () { toggleSidebar(); });
- $('#volume-slider').on('input', function () {
- vid.volume = parseFloat($(this).val());
- updateMuteIcon();
- });
- // Progress bar click / drag
- $('#progress-wrap').on('click', function (e) {
- if (vid.duration) {
- var pct = e.offsetX / $(this).width();
- vid.currentTime = pct * vid.duration;
- }
- });
- // Video events
- $(vid).on('timeupdate', function () {
- if (!vid.duration) { return; }
- var pct = (vid.currentTime / vid.duration) * 100;
- $prog.css('width', pct + '%');
- $thumb.css('left', 'calc(' + pct + '% - 7px)');
- $time.text(formatTime(vid.currentTime) + ' / ' + formatTime(vid.duration));
- });
- $(vid).on('play', function () {
- $('#play-icon').attr('src', 'img/icons/pause_white.svg');
- // Periodically save position for videos longer than 1 hr
- if (watchSaveInterval) { clearInterval(watchSaveInterval); }
- watchSaveInterval = setInterval(function () {
- if (!vid.paused && vid.duration > 3600) { saveWatchPosition(); }
- }, 30000);
- });
- $(vid).on('pause', function () {
- $('#play-icon').attr('src', 'img/icons/play_white.svg');
- if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
- if (vid.duration > 3600 && vid.currentTime > 30) { saveWatchPosition(); }
- });
- $(vid).on('ended', function () {
- clearWatchPosition(); // video finished naturally — remove resume point
- if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
- cancelCountdown();
- if (repeatSingle) {
- vid.currentTime = 0;
- vid.play();
- } else if (playingIndex < currentEpisodes.length - 1 && autoplayEnabled) {
- startNextCountdown();
- }
- });
- $(vid).on('volumechange', function () {
- updateMuteIcon();
- localStorage.setItem('movie_volume', vid.volume);
- localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
- });
- // Auto-hide controls
- $('#video-container').on('mousemove touchstart', function () { showControls(); });
- // Click on video = play/pause
- $(vid).on('click', function () { togglePlay(); });
- // Hide back button when in fullscreen (JS reinforcement)
- document.addEventListener('fullscreenchange', function () {
- $('#player-back').toggle(!document.fullscreenElement);
- });
- document.addEventListener('webkitfullscreenchange', function () {
- $('#player-back').toggle(!document.webkitFullscreenElement);
- });
- function updateMuteIcon() {
- var isMuted = vid.muted || vid.volume === 0;
- $('#mute-icon').attr('src', isMuted ? 'img/icons/mute_white.svg' : 'img/icons/volume_white.svg');
- $('#volume-slider').val(vid.muted ? 0 : vid.volume);
- }
- }
- function togglePlay() {
- $('#resume-popup').removeClass('active');
- var vid = document.getElementById('main-video');
- if (vid.paused) { vid.play(); } else { vid.pause(); }
- }
- function playOffset(offset) {
- var next = playingIndex + offset;
- if (next < 0) { next = 0; }
- if (next >= currentEpisodes.length) { next = currentEpisodes.length - 1; }
- if (next !== playingIndex) { startPlayback(next); }
- }
- function showControls() {
- $('#video-controls').removeClass('hidden');
- clearTimeout(controlsTimer);
- controlsTimer = setTimeout(function () {
- var vid = document.getElementById('main-video');
- if (!vid.paused) { $('#video-controls').addClass('hidden'); }
- }, 3000);
- }
- function toggleFullscreen() {
- var el = document.getElementById('view-player');
- if (!document.fullscreenElement) {
- el.requestFullscreen && el.requestFullscreen();
- } else {
- document.exitFullscreen && document.exitFullscreen();
- }
- }
- function toggleSidebar() {
- $('#playlist-sidebar').toggleClass('collapsed');
- }
- function startNextCountdown() {
- var total = 5;
- var remaining = total;
- var $bar = $('#next-countdown-bar');
- var $num = $('#countdown-num');
- $bar.css('transition', 'none').css('width', '100%');
- $num.text(remaining);
- $('#next-countdown').show();
- countdownTimer = setInterval(function () {
- remaining--;
- $num.text(remaining);
- $bar.css('transition', 'width 1s linear').css('width', (remaining / total * 100) + '%');
- if (remaining <= 0) { cancelCountdown(); playOffset(1); }
- }, 1000);
- }
- function cancelCountdown() {
- if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
- $('#next-countdown').hide();
- }
- // ─── Player context menu ──────────────────────────────────────────────────────
- var infoRefreshTimer = null;
- var currentInfoTab = 'props';
- function initContextMenu() {
- var vid = document.getElementById('main-video');
- var $ctx = $('#player-ctx');
- // Right-click on the video container
- $('#video-container').on('contextmenu', function (e) {
- e.preventDefault();
- if (playingIndex < 0) { return; }
- // Update item disabled / active states
- var paused = vid.paused;
- $('#ctx-play').toggleClass('ctx-disabled', !paused);
- $('#ctx-pause').toggleClass('ctx-disabled', paused);
- $('#ctx-prev').toggleClass('ctx-disabled', playingIndex <= 0);
- $('#ctx-next').toggleClass('ctx-disabled', playingIndex >= currentEpisodes.length - 1);
- $('#ctx-repeat').toggleClass('ctx-active', repeatSingle)
- .html('<i class="ctx-icon">' + (repeatSingle ? '✓' : '↺') + '</i>Repeat: ' + (repeatSingle ? 'On' : 'Off'));
- // Position menu, clamp inside container
- var rect = this.getBoundingClientRect();
- var x = e.clientX - rect.left;
- var y = e.clientY - rect.top;
- $ctx.css({ left: x, top: y, display: 'block' });
- var mw = $ctx.outerWidth(), mh = $ctx.outerHeight();
- if (x + mw > rect.width) { $ctx.css('left', Math.max(0, x - mw)); }
- if (y + mh > rect.height) { $ctx.css('top', Math.max(0, y - mh)); }
- showControls();
- });
- // Dismiss on any click outside the menu
- $(document).on('mousedown.ctx', function (e) {
- if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
- });
- // Dismiss on Escape
- $(document).on('keydown.ctx', function (e) {
- if (e.key === 'Escape') { $ctx.hide(); closeVideoInfo(); }
- });
- $('#ctx-play').on('click', function () { vid.play(); $ctx.hide(); });
- $('#ctx-pause').on('click', function () { vid.pause(); $ctx.hide(); });
- $('#ctx-prev').on('click', function () { cancelCountdown(); playOffset(-1); $ctx.hide(); });
- $('#ctx-next').on('click', function () { cancelCountdown(); playOffset(1); $ctx.hide(); });
- $('#ctx-repeat').on('click', function () { repeatSingle = !repeatSingle; $ctx.hide(); });
- $('#ctx-props').on('click', function () { $ctx.hide(); openVideoInfo('props'); });
- $('#ctx-stats').on('click', function () { $ctx.hide(); openVideoInfo('stats'); });
- }
- function openVideoInfo(tab) {
- currentInfoTab = tab || 'props';
- $('#video-info-modal').show();
- $('.info-tab').removeClass('active').eq(currentInfoTab === 'props' ? 0 : 1).addClass('active');
- $('#video-info-title').text(currentInfoTab === 'props' ? 'Video Properties' : 'Streaming Stats');
- renderInfoContent();
- if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
- if (currentInfoTab === 'stats') {
- infoRefreshTimer = setInterval(renderInfoContent, 1000);
- }
- }
- function closeVideoInfo() {
- $('#video-info-modal').hide();
- if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
- }
- function showInfoTab(tab) {
- currentInfoTab = tab;
- $('.info-tab').removeClass('active').eq(tab === 'props' ? 0 : 1).addClass('active');
- $('#video-info-title').text(tab === 'props' ? 'Video Properties' : 'Streaming Stats');
- if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
- if (tab === 'stats') { infoRefreshTimer = setInterval(renderInfoContent, 1000); }
- renderInfoContent();
- }
- function infoRow(label, value) {
- return '<div class="info-row"><span class="info-label">' + escapeHtml(String(label)) + '</span>'
- + '<span class="info-value">' + escapeHtml(String(value)) + '</span></div>';
- }
- function renderInfoContent() {
- var vid = document.getElementById('main-video');
- var ep = (playingIndex >= 0 && currentEpisodes[playingIndex]) ? currentEpisodes[playingIndex] : null;
- var html = '';
- if (currentInfoTab === 'props') {
- html += infoRow('Title', ep ? ep.name : '–');
- html += infoRow('Resolution', (vid.videoWidth && vid.videoHeight)
- ? vid.videoWidth + ' × ' + vid.videoHeight : '–');
- html += infoRow('Duration', vid.duration ? formatTime(vid.duration) : '–');
- html += infoRow('Position', vid.currentTime ? formatTime(vid.currentTime) : '–');
- html += infoRow('Playback speed', vid.playbackRate + '×');
- html += infoRow('Volume', vid.muted ? 'Muted' : Math.round(vid.volume * 100) + '%');
- if (ep) { html += infoRow('File path', ep.filepath || '–'); }
- } else {
- var rsLabels = ['No info', 'Metadata only', 'Have current data', 'Have future data', 'Enough data'];
- var nsLabels = ['Empty', 'Idle', 'Loading', 'No source'];
- html += infoRow('Ready state', rsLabels[vid.readyState] || vid.readyState);
- html += infoRow('Network state', nsLabels[vid.networkState] || vid.networkState);
- // Buffer ahead
- var bufEnd = 0;
- if (vid.buffered && vid.buffered.length > 0) {
- for (var i = 0; i < vid.buffered.length; i++) {
- if (vid.buffered.start(i) <= vid.currentTime + 0.1) {
- bufEnd = Math.max(bufEnd, vid.buffered.end(i));
- }
- }
- }
- html += infoRow('Buffer ahead', Math.max(0, bufEnd - vid.currentTime).toFixed(1) + 's');
- html += infoRow('Total buffered', vid.buffered.length > 0
- ? formatTime(vid.buffered.end(vid.buffered.length - 1)) : '–');
- if (vid.getVideoPlaybackQuality) {
- var q = vid.getVideoPlaybackQuality();
- html += infoRow('Frames decoded', q.totalVideoFrames || 0);
- html += infoRow('Frames dropped', q.droppedVideoFrames || 0);
- }
- html += infoRow('Stalled / ended', vid.ended ? 'Ended' : (vid.readyState < 3 ? 'Yes' : 'No'));
- }
- $('#video-info-body').html(html);
- }
- function formatTime(s) {
- if (isNaN(s)) { return '0:00'; }
- var h = Math.floor(s / 3600);
- var m = Math.floor((s % 3600) / 60);
- var sec = Math.floor(s % 60);
- var parts = [];
- if (h > 0) { parts.push(h); }
- parts.push(m);
- parts.push(sec < 10 ? '0' + sec : sec);
- return parts.join(':');
- }
- // ─── Keyboard / TV-remote navigation ─────────────────────────────────────────
- function initKeyboard() {
- $(document).on('keydown', function (e) {
- var activeView = $('.view.active').attr('id');
- // ── Player shortcuts ──────────────────────────────────────────────────
- if (activeView === 'view-player') {
- var vid = document.getElementById('main-video');
- switch (e.key) {
- case ' ':
- case 'k':
- e.preventDefault(); togglePlay(); showControls(); break;
- case 'ArrowRight':
- e.preventDefault(); vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10); showControls(); break;
- case 'ArrowLeft':
- e.preventDefault(); vid.currentTime = Math.max(0, vid.currentTime - 10); showControls(); break;
- case 'ArrowUp':
- e.preventDefault(); vid.volume = Math.min(1, vid.volume + 0.1); showControls(); break;
- case 'ArrowDown':
- e.preventDefault(); vid.volume = Math.max(0, vid.volume - 0.1); showControls(); break;
- case 'f':
- case 'F':
- e.preventDefault(); toggleFullscreen(); break;
- case 'm':
- case 'M':
- e.preventDefault(); vid.muted = !vid.muted; break;
- case 'l':
- case 'L':
- e.preventDefault(); toggleSidebar(); break;
- case 'n':
- e.preventDefault(); playOffset(1); break;
- case 'p':
- e.preventDefault(); playOffset(-1); break;
- case 'Escape':
- e.preventDefault(); closePlayer(); break;
- case 'MediaPlayPause':
- e.preventDefault(); togglePlay(); break;
- case 'MediaTrackNext':
- e.preventDefault(); playOffset(1); break;
- case 'MediaTrackPrevious':
- e.preventDefault(); playOffset(-1); break;
- }
- return;
- }
- // ── Grid / detail navigation (TV remote) ──────────────────────────────
- if (e.key === 'Escape') {
- e.preventDefault();
- if (activeView === 'view-detail') { showLibrary(); }
- return;
- }
- if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Enter'].indexOf(e.key) === -1) { return; }
- e.preventDefault();
- focusMode = true;
- var $focusables = $('.view.active [tabindex="0"]:visible');
- if ($focusables.length === 0) { return; }
- var $cur = $(document.activeElement);
- var curIdx = $focusables.index($cur);
- if (curIdx < 0) {
- // Nothing focused yet – focus first element
- $focusables.first().focus();
- return;
- }
- if (e.key === 'Enter') {
- $cur.trigger('click');
- return;
- }
- // Compute next focus index based on grid layout
- var nextIdx = computeNextFocus($focusables, curIdx, e.key);
- if (nextIdx >= 0 && nextIdx < $focusables.length) {
- $focusables.eq(nextIdx).focus();
- $focusables.eq(nextIdx)[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
- }
- });
- }
- function computeNextFocus($els, curIdx, key) {
- if (key === 'ArrowDown') { return Math.min($els.length - 1, curIdx + 1); }
- if (key === 'ArrowUp') { return Math.max(0, curIdx - 1); }
- // For left/right in a grid, figure out how many columns there are
- var $cur = $els.eq(curIdx);
- var $parent = $cur.parent();
- if ($parent.hasClass('album-grid')) {
- var colCount = Math.round($parent.width() / ($cur.outerWidth(true) || 1)) || 1;
- if (key === 'ArrowRight') { return Math.min($els.length - 1, curIdx + 1); }
- if (key === 'ArrowLeft') { return Math.max(0, curIdx - 1); }
- }
- if (key === 'ArrowRight') { return Math.min($els.length - 1, curIdx + 1); }
- if (key === 'ArrowLeft') { return Math.max(0, curIdx - 1); }
- return curIdx;
- }
- // ─── Search ───────────────────────────────────────────────────────────────────
- function initSearch() {
- $('#search-input').on('input', function () {
- var q = $(this).val().trim().toLowerCase();
- if (q.length === 0) {
- renderLibrary(library);
- return;
- }
- var filtered = library.filter(function (a) {
- return a.name.toLowerCase().indexOf(q) > -1;
- });
- renderLibrary(filtered);
- });
- // Prevent keyboard nav from hijacking search input
- $('#search-input').on('keydown', function (e) {
- e.stopPropagation();
- if (e.key === 'Escape') { $(this).val(''); renderLibrary(library); $(this).blur(); }
- });
- }
- // ─── Utilities ────────────────────────────────────────────────────────────────
- function escapeHtml(str) {
- if (!str) { return ''; }
- return String(str)
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"');
- }
- function escapeAttr(str) { return escapeHtml(str); }
- var toastTimer;
- function showToast(msg) {
- clearTimeout(toastTimer);
- $('#toast').text(msg).addClass('show');
- toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
- }
- </script>
- </body>
- </html>
|