diff --git a/README.md b/README.md index fa8f76c..65778be 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,21 @@ ## ๐Ÿ“ ๋ณ€๊ฒฝ ์ด๋ ฅ (Changelog) +### v0.5.2 (2026-01-04) +- **์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋น„๋””์˜ค ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ ๋„์ž…**: + - `templates/anime_downloader/components/video_modal.html` - ๊ณตํ†ต ๋ชจ๋‹ฌ HTML + - `static/js/video_modal.js` - VideoModal JavaScript ๋ชจ๋“ˆ (API ์ œ๊ณต) + - `static/css/video_modal.css` - ๋น„๋””์˜ค ๋ชจ๋‹ฌ ์ „์šฉ ์Šคํƒ€์ผ์‹œํŠธ +- **Alist ์Šคํƒ€์ผ UI ๊ฐœ์„ **: + - **์—ํ”ผ์†Œ๋“œ ๋“œ๋กญ๋‹ค์šด**: ํŒŒ๋ž€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ๋ฐฐ๊ฒฝ์˜ ์—ํ”ผ์†Œ๋“œ ์„ ํƒ๊ธฐ + - **์ž๋™ ๋‹ค์Œ ํ† ๊ธ€ ์Šค์œ„์น˜**: iOS ์Šคํƒ€์ผ ์Šฌ๋ผ์ด๋” ํ† ๊ธ€ + - **์™ธ๋ถ€ ํ”Œ๋ ˆ์ด์–ด ๋ฒ„ํŠผ**: IINA, PotPlayer, VLC, nPlayer, Infuse, OmniPlayer, MX Player, MPV ์ง€์› + - ํ”Œ๋ ˆ์ด์–ด ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ์ถ”๊ฐ€ (`static/img/players/`) +- **์ฝ”๋“œ ์žฌ์‚ฌ์šฉ์„ฑ ํ–ฅ์ƒ**: + - Ohli24 list ํŽ˜์ด์ง€์—์„œ ์ธ๋ผ์ธ ์ฝ”๋“œ ~145์ค„ โ†’ ~9์ค„๋กœ ์ถ•์†Œ + - `VideoModal.init({ package_name, sub })` API๋กœ ๊ฐ„ํŽธ ์ดˆ๊ธฐํ™” + - `VideoModal.openWithPath(path)` / `.openWithUrl(url)` / `.openWithPlaylist(list)` ๋ฉ”์„œ๋“œ ์ œ๊ณต + ### v0.5.1 (2026-01-04) - **Ohli24 ๋ ˆ์ด์•„์›ƒ ํ‘œ์ค€ํ™”**: - ๋ชจ๋“  Ohli24 ํŽ˜์ด์ง€(Setting, Search, Queue, List, Request)์— ์ผ๊ด€๋œ 1400px max-width ๋ฐ ์ค‘์•™ ์ •๋ ฌ ์ ์šฉ diff --git a/info.yaml b/info.yaml index df912ce..7c5215c 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "์• ๋‹ˆ ๋‹ค์šด๋กœ๋”" -version: "0.5.18" +version: "0.5.20" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/lib/ffmpeg_queue_v1.py b/lib/ffmpeg_queue_v1.py index 1311750..0ca4d35 100644 --- a/lib/ffmpeg_queue_v1.py +++ b/lib/ffmpeg_queue_v1.py @@ -223,11 +223,28 @@ class FfmpegQueue(object): # ๋‹ค์šด๋กœ๋“œ ๋ฐฉ๋ฒ• ํ™•์ธ download_method = P.ModelSetting.get(f"{self.name}_download_method") - # .ytdl ํŒŒ์ผ์ด ์žˆ๊ฑฐ๋‚˜, ytdlp/aria2c ๋ชจ๋“œ์ธ ๊ฒฝ์šฐ 'ํŒŒ์ผ ์žˆ์Œ'์œผ๋กœ ๊ฑด๋„ˆ๋›ฐ์ง€ ์•Š์Œ (์ด์–ด๋ฐ›๊ธฐ ํ—ˆ์šฉ) + # ๋ฏธ์™„์„ฑ ๋‹ค์šด๋กœ๋“œ ๊ฐ์ง€ (Frag ํŒŒ์ผ, .ytdl ํŒŒ์ผ, .part ํŒŒ์ผ) + # ์ด๋Ÿฐ ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด ์ด์–ด๋ฐ›๊ธฐ ํ—ˆ์šฉ is_ytdlp = download_method in ['ytdlp', 'aria2c'] has_ytdl_file = os.path.exists(filepath + ".ytdl") + has_part_file = os.path.exists(filepath + ".part") - if os.path.exists(filepath) and not (is_ytdlp or has_ytdl_file): + # Frag ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ (๊ฐ™์€ ํด๋”์— Frag* ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด ๋ฏธ์™„์„ฑ) + has_frag_files = False + try: + import glob + dirname = os.path.dirname(filepath) + if dirname and os.path.exists(dirname): + frag_pattern = os.path.join(dirname, "*Frag*") + has_frag_files = len(glob.glob(frag_pattern)) > 0 + if has_frag_files: + logger.info(f"[Resume] Found Frag files in {dirname}, allowing re-download") + except Exception as e: + logger.debug(f"Frag check error: {e}") + + is_incomplete = has_ytdl_file or has_part_file or has_frag_files + + if os.path.exists(filepath) and not (is_ytdlp or is_incomplete): logger.info(f"File already exists: {filepath}") entity.ffmpeg_status = 8 # COMPLETED_EXIST entity.ffmpeg_status_kor = "ํŒŒ์ผ ์žˆ์Œ" @@ -589,8 +606,22 @@ class FfmpegQueue(object): ret["ret"] = "refresh" elif cmd == "reset": if self.download_queue is not None: - with self.download_queue.mutex: - self.download_queue.queue.clear() + # ํ ๋น„์šฐ๊ธฐ (ํ‘œ์ค€ Queue์™€ gevent Queue ๋ชจ๋‘ ํ˜ธํ™˜) + try: + # ํ‘œ์ค€ Queue์˜ ๊ฒฝ์šฐ + if hasattr(self.download_queue, 'mutex'): + with self.download_queue.mutex: + self.download_queue.queue.clear() + else: + # gevent Queue์˜ ๊ฒฝ์šฐ - ํ•˜๋‚˜์”ฉ ๊บผ๋‚ด์„œ ๋น„์šฐ๊ธฐ + while not self.download_queue.empty(): + try: + self.download_queue.get_nowait() + except: + break + except Exception as e: + logger.debug(f"Queue clear error (non-critical): {e}") + for _ in self.entity_list: # ๋‹ค์šด๋กœ๋“œ์ค‘ ์ƒํƒœ์ธ ๊ฒฝ์šฐ์—๋งŒ ์ค‘์ง€ ์‹œ๋„ if _.ffmpeg_status == 5: diff --git a/lib/ytdlp_downloader.py b/lib/ytdlp_downloader.py index 8720e13..1714375 100644 --- a/lib/ytdlp_downloader.py +++ b/lib/ytdlp_downloader.py @@ -172,17 +172,11 @@ class YtdlpDownloader: # ์ฃผ์˜: --external-downloader aria2c๋Š” HLS ํ”„๋ž˜๊ทธ๋จผํŠธ์—์„œ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ํฌ๋ฏ€๋กœ ์ œ๊ฑฐํ•จ - # 1.5 ํ™˜๊ฒฝ๋ณ„ ๋ธŒ๋ผ์šฐ์ € ์œ„์žฅ ์„ค์ • (Impersonate) - # macOS์—์„œ๋Š” ๊ณ ๊ธ‰ ์œ„์žฅ ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๋˜, ์ข…์†์„ฑ ๋ฌธ์ œ๊ฐ€ ์žฆ์€ Linux/Docker์—์„œ๋Š” UA ์ˆ˜๋™ ์ง€์ • - is_mac = platform.system() == 'Darwin' - if is_mac: - cmd += ['--impersonate', 'chrome-120'] - logger.debug("Using yt-dlp --impersonate chrome-120 (macOS detected)") - else: - # Docker/Linux: impersonate ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ถ€์žฌ ๊ฐ€๋Šฅํ•˜๋ฏ€๋กœ UA ์ˆ˜๋™ ์„ค์ • - user_agent = self.headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') - cmd += ['--user-agent', user_agent] - logger.debug(f"Using manual User-Agent on {platform.system()}: {user_agent}") + # 1.5 ๋ธŒ๋ผ์šฐ์ € ์œ„์žฅ ์„ค์ • (User-Agent) + # --impersonate ์˜ต์…˜์€ curl-impersonate ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•˜๋ฏ€๋กœ ์ˆ˜๋™ UA ์‚ฌ์šฉ + user_agent = self.headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') + cmd += ['--user-agent', user_agent] + logger.debug(f"Using manual User-Agent: {user_agent}") # 2. ํ”„๋ก์‹œ ์„ค์ • if self.proxy: @@ -342,10 +336,14 @@ class YtdlpDownloader: elapsed = time.time() - self.start_time self.elapsed_time = self.format_time(elapsed) - # [์ตœ์ ํ™”] ์ง„ํ–‰๋ฅ ์ด 1% ์ด์ƒ ์ฐจ์ด๋‚˜๊ฑฐ๋‚˜, 100%์ธ ๊ฒฝ์šฐ์—๋งŒ ์ฝœ๋ฐฑ ํ˜ธ์ถœ (๋กœ๊ทธ ๋ถ€ํ•˜ ๊ฐ์†Œ) + # [์ตœ์ ํ™”] 10% ๋‹จ์œ„๋กœ๋งŒ ๋กœ๊ทธ ์ถœ๋ ฅ (๋กœ๊ทธ ๋ถ€ํ•˜ ๊ฐ์†Œ) if self.callback and (int(new_percent) > int(self.percent) or new_percent >= 100): + old_tens = int(self.percent) // 10 + new_tens = int(new_percent) // 10 self.percent = new_percent - logger.info(f"[yt-dlp progress] {int(self.percent)}% speed={self.current_speed}") + # 10% ๋‹จ์œ„๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๊ฑฐ๋‚˜ 100%์ผ ๋•Œ๋งŒ ๋กœ๊ทธ ์ถœ๋ ฅ + if new_tens > old_tens or new_percent >= 100: + logger.info(f"[yt-dlp] {int(self.percent)}% speed={self.current_speed}") self.callback(percent=int(self.percent), current=int(self.percent), total=100, speed=self.current_speed, elapsed=self.elapsed_time) else: self.percent = new_percent diff --git a/mod_linkkf.py b/mod_linkkf.py index 80b8680..9bd49c8 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -1531,13 +1531,36 @@ class LogicLinkkf(AnimeModuleBase): # 2. Check DB for completion status FIRST (before expensive operations) db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"]) - if db_entity is not None and db_entity.status == "completed": + # 3. Early file existence check - filepath is already in episode_info from get_series_info + filepath = episode_info.get("filepath") + + # ๋ฏธ์™„์„ฑ ๋‹ค์šด๋กœ๋“œ ๊ฐ์ง€ (Frag ํŒŒ์ผ, .ytdl ํŒŒ์ผ, .part ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด ์žฌ๋‹ค์šด๋กœ๋“œ ํ—ˆ์šฉ) + has_incomplete_files = False + if filepath: + import glob + dirname = os.path.dirname(filepath) + has_ytdl = os.path.exists(filepath + ".ytdl") + has_part = os.path.exists(filepath + ".part") + has_frag = False + if dirname and os.path.exists(dirname): + frag_pattern = os.path.join(dirname, "*Frag*") + has_frag = len(glob.glob(frag_pattern)) > 0 + has_incomplete_files = has_ytdl or has_part or has_frag + + if has_incomplete_files: + logger.info(f"[Resume] Incomplete download detected, allowing re-download: {filepath}") + # DB ์ƒํƒœ๊ฐ€ completed์ด๋ฉด wait๋กœ ๋ณ€๊ฒฝ + if db_entity is not None and db_entity.status == "completed": + db_entity.status = "wait" + db_entity.save() + + # DB ์™„๋ฃŒ ์ฒดํฌ (๋ฏธ์™„์„ฑ ํŒŒ์ผ์ด ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ) + if db_entity is not None and db_entity.status == "completed" and not has_incomplete_files: logger.info(f"[Skip] Already completed in DB: {episode_info.get('program_title')} {episode_info.get('title')}") return "db_completed" - # 3. Early file existence check - filepath is already in episode_info from get_series_info - filepath = episode_info.get("filepath") - if filepath and os.path.exists(filepath): + # ํŒŒ์ผ ์กด์žฌ ์ฒดํฌ (๋ฏธ์™„์„ฑ ํŒŒ์ผ์ด ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ) + if filepath and os.path.exists(filepath) and not has_incomplete_files: logger.info(f"[Skip] File already exists: {filepath}") # Update DB status to completed if not already if db_entity is not None and db_entity.status != "completed": diff --git a/static/css/video_modal.css b/static/css/video_modal.css new file mode 100644 index 0000000..b9b41b1 --- /dev/null +++ b/static/css/video_modal.css @@ -0,0 +1,246 @@ +/** + * Video Modal Component Styles + * Reusable video player modal for Anime Downloader + */ + +/* Video Container */ +.video-container { + position: relative; + overflow: hidden; + background: #000; +} + +/* Zoom Button */ +.video-zoom-btn { + position: absolute; + top: 15px; + right: 15px; + z-index: 10; + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.15); + color: white; + width: 38px; + height: 38px; + border-radius: 10px; + opacity: 0; + transition: opacity 0.3s; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.video-container:hover .video-zoom-btn { + opacity: 1; +} +.video-zoom-btn.active { + background: #3b82f6; + border-color: transparent; +} + +/* Episode Selector Row (Alist Style) */ +.episode-selector-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Episode Dropdown */ +.episode-dropdown-wrapper { + position: relative; + flex: 1; +} +.episode-dropdown { + width: 100%; + padding: 10px 40px 10px 14px; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + border: none; + border-radius: 8px; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); +} +.episode-dropdown:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.5); +} +.episode-dropdown:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4); +} +.episode-dropdown option { + background: #1e293b; + color: #f1f5f9; + padding: 10px; +} +.dropdown-arrow { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: white; + pointer-events: none; +} + +/* Auto-Next Toggle Switch */ +.auto-next-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} +.auto-next-toggle input { + display: none; +} +.toggle-label { + font-size: 13px; + color: #94a3b8; + font-weight: 500; + transition: color 0.2s; +} +.auto-next-toggle input:checked ~ .toggle-label { + color: #f1f5f9; +} +.toggle-switch { + position: relative; + width: 44px; + height: 24px; + background: #334155; + border-radius: 12px; + transition: all 0.3s ease; +} +.toggle-switch::after { + content: ''; + position: absolute; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + top: 2px; + left: 2px; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} +.auto-next-toggle input:checked ~ .toggle-switch { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); +} +.auto-next-toggle input:checked ~ .toggle-switch::after { + left: 22px; +} + +/* External Players Section */ +.external-players { + padding: 8px 16px; + background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} +.external-players-grid { + display: flex; + flex-wrap: nowrap; + justify-content: center; + gap: 10px; +} +.ext-player-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: rgba(59, 130, 246, 0.12); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 10px; + text-decoration: none; + transition: all 0.2s ease; + cursor: pointer; +} +.ext-player-btn:hover { + background: rgba(59, 130, 246, 0.3); + border-color: rgba(59, 130, 246, 0.5); + transform: translateY(-2px) scale(1.05); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} +.ext-player-btn img { + width: 28px; + height: 28px; + object-fit: contain; +} + +/* Video.js Theme Overrides */ +.video-js.vjs-theme-fantasy .vjs-big-play-button { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%) !important; + border: none !important; + width: 90px !important; + height: 90px !important; + line-height: 90px !important; + border-radius: 50% !important; + box-shadow: 0 0 30px rgba(37, 99, 235, 0.6) !important; + transition: all 0.3s ease !important; +} +.video-js.vjs-theme-fantasy .vjs-big-play-button .vjs-icon-placeholder:before { + font-size: 60px !important; + line-height: 90px !important; +} +.video-js .vjs-control-bar { + background: rgba(15, 23, 42, 0.8) !important; + backdrop-filter: blur(10px) !important; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + #videoModal .modal-dialog { + width: 100% !important; + margin: 0 !important; + } + #videoModal .modal-content { + border-radius: 0 !important; + height: 100vh; + display: flex; + flex-direction: column; + } + #video-player { + max-height: 100vh !important; + height: 100% !important; + } + .video-container { + flex: 1; + display: flex; + align-items: center; + } + .playlist-controls { + padding-bottom: 25px; /* Mobile safe area */ + } + .video-zoom-btn { + opacity: 0.8; + top: 10px; + right: 10px; + } + .episode-selector-row { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + .auto-next-toggle { + justify-content: flex-end; + } + .external-players-grid { + gap: 6px; + } + .ext-player-btn { + padding: 6px; + } + .ext-player-btn img { + width: 24px; + height: 24px; + } +} diff --git a/static/img/players/iina.webp b/static/img/players/iina.webp new file mode 100644 index 0000000..d9c0bb5 Binary files /dev/null and b/static/img/players/iina.webp differ diff --git a/static/img/players/infuse.webp b/static/img/players/infuse.webp new file mode 100644 index 0000000..93b53d8 Binary files /dev/null and b/static/img/players/infuse.webp differ diff --git a/static/img/players/mpv.webp b/static/img/players/mpv.webp new file mode 100644 index 0000000..6d90b25 Binary files /dev/null and b/static/img/players/mpv.webp differ diff --git a/static/img/players/mxplayer.webp b/static/img/players/mxplayer.webp new file mode 100644 index 0000000..5cacbbe Binary files /dev/null and b/static/img/players/mxplayer.webp differ diff --git a/static/img/players/nplayer.webp b/static/img/players/nplayer.webp new file mode 100644 index 0000000..ef20765 Binary files /dev/null and b/static/img/players/nplayer.webp differ diff --git a/static/img/players/omniplayer.webp b/static/img/players/omniplayer.webp new file mode 100644 index 0000000..92f4c68 Binary files /dev/null and b/static/img/players/omniplayer.webp differ diff --git a/static/img/players/potplayer.webp b/static/img/players/potplayer.webp new file mode 100644 index 0000000..14eba7b Binary files /dev/null and b/static/img/players/potplayer.webp differ diff --git a/static/img/players/vlc.webp b/static/img/players/vlc.webp new file mode 100644 index 0000000..184ab94 Binary files /dev/null and b/static/img/players/vlc.webp differ diff --git a/static/js/video_modal.js b/static/js/video_modal.js new file mode 100644 index 0000000..158a1d9 --- /dev/null +++ b/static/js/video_modal.js @@ -0,0 +1,291 @@ +/** + * Video Modal Component JavaScript + * Reusable video player modal for Anime Downloader + * + * Usage: + * VideoModal.init({ package_name: 'anime_downloader', sub: 'ohli24' }); + * VideoModal.openWithPath('/path/to/video.mp4'); + */ + +var VideoModal = (function() { + 'use strict'; + + var config = { + package_name: 'anime_downloader', + sub: 'ohli24' + }; + + var videoPlayer = null; + var playlist = []; + var currentPlaylistIndex = 0; + var currentPlayingPath = ''; + var isVideoZoomed = false; + + /** + * Initialize the video modal + * @param {Object} options - Configuration options + * @param {string} options.package_name - Package name (default: 'anime_downloader') + * @param {string} options.sub - Sub-module name (e.g., 'ohli24', 'linkkf') + */ + function init(options) { + config = Object.assign(config, options || {}); + bindEvents(); + console.log('[VideoModal] Initialized with config:', config); + } + + /** + * Bind all event handlers + */ + function bindEvents() { + // Dropdown episode selection + $('#episode-dropdown').off('change').on('change', function() { + var index = parseInt($(this).val()); + if (index !== currentPlaylistIndex && index >= 0 && index < playlist.length) { + currentPlaylistIndex = index; + playVideoAtIndex(index); + } + }); + + // Video zoom button + $('#btn-video-zoom').off('click').on('click', function() { + isVideoZoomed = !isVideoZoomed; + if (isVideoZoomed) { + $('#video-player').css({ + 'object-fit': 'cover', + 'max-height': '100vh' + }); + $(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress'); + } else { + $('#video-player').css({ + 'object-fit': 'contain', + 'max-height': '80vh' + }); + $(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand'); + } + }); + + // Modal events + $('#videoModal').off('show.bs.modal').on('show.bs.modal', function() { + $('body').addClass('modal-video-open'); + }); + + $('#videoModal').off('hide.bs.modal').on('hide.bs.modal', function() { + if (videoPlayer) { + videoPlayer.pause(); + } + }); + + $('#videoModal').off('hidden.bs.modal').on('hidden.bs.modal', function() { + $('body').removeClass('modal-video-open'); + if (isVideoZoomed) { + isVideoZoomed = false; + $('#video-player').css({ + 'object-fit': 'contain', + 'max-height': '80vh' + }); + $('#btn-video-zoom').removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand'); + } + }); + } + + /** + * Open modal with a file path (fetches playlist from server) + * @param {string} filePath - Path to the video file + */ + function openWithPath(filePath) { + $.ajax({ + url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath), + type: 'GET', + dataType: 'json', + success: function(data) { + playlist = data.playlist || []; + currentPlaylistIndex = data.current_index || 0; + currentPlayingPath = filePath; + + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); + initPlayer(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + }, + error: function() { + // Fallback: single file + playlist = [{ name: filePath.split('/').pop(), path: filePath }]; + currentPlaylistIndex = 0; + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); + initPlayer(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + } + }); + } + + /** + * Open modal with a direct stream URL + * @param {string} streamUrl - Direct URL to stream + * @param {string} title - Optional title + */ + function openWithUrl(streamUrl, title) { + playlist = [{ name: title || 'Video', path: streamUrl }]; + currentPlaylistIndex = 0; + initPlayer(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + } + + /** + * Open modal with a playlist array + * @param {Array} playlistData - Array of {name, path} objects + * @param {number} startIndex - Index to start playing from + */ + function openWithPlaylist(playlistData, startIndex) { + playlist = playlistData || []; + currentPlaylistIndex = startIndex || 0; + if (playlist.length > 0) { + var filePath = playlist[currentPlaylistIndex].path; + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); + initPlayer(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + } + } + + /** + * Initialize or update Video.js player + * @param {string} streamUrl - URL to play + */ + function initPlayer(streamUrl) { + if (!videoPlayer) { + videoPlayer = videojs('video-player', { + controls: true, + autoplay: false, + preload: 'auto', + fluid: true, + playbackRates: [0.5, 1, 1.5, 2], + controlBar: { + skipButtons: { forward: 10, backward: 10 } + } + }); + + // Auto-next on video end + videoPlayer.on('ended', function() { + var autoNextEnabled = $('#auto-next-checkbox').is(':checked'); + if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) { + currentPlaylistIndex++; + playVideoAtIndex(currentPlaylistIndex); + } + }); + } + + videoPlayer.src({ type: 'video/mp4', src: streamUrl }); + } + + /** + * Play video at specific playlist index + * @param {number} index - Playlist index + */ + function playVideoAtIndex(index) { + if (index < 0 || index >= playlist.length) return; + currentPlaylistIndex = index; + var item = playlist[index]; + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(item.path); + + if (videoPlayer) { + videoPlayer.src({ type: 'video/mp4', src: streamUrl }); + videoPlayer.play(); + } + + updatePlaylistUI(); + } + + /** + * Update playlist UI (dropdown, external player buttons) + */ + function updatePlaylistUI() { + if (!playlist || playlist.length === 0) return; + + var currentFile = playlist[currentPlaylistIndex]; + + // Update dropdown + var $dropdown = $('#episode-dropdown'); + if ($dropdown.find('option').length !== playlist.length) { + var optionsHtml = ''; + for (var i = 0; i < playlist.length; i++) { + optionsHtml += ''; + } + $dropdown.html(optionsHtml); + } + $dropdown.val(currentPlaylistIndex); + + // Update external player buttons + updateExternalPlayerButtons(); + } + + /** + * Update external player buttons + */ + function updateExternalPlayerButtons() { + var currentFile = playlist[currentPlaylistIndex]; + if (!currentFile || !currentFile.path) return; + + var streamUrl = window.location.origin + '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(currentFile.path); + var filename = currentFile.name || 'video.mp4'; + var encodedUrl = encodeURIComponent(streamUrl); + var doubleEncodedUrl = encodeURIComponent(encodedUrl); + + var imgBase = '/' + config.package_name + '/static/img/players/'; + + var players = [ + { name: 'IINA', img: imgBase + 'iina.webp', url: 'iina://weblink?url=' + encodedUrl }, + { name: 'PotPlayer', img: imgBase + 'potplayer.webp', url: 'potplayer://' + streamUrl }, + { name: 'VLC', img: imgBase + 'vlc.webp', url: 'vlc://' + streamUrl }, + { name: 'nPlayer', img: imgBase + 'nplayer.webp', url: 'nplayer-' + streamUrl }, + { name: 'Infuse', img: imgBase + 'infuse.webp', url: 'infuse://x-callback-url/play?url=' + streamUrl }, + { name: 'OmniPlayer', img: imgBase + 'omniplayer.webp', url: 'omniplayer://weblink?url=' + streamUrl }, + { name: 'MX Player', img: imgBase + 'mxplayer.webp', url: 'intent:' + streamUrl + '#Intent;package=com.mxtech.videoplayer.ad;S.title=' + encodeURIComponent(filename) + ';end' }, + { name: 'MPV', img: imgBase + 'mpv.webp', url: 'mpv://' + doubleEncodedUrl }, + ]; + + var html = ''; + for (var i = 0; i < players.length; i++) { + var p = players[i]; + html += ''; + html += '' + p.name + ''; + html += ''; + } + + $('#external-player-buttons').html(html); + } + + /** + * Close the modal + */ + function close() { + $('#videoModal').modal('hide'); + } + + /** + * Get current playlist + */ + function getPlaylist() { + return playlist; + } + + /** + * Get current index + */ + function getCurrentIndex() { + return currentPlaylistIndex; + } + + // Public API + return { + init: init, + openWithPath: openWithPath, + openWithUrl: openWithUrl, + openWithPlaylist: openWithPlaylist, + playVideoAtIndex: playVideoAtIndex, + close: close, + getPlaylist: getPlaylist, + getCurrentIndex: getCurrentIndex + }; +})(); diff --git a/templates/anime_downloader/components/video_modal.html b/templates/anime_downloader/components/video_modal.html new file mode 100644 index 0000000..94d773c --- /dev/null +++ b/templates/anime_downloader/components/video_modal.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/templates/anime_downloader_linkkf_list.html b/templates/anime_downloader_linkkf_list.html index 0e39f31..2974465 100644 --- a/templates/anime_downloader_linkkf_list.html +++ b/templates/anime_downloader_linkkf_list.html @@ -3,9 +3,9 @@ + - - + {% endblock %} \ No newline at end of file