feat: Add reusable video modal component with Alist-style UI
15
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 및 중앙 정렬 적용
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: "애니 다운로더"
|
||||
version: "0.5.18"
|
||||
version: "0.5.20"
|
||||
package_name: "anime_downloader"
|
||||
developer: "projectdx"
|
||||
description: "anime downloader"
|
||||
|
||||
@@ -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:
|
||||
# 큐 비우기 (표준 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:
|
||||
|
||||
@@ -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 수동 설정
|
||||
# 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 on {platform.system()}: {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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
# 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":
|
||||
|
||||
246
static/css/video_modal.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
static/img/players/iina.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
static/img/players/infuse.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
static/img/players/mpv.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/img/players/mxplayer.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/img/players/nplayer.webp
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
static/img/players/omniplayer.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
static/img/players/potplayer.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
static/img/players/vlc.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
291
static/js/video_modal.js
Normal file
@@ -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 += '<option value="' + i + '">' + playlist[i].name + '</option>';
|
||||
}
|
||||
$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 += '<a href="' + p.url + '" class="ext-player-btn" title="' + p.name + '">';
|
||||
html += '<img src="' + p.img + '" alt="' + p.name + '">';
|
||||
html += '</a>';
|
||||
}
|
||||
|
||||
$('#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
|
||||
};
|
||||
})();
|
||||
61
templates/anime_downloader/components/video_modal.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- Video Modal Component for Anime Downloader -->
|
||||
<!-- Usage: include 'anime_downloader/components/video_modal.html' in your template -->
|
||||
|
||||
<!-- Video.js CDN -->
|
||||
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
|
||||
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
|
||||
|
||||
<!-- Video Player Modal -->
|
||||
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-labelledby="videoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content" style="background: #0f172a; border-radius: 12px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<h5 class="modal-title" id="videoModalLabel" style="color: #f1f5f9;">비디오 플레이어</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9;">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<div class="video-container" style="position: relative; overflow: hidden; background: #000;">
|
||||
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" playsinline webkit-playsinline style="width: 100%; height: auto; max-height: 80vh;">
|
||||
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
|
||||
</video>
|
||||
<!-- 화면 꽉 채우기 토글 버튼 (모바일용) -->
|
||||
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
|
||||
<i class="fa fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 플레이리스트 컨트롤 UI (Alist 스타일) -->
|
||||
<div class="playlist-controls">
|
||||
<!-- 에피소드 선택 드롭다운 + 자동 다음 토글 -->
|
||||
<div class="episode-selector-row">
|
||||
<div class="episode-dropdown-wrapper">
|
||||
<select id="episode-dropdown" class="episode-dropdown">
|
||||
<!-- JavaScript에서 옵션 동적 생성 -->
|
||||
</select>
|
||||
<svg class="dropdown-arrow" viewBox="0 0 15 15" aria-hidden="true">
|
||||
<path d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<label class="auto-next-toggle">
|
||||
<input type="checkbox" id="auto-next-checkbox" checked>
|
||||
<span class="toggle-label">자동 다음</span>
|
||||
<span class="toggle-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 외부 플레이어 버튼 -->
|
||||
<div class="external-players">
|
||||
<div class="external-players-grid" id="external-player-buttons">
|
||||
<!-- 버튼들은 JavaScript에서 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/video_modal.css') }}"/>
|
||||
<script src="{{ url_for('.static', filename='js/video_modal.js') }}"></script>
|
||||
@@ -3,9 +3,9 @@
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
|
||||
|
||||
<style>
|
||||
/* Search Container */
|
||||
|
||||
.search-container {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
.queue-header-container {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
margin-bottom: 20px; border-bottom: 1px solid rgba(16, 185, 129, 0.2); padding-bottom: 10px;
|
||||
|
||||
@@ -62,63 +62,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Animate.css & Video.js -->
|
||||
<!-- Animate.css -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
|
||||
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
|
||||
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
|
||||
|
||||
<!-- Video Player Modal -->
|
||||
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-labelledby="videoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content" style="background: #0f172a; border-radius: 12px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<h5 class="modal-title" id="videoModalLabel" style="color: #f1f5f9;">비디오 플레이어</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9;">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<div class="video-container" style="position: relative; overflow: hidden; background: #000;">
|
||||
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" playsinline webkit-playsinline style="width: 100%; height: auto; max-height: 80vh;">
|
||||
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
|
||||
</video>
|
||||
<!-- 화면 꽉 채우기 토글 버튼 (모바일용) -->
|
||||
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
|
||||
<i class="fa fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 플레이리스트 컨트롤 UI -->
|
||||
<div class="playlist-controls">
|
||||
<!-- 현재 재생 정보 + 버튼 -->
|
||||
<div class="playlist-header">
|
||||
<button id="btn-prev-ep" class="nav-btn" style="display: none;" title="이전 에피소드">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="playing-info">
|
||||
<div id="current-video-title" class="video-title"></div>
|
||||
<div id="playlist-progress" class="progress-text"></div>
|
||||
</div>
|
||||
<button id="btn-next-ep" class="nav-btn" style="display: none;" title="다음 에피소드">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
<div class="control-group">
|
||||
<button id="btn-toggle-playlist" class="action-btn" title="목록 토글">
|
||||
<i class="fa fa-list-ul"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Video Modal Component -->
|
||||
{% include 'anime_downloader/components/video_modal.html' %}
|
||||
|
||||
<!-- 에피소드 목록 -->
|
||||
<div id="playlist-list-container" class="playlist-drawer">
|
||||
<div id="playlist-list" class="playlist-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('.static', filename='js/sjva_global1.js') }}"></script>
|
||||
<script src="{{ url_for('.static', filename='js/sjva_ui14.js') }}"></script>
|
||||
@@ -253,146 +202,16 @@
|
||||
global_sub_request_search('1')
|
||||
});
|
||||
|
||||
// 비디오 보기 버튼 클릭 핸들러 (플레이리스트 지원)
|
||||
var videoPlayer = null;
|
||||
var playlist = [];
|
||||
var currentPlaylistIndex = 0;
|
||||
|
||||
function playVideoAtIndex(index) {
|
||||
if (index < 0 || index >= playlist.length) return;
|
||||
currentPlaylistIndex = index;
|
||||
var item = playlist[index];
|
||||
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(item.path);
|
||||
|
||||
if (videoPlayer) {
|
||||
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
|
||||
videoPlayer.play();
|
||||
}
|
||||
|
||||
// 플레이리스트 UI 업데이트 (현재 파일명, 버튼 상태, 목록 표시)
|
||||
updatePlaylistUI();
|
||||
}
|
||||
// Video Modal 초기화
|
||||
VideoModal.init({ package_name: package_name, sub: sub });
|
||||
|
||||
// 비디오 보기 버튼 클릭 핸들러
|
||||
$("body").on('click', '.btn-watch', function (e) {
|
||||
e.preventDefault();
|
||||
var filePath = $(this).data('path');
|
||||
|
||||
// 플레이리스트 API 호출
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + 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 = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
|
||||
// Video.js 초기화
|
||||
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 }
|
||||
}
|
||||
VideoModal.openWithPath(filePath);
|
||||
});
|
||||
|
||||
// 비디오 종료 시 다음 에피소드 자동 재생
|
||||
videoPlayer.on('ended', function() {
|
||||
if (currentPlaylistIndex < playlist.length - 1) {
|
||||
currentPlaylistIndex++;
|
||||
playVideoAtIndex(currentPlaylistIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
|
||||
|
||||
// 플레이리스트 UI 업데이트
|
||||
updatePlaylistUI();
|
||||
|
||||
// 모달 열기
|
||||
$('#videoModal').modal('show');
|
||||
},
|
||||
error: function() {
|
||||
// 에러 시 기본 동작
|
||||
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
if (!videoPlayer) {
|
||||
videoPlayer = videojs('video-player', { controls: true, fluid: true });
|
||||
}
|
||||
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
|
||||
$('#videoModal').modal('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 플레이리스트 UI 업데이트 함수
|
||||
function updatePlaylistUI() {
|
||||
if (!playlist || playlist.length === 0) return;
|
||||
|
||||
var currentFile = playlist[currentPlaylistIndex];
|
||||
$('#current-video-title').text(currentFile ? currentFile.name : '');
|
||||
$('#playlist-progress').text((currentPlaylistIndex + 1) + ' / ' + playlist.length + ' 에피소드');
|
||||
|
||||
// 이전/다음 버튼 표시
|
||||
if (currentPlaylistIndex > 0) {
|
||||
$('#btn-prev-ep').show();
|
||||
} else {
|
||||
$('#btn-prev-ep').hide();
|
||||
}
|
||||
if (currentPlaylistIndex < playlist.length - 1) {
|
||||
$('#btn-next-ep').show();
|
||||
} else {
|
||||
$('#btn-next-ep').hide();
|
||||
}
|
||||
|
||||
// 에피소드 목록 렌더링
|
||||
var listHtml = '';
|
||||
for (var i = 0; i < playlist.length; i++) {
|
||||
var isActive = (i === currentPlaylistIndex) ? 'active' : '';
|
||||
listHtml += '<div class="playlist-item ' + isActive + '" data-index="' + i + '">';
|
||||
listHtml += '<span class="ep-num">E' + (i + 1) + '</span>';
|
||||
listHtml += '<span>' + playlist[i].name + '</span>';
|
||||
listHtml += '</div>';
|
||||
}
|
||||
$('#playlist-list').html(listHtml);
|
||||
}
|
||||
|
||||
// 이전 에피소드 버튼
|
||||
$('#btn-prev-ep').click(function() {
|
||||
if (currentPlaylistIndex > 0) {
|
||||
currentPlaylistIndex--;
|
||||
playVideoAtIndex(currentPlaylistIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// 다음 에피소드 버튼
|
||||
$('#btn-next-ep').click(function() {
|
||||
if (currentPlaylistIndex < playlist.length - 1) {
|
||||
currentPlaylistIndex++;
|
||||
playVideoAtIndex(currentPlaylistIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// 목록 토글 버튼
|
||||
$('#btn-toggle-playlist').click(function() {
|
||||
$(this).toggleClass('active');
|
||||
$('#playlist-list-container').slideToggle(200);
|
||||
});
|
||||
|
||||
// 목록 아이템 클릭
|
||||
$(document).on('click', '.playlist-item', function() {
|
||||
var index = parseInt($(this).data('index'));
|
||||
if (index !== currentPlaylistIndex) {
|
||||
currentPlaylistIndex = index;
|
||||
playVideoAtIndex(index);
|
||||
}
|
||||
});
|
||||
|
||||
// 비디오 줌/확장 처리 (모바일 Fullscreen 꽉 차게)
|
||||
var isVideoZoomed = false;
|
||||
@@ -684,6 +503,167 @@
|
||||
.episode-card { padding: 10px; }
|
||||
.episode-thumb { width: 50px; height: 70px; }
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.external-players-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
.ext-player-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.episode-selector-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.episode-dropdown-wrapper {
|
||||
max-width: none;
|
||||
}
|
||||
.auto-next-toggle {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||