feat: Add reusable video modal component with Alist-style UI

This commit is contained in:
2026-01-04 15:36:52 +09:00
parent 4c20f96cef
commit 150a3a9fb0
19 changed files with 859 additions and 215 deletions

View File

@@ -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">&times;</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 %}