feat: Implement video playlist feature with navigation controls and auto-play.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
title: "애니 다운로더"
|
title: "애니 다운로더"
|
||||||
version: "0.2.3"
|
version: "0.3.0"
|
||||||
package_name: "anime_downloader"
|
package_name: "anime_downloader"
|
||||||
developer: "projectdx"
|
developer: "projectdx"
|
||||||
description: "anime downloader"
|
description: "anime downloader"
|
||||||
|
|||||||
@@ -381,6 +381,72 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
logger.error(f"Stream video error: {e}")
|
logger.error(f"Stream video error: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
elif sub == "get_playlist":
|
||||||
|
# 현재 파일과 같은 폴더에서 다음 에피소드들 찾기
|
||||||
|
try:
|
||||||
|
file_path = request.args.get("path", "")
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
return jsonify({"error": "File not found", "playlist": [], "current_index": 0}), 404
|
||||||
|
|
||||||
|
# 보안 체크
|
||||||
|
download_path = P.ModelSetting.get("ohli24_download_path")
|
||||||
|
if not file_path.startswith(download_path):
|
||||||
|
return jsonify({"error": "Access denied", "playlist": [], "current_index": 0}), 403
|
||||||
|
|
||||||
|
folder = os.path.dirname(file_path)
|
||||||
|
current_file = os.path.basename(file_path)
|
||||||
|
|
||||||
|
# 파일명에서 SxxExx 패턴 추출
|
||||||
|
ep_match = re.search(r'\.S(\d+)E(\d+)\.', current_file, re.IGNORECASE)
|
||||||
|
if not ep_match:
|
||||||
|
# 패턴 없으면 현재 파일만 반환
|
||||||
|
return jsonify({
|
||||||
|
"playlist": [{"path": file_path, "name": current_file}],
|
||||||
|
"current_index": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
current_season = int(ep_match.group(1))
|
||||||
|
current_episode = int(ep_match.group(2))
|
||||||
|
|
||||||
|
# 같은 폴더의 모든 mp4 파일 가져오기
|
||||||
|
all_files = []
|
||||||
|
for f in os.listdir(folder):
|
||||||
|
if f.endswith('.mp4'):
|
||||||
|
match = re.search(r'\.S(\d+)E(\d+)\.', f, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
s = int(match.group(1))
|
||||||
|
e = int(match.group(2))
|
||||||
|
all_files.append({
|
||||||
|
"path": os.path.join(folder, f),
|
||||||
|
"name": f,
|
||||||
|
"season": s,
|
||||||
|
"episode": e
|
||||||
|
})
|
||||||
|
|
||||||
|
# 시즌/에피소드 순으로 정렬
|
||||||
|
all_files.sort(key=lambda x: (x["season"], x["episode"]))
|
||||||
|
|
||||||
|
# 현재 에피소드 이상인 것만 필터링 (현재 + 다음 에피소드들)
|
||||||
|
playlist = []
|
||||||
|
current_index = 0
|
||||||
|
for i, f in enumerate(all_files):
|
||||||
|
if f["season"] == current_season and f["episode"] >= current_episode:
|
||||||
|
entry = {"path": f["path"], "name": f["name"]}
|
||||||
|
if f["episode"] == current_episode:
|
||||||
|
current_index = len(playlist)
|
||||||
|
playlist.append(entry)
|
||||||
|
|
||||||
|
logger.info(f"Playlist: {len(playlist)} items, current_index: {current_index}")
|
||||||
|
return jsonify({
|
||||||
|
"playlist": playlist,
|
||||||
|
"current_index": current_index
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get playlist error: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return jsonify({"error": str(e), "playlist": [], "current_index": 0}), 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
P.logger.error(f"Exception: {e}")
|
P.logger.error(f"Exception: {e}")
|
||||||
|
|||||||
@@ -50,7 +50,67 @@
|
|||||||
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" style="width: 100%; height: auto; max-height: 75vh;">
|
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" style="width: 100%; height: auto; max-height: 75vh;">
|
||||||
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
|
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
|
||||||
</video>
|
</video>
|
||||||
|
<!-- 플레이리스트 컨트롤 UI -->
|
||||||
|
<div class="playlist-controls" style="padding: 12px 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);">
|
||||||
|
<!-- 현재 재생 정보 + 버튼 -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button id="btn-prev-ep" class="playlist-nav-btn" style="display: none;" title="이전 에피소드">
|
||||||
|
<i class="fa fa-step-backward"></i>
|
||||||
|
</button>
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<div id="current-video-title" style="color: #fbbf24; font-weight: 600; font-size: 14px;"></div>
|
||||||
|
<div id="playlist-progress" style="color: #64748b; font-size: 12px; margin-top: 2px;"></div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-next-ep" class="playlist-nav-btn" style="display: none;" title="다음 에피소드">
|
||||||
|
<i class="fa fa-step-forward"></i>
|
||||||
|
</button>
|
||||||
|
<button id="btn-toggle-playlist" class="playlist-toggle-btn" title="에피소드 목록">
|
||||||
|
<i class="fa fa-list"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 에피소드 목록 (접히는) -->
|
||||||
|
<div id="playlist-list-container" style="display: none; margin-top: 12px; max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 8px;">
|
||||||
|
<div id="playlist-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.playlist-nav-btn {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||||
|
border: none; border-radius: 50%;
|
||||||
|
color: white; font-size: 14px;
|
||||||
|
cursor: pointer; transition: all 0.2s ease;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.playlist-nav-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); }
|
||||||
|
.playlist-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.playlist-toggle-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(100, 116, 139, 0.3);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #94a3b8; font-size: 13px;
|
||||||
|
cursor: pointer; transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.playlist-toggle-btn:hover { background: rgba(100, 116, 139, 0.5); color: #e2e8f0; }
|
||||||
|
.playlist-toggle-btn.active { background: rgba(59, 130, 246, 0.3); border-color: #3b82f6; color: #60a5fa; }
|
||||||
|
|
||||||
|
.playlist-item {
|
||||||
|
padding: 8px 12px; margin: 4px 0;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
color: #cbd5e1; font-size: 13px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.playlist-item:hover { background: rgba(59, 130, 246, 0.2); }
|
||||||
|
.playlist-item.active { background: linear-gradient(135deg, rgba(59, 130, 246, 0.4), rgba(37, 99, 235, 0.4)); color: #fbbf24; font-weight: 600; }
|
||||||
|
.playlist-item .ep-num { color: #64748b; font-size: 11px; min-width: 40px; }
|
||||||
|
</style>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,45 +232,199 @@
|
|||||||
global_sub_request_search('1')
|
global_sub_request_search('1')
|
||||||
});
|
});
|
||||||
|
|
||||||
// 비디오 보기 버튼 클릭 핸들러
|
// 비디오 보기 버튼 클릭 핸들러 (플레이리스트 지원)
|
||||||
var videoPlayer = null;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
$("body").on('click', '.btn-watch', function (e) {
|
$("body").on('click', '.btn-watch', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var filePath = $(this).data('path');
|
var filePath = $(this).data('path');
|
||||||
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
|
||||||
|
|
||||||
// Video.js 초기화 또는 소스 변경
|
// 플레이리스트 API 호출
|
||||||
if (videoPlayer) {
|
$.ajax({
|
||||||
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
|
url: '/' + package_name + '/ajax/' + sub + '/get_playlist?path=' + encodeURIComponent(filePath),
|
||||||
} else {
|
type: 'GET',
|
||||||
videoPlayer = videojs('video-player', {
|
dataType: 'json',
|
||||||
controls: true,
|
success: function(data) {
|
||||||
autoplay: false,
|
playlist = data.playlist || [];
|
||||||
preload: 'auto',
|
currentPlaylistIndex = data.current_index || 0;
|
||||||
fluid: true,
|
currentPlayingPath = filePath; // 실시간 갱신용 경로 저장
|
||||||
playbackRates: [0.5, 1, 1.5, 2],
|
|
||||||
controlBar: {
|
var streamUrl = '/' + package_name + '/ajax/' + sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||||
skipButtons: { forward: 10, backward: 10 }
|
|
||||||
|
// 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 });
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모달 열기
|
// 에피소드 목록 렌더링
|
||||||
$('#videoModal').modal('show');
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 모달 닫을 때 비디오 정지 + 포커스 해제 (aria-hidden 경고 방지)
|
// 모달 닫을 때 비디오 정지 + 포커스 해제 (aria-hidden 경고 방지)
|
||||||
|
var playlistRefreshInterval = null;
|
||||||
|
var currentPlayingPath = null; // 현재 재생 중인 파일 경로 저장
|
||||||
|
|
||||||
|
function startPlaylistRefresh() {
|
||||||
|
if (playlistRefreshInterval) return;
|
||||||
|
playlistRefreshInterval = setInterval(function() {
|
||||||
|
if (!currentPlayingPath) return;
|
||||||
|
$.ajax({
|
||||||
|
url: '/' + package_name + '/ajax/' + sub + '/get_playlist?path=' + encodeURIComponent(currentPlayingPath),
|
||||||
|
type: 'GET',
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(data) {
|
||||||
|
var newPlaylist = data.playlist || [];
|
||||||
|
// 새 에피소드 추가됐는지 확인
|
||||||
|
if (newPlaylist.length > playlist.length) {
|
||||||
|
var addedCount = newPlaylist.length - playlist.length;
|
||||||
|
playlist = newPlaylist;
|
||||||
|
updatePlaylistUI();
|
||||||
|
// 알림 표시
|
||||||
|
$.notify('<i class="fa fa-download"></i> ' + addedCount + '개 에피소드 추가됨!', {type: 'success'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 10000); // 10초마다 체크
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPlaylistRefresh() {
|
||||||
|
if (playlistRefreshInterval) {
|
||||||
|
clearInterval(playlistRefreshInterval);
|
||||||
|
playlistRefreshInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#videoModal').on('show.bs.modal', function() {
|
||||||
|
startPlaylistRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
$('#videoModal').on('hide.bs.modal', function () {
|
$('#videoModal').on('hide.bs.modal', function () {
|
||||||
|
stopPlaylistRefresh();
|
||||||
// 포커스된 요소에서 포커스 해제 (aria-hidden 경고 방지)
|
// 포커스된 요소에서 포커스 해제 (aria-hidden 경고 방지)
|
||||||
document.activeElement.blur();
|
document.activeElement.blur();
|
||||||
|
// 목록 닫기
|
||||||
|
$('#playlist-list-container').hide();
|
||||||
|
$('#btn-toggle-playlist').removeClass('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#videoModal').on('hidden.bs.modal', function () {
|
$('#videoModal').on('hidden.bs.modal', function () {
|
||||||
if (videoPlayer) {
|
if (videoPlayer) {
|
||||||
videoPlayer.pause();
|
videoPlayer.pause();
|
||||||
}
|
}
|
||||||
|
currentPlayingPath = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function make_list(data) {
|
function make_list(data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user