mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-09 21:42:31 -05:00
Compare commits
15 Commits
3abc012de1
...
feat-playl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d98da238 | ||
|
|
fb61b78573 | ||
|
|
a15a9403fe | ||
|
|
894e39ed2b | ||
|
|
a90fcbf8dd | ||
|
|
1b3cdfd302 | ||
|
|
cd7dd4f72c | ||
|
|
9b3d9fe1e7 | ||
|
|
ea340b6a2e | ||
|
|
ba2c31b1e6 | ||
|
|
5eb6fafb8c | ||
|
|
c035bcddf5 | ||
|
|
01912ea1f9 | ||
|
|
d9f299af4d | ||
|
|
e80590a3aa |
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pycqa/flake8
|
- repo: https://github.com/pycqa/flake8
|
||||||
rev: 6.0.0
|
rev: 6.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VERSION = "7.1.0"
|
VERSION = "7.2.5"
|
||||||
|
|||||||
@@ -178,14 +178,11 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
state = cleaned_data.get("state")
|
state = cleaned_data.get("state")
|
||||||
categories = cleaned_data.get("category")
|
categories = cleaned_data.get("category")
|
||||||
|
|
||||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
if state in ['private', 'unlisted']:
|
||||||
|
custom_permissions = self.instance.permissions.exists()
|
||||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||||
|
if rbac_categories or custom_permissions:
|
||||||
if rbac_categories and state in ['private', 'unlisted']:
|
|
||||||
# Make the confirm_state field visible and add it to the layout
|
|
||||||
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||||
|
|
||||||
# add it after the state field
|
|
||||||
state_index = None
|
state_index = None
|
||||||
for i, layout_item in enumerate(self.helper.layout):
|
for i, layout_item in enumerate(self.helper.layout):
|
||||||
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||||
@@ -198,8 +195,12 @@ class MediaPublishForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(*layout_items)
|
self.helper.layout = Layout(*layout_items)
|
||||||
|
|
||||||
if not cleaned_data.get('confirm_state'):
|
if not cleaned_data.get('confirm_state'):
|
||||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
|
if rbac_categories:
|
||||||
self.add_error('confirm_state', error_message)
|
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to categories: {', '.join(rbac_categories)}"
|
||||||
|
self.add_error('confirm_state', error_message)
|
||||||
|
if custom_permissions:
|
||||||
|
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
|
||||||
|
self.add_error('confirm_state', error_message)
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|||||||
@@ -910,7 +910,9 @@ def trim_video_method(media_file_path, timestamps_list):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||||
output_file = os.path.join(temp_dir, "output.mp4")
|
# Detect input file extension to preserve original format
|
||||||
|
_, input_ext = os.path.splitext(media_file_path)
|
||||||
|
output_file = os.path.join(temp_dir, f"output{input_ext}")
|
||||||
|
|
||||||
segment_files = []
|
segment_files = []
|
||||||
for i, item in enumerate(timestamps_list):
|
for i, item in enumerate(timestamps_list):
|
||||||
@@ -920,7 +922,7 @@ def trim_video_method(media_file_path, timestamps_list):
|
|||||||
|
|
||||||
# For single timestamp, we can use the output file directly
|
# For single timestamp, we can use the output file directly
|
||||||
# For multiple timestamps, we need to create segment files
|
# For multiple timestamps, we need to create segment files
|
||||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
|
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}{input_ext}")
|
||||||
|
|
||||||
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
||||||
|
|
||||||
|
|||||||
@@ -272,12 +272,16 @@ def show_related_media_content(media, request, limit):
|
|||||||
category = media.category.first()
|
category = media.category.first()
|
||||||
if category:
|
if category:
|
||||||
q_category = Q(listable=True, category=category)
|
q_category = Q(listable=True, category=category)
|
||||||
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]
|
# 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]
|
||||||
m = list(itertools.chain(m, q_res))
|
m = list(itertools.chain(m, q_res))
|
||||||
|
|
||||||
if len(m) < limit:
|
if len(m) < limit:
|
||||||
q_generic = Q(listable=True)
|
q_generic = Q(listable=True)
|
||||||
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]
|
# 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]
|
||||||
m = list(itertools.chain(m, q_res))
|
m = list(itertools.chain(m, q_res))
|
||||||
|
|
||||||
m = list(set(m[:limit])) # remove duplicates
|
m = list(set(m[:limit])) # remove duplicates
|
||||||
@@ -490,7 +494,6 @@ def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
|||||||
state=helpers.get_default_state(user=original_media.user),
|
state=helpers.get_default_state(user=original_media.user),
|
||||||
is_reviewed=original_media.is_reviewed,
|
is_reviewed=original_media.is_reviewed,
|
||||||
encoding_status=original_media.encoding_status,
|
encoding_status=original_media.encoding_status,
|
||||||
listable=original_media.listable,
|
|
||||||
add_date=timezone.now(),
|
add_date=timezone.now(),
|
||||||
video_height=original_media.video_height,
|
video_height=original_media.video_height,
|
||||||
size=original_media.size,
|
size=original_media.size,
|
||||||
@@ -666,11 +669,8 @@ def change_media_owner(media_id, new_user):
|
|||||||
media.user = new_user
|
media.user = new_user
|
||||||
media.save(update_fields=["user"])
|
media.save(update_fields=["user"])
|
||||||
|
|
||||||
# Update any related permissions
|
# Optimize: Update any related permissions in bulk instead of loop
|
||||||
media_permissions = models.MediaPermission.objects.filter(media=media)
|
models.MediaPermission.objects.filter(media=media).update(owner_user=new_user)
|
||||||
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
|
# remove any existing permissions for the new user, since they are now owner
|
||||||
models.MediaPermission.objects.filter(media=media, user=new_user).delete()
|
models.MediaPermission.objects.filter(media=media, user=new_user).delete()
|
||||||
@@ -713,7 +713,6 @@ def copy_media(media):
|
|||||||
state=helpers.get_default_state(user=media.user),
|
state=helpers.get_default_state(user=media.user),
|
||||||
is_reviewed=media.is_reviewed,
|
is_reviewed=media.is_reviewed,
|
||||||
encoding_status=media.encoding_status,
|
encoding_status=media.encoding_status,
|
||||||
listable=media.listable,
|
|
||||||
add_date=timezone.now(),
|
add_date=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -730,4 +729,6 @@ def copy_media(media):
|
|||||||
def is_media_allowed_type(media):
|
def is_media_allowed_type(media):
|
||||||
if "all" in settings.ALLOWED_MEDIA_UPLOAD_TYPES:
|
if "all" in settings.ALLOWED_MEDIA_UPLOAD_TYPES:
|
||||||
return True
|
return True
|
||||||
|
if media.media_type == "playlist":
|
||||||
|
return True
|
||||||
return media.media_type in settings.ALLOWED_MEDIA_UPLOAD_TYPES
|
return media.media_type in settings.ALLOWED_MEDIA_UPLOAD_TYPES
|
||||||
|
|||||||
42
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
42
files/migrations/0014_alter_subtitle_options_and_more.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-21 12:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import files.models.utils
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('files', '0013_page_tinymcemedia'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='subtitle',
|
||||||
|
options={'ordering': ['language__title'], 'verbose_name': 'Caption', 'verbose_name_plural': 'Captions'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='transcriptionrequest',
|
||||||
|
options={'verbose_name': 'Caption Request', 'verbose_name_plural': 'Caption Requests'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='videotrimrequest',
|
||||||
|
options={'verbose_name': 'Trim Request', 'verbose_name_plural': 'Trim Requests'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='media',
|
||||||
|
name='linked_playlist',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='If set, this Media represents a Playlist in listings', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media_representation', to='files.playlist'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='media_file',
|
||||||
|
field=models.FileField(blank=True, help_text='media file', max_length=500, null=True, upload_to=files.models.utils.original_media_file_path, verbose_name='media file'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='media',
|
||||||
|
name='media_type',
|
||||||
|
field=models.CharField(blank=True, choices=[('video', 'Video'), ('image', 'Image'), ('pdf', 'Pdf'), ('audio', 'Audio'), ('playlist', 'Playlist')], db_index=True, default='video', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -91,10 +91,10 @@ class Category(models.Model):
|
|||||||
if self.listings_thumbnail:
|
if self.listings_thumbnail:
|
||||||
return self.listings_thumbnail
|
return self.listings_thumbnail
|
||||||
|
|
||||||
if Media.objects.filter(category=self, state="public").exists():
|
# Optimize: Use first() directly instead of exists() + first() (saves one query)
|
||||||
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
|
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
|
||||||
if media:
|
if media:
|
||||||
return media.thumbnail_url
|
return media.thumbnail_url
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ class Media(models.Model):
|
|||||||
|
|
||||||
likes = models.IntegerField(db_index=True, default=1)
|
likes = models.IntegerField(db_index=True, default=1)
|
||||||
|
|
||||||
|
linked_playlist = models.ForeignKey(
|
||||||
|
"Playlist",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
related_name="media_representation",
|
||||||
|
help_text="If set, this Media represents a Playlist in listings",
|
||||||
|
)
|
||||||
|
|
||||||
listable = models.BooleanField(default=False, help_text="Whether it will appear on listings")
|
listable = models.BooleanField(default=False, help_text="Whether it will appear on listings")
|
||||||
|
|
||||||
md5sum = models.CharField(max_length=50, blank=True, null=True, help_text="Not exposed, used internally")
|
md5sum = models.CharField(max_length=50, blank=True, null=True, help_text="Not exposed, used internally")
|
||||||
@@ -93,6 +102,8 @@ class Media(models.Model):
|
|||||||
"media file",
|
"media file",
|
||||||
upload_to=original_media_file_path,
|
upload_to=original_media_file_path,
|
||||||
max_length=500,
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
help_text="media file",
|
help_text="media file",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -240,7 +251,10 @@ class Media(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.title:
|
if not self.title:
|
||||||
self.title = self.media_file.path.split("/")[-1]
|
if self.media_file:
|
||||||
|
self.title = self.media_file.path.split("/")[-1]
|
||||||
|
elif self.linked_playlist:
|
||||||
|
self.title = self.linked_playlist.title
|
||||||
|
|
||||||
strip_text_items = ["title", "description"]
|
strip_text_items = ["title", "description"]
|
||||||
for item in strip_text_items:
|
for item in strip_text_items:
|
||||||
@@ -282,7 +296,7 @@ class Media(models.Model):
|
|||||||
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
||||||
)
|
)
|
||||||
|
|
||||||
if transcription_changed and self.media_type == "video":
|
if transcription_changed and self.media_type in ["video", "audio"]:
|
||||||
self.transcribe_function()
|
self.transcribe_function()
|
||||||
|
|
||||||
# Update the original values for next comparison
|
# Update the original values for next comparison
|
||||||
@@ -295,6 +309,10 @@ class Media(models.Model):
|
|||||||
|
|
||||||
self.state = helpers.get_default_state(user=self.user)
|
self.state = helpers.get_default_state(user=self.user)
|
||||||
|
|
||||||
|
# Set encoding_status to success for playlist type
|
||||||
|
if self.media_type == "playlist":
|
||||||
|
self.encoding_status = "success"
|
||||||
|
|
||||||
# condition to appear on listings
|
# condition to appear on listings
|
||||||
if self.state == "public" and self.encoding_status == "success" and self.is_reviewed is True:
|
if self.state == "public" and self.encoding_status == "success" and self.is_reviewed is True:
|
||||||
self.listable = True
|
self.listable = True
|
||||||
@@ -329,10 +347,17 @@ class Media(models.Model):
|
|||||||
|
|
||||||
if to_transcribe:
|
if to_transcribe:
|
||||||
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
||||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
|
tasks.whisper_transcribe.apply_async(
|
||||||
|
args=[self.friendly_token, False],
|
||||||
|
countdown=10,
|
||||||
|
)
|
||||||
|
|
||||||
if to_transcribe_and_translate:
|
if to_transcribe_and_translate:
|
||||||
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
||||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
|
tasks.whisper_transcribe.apply_async(
|
||||||
|
args=[self.friendly_token, True],
|
||||||
|
countdown=10,
|
||||||
|
)
|
||||||
|
|
||||||
def update_search_vector(self):
|
def update_search_vector(self):
|
||||||
"""
|
"""
|
||||||
@@ -376,11 +401,16 @@ class Media(models.Model):
|
|||||||
Performs all related tasks, as check for media type,
|
Performs all related tasks, as check for media type,
|
||||||
video duration, encode
|
video duration, encode
|
||||||
"""
|
"""
|
||||||
|
# Skip media_init for playlist type as it has no media file to process
|
||||||
|
if self.media_type == "playlist" or self.linked_playlist:
|
||||||
|
return True
|
||||||
|
|
||||||
self.set_media_type()
|
self.set_media_type()
|
||||||
from ..methods import is_media_allowed_type
|
from ..methods import is_media_allowed_type
|
||||||
|
|
||||||
if not is_media_allowed_type(self):
|
if not is_media_allowed_type(self):
|
||||||
helpers.rm_file(self.media_file.path)
|
if self.media_file and self.media_file.path:
|
||||||
|
helpers.rm_file(self.media_file.path)
|
||||||
if self.state == "public":
|
if self.state == "public":
|
||||||
self.state = "unlisted"
|
self.state = "unlisted"
|
||||||
self.save(update_fields=["state"])
|
self.save(update_fields=["state"])
|
||||||
@@ -758,11 +788,16 @@ class Media(models.Model):
|
|||||||
Prioritize uploaded_thumbnail, if exists, then thumbnail
|
Prioritize uploaded_thumbnail, if exists, then thumbnail
|
||||||
that is auto-generated
|
that is auto-generated
|
||||||
"""
|
"""
|
||||||
|
# If this media represents a playlist, use playlist's thumbnail
|
||||||
|
if self.linked_playlist:
|
||||||
|
return self.linked_playlist.thumbnail_url
|
||||||
|
|
||||||
if self.uploaded_thumbnail:
|
if self.uploaded_thumbnail:
|
||||||
return helpers.url_from_path(self.uploaded_thumbnail.path)
|
return helpers.url_from_path(self.uploaded_thumbnail.path)
|
||||||
if self.thumbnail:
|
if self.thumbnail:
|
||||||
return helpers.url_from_path(self.thumbnail.path)
|
return helpers.url_from_path(self.thumbnail.path)
|
||||||
|
if self.media_type == "audio":
|
||||||
|
return helpers.url_from_path("userlogos/poster_audio.jpg")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -771,11 +806,17 @@ class Media(models.Model):
|
|||||||
Prioritize uploaded_poster, if exists, then poster
|
Prioritize uploaded_poster, if exists, then poster
|
||||||
that is auto-generated
|
that is auto-generated
|
||||||
"""
|
"""
|
||||||
|
# If this media represents a playlist, use playlist's thumbnail
|
||||||
|
if self.linked_playlist:
|
||||||
|
return self.linked_playlist.thumbnail_url
|
||||||
|
|
||||||
if self.uploaded_poster:
|
if self.uploaded_poster:
|
||||||
return helpers.url_from_path(self.uploaded_poster.path)
|
return helpers.url_from_path(self.uploaded_poster.path)
|
||||||
if self.poster:
|
if self.poster:
|
||||||
return helpers.url_from_path(self.poster.path)
|
return helpers.url_from_path(self.poster.path)
|
||||||
|
if self.media_type == "audio":
|
||||||
|
return helpers.url_from_path("userlogos/poster_audio.jpg")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -905,6 +946,14 @@ class Media(models.Model):
|
|||||||
return helpers.url_from_path(self.user.logo.path)
|
return helpers.url_from_path(self.user.logo.path)
|
||||||
|
|
||||||
def get_absolute_url(self, api=False, edit=False):
|
def get_absolute_url(self, api=False, edit=False):
|
||||||
|
# If this media represents a playlist, redirect to playlist page
|
||||||
|
if self.linked_playlist:
|
||||||
|
if edit:
|
||||||
|
# For now, playlist editing is not supported via media edit page
|
||||||
|
return self.linked_playlist.get_absolute_url(api=api)
|
||||||
|
# Start playback from first media when clicking on playlist in listings
|
||||||
|
return self.linked_playlist.get_absolute_url(api=api, start_playback=True)
|
||||||
|
|
||||||
if edit:
|
if edit:
|
||||||
return f"{reverse('edit_media')}?m={self.friendly_token}"
|
return f"{reverse('edit_media')}?m={self.friendly_token}"
|
||||||
if api:
|
if api:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
|
|
||||||
@@ -31,7 +33,25 @@ class Playlist(models.Model):
|
|||||||
def media_count(self):
|
def media_count(self):
|
||||||
return self.media.filter(listable=True).count()
|
return self.media.filter(listable=True).count()
|
||||||
|
|
||||||
def get_absolute_url(self, api=False):
|
def get_first_media(self):
|
||||||
|
"""Get the first media item in the playlist"""
|
||||||
|
pm = self.playlistmedia_set.filter(media__listable=True).first()
|
||||||
|
return pm.media if pm else None
|
||||||
|
|
||||||
|
def get_absolute_url(self, api=False, start_playback=False):
|
||||||
|
"""
|
||||||
|
Get the URL for this playlist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api: If True, return API URL
|
||||||
|
start_playback: If True, return URL to first media with playlist context
|
||||||
|
"""
|
||||||
|
if start_playback and not api:
|
||||||
|
# Get first media and return its URL with playlist parameter
|
||||||
|
first_media = self.get_first_media()
|
||||||
|
if first_media:
|
||||||
|
return f"{first_media.get_absolute_url()}&pl={self.friendly_token}"
|
||||||
|
|
||||||
if api:
|
if api:
|
||||||
return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token})
|
return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token})
|
||||||
else:
|
else:
|
||||||
@@ -41,6 +61,11 @@ class Playlist(models.Model):
|
|||||||
def url(self):
|
def url(self):
|
||||||
return self.get_absolute_url()
|
return self.get_absolute_url()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playback_url(self):
|
||||||
|
"""URL that starts playing the first media in the playlist"""
|
||||||
|
return self.get_absolute_url(start_playback=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_url(self):
|
def api_url(self):
|
||||||
return self.get_absolute_url(api=True)
|
return self.get_absolute_url(api=True)
|
||||||
@@ -95,3 +120,46 @@ class PlaylistMedia(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["ordering", "-action_date"]
|
ordering = ["ordering", "-action_date"]
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Playlist)
|
||||||
|
def create_or_update_playlist_media(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
Automatically create or update a Media object that represents this Playlist in listings.
|
||||||
|
This allows playlists to appear alongside regular media in search results and listings.
|
||||||
|
"""
|
||||||
|
from .media import Media
|
||||||
|
|
||||||
|
# Check if a Media representation already exists for this playlist
|
||||||
|
media_representation = Media.objects.filter(linked_playlist=instance).first()
|
||||||
|
|
||||||
|
if media_representation:
|
||||||
|
# Update existing media representation
|
||||||
|
media_representation.title = instance.title
|
||||||
|
media_representation.description = instance.description
|
||||||
|
media_representation.user = instance.user
|
||||||
|
media_representation.media_type = "playlist"
|
||||||
|
media_representation.encoding_status = "success"
|
||||||
|
media_representation.save()
|
||||||
|
else:
|
||||||
|
# Create new media representation for this playlist
|
||||||
|
Media.objects.create(
|
||||||
|
title=instance.title,
|
||||||
|
description=instance.description,
|
||||||
|
user=instance.user,
|
||||||
|
linked_playlist=instance,
|
||||||
|
media_type="playlist",
|
||||||
|
encoding_status="success",
|
||||||
|
# Inherit the same state and review status defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=Playlist)
|
||||||
|
def delete_playlist_media(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Delete the associated Media representation when a Playlist is deleted.
|
||||||
|
"""
|
||||||
|
from .media import Media
|
||||||
|
|
||||||
|
# Delete any Media objects that represent this playlist
|
||||||
|
Media.objects.filter(linked_playlist=instance).delete()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ MEDIA_TYPES_SUPPORTED = (
|
|||||||
("image", "Image"),
|
("image", "Image"),
|
||||||
("pdf", "Pdf"),
|
("pdf", "Pdf"),
|
||||||
("audio", "Audio"),
|
("audio", "Audio"),
|
||||||
|
("playlist", "Playlist"),
|
||||||
)
|
)
|
||||||
|
|
||||||
ENCODE_EXTENSIONS = (
|
ENCODE_EXTENSIONS = (
|
||||||
|
|||||||
@@ -69,15 +69,13 @@ class MediaList(APIView):
|
|||||||
if user:
|
if user:
|
||||||
base_filters &= Q(user=user)
|
base_filters &= Q(user=user)
|
||||||
|
|
||||||
base_queryset = Media.objects.prefetch_related("user", "tags")
|
base_queryset = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||||
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return base_queryset.filter(base_filters)
|
return base_queryset.filter(base_filters)
|
||||||
|
|
||||||
# Build OR conditions for authenticated users
|
conditions = base_filters
|
||||||
conditions = base_filters # Start with listable media
|
|
||||||
|
|
||||||
# Add user permissions
|
|
||||||
permission_filter = {'user': request.user}
|
permission_filter = {'user': request.user}
|
||||||
if user:
|
if user:
|
||||||
permission_filter['owner_user'] = user
|
permission_filter['owner_user'] = user
|
||||||
@@ -88,7 +86,6 @@ class MediaList(APIView):
|
|||||||
perm_conditions &= Q(user=user)
|
perm_conditions &= Q(user=user)
|
||||||
conditions |= perm_conditions
|
conditions |= perm_conditions
|
||||||
|
|
||||||
# Add RBAC conditions
|
|
||||||
if getattr(settings, 'USE_RBAC', False):
|
if getattr(settings, 'USE_RBAC', False):
|
||||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||||
rbac_conditions = Q(category__in=rbac_categories)
|
rbac_conditions = Q(category__in=rbac_categories)
|
||||||
@@ -99,7 +96,6 @@ class MediaList(APIView):
|
|||||||
return base_queryset.filter(conditions).distinct()
|
return base_queryset.filter(conditions).distinct()
|
||||||
|
|
||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
# Show media
|
|
||||||
# authenticated users can see:
|
# authenticated users can see:
|
||||||
|
|
||||||
# All listable media (public access)
|
# All listable media (public access)
|
||||||
@@ -118,7 +114,6 @@ class MediaList(APIView):
|
|||||||
publish_state = params.get('publish_state', '').strip()
|
publish_state = params.get('publish_state', '').strip()
|
||||||
query = params.get("q", "").strip().lower()
|
query = params.get("q", "").strip().lower()
|
||||||
|
|
||||||
# Handle combined sort options (e.g., title_asc, views_desc)
|
|
||||||
parsed_combined = False
|
parsed_combined = False
|
||||||
if sort_by and '_' in sort_by:
|
if sort_by and '_' in sort_by:
|
||||||
parts = sort_by.rsplit('_', 1)
|
parts = sort_by.rsplit('_', 1)
|
||||||
@@ -164,17 +159,17 @@ class MediaList(APIView):
|
|||||||
media = show_recommended_media(request, limit=50)
|
media = show_recommended_media(request, limit=50)
|
||||||
already_sorted = True
|
already_sorted = True
|
||||||
elif show_param == "featured":
|
elif show_param == "featured":
|
||||||
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user", "tags")
|
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user", "tags").select_related("linked_playlist")
|
||||||
elif show_param == "shared_by_me":
|
elif show_param == "shared_by_me":
|
||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
media = Media.objects.none()
|
media = Media.objects.none()
|
||||||
else:
|
else:
|
||||||
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user", "tags").distinct()
|
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user", "tags").select_related("linked_playlist").distinct()
|
||||||
elif show_param == "shared_with_me":
|
elif show_param == "shared_with_me":
|
||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
media = Media.objects.none()
|
media = Media.objects.none()
|
||||||
else:
|
else:
|
||||||
base_queryset = Media.objects.prefetch_related("user", "tags")
|
base_queryset = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||||
|
|
||||||
# Build OR conditions similar to _get_media_queryset
|
# Build OR conditions similar to _get_media_queryset
|
||||||
conditions = Q(permissions__user=request.user)
|
conditions = Q(permissions__user=request.user)
|
||||||
@@ -188,14 +183,14 @@ class MediaList(APIView):
|
|||||||
user_queryset = User.objects.all()
|
user_queryset = User.objects.all()
|
||||||
user = get_object_or_404(user_queryset, username=author_param)
|
user = get_object_or_404(user_queryset, username=author_param)
|
||||||
if self.request.user == user or is_mediacms_editor(self.request.user):
|
if self.request.user == user or is_mediacms_editor(self.request.user):
|
||||||
media = Media.objects.filter(user=user).prefetch_related("user", "tags")
|
media = Media.objects.filter(user=user).prefetch_related("user", "tags").select_related("linked_playlist")
|
||||||
else:
|
else:
|
||||||
media = self._get_media_queryset(request, user)
|
media = self._get_media_queryset(request, user)
|
||||||
already_sorted = True
|
already_sorted = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if is_mediacms_editor(self.request.user):
|
if is_mediacms_editor(self.request.user):
|
||||||
media = Media.objects.prefetch_related("user", "tags")
|
media = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||||
else:
|
else:
|
||||||
media = self._get_media_queryset(request)
|
media = self._get_media_queryset(request)
|
||||||
already_sorted = True
|
already_sorted = True
|
||||||
@@ -237,14 +232,14 @@ class MediaList(APIView):
|
|||||||
if not already_sorted:
|
if not already_sorted:
|
||||||
media = media.order_by(f"{ordering}{sort_by}")
|
media = media.order_by(f"{ordering}{sort_by}")
|
||||||
|
|
||||||
media = media[:1000] # limit to 1000 results
|
media = media[:1000]
|
||||||
|
|
||||||
paginator = pagination_class()
|
paginator = pagination_class()
|
||||||
|
|
||||||
page = paginator.paginate_queryset(media, request)
|
page = paginator.paginate_queryset(media, request)
|
||||||
|
|
||||||
serializer = MediaSerializer(page, many=True, context={"request": request})
|
serializer = MediaSerializer(page, many=True, context={"request": request})
|
||||||
# Collect all unique tags from the current page results
|
|
||||||
tags_set = set()
|
tags_set = set()
|
||||||
for media_obj in page:
|
for media_obj in page:
|
||||||
for tag in media_obj.tags.all():
|
for tag in media_obj.tags.all():
|
||||||
@@ -354,28 +349,23 @@ class MediaBulkUserActions(APIView):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def post(self, request, format=None):
|
def post(self, request, format=None):
|
||||||
# Check if user is authenticated
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
# Get required parameters
|
|
||||||
media_ids = request.data.get('media_ids', [])
|
media_ids = request.data.get('media_ids', [])
|
||||||
action = request.data.get('action')
|
action = request.data.get('action')
|
||||||
|
|
||||||
# Validate required parameters
|
|
||||||
if not media_ids:
|
if not media_ids:
|
||||||
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if not action:
|
if not action:
|
||||||
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
|
||||||
|
|
||||||
if not media:
|
if not media:
|
||||||
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Process based on action
|
|
||||||
if action == "enable_comments":
|
if action == "enable_comments":
|
||||||
media.update(enable_comments=True)
|
media.update(enable_comments=True)
|
||||||
return Response({"detail": f"Comments enabled for {media.count()} media items"})
|
return Response({"detail": f"Comments enabled for {media.count()} media items"})
|
||||||
@@ -446,12 +436,10 @@ class MediaBulkUserActions(APIView):
|
|||||||
if state not in valid_states:
|
if state not in valid_states:
|
||||||
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
|
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 not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
|
||||||
if state == "public":
|
if state == "public":
|
||||||
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
|
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:
|
for m in media:
|
||||||
m.state = state
|
m.state = state
|
||||||
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
|
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
|
||||||
@@ -495,8 +483,6 @@ class MediaBulkUserActions(APIView):
|
|||||||
if ownership_type not in valid_ownership_types:
|
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)
|
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()
|
media_count = media.count()
|
||||||
|
|
||||||
users = (
|
users = (
|
||||||
@@ -523,7 +509,6 @@ class MediaBulkUserActions(APIView):
|
|||||||
if not usernames:
|
if not usernames:
|
||||||
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
users = User.objects.filter(username__in=usernames)
|
||||||
if not users.exists():
|
if not users.exists():
|
||||||
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@@ -548,22 +533,17 @@ class MediaBulkUserActions(APIView):
|
|||||||
if not usernames:
|
if not usernames:
|
||||||
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
users = User.objects.filter(username__in=usernames)
|
||||||
if not users.exists():
|
if not users.exists():
|
||||||
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
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()
|
MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
|
||||||
|
|
||||||
return Response({"detail": "Action succeeded"})
|
return Response({"detail": "Action succeeded"})
|
||||||
|
|
||||||
elif action == "playlist_membership":
|
elif action == "playlist_membership":
|
||||||
# Find playlists that contain ALL the selected media (intersection)
|
|
||||||
|
|
||||||
media_count = media.count()
|
media_count = media.count()
|
||||||
|
|
||||||
# Query playlists owned by user that contain these media
|
|
||||||
results = list(
|
results = list(
|
||||||
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
|
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
|
||||||
.values('id', 'friendly_token', 'title')
|
.values('id', 'friendly_token', 'title')
|
||||||
@@ -574,21 +554,15 @@ class MediaBulkUserActions(APIView):
|
|||||||
return Response({'results': results})
|
return Response({'results': results})
|
||||||
|
|
||||||
elif action == "category_membership":
|
elif action == "category_membership":
|
||||||
# Find categories that contain ALL the selected media (intersection)
|
|
||||||
|
|
||||||
media_count = media.count()
|
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))
|
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})
|
return Response({'results': results})
|
||||||
|
|
||||||
elif action == "tag_membership":
|
elif action == "tag_membership":
|
||||||
# Find tags that contain ALL the selected media (intersection)
|
|
||||||
|
|
||||||
media_count = media.count()
|
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))
|
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})
|
return Response({'results': results})
|
||||||
@@ -605,7 +579,6 @@ class MediaBulkUserActions(APIView):
|
|||||||
added_count = 0
|
added_count = 0
|
||||||
for category in categories:
|
for category in categories:
|
||||||
for m in media:
|
for m in media:
|
||||||
# Add media to category (ManyToMany relationship)
|
|
||||||
if not m.category.filter(uid=category.uid).exists():
|
if not m.category.filter(uid=category.uid).exists():
|
||||||
m.category.add(category)
|
m.category.add(category)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
@@ -624,7 +597,6 @@ class MediaBulkUserActions(APIView):
|
|||||||
removed_count = 0
|
removed_count = 0
|
||||||
for category in categories:
|
for category in categories:
|
||||||
for m in media:
|
for m in media:
|
||||||
# Remove media from category (ManyToMany relationship)
|
|
||||||
if m.category.filter(uid=category.uid).exists():
|
if m.category.filter(uid=category.uid).exists():
|
||||||
m.category.remove(category)
|
m.category.remove(category)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
@@ -643,7 +615,6 @@ class MediaBulkUserActions(APIView):
|
|||||||
added_count = 0
|
added_count = 0
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
for m in media:
|
for m in media:
|
||||||
# Add media to tag (ManyToMany relationship)
|
|
||||||
if not m.tags.filter(title=tag.title).exists():
|
if not m.tags.filter(title=tag.title).exists():
|
||||||
m.tags.add(tag)
|
m.tags.add(tag)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
@@ -662,7 +633,6 @@ class MediaBulkUserActions(APIView):
|
|||||||
removed_count = 0
|
removed_count = 0
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
for m in media:
|
for m in media:
|
||||||
# Remove media from tag (ManyToMany relationship)
|
|
||||||
if m.tags.filter(title=tag.title).exists():
|
if m.tags.filter(title=tag.title).exists():
|
||||||
m.tags.remove(tag)
|
m.tags.remove(tag)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
@@ -1025,7 +995,7 @@ class MediaSearch(APIView):
|
|||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
if is_mediacms_editor(self.request.user):
|
if is_mediacms_editor(self.request.user):
|
||||||
media = Media.objects.prefetch_related("user", "tags")
|
media = Media.objects.prefetch_related("user", "tags").select_related("linked_playlist")
|
||||||
basic_query = Q()
|
basic_query = Q()
|
||||||
else:
|
else:
|
||||||
basic_query = Q(listable=True) | Q(permissions__user=request.user) | Q(user=request.user)
|
basic_query = Q(listable=True) | Q(permissions__user=request.user) | Q(user=request.user)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||||
|
const [isAudioFile, setIsAudioFile] = useState(false);
|
||||||
|
|
||||||
// Refs for hold-to-continue functionality
|
// Refs for hold-to-continue functionality
|
||||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -41,12 +42,13 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
setVideoUrl(url);
|
setVideoUrl(url);
|
||||||
|
|
||||||
// Check if the media is an audio file and set poster image
|
// Check if the media is an audio file and set poster image
|
||||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
|
setIsAudioFile(audioFile);
|
||||||
|
|
||||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
// 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 mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
// Function to jump 15 seconds backward
|
// Function to jump 15 seconds backward
|
||||||
@@ -128,22 +130,34 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* iOS-optimized Video Element with Native Controls */}
|
{/* Video container with persistent background for audio files */}
|
||||||
<video
|
<div className="ios-video-wrapper">
|
||||||
ref={(ref) => setIosVideoRef(ref)}
|
{/* Persistent background image for audio files (Safari fix) */}
|
||||||
className="w-full rounded-md"
|
{isAudioFile && posterImage && (
|
||||||
src={videoUrl}
|
<div
|
||||||
controls
|
className="ios-audio-poster-background"
|
||||||
playsInline
|
style={{ backgroundImage: `url(${posterImage})` }}
|
||||||
webkit-playsinline="true"
|
aria-hidden="true"
|
||||||
x-webkit-airplay="allow"
|
/>
|
||||||
preload="auto"
|
)}
|
||||||
crossOrigin="anonymous"
|
|
||||||
poster={posterImage}
|
{/* iOS-optimized Video Element with Native Controls */}
|
||||||
>
|
<video
|
||||||
<source src={videoUrl} type="video/mp4" />
|
ref={(ref) => setIosVideoRef(ref)}
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
|
||||||
</video>
|
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 Video Skip Controls */}
|
{/* iOS Video Skip Controls */}
|
||||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||||
|
|||||||
@@ -268,13 +268,8 @@ const TimelineControls = ({
|
|||||||
// Update editing title when selected segment changes
|
// Update editing title when selected segment changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSegment) {
|
if (selectedSegment) {
|
||||||
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
|
// Always show the chapter title in the textarea, whether it's default or custom
|
||||||
const isDefaultChapterName = selectedSegment.chapterTitle &&
|
setEditingChapterTitle(selectedSegment.chapterTitle || '');
|
||||||
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
|
|
||||||
|
|
||||||
// If it's a default name, show empty string so placeholder appears
|
|
||||||
// If it's a custom title, show the actual title
|
|
||||||
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
|
|
||||||
} else {
|
} else {
|
||||||
setEditingChapterTitle('');
|
setEditingChapterTitle('');
|
||||||
}
|
}
|
||||||
@@ -4087,4 +4082,4 @@ const TimelineControls = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TimelineControls;
|
export default TimelineControls;
|
||||||
@@ -353,8 +353,18 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-player-container">
|
<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
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
onClick={handleVideoClick}
|
onClick={handleVideoClick}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const useVideoChapters = () => {
|
|||||||
// Sort by start time to find chronological position
|
// Sort by start time to find chronological position
|
||||||
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
||||||
// Find the index of our new segment
|
// Find the index of our new segment
|
||||||
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
|
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
|
||||||
return `Chapter ${chapterIndex + 1}`;
|
return `Chapter ${chapterIndex + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,12 +28,18 @@ const useVideoChapters = () => {
|
|||||||
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
||||||
// Sort segments by start time
|
// Sort segments by start time
|
||||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Renumber each segment based on its chronological position
|
// Renumber each segment based on its chronological position
|
||||||
return sortedSegments.map((segment, index) => ({
|
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles
|
||||||
...segment,
|
return sortedSegments.map((segment, index) => {
|
||||||
chapterTitle: `Chapter ${index + 1}`
|
const currentTitle = segment.chapterTitle || '';
|
||||||
}));
|
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||||
@@ -124,9 +130,7 @@ const useVideoChapters = () => {
|
|||||||
let initialSegments: Segment[] = [];
|
let initialSegments: Segment[] = [];
|
||||||
|
|
||||||
// Check if we have existing chapters from the backend
|
// Check if we have existing chapters from the backend
|
||||||
const existingChapters =
|
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
|
||||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (existingChapters.length > 0) {
|
if (existingChapters.length > 0) {
|
||||||
// Create segments from existing chapters
|
// Create segments from existing chapters
|
||||||
@@ -150,7 +154,7 @@ const useVideoChapters = () => {
|
|||||||
// Create a default segment that spans the entire video on first load
|
// Create a default segment that spans the entire video on first load
|
||||||
const initialSegment: Segment = {
|
const initialSegment: Segment = {
|
||||||
id: 1,
|
id: 1,
|
||||||
chapterTitle: '',
|
chapterTitle: 'Chapter 1',
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
endTime: video.duration,
|
endTime: video.duration,
|
||||||
};
|
};
|
||||||
@@ -225,7 +229,7 @@ const useVideoChapters = () => {
|
|||||||
logger.debug('Adding Safari-specific event listeners for audio support');
|
logger.debug('Adding Safari-specific event listeners for audio support');
|
||||||
video.addEventListener('canplay', handleCanPlay);
|
video.addEventListener('canplay', handleCanPlay);
|
||||||
video.addEventListener('loadeddata', handleLoadedData);
|
video.addEventListener('loadeddata', handleLoadedData);
|
||||||
|
|
||||||
// Additional timeout fallback for Safari audio files
|
// Additional timeout fallback for Safari audio files
|
||||||
const safariTimeout = setTimeout(() => {
|
const safariTimeout = setTimeout(() => {
|
||||||
if (video.duration && duration === 0) {
|
if (video.duration && duration === 0) {
|
||||||
@@ -261,21 +265,21 @@ const useVideoChapters = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSafari() && videoRef.current) {
|
if (isSafari() && videoRef.current) {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|
||||||
const initializeSafariOnInteraction = () => {
|
const initializeSafariOnInteraction = () => {
|
||||||
// Try to load video metadata by attempting to play and immediately pause
|
// Try to load video metadata by attempting to play and immediately pause
|
||||||
const attemptInitialization = async () => {
|
const attemptInitialization = async () => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
||||||
|
|
||||||
// Briefly play to trigger metadata loading, then pause
|
// Briefly play to trigger metadata loading, then pause
|
||||||
await video.play();
|
await video.play();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
// Check if we now have duration and initialize if needed
|
// Check if we now have duration and initialize if needed
|
||||||
if (video.duration > 0 && clipSegments.length === 0) {
|
if (video.duration > 0 && clipSegments.length === 0) {
|
||||||
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
||||||
|
|
||||||
const defaultSegment: Segment = {
|
const defaultSegment: Segment = {
|
||||||
id: 1,
|
id: 1,
|
||||||
chapterTitle: '',
|
chapterTitle: '',
|
||||||
@@ -286,14 +290,14 @@ const useVideoChapters = () => {
|
|||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
setClipSegments([defaultSegment]);
|
setClipSegments([defaultSegment]);
|
||||||
|
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: video.duration,
|
trimEnd: video.duration,
|
||||||
splitPoints: [],
|
splitPoints: [],
|
||||||
clipSegments: [defaultSegment],
|
clipSegments: [defaultSegment],
|
||||||
};
|
};
|
||||||
|
|
||||||
setHistory([initialState]);
|
setHistory([initialState]);
|
||||||
setHistoryPosition(0);
|
setHistoryPosition(0);
|
||||||
}
|
}
|
||||||
@@ -315,7 +319,7 @@ const useVideoChapters = () => {
|
|||||||
// Add listeners for various user interactions
|
// Add listeners for various user interactions
|
||||||
document.addEventListener('click', handleUserInteraction);
|
document.addEventListener('click', handleUserInteraction);
|
||||||
document.addEventListener('keydown', handleUserInteraction);
|
document.addEventListener('keydown', handleUserInteraction);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleUserInteraction);
|
document.removeEventListener('click', handleUserInteraction);
|
||||||
document.removeEventListener('keydown', handleUserInteraction);
|
document.removeEventListener('keydown', handleUserInteraction);
|
||||||
@@ -332,7 +336,7 @@ const useVideoChapters = () => {
|
|||||||
// This play/pause will trigger metadata loading in Safari
|
// This play/pause will trigger metadata loading in Safari
|
||||||
await video.play();
|
await video.play();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
// The metadata events should fire now and initialize segments
|
// The metadata events should fire now and initialize segments
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -564,8 +568,11 @@ const useVideoChapters = () => {
|
|||||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Renumber all segments to ensure proper chronological naming
|
||||||
|
const renumberedSegments = renumberAllSegments(e.detail.segments);
|
||||||
|
|
||||||
// Update segment state immediately for UI feedback
|
// Update segment state immediately for UI feedback
|
||||||
setClipSegments(e.detail.segments);
|
setClipSegments(renumberedSegments);
|
||||||
|
|
||||||
// Always save state to history for non-intermediate actions
|
// Always save state to history for non-intermediate actions
|
||||||
if (isSignificantChange) {
|
if (isSignificantChange) {
|
||||||
@@ -573,7 +580,7 @@ const useVideoChapters = () => {
|
|||||||
// ensure we capture the state properly
|
// ensure we capture the state properly
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Deep clone to ensure state is captured correctly
|
// Deep clone to ensure state is captured correctly
|
||||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
|
||||||
|
|
||||||
// Create a complete state snapshot
|
// Create a complete state snapshot
|
||||||
const stateWithAction: EditorState = {
|
const stateWithAction: EditorState = {
|
||||||
@@ -919,10 +926,10 @@ const useVideoChapters = () => {
|
|||||||
const singleChapter = backendChapters[0];
|
const singleChapter = backendChapters[0];
|
||||||
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
|
||||||
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
|
||||||
|
|
||||||
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
|
||||||
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
|
||||||
|
|
||||||
if (isFullVideoChapter) {
|
if (isFullVideoChapter) {
|
||||||
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
logger.debug('Manual save: Single chapter spans full video - sending empty array');
|
||||||
backendChapters = [];
|
backendChapters = [];
|
||||||
|
|||||||
@@ -8,12 +8,40 @@
|
|||||||
overflow: hidden;
|
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 {
|
.ios-video-player-container video {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
background-color: black;
|
}
|
||||||
|
|
||||||
|
/* Make video transparent only for audio files with poster so background shows through */
|
||||||
|
.ios-video-player-container video.audio-with-poster {
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-time-display {
|
.ios-time-display {
|
||||||
|
|||||||
@@ -76,10 +76,26 @@
|
|||||||
user-select: none;
|
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 {
|
.video-player-container video {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
/* Force hardware acceleration */
|
/* Force hardware acceleration */
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
-webkit-transform: translateZ(0);
|
-webkit-transform: translateZ(0);
|
||||||
@@ -88,6 +104,11 @@
|
|||||||
user-select: none;
|
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 */
|
/* iOS-specific styles */
|
||||||
@supports (-webkit-touch-callout: none) {
|
@supports (-webkit-touch-callout: none) {
|
||||||
.video-player-container video {
|
.video-player-container video {
|
||||||
@@ -109,6 +130,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container:hover .play-pause-indicator {
|
.video-player-container:hover .play-pause-indicator {
|
||||||
@@ -187,6 +209,7 @@
|
|||||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container:hover .video-controls {
|
.video-player-container:hover .video-controls {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||||
|
const [isAudioFile, setIsAudioFile] = useState(false);
|
||||||
|
|
||||||
// Refs for hold-to-continue functionality
|
// Refs for hold-to-continue functionality
|
||||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -41,12 +42,13 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
setVideoUrl(url);
|
setVideoUrl(url);
|
||||||
|
|
||||||
// Check if the media is an audio file and set poster image
|
// Check if the media is an audio file and set poster image
|
||||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
|
setIsAudioFile(audioFile);
|
||||||
|
|
||||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
// 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 mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
// Function to jump 15 seconds backward
|
// Function to jump 15 seconds backward
|
||||||
@@ -128,22 +130,34 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* iOS-optimized Video Element with Native Controls */}
|
{/* Video container with persistent background for audio files */}
|
||||||
<video
|
<div className="ios-video-wrapper">
|
||||||
ref={(ref) => setIosVideoRef(ref)}
|
{/* Persistent background image for audio files (Safari fix) */}
|
||||||
className="w-full rounded-md"
|
{isAudioFile && posterImage && (
|
||||||
src={videoUrl}
|
<div
|
||||||
controls
|
className="ios-audio-poster-background"
|
||||||
playsInline
|
style={{ backgroundImage: `url(${posterImage})` }}
|
||||||
webkit-playsinline="true"
|
aria-hidden="true"
|
||||||
x-webkit-airplay="allow"
|
/>
|
||||||
preload="auto"
|
)}
|
||||||
crossOrigin="anonymous"
|
|
||||||
poster={posterImage}
|
{/* iOS-optimized Video Element with Native Controls */}
|
||||||
>
|
<video
|
||||||
<source src={videoUrl} type="video/mp4" />
|
ref={(ref) => setIosVideoRef(ref)}
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
|
||||||
</video>
|
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 Video Skip Controls */}
|
{/* iOS Video Skip Controls */}
|
||||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||||
|
|||||||
@@ -353,8 +353,18 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-player-container">
|
<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
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
onClick={handleVideoClick}
|
onClick={handleVideoClick}
|
||||||
|
|||||||
@@ -8,12 +8,40 @@
|
|||||||
overflow: hidden;
|
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 {
|
.ios-video-player-container video {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
background-color: black;
|
}
|
||||||
|
|
||||||
|
/* Make video transparent only for audio files with poster so background shows through */
|
||||||
|
.ios-video-player-container video.audio-with-poster {
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-time-display {
|
.ios-time-display {
|
||||||
|
|||||||
@@ -76,10 +76,26 @@
|
|||||||
user-select: none;
|
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 {
|
.video-player-container video {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
/* Force hardware acceleration */
|
/* Force hardware acceleration */
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
-webkit-transform: translateZ(0);
|
-webkit-transform: translateZ(0);
|
||||||
@@ -88,6 +104,11 @@
|
|||||||
user-select: none;
|
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 */
|
/* iOS-specific styles */
|
||||||
@supports (-webkit-touch-callout: none) {
|
@supports (-webkit-touch-callout: none) {
|
||||||
.video-player-container video {
|
.video-player-container video {
|
||||||
@@ -109,6 +130,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container:hover .play-pause-indicator {
|
.video-player-container:hover .play-pause-indicator {
|
||||||
@@ -187,6 +209,7 @@
|
|||||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container:hover .video-controls {
|
.video-player-container:hover .video-controls {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CustomChaptersOverlay extends Component {
|
|||||||
this.touchStartTime = 0;
|
this.touchStartTime = 0;
|
||||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||||
this.isSmallScreen = window.innerWidth <= 480;
|
this.isSmallScreen = window.innerWidth <= 480;
|
||||||
|
this.scrollY = 0; // Track scroll position before locking
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.createOverlay = this.createOverlay.bind(this);
|
this.createOverlay = this.createOverlay.bind(this);
|
||||||
@@ -31,6 +32,8 @@ class CustomChaptersOverlay extends Component {
|
|||||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||||
this.handleResize = this.handleResize.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
|
// Initialize after player is ready
|
||||||
this.player().ready(() => {
|
this.player().ready(() => {
|
||||||
@@ -65,6 +68,9 @@ class CustomChaptersOverlay extends Component {
|
|||||||
|
|
||||||
const el = this.player().el();
|
const el = this.player().el();
|
||||||
if (el) el.classList.remove('chapters-open');
|
if (el) el.classList.remove('chapters-open');
|
||||||
|
|
||||||
|
// Restore body scroll on mobile when closing
|
||||||
|
this.unlockBodyScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupResizeListener() {
|
setupResizeListener() {
|
||||||
@@ -164,6 +170,8 @@ class CustomChaptersOverlay extends Component {
|
|||||||
this.overlay.style.display = 'none';
|
this.overlay.style.display = 'none';
|
||||||
const el = this.player().el();
|
const el = this.player().el();
|
||||||
if (el) el.classList.remove('chapters-open');
|
if (el) el.classList.remove('chapters-open');
|
||||||
|
// Restore body scroll on mobile when closing
|
||||||
|
this.unlockBodyScroll();
|
||||||
};
|
};
|
||||||
chapterClose.appendChild(closeBtn);
|
chapterClose.appendChild(closeBtn);
|
||||||
playlistTitle.appendChild(chapterClose);
|
playlistTitle.appendChild(chapterClose);
|
||||||
@@ -355,6 +363,37 @@ 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() {
|
toggleOverlay() {
|
||||||
if (!this.overlay) return;
|
if (!this.overlay) return;
|
||||||
|
|
||||||
@@ -369,17 +408,11 @@ class CustomChaptersOverlay extends Component {
|
|||||||
navigator.vibrate(30);
|
navigator.vibrate(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent body scroll on mobile when overlay is open
|
// Lock/unlock body scroll on mobile when overlay opens/closes
|
||||||
if (this.isMobile) {
|
if (isHidden) {
|
||||||
if (isHidden) {
|
this.lockBodyScroll();
|
||||||
document.body.style.overflow = 'hidden';
|
} else {
|
||||||
document.body.style.position = 'fixed';
|
this.unlockBodyScroll();
|
||||||
document.body.style.width = '100%';
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.body.style.position = '';
|
|
||||||
document.body.style.width = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -390,7 +423,9 @@ class CustomChaptersOverlay extends Component {
|
|||||||
m.classList.remove('vjs-lock-showing');
|
m.classList.remove('vjs-lock-showing');
|
||||||
m.style.display = 'none';
|
m.style.display = 'none';
|
||||||
});
|
});
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// Ignore errors when closing menus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCurrentChapter() {
|
updateCurrentChapter() {
|
||||||
@@ -406,7 +441,6 @@ class CustomChaptersOverlay extends Component {
|
|||||||
currentTime >= chapter.startTime &&
|
currentTime >= chapter.startTime &&
|
||||||
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].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');
|
const dynamic = item.querySelector('.meta-dynamic');
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
currentChapterIndex = index;
|
currentChapterIndex = index;
|
||||||
@@ -463,11 +497,7 @@ class CustomChaptersOverlay extends Component {
|
|||||||
if (el) el.classList.remove('chapters-open');
|
if (el) el.classList.remove('chapters-open');
|
||||||
|
|
||||||
// Restore body scroll on mobile
|
// Restore body scroll on mobile
|
||||||
if (this.isMobile) {
|
this.unlockBodyScroll();
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.body.style.position = '';
|
|
||||||
document.body.style.width = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,11 +509,7 @@ class CustomChaptersOverlay extends Component {
|
|||||||
if (el) el.classList.remove('chapters-open');
|
if (el) el.classList.remove('chapters-open');
|
||||||
|
|
||||||
// Restore body scroll on mobile when disposing
|
// Restore body scroll on mobile when disposing
|
||||||
if (this.isMobile) {
|
this.unlockBodyScroll();
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.body.style.position = '';
|
|
||||||
document.body.style.width = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up event listeners
|
// Clean up event listeners
|
||||||
if (this.handleResize) {
|
if (this.handleResize) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class CustomSettingsMenu extends Component {
|
|||||||
this.isMobile = this.detectMobile();
|
this.isMobile = this.detectMobile();
|
||||||
this.isSmallScreen = window.innerWidth <= 480;
|
this.isSmallScreen = window.innerWidth <= 480;
|
||||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||||
|
this.scrollY = 0; // Track scroll position before locking
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.createSettingsButton = this.createSettingsButton.bind(this);
|
this.createSettingsButton = this.createSettingsButton.bind(this);
|
||||||
@@ -41,6 +42,8 @@ class CustomSettingsMenu extends Component {
|
|||||||
this.detectMobile = this.detectMobile.bind(this);
|
this.detectMobile = this.detectMobile.bind(this);
|
||||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||||
this.setupResizeListener = this.setupResizeListener.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
|
// Initialize after player is ready
|
||||||
this.player().ready(() => {
|
this.player().ready(() => {
|
||||||
@@ -656,6 +659,8 @@ class CustomSettingsMenu extends Component {
|
|||||||
if (btnEl) {
|
if (btnEl) {
|
||||||
btnEl.classList.remove('settings-clicked');
|
btnEl.classList.remove('settings-clicked');
|
||||||
}
|
}
|
||||||
|
// Restore body scroll on mobile when closing
|
||||||
|
this.unlockBodyScroll();
|
||||||
};
|
};
|
||||||
|
|
||||||
closeButton.addEventListener('click', closeFunction);
|
closeButton.addEventListener('click', closeFunction);
|
||||||
@@ -942,6 +947,37 @@ class CustomSettingsMenu extends Component {
|
|||||||
this.startSubtitleSync();
|
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) {
|
toggleSettings(e) {
|
||||||
// e.stopPropagation();
|
// e.stopPropagation();
|
||||||
const isVisible = this.settingsOverlay.classList.contains('show');
|
const isVisible = this.settingsOverlay.classList.contains('show');
|
||||||
@@ -954,11 +990,7 @@ class CustomSettingsMenu extends Component {
|
|||||||
this.stopKeepingControlsVisible();
|
this.stopKeepingControlsVisible();
|
||||||
|
|
||||||
// Restore body scroll on mobile when closing
|
// Restore body scroll on mobile when closing
|
||||||
if (this.isMobile) {
|
this.unlockBodyScroll();
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.body.style.position = '';
|
|
||||||
document.body.style.width = '';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.settingsOverlay.classList.add('show');
|
this.settingsOverlay.classList.add('show');
|
||||||
this.settingsOverlay.style.display = 'block';
|
this.settingsOverlay.style.display = 'block';
|
||||||
@@ -972,11 +1004,7 @@ class CustomSettingsMenu extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prevent body scroll on mobile when overlay is open
|
// Prevent body scroll on mobile when overlay is open
|
||||||
if (this.isMobile) {
|
this.lockBodyScroll();
|
||||||
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
|
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles
|
||||||
@@ -1002,6 +1030,9 @@ class CustomSettingsMenu extends Component {
|
|||||||
this.settingsOverlay.classList.add('show');
|
this.settingsOverlay.classList.add('show');
|
||||||
this.settingsOverlay.style.display = 'block';
|
this.settingsOverlay.style.display = 'block';
|
||||||
|
|
||||||
|
// Lock body scroll when opening
|
||||||
|
this.lockBodyScroll();
|
||||||
|
|
||||||
// Hide other submenus and show subtitles submenu
|
// Hide other submenus and show subtitles submenu
|
||||||
this.speedSubmenu.style.display = 'none';
|
this.speedSubmenu.style.display = 'none';
|
||||||
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
|
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
|
||||||
@@ -1072,11 +1103,7 @@ class CustomSettingsMenu extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore body scroll on mobile when closing
|
// Restore body scroll on mobile when closing
|
||||||
if (this.isMobile) {
|
this.unlockBodyScroll();
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.body.style.position = '';
|
|
||||||
document.body.style.width = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,6 +1444,8 @@ class CustomSettingsMenu extends Component {
|
|||||||
if (btnEl) {
|
if (btnEl) {
|
||||||
btnEl.classList.remove('settings-clicked');
|
btnEl.classList.remove('settings-clicked');
|
||||||
}
|
}
|
||||||
|
// Restore body scroll on mobile when closing
|
||||||
|
this.unlockBodyScroll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1493,11 +1522,7 @@ class CustomSettingsMenu extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore body scroll on mobile when disposing
|
// Restore body scroll on mobile when disposing
|
||||||
if (this.isMobile) {
|
this.unlockBodyScroll();
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.body.style.position = '';
|
|
||||||
document.body.style.width = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove DOM elements
|
// Remove DOM elements
|
||||||
if (this.settingsOverlay) {
|
if (this.settingsOverlay) {
|
||||||
|
|||||||
@@ -26,17 +26,12 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
|||||||
csrfToken,
|
csrfToken,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedState, setSelectedState] = useState('public');
|
const [selectedState, setSelectedState] = useState('public');
|
||||||
const [initialState, setInitialState] = useState('public');
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
// Reset state when modal closes
|
// Reset state when modal closes
|
||||||
setSelectedState('public');
|
setSelectedState('public');
|
||||||
setInitialState('public');
|
|
||||||
} else {
|
|
||||||
// When modal opens, set initial state
|
|
||||||
setInitialState('public');
|
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -79,7 +74,9 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const hasStateChanged = selectedState !== initialState;
|
// 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.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="publish-state-modal-overlay">
|
<div className="publish-state-modal-overlay">
|
||||||
@@ -116,7 +113,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
|||||||
<button
|
<button
|
||||||
className="publish-state-btn publish-state-btn-submit"
|
className="publish-state-btn publish-state-btn-submit"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isProcessing || !hasStateChanged}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? translateString('Processing...') : translateString('Submit')}
|
{isProcessing ? translateString('Processing...') : translateString('Submit')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ a.item-thumb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item.playlist-item & {
|
||||||
|
&:before {
|
||||||
|
content: '\e05f'; // Material icon: playlist_play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.item.category-item & {
|
.item.category-item & {
|
||||||
&:before {
|
&:before {
|
||||||
content: '\e892';
|
content: '\e892';
|
||||||
|
|||||||
@@ -31,14 +31,11 @@ export function PlaylistItem(props) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
style={!thumbnailUrl ? null : { backgroundImage: "url('" + thumbnailUrl + "')" }}
|
||||||
>
|
>
|
||||||
<div className="playlist-count">
|
{!thumbnailUrl ? null : (
|
||||||
<div>
|
<div key="item-type-icon" className="item-type-icon">
|
||||||
<div>
|
<div></div>
|
||||||
<span>{props.media_count}</span>
|
|
||||||
<i className="material-icons">playlist_play</i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="playlist-hover-play-all">
|
<div className="playlist-hover-play-all">
|
||||||
<div>
|
<div>
|
||||||
@@ -53,9 +50,6 @@ export function PlaylistItem(props) {
|
|||||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||||
{titleComponent()}
|
{titleComponent()}
|
||||||
{metaComponents()}
|
{metaComponents()}
|
||||||
<a href={props.link} title="" className="view-full-playlist">
|
|
||||||
VIEW FULL PLAYLIST
|
|
||||||
</a>
|
|
||||||
</UnderThumbWrapper>
|
</UnderThumbWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ function downloadOptionsList() {
|
|||||||
for (g in encodings_info[k]) {
|
for (g in encodings_info[k]) {
|
||||||
if (encodings_info[k].hasOwnProperty(g)) {
|
if (encodings_info[k].hasOwnProperty(g)) {
|
||||||
if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) {
|
if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) {
|
||||||
|
// Use original media URL for download instead of encoded version
|
||||||
|
const originalUrl = media_data.original_media_url;
|
||||||
|
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
|
||||||
|
|
||||||
optionsList[encodings_info[k][g].title] = {
|
optionsList[encodings_info[k][g].title] = {
|
||||||
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')',
|
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')',
|
||||||
link: formatInnerLink(encodings_info[k][g].url, SiteContext._currentValue.url),
|
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
||||||
linkAttr: {
|
linkAttr: {
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
download: media_data.title + '_' + k + '_' + g.toUpperCase(),
|
download: originalFilename,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -36,12 +40,16 @@ function downloadOptionsList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract actual filename from the original media URL
|
||||||
|
const originalUrl = media_data.original_media_url;
|
||||||
|
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
|
||||||
|
|
||||||
optionsList.original_media_url = {
|
optionsList.original_media_url = {
|
||||||
text: 'Original file (' + media_data.size + ')',
|
text: 'Original file (' + media_data.size + ')',
|
||||||
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
||||||
linkAttr: {
|
linkAttr: {
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
download: media_data.title,
|
download: originalFilename,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
|||||||
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
|
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Extract actual filename from URL for non-video downloads
|
||||||
|
const originalUrl = MediaPageStore.get('media-original-url');
|
||||||
|
this.downloadFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : this.props.title;
|
||||||
|
|
||||||
this.updateStateValues = this.updateStateValues.bind(this);
|
this.updateStateValues = this.updateStateValues.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +175,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
|||||||
.downloadLink ? (
|
.downloadLink ? (
|
||||||
<VideoMediaDownloadLink />
|
<VideoMediaDownloadLink />
|
||||||
) : (
|
) : (
|
||||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
|
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
|||||||
.downloadLink ? (
|
.downloadLink ? (
|
||||||
<VideoMediaDownloadLink />
|
<VideoMediaDownloadLink />
|
||||||
) : (
|
) : (
|
||||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
|
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||||
|
|||||||
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useBulkActions } from '../hooks/useBulkActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-Order Component that provides bulk actions functionality
|
||||||
|
* to class components via props
|
||||||
|
*/
|
||||||
|
export function withBulkActions(WrappedComponent) {
|
||||||
|
return function WithBulkActionsComponent(props) {
|
||||||
|
const bulkActions = useBulkActions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WrappedComponent
|
||||||
|
{...props}
|
||||||
|
bulkActions={bulkActions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
BIN
media_files/userlogos/poster_audio.jpg
Normal file
BIN
media_files/userlogos/poster_audio.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
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
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