mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-10 22:12:32 -05:00
Compare commits
4 Commits
v7.2.0
...
3abc012de1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3abc012de1 | ||
|
|
fef262496d | ||
|
|
3e79f5a558 | ||
|
|
a320375e16 |
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 6.1.0
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/pycqa/isort
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "7.2.0"
|
||||
VERSION = "7.1.0"
|
||||
|
||||
@@ -272,16 +272,12 @@ def show_related_media_content(media, request, limit):
|
||||
category = media.category.first()
|
||||
if category:
|
||||
q_category = Q(listable=True, category=category)
|
||||
# Fix: Ensure slice index is never negative
|
||||
remaining = max(0, limit - len(m))
|
||||
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
|
||||
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
|
||||
m = list(itertools.chain(m, q_res))
|
||||
|
||||
if len(m) < limit:
|
||||
q_generic = Q(listable=True)
|
||||
# Fix: Ensure slice index is never negative
|
||||
remaining = max(0, limit - len(m))
|
||||
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
|
||||
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
|
||||
m = list(itertools.chain(m, q_res))
|
||||
|
||||
m = list(set(m[:limit])) # remove duplicates
|
||||
@@ -670,8 +666,11 @@ def change_media_owner(media_id, new_user):
|
||||
media.user = new_user
|
||||
media.save(update_fields=["user"])
|
||||
|
||||
# Optimize: Update any related permissions in bulk instead of loop
|
||||
models.MediaPermission.objects.filter(media=media).update(owner_user=new_user)
|
||||
# Update any related permissions
|
||||
media_permissions = models.MediaPermission.objects.filter(media=media)
|
||||
for permission in media_permissions:
|
||||
permission.owner_user = new_user
|
||||
permission.save(update_fields=["owner_user"])
|
||||
|
||||
# remove any existing permissions for the new user, since they are now owner
|
||||
models.MediaPermission.objects.filter(media=media, user=new_user).delete()
|
||||
|
||||
@@ -91,10 +91,10 @@ class Category(models.Model):
|
||||
if self.listings_thumbnail:
|
||||
return self.listings_thumbnail
|
||||
|
||||
# Optimize: Use first() directly instead of exists() + first() (saves one query)
|
||||
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
|
||||
if media:
|
||||
return media.thumbnail_url
|
||||
if Media.objects.filter(category=self, state="public").exists():
|
||||
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
|
||||
if media:
|
||||
return media.thumbnail_url
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -74,8 +74,10 @@ class MediaList(APIView):
|
||||
if not request.user.is_authenticated:
|
||||
return base_queryset.filter(base_filters)
|
||||
|
||||
conditions = base_filters
|
||||
# Build OR conditions for authenticated users
|
||||
conditions = base_filters # Start with listable media
|
||||
|
||||
# Add user permissions
|
||||
permission_filter = {'user': request.user}
|
||||
if user:
|
||||
permission_filter['owner_user'] = user
|
||||
@@ -86,6 +88,7 @@ class MediaList(APIView):
|
||||
perm_conditions &= Q(user=user)
|
||||
conditions |= perm_conditions
|
||||
|
||||
# Add RBAC conditions
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
rbac_conditions = Q(category__in=rbac_categories)
|
||||
@@ -96,6 +99,7 @@ class MediaList(APIView):
|
||||
return base_queryset.filter(conditions).distinct()
|
||||
|
||||
def get(self, request, format=None):
|
||||
# Show media
|
||||
# authenticated users can see:
|
||||
|
||||
# All listable media (public access)
|
||||
@@ -114,6 +118,7 @@ class MediaList(APIView):
|
||||
publish_state = params.get('publish_state', '').strip()
|
||||
query = params.get("q", "").strip().lower()
|
||||
|
||||
# Handle combined sort options (e.g., title_asc, views_desc)
|
||||
parsed_combined = False
|
||||
if sort_by and '_' in sort_by:
|
||||
parts = sort_by.rsplit('_', 1)
|
||||
@@ -232,14 +237,14 @@ class MediaList(APIView):
|
||||
if not already_sorted:
|
||||
media = media.order_by(f"{ordering}{sort_by}")
|
||||
|
||||
media = media[:1000]
|
||||
media = media[:1000] # limit to 1000 results
|
||||
|
||||
paginator = pagination_class()
|
||||
|
||||
page = paginator.paginate_queryset(media, request)
|
||||
|
||||
serializer = MediaSerializer(page, many=True, context={"request": request})
|
||||
|
||||
# Collect all unique tags from the current page results
|
||||
tags_set = set()
|
||||
for media_obj in page:
|
||||
for tag in media_obj.tags.all():
|
||||
@@ -349,23 +354,28 @@ class MediaBulkUserActions(APIView):
|
||||
},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Check if user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Get required parameters
|
||||
media_ids = request.data.get('media_ids', [])
|
||||
action = request.data.get('action')
|
||||
|
||||
# Validate required parameters
|
||||
if not media_ids:
|
||||
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not action:
|
||||
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get media objects owned by the user
|
||||
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
|
||||
|
||||
if not media:
|
||||
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Process based on action
|
||||
if action == "enable_comments":
|
||||
media.update(enable_comments=True)
|
||||
return Response({"detail": f"Comments enabled for {media.count()} media items"})
|
||||
@@ -436,10 +446,12 @@ class MediaBulkUserActions(APIView):
|
||||
if state not in valid_states:
|
||||
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check if user can set public state
|
||||
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
|
||||
if state == "public":
|
||||
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update media state
|
||||
for m in media:
|
||||
m.state = state
|
||||
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
|
||||
@@ -483,6 +495,8 @@ class MediaBulkUserActions(APIView):
|
||||
if ownership_type not in valid_ownership_types:
|
||||
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Find users who have the permission on ALL media items (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
users = (
|
||||
@@ -509,6 +523,7 @@ class MediaBulkUserActions(APIView):
|
||||
if not usernames:
|
||||
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get valid users from the provided usernames
|
||||
users = User.objects.filter(username__in=usernames)
|
||||
if not users.exists():
|
||||
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -533,17 +548,22 @@ class MediaBulkUserActions(APIView):
|
||||
if not usernames:
|
||||
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get valid users from the provided usernames
|
||||
users = User.objects.filter(username__in=usernames)
|
||||
if not users.exists():
|
||||
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Delete MediaPermission objects matching the criteria
|
||||
MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
|
||||
|
||||
return Response({"detail": "Action succeeded"})
|
||||
|
||||
elif action == "playlist_membership":
|
||||
# Find playlists that contain ALL the selected media (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
# Query playlists owned by user that contain these media
|
||||
results = list(
|
||||
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
|
||||
.values('id', 'friendly_token', 'title')
|
||||
@@ -554,15 +574,21 @@ class MediaBulkUserActions(APIView):
|
||||
return Response({'results': results})
|
||||
|
||||
elif action == "category_membership":
|
||||
# Find categories that contain ALL the selected media (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
# Query categories that contain these media
|
||||
results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
|
||||
|
||||
return Response({'results': results})
|
||||
|
||||
elif action == "tag_membership":
|
||||
# Find tags that contain ALL the selected media (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
# Query tags that contain these media
|
||||
results = list(Tag.objects.filter(media__in=media).values('title').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
|
||||
|
||||
return Response({'results': results})
|
||||
@@ -579,6 +605,7 @@ class MediaBulkUserActions(APIView):
|
||||
added_count = 0
|
||||
for category in categories:
|
||||
for m in media:
|
||||
# Add media to category (ManyToMany relationship)
|
||||
if not m.category.filter(uid=category.uid).exists():
|
||||
m.category.add(category)
|
||||
added_count += 1
|
||||
@@ -597,6 +624,7 @@ class MediaBulkUserActions(APIView):
|
||||
removed_count = 0
|
||||
for category in categories:
|
||||
for m in media:
|
||||
# Remove media from category (ManyToMany relationship)
|
||||
if m.category.filter(uid=category.uid).exists():
|
||||
m.category.remove(category)
|
||||
removed_count += 1
|
||||
@@ -615,6 +643,7 @@ class MediaBulkUserActions(APIView):
|
||||
added_count = 0
|
||||
for tag in tags:
|
||||
for m in media:
|
||||
# Add media to tag (ManyToMany relationship)
|
||||
if not m.tags.filter(title=tag.title).exists():
|
||||
m.tags.add(tag)
|
||||
added_count += 1
|
||||
@@ -633,6 +662,7 @@ class MediaBulkUserActions(APIView):
|
||||
removed_count = 0
|
||||
for tag in tags:
|
||||
for m in media:
|
||||
# Remove media from tag (ManyToMany relationship)
|
||||
if m.tags.filter(title=tag.title).exists():
|
||||
m.tags.remove(tag)
|
||||
removed_count += 1
|
||||
|
||||
@@ -13,7 +13,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||
const [isAudioFile, setIsAudioFile] = useState(false);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -42,13 +41,12 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
setVideoUrl(url);
|
||||
|
||||
// Check if the media is an audio file and set poster image
|
||||
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
setIsAudioFile(audioFile);
|
||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
|
||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
@@ -130,34 +128,22 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Video container with persistent background for audio files */}
|
||||
<div className="ios-video-wrapper">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="ios-audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
</div>
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
|
||||
@@ -26,18 +26,6 @@ const mediaPageLinkStyles = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||
const parseTimeToSeconds = (timeString: string): number => {
|
||||
const parts = timeString.split(':');
|
||||
if (parts.length !== 3) return 0;
|
||||
|
||||
const hours = parseInt(parts[0], 10) || 0;
|
||||
const minutes = parseInt(parts[1], 10) || 0;
|
||||
const seconds = parseFloat(parts[2]) || 0;
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
interface TimelineControlsProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -203,17 +191,7 @@ const TimelineControls = ({
|
||||
setIsAutoSaving(true);
|
||||
|
||||
// Format segments data for API request - use ref to get latest segments and sort by start time
|
||||
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
|
||||
const chapters = clipSegmentsRef.current
|
||||
.filter((segment) => {
|
||||
// Filter out empty titles
|
||||
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
|
||||
return false;
|
||||
}
|
||||
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
|
||||
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
|
||||
return !isDefaultName;
|
||||
})
|
||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||
.map((chapter) => ({
|
||||
startTime: formatDetailedTime(chapter.startTime),
|
||||
@@ -221,7 +199,7 @@ const TimelineControls = ({
|
||||
chapterTitle: chapter.chapterTitle,
|
||||
}));
|
||||
|
||||
logger.debug('Filtered chapters (only custom titles):', chapters);
|
||||
logger.debug('chapters', chapters);
|
||||
|
||||
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
|
||||
// For testing, use '1234' if no mediaId is available
|
||||
@@ -229,13 +207,12 @@ const TimelineControls = ({
|
||||
|
||||
logger.debug('mediaId', finalMediaId);
|
||||
|
||||
if (!finalMediaId) {
|
||||
logger.debug('No mediaId, skipping auto-save');
|
||||
if (!finalMediaId || chapters.length === 0) {
|
||||
logger.debug('No mediaId or segments, skipping auto-save');
|
||||
setIsAutoSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save chapters (empty array if no chapters have titles)
|
||||
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
|
||||
|
||||
const response = await autoSaveVideo(finalMediaId, { chapters });
|
||||
@@ -522,20 +499,11 @@ const TimelineControls = ({
|
||||
|
||||
try {
|
||||
// Format chapters data for API request - sort by start time first
|
||||
// ONLY save chapters that have custom titles - filter out chapters without titles or with default names
|
||||
const chapters = clipSegments
|
||||
.filter((segment) => {
|
||||
// Filter out empty titles
|
||||
if (!segment.chapterTitle || !segment.chapterTitle.trim()) {
|
||||
return false;
|
||||
}
|
||||
// Filter out default chapter names like "Chapter 1", "Chapter 2", etc.
|
||||
const isDefaultName = /^Chapter \d+$/.test(segment.chapterTitle);
|
||||
return !isDefaultName;
|
||||
})
|
||||
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
|
||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||
.map((segment) => ({
|
||||
chapterTitle: segment.chapterTitle,
|
||||
chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
|
||||
from: formatDetailedTime(segment.startTime),
|
||||
to: formatDetailedTime(segment.endTime),
|
||||
}));
|
||||
|
||||
@@ -353,18 +353,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
|
||||
@@ -8,40 +8,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Video wrapper for positioning background */
|
||||
.ios-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.ios-audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ios-video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.ios-video-player-container video.audio-with-poster {
|
||||
background-color: transparent;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.ios-time-display {
|
||||
|
||||
@@ -76,26 +76,10 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
/* Force hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
@@ -104,11 +88,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.video-player-container video.audio-with-poster {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
@@ -130,7 +109,6 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
@@ -209,7 +187,6 @@
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
|
||||
@@ -13,7 +13,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||
const [isAudioFile, setIsAudioFile] = useState(false);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -42,13 +41,12 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
setVideoUrl(url);
|
||||
|
||||
// Check if the media is an audio file and set poster image
|
||||
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
setIsAudioFile(audioFile);
|
||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
|
||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
@@ -130,34 +128,22 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Video container with persistent background for audio files */}
|
||||
<div className="ios-video-wrapper">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="ios-audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
</div>
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
|
||||
@@ -353,18 +353,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
|
||||
@@ -8,40 +8,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Video wrapper for positioning background */
|
||||
.ios-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.ios-audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ios-video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.ios-video-player-container video.audio-with-poster {
|
||||
background-color: transparent;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.ios-time-display {
|
||||
|
||||
@@ -76,26 +76,10 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
/* Force hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
@@ -104,11 +88,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.video-player-container video.audio-with-poster {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
@@ -130,7 +109,6 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
@@ -209,7 +187,6 @@
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
|
||||
@@ -20,7 +20,6 @@ class CustomChaptersOverlay extends Component {
|
||||
this.touchStartTime = 0;
|
||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
this.scrollY = 0; // Track scroll position before locking
|
||||
|
||||
// Bind methods
|
||||
this.createOverlay = this.createOverlay.bind(this);
|
||||
@@ -32,8 +31,6 @@ class CustomChaptersOverlay extends Component {
|
||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.lockBodyScroll = this.lockBodyScroll.bind(this);
|
||||
this.unlockBodyScroll = this.unlockBodyScroll.bind(this);
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
@@ -68,9 +65,6 @@ class CustomChaptersOverlay extends Component {
|
||||
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
|
||||
setupResizeListener() {
|
||||
@@ -170,8 +164,6 @@ class CustomChaptersOverlay extends Component {
|
||||
this.overlay.style.display = 'none';
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
};
|
||||
chapterClose.appendChild(closeBtn);
|
||||
playlistTitle.appendChild(chapterClose);
|
||||
@@ -363,37 +355,6 @@ class CustomChaptersOverlay extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
lockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Save current scroll position
|
||||
this.scrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
// Lock body scroll with proper iOS handling
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.top = `-${this.scrollY}px`;
|
||||
document.body.style.left = '0';
|
||||
document.body.style.right = '0';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
|
||||
unlockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Restore body scroll
|
||||
const scrollY = this.scrollY;
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.top = '';
|
||||
document.body.style.left = '';
|
||||
document.body.style.right = '';
|
||||
document.body.style.width = '';
|
||||
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
|
||||
toggleOverlay() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
@@ -408,11 +369,17 @@ class CustomChaptersOverlay extends Component {
|
||||
navigator.vibrate(30);
|
||||
}
|
||||
|
||||
// Lock/unlock body scroll on mobile when overlay opens/closes
|
||||
if (isHidden) {
|
||||
this.lockBodyScroll();
|
||||
} else {
|
||||
this.unlockBodyScroll();
|
||||
// Prevent body scroll on mobile when overlay is open
|
||||
if (this.isMobile) {
|
||||
if (isHidden) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -423,9 +390,7 @@ class CustomChaptersOverlay extends Component {
|
||||
m.classList.remove('vjs-lock-showing');
|
||||
m.style.display = 'none';
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors when closing menus
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateCurrentChapter() {
|
||||
@@ -441,6 +406,7 @@ class CustomChaptersOverlay extends Component {
|
||||
currentTime >= chapter.startTime &&
|
||||
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime);
|
||||
|
||||
const handle = item.querySelector('.playlist-drag-handle');
|
||||
const dynamic = item.querySelector('.meta-dynamic');
|
||||
if (isPlaying) {
|
||||
currentChapterIndex = index;
|
||||
@@ -497,7 +463,11 @@ class CustomChaptersOverlay extends Component {
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile
|
||||
this.unlockBodyScroll();
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +479,11 @@ class CustomChaptersOverlay extends Component {
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile when disposing
|
||||
this.unlockBodyScroll();
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
if (this.handleResize) {
|
||||
|
||||
@@ -25,7 +25,6 @@ class CustomSettingsMenu extends Component {
|
||||
this.isMobile = this.detectMobile();
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||
this.scrollY = 0; // Track scroll position before locking
|
||||
|
||||
// Bind methods
|
||||
this.createSettingsButton = this.createSettingsButton.bind(this);
|
||||
@@ -42,8 +41,6 @@ class CustomSettingsMenu extends Component {
|
||||
this.detectMobile = this.detectMobile.bind(this);
|
||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||
this.lockBodyScroll = this.lockBodyScroll.bind(this);
|
||||
this.unlockBodyScroll = this.unlockBodyScroll.bind(this);
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
@@ -659,8 +656,6 @@ class CustomSettingsMenu extends Component {
|
||||
if (btnEl) {
|
||||
btnEl.classList.remove('settings-clicked');
|
||||
}
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', closeFunction);
|
||||
@@ -947,37 +942,6 @@ class CustomSettingsMenu extends Component {
|
||||
this.startSubtitleSync();
|
||||
}
|
||||
|
||||
lockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Save current scroll position
|
||||
this.scrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
// Lock body scroll with proper iOS handling
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.top = `-${this.scrollY}px`;
|
||||
document.body.style.left = '0';
|
||||
document.body.style.right = '0';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
|
||||
unlockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Restore body scroll
|
||||
const scrollY = this.scrollY;
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.top = '';
|
||||
document.body.style.left = '';
|
||||
document.body.style.right = '';
|
||||
document.body.style.width = '';
|
||||
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
|
||||
toggleSettings(e) {
|
||||
// e.stopPropagation();
|
||||
const isVisible = this.settingsOverlay.classList.contains('show');
|
||||
@@ -990,7 +954,11 @@ class CustomSettingsMenu extends Component {
|
||||
this.stopKeepingControlsVisible();
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
} else {
|
||||
this.settingsOverlay.classList.add('show');
|
||||
this.settingsOverlay.style.display = 'block';
|
||||
@@ -1004,7 +972,11 @@ class CustomSettingsMenu extends Component {
|
||||
}
|
||||
|
||||
// Prevent body scroll on mobile when overlay is open
|
||||
this.lockBodyScroll();
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles
|
||||
@@ -1030,9 +1002,6 @@ class CustomSettingsMenu extends Component {
|
||||
this.settingsOverlay.classList.add('show');
|
||||
this.settingsOverlay.style.display = 'block';
|
||||
|
||||
// Lock body scroll when opening
|
||||
this.lockBodyScroll();
|
||||
|
||||
// Hide other submenus and show subtitles submenu
|
||||
this.speedSubmenu.style.display = 'none';
|
||||
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
|
||||
@@ -1103,7 +1072,11 @@ class CustomSettingsMenu extends Component {
|
||||
}
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1444,8 +1417,6 @@ class CustomSettingsMenu extends Component {
|
||||
if (btnEl) {
|
||||
btnEl.classList.remove('settings-clicked');
|
||||
}
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1522,7 +1493,11 @@ class CustomSettingsMenu extends Component {
|
||||
}
|
||||
|
||||
// Restore body scroll on mobile when disposing
|
||||
this.unlockBodyScroll();
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
|
||||
// Remove DOM elements
|
||||
if (this.settingsOverlay) {
|
||||
|
||||
@@ -26,12 +26,17 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
||||
csrfToken,
|
||||
}) => {
|
||||
const [selectedState, setSelectedState] = useState('public');
|
||||
const [initialState, setInitialState] = useState('public');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setSelectedState('public');
|
||||
setInitialState('public');
|
||||
} else {
|
||||
// When modal opens, set initial state
|
||||
setInitialState('public');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -74,9 +79,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Note: We don't check hasStateChanged because the modal doesn't know the actual
|
||||
// current state of the selected media. Users should be able to set any state.
|
||||
// If the state is already the same, the backend will handle it gracefully.
|
||||
const hasStateChanged = selectedState !== initialState;
|
||||
|
||||
return (
|
||||
<div className="publish-state-modal-overlay">
|
||||
@@ -113,7 +116,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
||||
<button
|
||||
className="publish-state-btn publish-state-btn-submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={isProcessing}
|
||||
disabled={isProcessing || !hasStateChanged}
|
||||
>
|
||||
{isProcessing ? translateString('Processing...') : translateString('Submit')}
|
||||
</button>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user