feat: Add reusable video modal component with Alist-style UI
This commit is contained in:
61
templates/anime_downloader/components/video_modal.html
Normal file
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>
|
||||
|
||||
<!-- 에피소드 목록 -->
|
||||
<div id="playlist-list-container" class="playlist-drawer">
|
||||
<div id="playlist-list" class="playlist-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Video Modal Component -->
|
||||
{% include 'anime_downloader/components/video_modal.html' %}
|
||||
|
||||
</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,147 +202,17 @@
|
||||
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 }
|
||||
}
|
||||
});
|
||||
|
||||
// 비디오 종료 시 다음 에피소드 자동 재생
|
||||
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);
|
||||
}
|
||||
VideoModal.openWithPath(filePath);
|
||||
});
|
||||
|
||||
|
||||
// 비디오 줌/확장 처리 (모바일 Fullscreen 꽉 차게)
|
||||
var isVideoZoomed = false;
|
||||
$('#btn-video-zoom').click(function() {
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user