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 += '';
+ 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 @@
+
+
+
+
+
+
+
+
+