feat: Add reusable video modal component with Alist-style UI
This commit is contained in:
@@ -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