feat: Add reusable video modal component with Alist-style UI
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
|
||||
};
|
||||
})();
|
||||