diff --git a/.gitignore b/.gitignore index 96daeeee4..56b142266 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,6 @@ tmp # local python version/venv .python-version + +# AI artifact files +CLAUDE.md \ No newline at end of file diff --git a/cds/config.py b/cds/config.py index 66b337d84..361b1a0d4 100644 --- a/cds/config.py +++ b/cds/config.py @@ -1297,7 +1297,6 @@ def _parse_env_bool(var_name, default=None): # Licence key and base URL for THEO player THEOPLAYER_LIBRARY_LOCATION = None THEOPLAYER_LICENSE = None - # Wowza server URL for m3u8 playlist generation WOWZA_PLAYLIST_URL = ( "https://wowza.cern.ch/cds/_definist_/smil:" "{filepath}/playlist.m3u8" @@ -1569,7 +1568,7 @@ def _parse_env_bool(var_name, default=None): # The number of max videos per project. It blocks the upload of new videos in a # project only client side -DEPOSIT_PROJECT_MAX_N_VIDEOS = 10 +DEPOSIT_PROJECT_MAX_N_VIDEOS = 20 ############################################################################### # Keywords diff --git a/cds/modules/deposit/api.py b/cds/modules/deposit/api.py index 5cc9bed37..380ec7d87 100644 --- a/cds/modules/deposit/api.py +++ b/cds/modules/deposit/api.py @@ -76,7 +76,7 @@ ) from ..records.minters import cds_doi_generator, is_local_doi, report_number_minter from ..records.resolver import record_resolver -from ..records.utils import is_record, lowercase_value +from ..records.utils import is_record, lowercase_value, parse_video_chapters from ..records.validators import PartialDraft4Validator from ..records.permissions import is_public from .errors import DiscardConflict @@ -504,7 +504,7 @@ def create(cls, data, id_=None, **kwargs): data.setdefault("_access", {}) access_update = data["_access"].setdefault("update", []) try: - if current_user.email not in access_update: + if current_user.email not in access_update: # Add the current user to the ``_access.update`` list access_update.append(current_user.email) except AttributeError: @@ -905,11 +905,95 @@ def _publish_edited(self): return super(Video, self)._publish_edited() + def _has_chapters_changed(self, old_record=None): + """Check if chapters in description have changed.""" + current_description = self.get("description", "") + current_chapters = parse_video_chapters(current_description) + + if old_record is None: + # First publish - trigger if chapters exist + return len(current_chapters) > 0 + + old_description = old_record.get("description", "") + old_chapters = parse_video_chapters(old_description) + + # Compare chapter timestamps and titles + if len(current_chapters) != len(old_chapters): + return True + + for curr, old in zip(current_chapters, old_chapters): + if curr["seconds"] != old["seconds"] or curr["title"] != old["title"]: + return True + + return False + + def _trigger_chapter_frame_extraction(self): + """Trigger chapter frame extraction asynchronously for existing video files.""" + try: + from ..flows.tasks import ExtractChapterFramesTask + from ..flows.models import FlowMetadata + + # Find the master video file + master_file = CDSVideosFilesIterator.get_master_video_file(self) + + if master_file is None: + current_app.logger.warning( + f"No master video file found for video {self.id}" + ) + return + + # Get the current flow for this deposit + current_flow = FlowMetadata.get_by_deposit(self["_deposit"]["id"]) + if current_flow is None: + current_app.logger.warning( + f"No current flow found for video {self.id}. Cannot trigger chapter frame extraction." + ) + return + + current_app.logger.info( + f"Triggering asynchronous ExtractChapterFramesTask for video {self.id} with flow {current_flow.id}" + ) + + # Prepare the payload for the async task with correct parameter names + payload = { + "deposit_id": str(self["_deposit"]["id"]), + "version_id": master_file["version_id"], # Keep as UUID, don't convert to string + "flow_id": str(current_flow.id), + "key": master_file["key"], + } + + current_app.logger.info(f"Submitting ExtractChapterFramesTask with payload: {payload}") + + # Submit the chapter frame extraction task asynchronously + ExtractChapterFramesTask.create_flow_tasks(payload) + task_result = ExtractChapterFramesTask().s(**payload).apply_async() + + current_app.logger.info( + f"ExtractChapterFramesTask submitted asynchronously for video {self.id}, flow_id: {current_flow.id}, task_id: {task_result.id}" + ) + + except Exception as e: + current_app.logger.error( + f"Failed to trigger async chapter frame extraction for video {self.id}: {e}" + ) + import traceback + + current_app.logger.error(f"Traceback: {traceback.format_exc()}") + @mark_as_action def publish(self, pid=None, id_=None, **kwargs): """Publish a video and update the related project.""" # save a copy of the old PID video_old_id = self["_deposit"]["id"] + + # Check if this is a republish and get the old record + old_record = None + try: + _, old_record = self.fetch_published() + except: + # First publish + pass + try: self["category"] = self.project["category"] self["type"] = self.project["type"] @@ -926,6 +1010,13 @@ def publish(self, pid=None, id_=None, **kwargs): # generate extra tags for files self._create_tags() + # Check if chapters have changed and trigger frame extraction if needed + if self._has_chapters_changed(old_record): + current_app.logger.info( + f"Chapters changed for video {self.id}, triggering frame extraction" + ) + self._trigger_chapter_frame_extraction() + # publish the video video_published = super(Video, self).publish(pid=pid, id_=id_, **kwargs) _, record_new = self.fetch_published() @@ -1088,7 +1179,6 @@ def _create_tags(self): except IndexError: return - def mint_doi(self): """Mint DOI.""" assert self.has_record() @@ -1109,7 +1199,7 @@ def mint_doi(self): status=PIDStatus.RESERVED, ) return self - + project_resolver = Resolver( pid_type="depid", diff --git a/cds/modules/deposit/receivers.py b/cds/modules/deposit/receivers.py index 18727fec7..d5949a39d 100644 --- a/cds/modules/deposit/receivers.py +++ b/cds/modules/deposit/receivers.py @@ -33,6 +33,7 @@ from cds.modules.flows.tasks import ( DownloadTask, ExtractFramesTask, + ExtractChapterFramesTask, ExtractMetadataTask, TranscodeVideoTask, ) @@ -87,4 +88,5 @@ def register_celery_class_based_tasks(sender, app=None): celery.register_task(ExtractMetadataTask()) celery.register_task(DownloadTask()) celery.register_task(ExtractFramesTask()) + celery.register_task(ExtractChapterFramesTask()) celery.register_task(TranscodeVideoTask()) diff --git a/cds/modules/deposit/static/json/cds_deposit/forms/video.json b/cds/modules/deposit/static/json/cds_deposit/forms/video.json index 981de0f3e..9ca4655ec 100644 --- a/cds/modules/deposit/static/json/cds_deposit/forms/video.json +++ b/cds/modules/deposit/static/json/cds_deposit/forms/video.json @@ -567,17 +567,127 @@ ], "related_links": [ { - "key": "related_links", + "key": "related_identifiers", "type": "array", - "add": "Add related links", + "add": "Add related identifiers", + "title": "Related Identifiers", + "description": "Add identifiers for related resources such as DOIs, URLs, or Indico event IDs.", "items": [ { - "title": "Name", - "key": "related_links[].name" + "title": "Identifier", + "key": "related_identifiers[].identifier", + "required": true, + "placeholder": "e.g., 10.1234/example.doi, https://example.com, 12345", + "description": "The identifier value (DOI, URL, or Indico event ID)" }, { - "title": "URL", - "key": "related_links[].url" + "title": "Scheme", + "key": "related_identifiers[].scheme", + "type": "select", + "required": true, + "placeholder": "Select identifier scheme", + "description": "The type of identifier scheme", + "titleMap": [ + { + "value": "URL", + "name": "URL (Uniform Resource Locator)" + }, + { + "value": "DOI", + "name": "DOI (Digital Object Identifier)" + }, + { + "value": "Indico", + "name": "Indico (Event ID)" + } + ] + }, + { + "title": "Relation Type", + "key": "related_identifiers[].relation_type", + "type": "select", + "required": true, + "placeholder": "Select relation type", + "description": "How this resource relates to the identified resource", + "titleMap": [ + { + "value": "IsPartOf", + "name": "Is part of" + }, + { + "value": "IsVariantFormOf", + "name": "Is variant form of" + } + ] + }, + { + "title": "Resource Type", + "key": "related_identifiers[].resource_type", + "type": "select", + "placeholder": "Select resource type (optional)", + "description": "The type of the related resource (optional)", + "titleMap": [ + { + "value": "Audiovisual", + "name": "Audiovisual" + }, + { + "value": "Collection", + "name": "Collection" + }, + { + "value": "DataPaper", + "name": "Data Paper" + }, + { + "value": "Dataset", + "name": "Dataset" + }, + { + "value": "Event", + "name": "Event" + }, + { + "value": "Image", + "name": "Image" + }, + { + "value": "InteractiveResource", + "name": "Interactive Resource" + }, + { + "value": "Model", + "name": "Model" + }, + { + "value": "PhysicalObject", + "name": "Physical Object" + }, + { + "value": "Service", + "name": "Service" + }, + { + "value": "Software", + "name": "Software" + }, + { + "value": "Sound", + "name": "Sound" + }, + { + "value": "Text", + "name": "Text" + }, + { + "value": "Workflow", + "name": "Workflow" + }, + { + "value": "Other", + "name": "Other" + } + ] } ] } diff --git a/cds/modules/deposit/static/templates/cds_deposit/angular-schema-form/array.html b/cds/modules/deposit/static/templates/cds_deposit/angular-schema-form/array.html index 09899ddee..5b7b88d3e 100755 --- a/cds/modules/deposit/static/templates/cds_deposit/angular-schema-form/array.html +++ b/cds/modules/deposit/static/templates/cds_deposit/angular-schema-form/array.html @@ -16,7 +16,7 @@
  • + ng-repeat="item in modelArray track by (item.id || item.uuid || $index)"> -
    - {{form.firstItemMessage}} +
    + {{::form.firstItemMessage}}
    + ng-repeat="item in modelArray track by (item.id || item.uuid || $index)"> -
    - {{form.firstItemMessage}} +
    + {{::form.firstItemMessage}}
    Click here to select videos to upload
      -
    • - {{ task | taskRepr }} - +
    • + {{ ::task | taskRepr }} +
    @@ -89,7 +89,7 @@

    Click here to select videos to upload

    Please notice that the transcoding platform at CERN is provided by the Webcast service and it is not managed by the CDS service.

    -
    +
    @@ -110,53 +110,16 @@

    Click here to select videos to upload

    --> - - -
    - -
    -
    - - Video #{{$index + 1}} (of {{$ctrl.maxNumberOfVideos}}) {{child.report_number[0]}} Published - - - - - -
    -
    - - - - - - - -
    -
    -
    -
    - - + + + + + + +

    Everything is published. @@ -209,6 +172,7 @@

    Click here to select more videos to upload

    +

    Multiple deposits with the same filename are not allowed: diff --git a/cds/modules/deposit/static/templates/cds_deposit/types/video/form.html b/cds/modules/deposit/static/templates/cds_deposit/types/video/form.html index 3e3fe027c..a100824c2 100644 --- a/cds/modules/deposit/static/templates/cds_deposit/types/video/form.html +++ b/cds/modules/deposit/static/templates/cds_deposit/types/video/form.html @@ -49,8 +49,8 @@

      -
    • {{$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.category}}
    • -
    • {{$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.type}}
    • +
    • {{::$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.category}}
    • +
    • {{::$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.type}}
    • Published

    @@ -117,11 +117,11 @@

    Category

    -

    {{$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.category}}

    +

    {{::$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.category}}

    Type

    -

    {{$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.type}}

    +

    {{::$ctrl.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.type}}


    @@ -151,10 +151,10 @@

    The video is not published.

      -
    • +
    • - {{ task | taskRepr }} - + {{ ::task | taskRepr }} +
    @@ -206,7 +206,7 @@

  • - Related links + Related information
  • @@ -217,7 +217,7 @@

    -
    +
    Published video restrictions

    -
    +
    Published video restrictions sf-options="$ctrl.sfOptions" >
    -
    +
    Tips and suggestions
    -
    +
    Tips and suggestions sf-options="$ctrl.sfOptions" >
    -
    +
    See JSON diff --git a/cds/modules/deposit/templates/cds_deposit/edit.html b/cds/modules/deposit/templates/cds_deposit/edit.html index aec1e1b52..5d8d4eaa6 100644 --- a/cds/modules/deposit/templates/cds_deposit/edit.html +++ b/cds/modules/deposit/templates/cds_deposit/edit.html @@ -1,6 +1,7 @@ {%- extends "cds_theme/page.html" %} {%- set title_prefix = record.get('title', {}).get('title', 'Project') -%} {%- set container_class = '{{container_class}} cds-max-fluid-width' -%} +{%- set body_css_classes = ['flex-container', 'bootstrap-padding-top', 'cds-deposit-edit-page'] -%} {%- block css %} {{ super() }} diff --git a/cds/modules/flows/tasks.py b/cds/modules/flows/tasks.py index eaa97952a..833fe4976 100644 --- a/cds/modules/flows/tasks.py +++ b/cds/modules/flows/tasks.py @@ -62,7 +62,7 @@ from ..opencast.api import OpenCast from ..opencast.error import RequestError from ..opencast.utils import get_qualities -from ..records.utils import to_string +from ..records.utils import to_string, parse_video_chapters from ..xrootd.utils import file_opener_xrootd from .deposit import index_deposit_project from .files import dispose_object_version, move_file_into_local @@ -197,7 +197,9 @@ def _meta_exception_envelope(self, exc): NOTE: workaround to be able to save the payload in celery in case of exceptions. """ - meta = dict(message=str(exc), payload=self._base_payload) + # Safety check in case base payload is not set yet + payload = getattr(self, '_base_payload', {}) + meta = dict(message=str(exc), payload=payload) return dict(exc_message=meta, exc_type=exc.__class__.__name__) def on_failure(self, exc, task_id, args, kwargs, einfo): @@ -223,7 +225,16 @@ def on_success(self, exc, task_id, args, kwargs): def _reindex_video_project(self): """Reindex video and project.""" with celery_app.flask_app.app_context(): - deposit_id = self._base_payload["deposit_id"] + # Safety check in case base payload is not set yet + if not hasattr(self, '_base_payload') or not self._base_payload or 'deposit_id' not in self._base_payload: + if hasattr(self, 'deposit_id') and self.deposit_id: + deposit_id = self.deposit_id + else: + self.log("Cannot reindex: deposit_id not available") + return + else: + deposit_id = self._base_payload["deposit_id"] + try: index_deposit_project(deposit_id) except PIDDeletedError: @@ -252,6 +263,7 @@ def set_base_payload(self): tags=self.object_version.get_tags(), master_id=self.object_version_id, ) + print("********* Base payload set: ", self._base_payload) def get_full_payload(self, **kwargs): """Get full payload merging base payload and kwargs.""" @@ -590,10 +602,10 @@ def progress_updater(current_frame): object_=self.object_version, output_dir=output_folder, progress_updater=progress_updater, - **options + **options, ), object_=self.object_version, - **options + **options, ) except Exception: db.session.rollback() @@ -601,6 +613,8 @@ def progress_updater(current_frame): self.clean(version_id=self.object_version_id) raise + total_frames = len(frames) + # Generate GIF images self._create_gif( bucket=str(self.object_version.bucket.id), @@ -618,7 +632,7 @@ def progress_updater(current_frame): db.session.commit() self.log("Finished task {0}".format(kwargs["task_id"])) - return "Created {0} frames.".format(len(frames)) + return "Created {0} frames.".format(total_frames) @classmethod def _time_position(cls, duration, frames_start=5, frames_end=95, frames_gap=10): @@ -648,7 +662,7 @@ def _create_tmp_frames( duration, output_dir, progress_updater=None, - **kwargs + **kwargs, ): """Create frames in temporary files.""" # Generate frames @@ -727,6 +741,268 @@ def _create_object( [ObjectVersionTag.create(obj, k, to_string(tags[k])) for k in tags] +class ExtractChapterFramesTask(AVCTask): + """Extract chapter frames task - dedicated task for chapter frame extraction only.""" + + name = "file_video_extract_chapter_frames" + + @staticmethod + def clean(version_id, *args, **kwargs): + """Delete generated chapter frame ObjectVersion slaves.""" + # remove all objects version "slave" with type "frame" that are chapter frames + tag_alias_1 = aliased(ObjectVersionTag) + tag_alias_2 = aliased(ObjectVersionTag) + tag_alias_3 = aliased(ObjectVersionTag) + + slaves = ( + ObjectVersion.query.join(tag_alias_1, ObjectVersion.tags) + .join(tag_alias_2, ObjectVersion.tags) + .join(tag_alias_3, ObjectVersion.tags) + .filter(tag_alias_1.key == "master", tag_alias_1.value == version_id) + .filter(tag_alias_2.key == "context_type", tag_alias_2.value == "frame") + .filter(tag_alias_3.key == "is_chapter_frame", tag_alias_3.value == "True") + .all() + ) + + for slave in slaves: + dispose_object_version(slave) + + def run(self, *args, **kwargs): + """Extract frames only at chapter timestamps from video description. + + This task is specifically designed to extract frames for chapters only, + without affecting other frame extraction processes. + + The task receives parameters through the standard AVCTask initialization: + - self.deposit_id: The deposit ID containing the video description + - self.object_version: The ObjectVersion of the master video file + - self.flow_id: The current flow ID for task metadata integration + """ + import tempfile + from cds.modules.deposit.api import deposit_video_resolver + + # Initialize task metadata for async execution + flow_task_metadata = self.get_or_create_flow_task() + kwargs["celery_task_id"] = str(self.request.id) + kwargs["task_id"] = str(flow_task_metadata.id) + kwargs["flow_id"] = self.flow_id + flow_task_metadata.payload = self.get_full_payload(**kwargs) + flow_task_metadata.status = FlowTaskStatus.STARTED + flow_task_metadata.message = "" + db.session.commit() + + self.log("=== Starting ExtractChapterFramesTask (Async) ===") + self.log( + f"Task parameters: deposit_id={self.deposit_id}, object_version_id={self.object_version_id}, flow_id={self.flow_id}" + ) + self.log(f"Celery task ID: {self.request.id}") + + try: + self.log( + f"Loaded ObjectVersion: {self.object_version.id} from bucket {self.object_version.bucket_id}" + ) + # Get the deposit to access the description + deposit_video = deposit_video_resolver(self.deposit_id) + description = deposit_video.get("description", "") + self.log(f"Deposit found: {self.deposit_id}") + self.log(f"Description length: {len(description)} characters") + self.log(f"Description preview: {description[:200]}...") + + # Parse chapters from description + chapters = parse_video_chapters(description) + self.log(f"Parsed {len(chapters)} chapters from description") + + if not chapters: + self.log( + "No chapters found in description - task completed with no action" + ) + return {"chapter_frames_extracted": 0, "status": "no_chapters"} + + # Log each chapter for debugging + for i, chapter in enumerate(chapters): + self.log( + f"Chapter {i+1}: {chapter['timestamp']} - {chapter['title'][:50]}..." + ) + + # Get video duration from metadata + duration = float(self._base_payload.get("tags", {}).get("duration", 0)) + self.log(f"Video duration: {duration} seconds") + + if duration == 0: + self.log("ERROR: Video duration is 0 - cannot extract frames") + return { + "chapter_frames_extracted": 0, + "status": "no_duration", + "error": "Video duration is 0", + } + + # Create temporary directory for frame extraction + with tempfile.TemporaryDirectory() as temp_dir: + self.log(f"Using temporary directory: {temp_dir}") + + chapter_frames = [] + successful_extractions = 0 + failed_extractions = 0 + + # Get the video file once and reuse it for all chapters + with move_file_into_local( + self.object_version, delete=False + ) as video_url: + self.log(f"Video file location: {video_url}") + + for i, chapter in enumerate(chapters): + chapter_seconds = chapter["seconds"] + chapter_title = chapter["title"] + chapter_timestamp = chapter["timestamp"] + + self.log( + f"Processing chapter {i+1}/{len(chapters)}: {chapter_timestamp} - {chapter_title}" + ) + + # Skip chapters that are beyond video duration + if chapter_seconds > duration: + self.log( + f"Skipping chapter at {chapter_seconds}s (beyond duration {duration}s)" + ) + failed_extractions += 1 + continue + + frame_filename = f"chapter-{chapter_seconds}.jpg" + frame_path = os.path.join(temp_dir, frame_filename) + self.log(f"Extracting frame to: {frame_path}") + + try: + # Use ffmpeg directly to extract a single frame at exact timestamp + import subprocess + + cmd = [ + "ffmpeg", + "-y", + "-accurate_seek", + "-ss", + str(chapter_seconds), + "-i", + video_url, + "-vframes", + "1", + "-qscale:v", + "1", + frame_path, + ] + self.log(f"Running ffmpeg command: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + self.log(f"FFmpeg failed with error: {result.stderr}") + failed_extractions += 1 + continue + + if ( + os.path.exists(frame_path) + and os.path.getsize(frame_path) > 0 + ): + self.log( + f"Frame extracted successfully: {frame_path} ({os.path.getsize(frame_path)} bytes)" + ) + + # Create ObjectVersion for chapter frame + with open(frame_path, "rb") as frame_file: + obj = ObjectVersion.create( + bucket=self.object_version.bucket, + key=frame_filename, + stream=frame_file, + ) + + # Tag the frame with metadata + ObjectVersionTag.create( + obj, + "master", + str(self.object_version.version_id), + ) + ObjectVersionTag.create( + obj, "context_type", "frame" + ) + ObjectVersionTag.create( + obj, "is_chapter_frame", "True" + ) + ObjectVersionTag.create( + obj, "chapter_timestamp", chapter_timestamp + ) + ObjectVersionTag.create( + obj, "chapter_seconds", str(chapter_seconds) + ) + ObjectVersionTag.create( + obj, "chapter_title", chapter_title[:200] + ) # Limit length + ObjectVersionTag.create( + obj, "chapter_index", str(i) + ) + + self.log( + f"Created ObjectVersion {obj.version_id} for chapter frame: {frame_filename}" + ) + chapter_frames.append(frame_path) + successful_extractions += 1 + + else: + self.log( + f"ERROR: Frame extraction failed - file not created or empty: {frame_path}" + ) + failed_extractions += 1 + + except Exception as frame_error: + self.log( + f"ERROR: Exception extracting frame for chapter {chapter_timestamp}: {frame_error}" + ) + self.log( + f"Chapter details: seconds={chapter_seconds}, title={chapter_title}" + ) + import traceback + + self.log(f"Traceback: {traceback.format_exc()}") + failed_extractions += 1 + + self.log(f"=== Chapter frame extraction completed ===") + self.log(f"Total chapters processed: {len(chapters)}") + self.log(f"Successful extractions: {successful_extractions}") + self.log(f"Failed extractions: {failed_extractions}") + self.log(f"Chapter frames created: {len(chapter_frames)}") + + # Update task metadata for successful completion + flow_task_metadata.status = FlowTaskStatus.SUCCESS + flow_task_metadata.message = ( + f"Extracted {len(chapter_frames)} chapter frames successfully" + ) + db.session.commit() + + result = { + "chapter_frames_extracted": len(chapter_frames), + "successful_extractions": successful_extractions, + "failed_extractions": failed_extractions, + "total_chapters": len(chapters), + "status": "completed", + } + + self.log(f"Task completed successfully: {result}") + return result + + except Exception as e: + self.log(f"FATAL ERROR in ExtractChapterFramesTask: {e}") + import traceback + + self.log(f"Full traceback: {traceback.format_exc()}") + + # Update task metadata for failure + flow_task_metadata.status = FlowTaskStatus.FAILURE + flow_task_metadata.message = f"Task failed: {str(e)}" + db.session.commit() + + result = {"chapter_frames_extracted": 0, "status": "error", "error": str(e)} + + self.log(f"Task failed: {result}") + return result + + class TranscodeVideoTask(AVCTask): """Transcode video task. @@ -793,7 +1069,7 @@ def _update_flow_tasks(self, flow_tasks, status, message, **kwargs): opencast_publication_tag=current_app.config["CDS_OPENCAST_QUALITIES"][ quality ]["opencast_publication_tag"], - **kwargs # may contain `opencast_event_id` + **kwargs, # may contain `opencast_event_id` ) # JSONb cols needs to be assigned (not updated) to be persisted flow_task_metadata.payload = new_payload @@ -848,7 +1124,7 @@ def _start_transcodable_flow_tasks_or_cancel(self, wanted_qualities=None): new_payload.update( task_id=str(t.id), celery_task_id=str(self.request.id), - **self._base_payload + **self._base_payload, ) # JSONb cols needs to be assigned (not updated) to be persisted t.payload = new_payload diff --git a/cds/modules/previewer/templates/cds_previewer/macros/player.html b/cds/modules/previewer/templates/cds_previewer/macros/player.html index a7301dd58..cded13ba2 100644 --- a/cds/modules/previewer/templates/cds_previewer/macros/player.html +++ b/cds/modules/previewer/templates/cds_previewer/macros/player.html @@ -124,6 +124,74 @@ player.controls = {{ (not embed_config.controlsOff) | default(True) | tojson }}; player.muted = {{ embed_config.muted | default(False) | tojson }}; + // Add chapters if they exist + {% if record and record.get("metadata", {}).get("description") %} + function parseChapters(description) { + if (!description) return []; + + const pattern = /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{2})\s*[-\s]*(.+?)(?=\n|$)/gm; + const chapters = []; + let match; + + while ((match = pattern.exec(description)) !== null) { + const [, timestampStr, title] = match; + + const timeParts = timestampStr.split(':'); + let totalSeconds; + + if (timeParts.length === 2) { + const [minutes, seconds] = timeParts.map(Number); + totalSeconds = minutes * 60 + seconds; + } else if (timeParts.length === 3) { + const [hours, minutes, seconds] = timeParts.map(Number); + totalSeconds = hours * 3600 + minutes * 60 + seconds; + } else { + continue; + } + + const cleanTitle = title.trim(); + if (cleanTitle) { + chapters.push({ + startTime: totalSeconds, + endTime: totalSeconds + 1, + text: cleanTitle + }); + } + } + + return chapters.sort((a, b) => a.startTime - b.startTime); + } + + player.addEventListener('sourcechange', function() { + const description = {{ record.get("metadata", {}).get("description", "") | tojson }}; + const chapters = parseChapters(description); + + if (chapters.length > 0) { + // Create chapter track + const chapterTrack = { + kind: 'chapters', + label: 'Chapters', + srclang: 'en', + mode: 'hidden', + cues: chapters.map(chapter => ({ + startTime: chapter.startTime, + endTime: chapter.endTime, + text: chapter.text + })) + }; + + // Add chapter track to player + if (player.textTracks) { + try { + player.textTracks.addTrack(chapterTrack); + } catch (e) { + console.warn('Could not add chapter track:', e); + } + } + } + }); + {% endif %} + {% if theo_config.showTitle and not embed_config.controlsOff %} // Append the title div var title = document.createElement('div'); diff --git a/cds/modules/records/serializers/schemas/video.py b/cds/modules/records/serializers/schemas/video.py index 88fcb3d8c..4d593ff94 100644 --- a/cds/modules/records/serializers/schemas/video.py +++ b/cds/modules/records/serializers/schemas/video.py @@ -19,7 +19,7 @@ """Video JSON schema.""" from invenio_jsonschemas import current_jsonschemas -from marshmallow import Schema, fields, pre_load, post_load +from marshmallow import Schema, fields, pre_load, post_load, post_dump from ....deposit.api import Video from ..fields.datetime import DateString @@ -166,6 +166,7 @@ class VideoSchema(StrictKeysSchema): ) collections = fields.List(fields.Str, many=True) additional_languages = fields.List(fields.Str, many=True) + chapters = fields.List(fields.Dict, dump_only=True) # Preservation fields location = fields.Str() @@ -177,3 +178,16 @@ def post_load(self, data, **kwargs): """Post load.""" data["$schema"] = current_jsonschemas.path_to_url(Video._schema) return data + + @post_dump(pass_many=False) + def post_dump(self, data, **kwargs): + """Post dump - add parsed chapters.""" + from cds.modules.records.utils import parse_video_chapters + + description = data.get('description', '') + if description: + data['chapters'] = parse_video_chapters(description) + else: + data['chapters'] = [] + + return data diff --git a/cds/modules/records/static/templates/cds_records/video/detail.html b/cds/modules/records/static/templates/cds_records/video/detail.html index a8444c7e1..20ce6b7ec 100644 --- a/cds/modules/records/static/templates/cds_records/video/detail.html +++ b/cds/modules/records/static/templates/cds_records/video/detail.html @@ -4,72 +4,15 @@
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -

    - Transcription -
    -

    -
    - -
    - -
    - - -
    -
    -
    -
    -
    +
    + +
    @@ -188,7 +131,7 @@

    {{translation.title.title}}

    -

    +

    @@ -258,6 +201,91 @@

    {{translation.title.title}}

    + + +
    +
    +
    +

    Chapters

    + +
    + + +
    +
    +
    + +
    + Chapter {{ chapter.timestamp }} +
    + +
    + +
    + {{ chapter.timestamp }} +
    +
    + +
    +
    + {{ cleanHtmlFromTitle(chapter.title) }} +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + Chapter {{ chapter.timestamp }} +
    + +
    + +
    + {{ chapter.timestamp }} +
    +
    + +
    +
    + {{ cleanHtmlFromTitle(chapter.title) }} +
    +
    +
    +
    +
    +
    + +
    @@ -266,7 +294,7 @@

    {{translation.title.title}}

    >
    -

    Transcriptions

    +

    Transcriptions

    Follow along or search within the transcript.
    @@ -296,6 +324,110 @@

    + +
    +
    +

    + In this video +
    +

    +
    + + + + + +
    + +
    + + +
    + +
    +
      +
    • +
      +
      +
      + Chapter {{ chapter.timestamp }} + +
      +
      +
      + +
      +
      +
      +
      +
      + {{ cleanHtmlFromTitle(chapter.title) }} +
      +
      + {{ chapter.timestamp }} +
      +
      +
      +
    • +
    +
    + + +
    +
    +
      +
    • +
      + {{ convertToMinutesSeconds(line.start) }} - {{ convertToMinutesSeconds(line.end) }} +
      +
      + {{ line.text }} +
      +
    • +
    +
    +
    +
    + + +
    + + +
    +
    + +
    diff --git a/cds/modules/records/static/templates/cds_records/video/downloads.html b/cds/modules/records/static/templates/cds_records/video/downloads.html index aa6e350a8..41953511d 100644 --- a/cds/modules/records/static/templates/cds_records/video/downloads.html +++ b/cds/modules/records/static/templates/cds_records/video/downloads.html @@ -86,34 +86,7 @@

    - - + diff --git a/cds/modules/records/utils.py b/cds/modules/records/utils.py index 542e590cb..926baf3df 100644 --- a/cds/modules/records/utils.py +++ b/cds/modules/records/utils.py @@ -26,6 +26,8 @@ import json +import re +from datetime import timedelta from html import unescape from urllib import parse @@ -482,3 +484,75 @@ def to_string(value): return value else: return json.dumps(value) + + +def parse_video_chapters(description): + """Parse YouTube-style chapter timestamps from video description. + + Looks for patterns like: + 00:00 Introduction + 0:30 Getting Started + 1:23:45 Advanced Topics + + Args: + description (str): Video description text + + Returns: + list: List of chapter dicts with 'timestamp', 'seconds', and 'title' keys + """ + if not description: + return [] + + # Regex pattern to match timestamp formats: + # - 0:00, 00:00, 0:0, 00:0, 0:00:00, 00:00:00, etc. + # - Followed by optional space/tab and chapter title + pattern = r'(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{1,2})\s*[-\s]*(.+?)(?=\n|$)' + + chapters = [] + matches = re.findall(pattern, description, re.MULTILINE) + + for timestamp_str, title in matches: + # Parse timestamp to seconds + time_parts = timestamp_str.split(':') + if len(time_parts) == 2: # MM:SS format + minutes, seconds = map(int, time_parts) + total_seconds = minutes * 60 + seconds + elif len(time_parts) == 3: # HH:MM:SS format + hours, minutes, seconds = map(int, time_parts) + total_seconds = hours * 3600 + minutes * 60 + seconds + else: + continue + + # Clean up title + title = title.strip() + if title: + chapters.append({ + 'timestamp': timestamp_str, + 'seconds': total_seconds, + 'title': title + }) + + # Sort chapters by timestamp + chapters.sort(key=lambda x: x['seconds']) + + return chapters + + +def seconds_to_timestamp(seconds): + """Convert seconds to timestamp string (MM:SS or HH:MM:SS). + + Args: + seconds (int): Number of seconds + + Returns: + str: Formatted timestamp string + """ + td = timedelta(seconds=seconds) + hours = td.seconds // 3600 + minutes = (td.seconds % 3600) // 60 + secs = td.seconds % 60 + + if hours > 0: + return f"{hours}:{minutes:02d}:{secs:02d}" + else: + return f"{minutes}:{secs:02d}" diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js index e77091a46..e1b8c79fc 100644 --- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js +++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/app.js @@ -35,6 +35,7 @@ import "./avc/components/cdsDeposits.js"; import "./avc/components/cdsForm.js"; import "./avc/components/cdsUploader.js"; import "./avc/components/cdsRemoteUploader.js"; +import "./avc/components/cdsVideoList.js"; $(document).ready(function () { // show warning if IE (not supported for deposit) diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js index 504c26514..3567cef9d 100644 --- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js +++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsActions.js @@ -32,10 +32,47 @@ function cdsActionsCtrl($scope, $q, cdsAPI) { that.actionHandler("DELETE").then(function () { var children = that.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.videos; - for (var i in children) { - if (children[i]._deposit.id === that.cdsDepositCtrl.id) { - children.splice(i, 1); + var videoIdToDelete = that.cdsDepositCtrl.id; + + // Find and remove the video from the array + var videoIndex = children.findIndex(function(video) { + return video._deposit.id === videoIdToDelete; + }); + + if (videoIndex !== -1) { + children.splice(videoIndex, 1); + + // Broadcast deletion event to update other UI components + $scope.$broadcast("cds.video.deleted", { + videoId: videoIdToDelete, + remainingVideos: children.length + }); + + // Force digest cycle to update UI + $scope.$applyAsync(); + } + }).catch(function(error) { + // Handle case where video was already deleted + if (error.status === 404) { + // Video already deleted, just remove from UI + var children = that.cdsDepositCtrl.cdsDepositsCtrl.master.metadata.videos; + var videoIdToDelete = that.cdsDepositCtrl.id; + + var videoIndex = children.findIndex(function(video) { + return video._deposit.id === videoIdToDelete; + }); + + if (videoIndex !== -1) { + children.splice(videoIndex, 1); + $scope.$broadcast("cds.video.deleted", { + videoId: videoIdToDelete, + remainingVideos: children.length + }); + $scope.$applyAsync(); } + } else { + // Re-throw other errors + throw error; } }); }; diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js index c7de1fa1f..01131a1cd 100644 --- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js +++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsDeposit.js @@ -274,6 +274,42 @@ function cdsDepositCtrl( } }; + this.refreshFormFromBackend = function () { + // Fetch latest deposit state from the backend to ensure consistency + if (that.links && that.links.self) { + cdsAPI.action(that.links.self, "GET", {}) + .then(function(response) { + // Update the record with latest backend state + var latestRecord = response.data.metadata; + + // Preserve form changes for specific fields if form is dirty + var preserveFields = ['title', 'description', 'contributors']; + var updatedRecord = angular.copy(latestRecord); + + if (!that.isPristine()) { + preserveFields.forEach(function(field) { + if (that.record[field] && !angular.equals(that.record[field], latestRecord[field])) { + // Keep the form value if it differs from backend (user might be editing) + updatedRecord[field] = that.record[field]; + } + }); + } + + // Update the record while preserving user changes + that.record = _.merge(that.record, updatedRecord); + + // Update deposit status and flows + that.updateDeposit(latestRecord); + that.fetchFlowTasksStatuses(); + + console.log('Form refreshed from backend at', new Date().toISOString()); + }) + .catch(function(error) { + console.warn('Failed to refresh form from backend:', error); + }); + } + }; + this.refetchRecordOnTaskStatusChanged = function (flowTasksById) { // init if not set yet var cachedFlowTasksById = _.isEmpty(that.cachedFlowTasksById) @@ -281,19 +317,22 @@ function cdsDepositCtrl( : that.cachedFlowTasksById; var reFetchRecord = false; + var statusHasChanged = false; for (var taskId in flowTasksById) { var cachedTask = _.get(cachedFlowTasksById, taskId, null); if (!cachedTask) { // something wrong with the cached tasks, maybe a flow restart? // refetch the record to be sure reFetchRecord = true; + statusHasChanged = true; break; } - var statusHasChanged = + var taskStatusChanged = flowTasksById[taskId]["status"] !== cachedTask["status"]; - if (statusHasChanged) { + if (taskStatusChanged) { reFetchRecord = true; + statusHasChanged = true; break; } } @@ -304,6 +343,15 @@ function cdsDepositCtrl( if (reFetchRecord) { that.cdsDepositsCtrl.fetchRecord(); } + + // Broadcast status change to update video list sidebar and other UI components + if (statusHasChanged) { + $scope.$broadcast("cds.deposit.status.changed", { + depositId: that.id, + taskStatuses: flowTasksById, + currentDepositStatus: that.currentDepositStatus + }); + } }; this.updateSubformatsTranscodingStatus = function (flowTasks) { @@ -379,6 +427,20 @@ function cdsDepositCtrl( if (anyTaskIsRunning) { that.fetchFlowTasksStatuses(); } + // Restart polling with appropriate interval if status changed + var currentInterval = getPollingInterval(); + if (that.fetchStatusInterval.$$intervalId && + (anyTaskIsRunning && currentInterval === 15000) || + (!anyTaskIsRunning && currentInterval === 5000)) { + setupSmartPolling(); + } + + // Ensure UI components stay synchronized during polling + $scope.$broadcast("cds.deposit.polling.update", { + depositId: that.id, + anyTaskIsRunning: anyTaskIsRunning, + currentDepositStatus: that.currentDepositStatus + }); }; // Update deposit based on extracted metadata from task @@ -471,6 +533,9 @@ function cdsDepositCtrl( } that.currentStartedTaskName = currentStartedTaskName; + // Store previous status to detect changes + var previousDepositStatus = that.currentDepositStatus; + // Change the Deposit Status var values = _.values(that.record._cds.state); if (!values.length) { @@ -484,6 +549,17 @@ function cdsDepositCtrl( } else { that.currentDepositStatus = depositStatuses.SUCCESS; } + + // Broadcast status change to update video list sidebar and other UI components + if (previousDepositStatus !== that.currentDepositStatus) { + $scope.$broadcast("cds.deposit.status.changed", { + depositId: that.id, + previousStatus: previousDepositStatus, + currentStatus: that.currentDepositStatus, + currentStartedTaskName: that.currentStartedTaskName, + taskStates: that.record._cds.state + }); + } }; // Check if extracted metadata is available for automatic form fill @@ -552,16 +628,51 @@ function cdsDepositCtrl( that.videoPreviewer(); // Update subformat statuses that.fetchFlowTasksStatuses(); - that.fetchStatusInterval = $interval( - that.recurrentFetchFlowTasksStatuses, - 5000 - ); - // What the order of contributors and check make it dirty, throttle the - // function for 1sec + // Smart polling: use different intervals based on task activity + var getPollingInterval = function() { + var anyTaskIsRunning = that.anyTaskIsRunning(); + return anyTaskIsRunning ? 5000 : 15000; // 5s when active, 15s when idle + }; + + var setupSmartPolling = function() { + if (that.fetchStatusInterval) { + $interval.cancel(that.fetchStatusInterval); + } + that.fetchStatusInterval = $interval( + that.recurrentFetchFlowTasksStatuses, + getPollingInterval() + ); + }; + + setupSmartPolling(); + + // Setup 5-minute interval for form re-initialization + var formRefreshInterval = $interval(function() { + that.refreshFormFromBackend(); + }, 300000); // 5 minutes = 300000ms + + // Clean up form refresh interval on component destroy + var originalDestroy = that.$onDestroy; + that.$onDestroy = function() { + try { + $interval.cancel(formRefreshInterval); + } catch (error) {} + if (originalDestroy) { + originalDestroy.call(that); + } + }; + // Watch contributors with optimized shallow watching and increased debounce + // Use shallow watching and manual comparison for better performance + var lastContributorsState = null; $scope.$watch( "$ctrl.record.contributors", - _.throttle(that.setDirty, 1000), - true + _.debounce(function(newVal, oldVal) { + if (newVal && (!lastContributorsState || !angular.equals(newVal, lastContributorsState))) { + lastContributorsState = angular.copy(newVal); + that.setDirty(); + } + }, 2000), + false // Changed from deep watching (true) to shallow watching (false) ); $scope.$watch("$ctrl.record._deposit.status", function () { $scope.$applyAsync(function () { // Manually trigger UI updates @@ -576,8 +687,8 @@ function cdsDepositCtrl( }); }); - // Listen for task status changes - $scope.$on("cds.deposit.task", function (evt, type, status, data) { + // Listen for task status changes - use throttling to prevent excessive updates + var taskStatusHandler = _.throttle(function (evt, type, status, data) { if (type == "file_video_metadata_extraction" && status == "SUCCESS") { var allMetadata = data.payload.extracted_metadata; that.setOnLocalStorage("metadata", allMetadata); @@ -585,26 +696,55 @@ function cdsDepositCtrl( } else if (type == "file_video_extract_frames" && status == "SUCCESS") { that.framesReady = true; } - }); + }, 500); + $scope.$on("cds.deposit.task", taskStatusHandler); + + // Cache status check results to avoid frequent recalculation + var _lastDepositStatus = null; + var _cachedStatusChecks = {}; + + function _getCachedStatusCheck(statusType) { + if (that.currentDepositStatus !== _lastDepositStatus) { + _cachedStatusChecks = {}; + _lastDepositStatus = that.currentDepositStatus; + } + return _cachedStatusChecks[statusType]; + } this.displayFailure = function () { - return that.currentDepositStatus === that.depositStatuses.FAILURE; + var cached = _getCachedStatusCheck('failure'); + if (cached === undefined) { + cached = _cachedStatusChecks['failure'] = that.currentDepositStatus === that.depositStatuses.FAILURE; + } + return cached; }; this.displayPending = function () { - return that.currentDepositStatus === that.depositStatuses.PENDING; + var cached = _getCachedStatusCheck('pending'); + if (cached === undefined) { + cached = _cachedStatusChecks['pending'] = that.currentDepositStatus === that.depositStatuses.PENDING; + } + return cached; }; this.displayStarted = function () { - return that.currentDepositStatus === that.depositStatuses.STARTED; + var cached = _getCachedStatusCheck('started'); + if (cached === undefined) { + cached = _cachedStatusChecks['started'] = that.currentDepositStatus === that.depositStatuses.STARTED; + } + return cached; }; this.displaySuccess = function () { - return ( - that.currentDepositStatus === that.depositStatuses.SUCCESS && - !that.isPublished() && - !that.record.recid - ); + var cached = _getCachedStatusCheck('success'); + if (cached === undefined) { + cached = _cachedStatusChecks['success'] = ( + that.currentDepositStatus === that.depositStatuses.SUCCESS && + !that.isPublished() && + !that.record.recid + ); + } + return cached; }; this.postSuccessProcess = function (responses) { diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js index ce70be33d..2dc64d6e1 100644 --- a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js +++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsUploader.js @@ -276,61 +276,66 @@ function cdsUploaderCtrl( this.addFiles = function (_files, invalidFiles, extraHeaders) { // Do nothing if files array is empty - if (!_files) { + if (!_files || _files.length === 0) { return; } // Remove any invalid files _files = _.difference(_files, invalidFiles || []); - // Filter out files without a valid MIME type or with zero size - _files = _files.filter((file) => { + // Filter out files without a valid MIME type or with zero size - optimized with early returns + var validFiles = []; + var invalidMessages = []; + + for (var i = 0; i < _files.length; i++) { + var file = _files[i]; if (!file.type || file.type.trim() === "") { - toaster.pop( - "warning", - "Invalid File Type", - `The file ${file.name} has no valid type.` - ); - return false; // Exclude invalid files + invalidMessages.push(`The file ${file.name} has no valid type.`); + continue; } - if (!file.size || file.size === 0) { - toaster.pop( - "warning", - "Empty File", - `The file ${file.name} is empty and cannot be uploaded.` - ); - return false; // Exclude zero-size files + invalidMessages.push(`The file ${file.name} is empty and cannot be uploaded.`); + continue; } - - return true; - }); - - // Make sure they have proper metadata - angular.forEach(_files, function (file) { + validFiles.push(file); + } + + // Show all invalid file messages at once + if (invalidMessages.length > 0) { + toaster.pop( + "warning", + "Invalid Files", + invalidMessages.join("
    "), + { bodyOutputType: "trustedHtml" } + ); + } + + _files = validFiles; + + // Optimize file processing - batch operations and reduce iterations + var keysToRemove = []; + var defaultHeaders = { "X-Invenio-File-Tags": "context_type=additional_file" }; + + for (var i = 0; i < _files.length; i++) { + var file = _files[i]; file.key = file.name; file.local = !file.receiver; file.isAdditional = true; - // Add any extra paramemters to the files - if (extraHeaders) { - file.headers = extraHeaders; - } - - if (!extraHeaders || !("X-Invenio-File-Tags" in extraHeaders)) { - file.headers = { - "X-Invenio-File-Tags": "context_type=additional_file", - }; - } - }); + + // Set headers efficiently + file.headers = extraHeaders && ("X-Invenio-File-Tags" in extraHeaders) + ? extraHeaders + : (extraHeaders ? angular.merge({}, extraHeaders, defaultHeaders) : defaultHeaders); + + // Collect keys to remove in one pass + keysToRemove.push(file.key); + } - // Find if any of the existing files has been replaced - // (file with same filename), and if yes remove it from the existing - // file list (aka from the interface). - _files = _.each(_files, function (file) { - // Remove the existing file from the list - _.remove(that.files, function (_f) { - return _f.key === file.key; + // Remove existing files with same keys in one efficient operation + if (keysToRemove.length > 0) { + that.files = that.files.filter(function(f) { + return keysToRemove.indexOf(f.key) === -1; }); - }); + } if ((invalidFiles || []).length > 0) { // Push a notification @@ -537,8 +542,32 @@ function cdsUploaderCtrl( } }; + // Cache file indices for better performance in frequent lookups + var _fileIndexCache = {}; + var _lastFilesLength = 0; + this.findFileIndex = function (files, key) { - return _.indexOf(files, _.find(that.files, { key: key })); + // Invalidate cache if files array changed + if (that.files.length !== _lastFilesLength) { + _fileIndexCache = {}; + _lastFilesLength = that.files.length; + } + + // Check cache first + if (_fileIndexCache[key] !== undefined) { + return _fileIndexCache[key]; + } + + // Find and cache the index + for (var i = 0; i < that.files.length; i++) { + if (that.files[i].key === key) { + _fileIndexCache[key] = i; + return i; + } + } + + _fileIndexCache[key] = -1; + return -1; }; this.validateSubtitles = function (_file) { diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsVideoList.js b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsVideoList.js new file mode 100644 index 000000000..5aff55787 --- /dev/null +++ b/cds/modules/theme/assets/bootstrap3/js/cds_deposit/avc/components/cdsVideoList.js @@ -0,0 +1,649 @@ +import angular from "angular"; +import _ from "lodash"; + +function cdsVideoListCtrl($scope, $timeout) { + var that = this; + + // State management for video list + this.selectedVideoId = null; + this.expandedVideoId = null; + this.loadingVideo = null; + + // Filter state + this.statusFilter = 'all'; // 'all', 'published', 'draft' + + // Performance optimization: cache computed values + this._statusCache = new Map(); + this._titleCache = new Map(); + this._thumbnailCache = new Map(); + + // Clear cache when videos change + var clearCache = function() { + that._statusCache.clear(); + that._titleCache.clear(); + that._thumbnailCache.clear(); + }; + + // Initialize component + this.$onInit = function() { + // Auto-select and expand first video if available + if (that.videos && that.videos.length > 0) { + that.selectedVideoId = that.videos[0]._deposit.id; + // Auto-expand first video after a short delay + $timeout(function() { + that.expandedVideoId = that.videos[0]._deposit.id; + }, 100); + } + + // Listen for deposit status changes to clear cache and update UI + $scope.$on("cds.deposit.status.changed", function(event, statusData) { + if (statusData && statusData.depositId) { + // Clear cache for the specific video that had status changes + that._statusCache.delete(statusData.depositId); + that._titleCache.delete(statusData.depositId); + that._thumbnailCache.delete(statusData.depositId); + + // Force UI update by triggering digest cycle + $scope.$applyAsync(); + } + }); + + // Listen for metadata updates which can also affect status display + $scope.$on("cds.deposit.metadata.update", function(event, data) { + // Clear all cache when metadata updates occur + clearCache(); + $scope.$applyAsync(); + }); + + // Listen for polling updates to ensure UI synchronization + $scope.$on("cds.deposit.polling.update", function(event, pollingData) { + if (pollingData && pollingData.depositId) { + // Clear cache for the specific video during polling to ensure fresh status + that._statusCache.delete(pollingData.depositId); + } + }); + + // Listen for video deletion events to update UI + $scope.$on("cds.video.deleted", function(event, deletionData) { + if (deletionData && deletionData.videoId) { + // Clear cache for the deleted video + that._statusCache.delete(deletionData.videoId); + that._titleCache.delete(deletionData.videoId); + that._thumbnailCache.delete(deletionData.videoId); + + // If the deleted video was selected, clear selection + if (that.selectedVideoId === deletionData.videoId) { + that.selectedVideoId = null; + that.expandedVideoId = null; + + // Auto-select first remaining video if any + if (that.videos && that.videos.length > 0) { + that.selectedVideoId = that.videos[0]._deposit.id; + $timeout(function() { + that.expandedVideoId = that.videos[0]._deposit.id; + }, 100); + } + } + + // Remove from expanded descriptions set + that.expandedDescriptions.delete(deletionData.videoId); + + // Force UI update + $scope.$applyAsync(); + } + }); + + // Watch for video changes and clear cache + $scope.$watch('$ctrl.videos', function(newVideos, oldVideos) { + if (newVideos !== oldVideos) { + clearCache(); + + // Handle video addition/removal + if (newVideos && oldVideos) { + // Check if new videos were added + if (newVideos.length > oldVideos.length) { + // Find the newly added video(s) + var newVideoIds = newVideos.map(v => v._deposit.id); + var oldVideoIds = oldVideos.map(v => v._deposit.id); + var addedVideoIds = newVideoIds.filter(id => !oldVideoIds.includes(id)); + + // Auto-select the newest added video + if (addedVideoIds.length > 0) { + var newestVideoId = addedVideoIds[addedVideoIds.length - 1]; + that.selectedVideoId = newestVideoId; + $timeout(function() { + that.expandedVideoId = newestVideoId; + }, 300); + } + } + } + + // Auto-select first video when videos are loaded initially + if (newVideos && newVideos.length > 0 && !that.selectedVideoId) { + that.selectedVideoId = newVideos[0]._deposit.id; + $timeout(function() { + that.expandedVideoId = newVideos[0]._deposit.id; + }, 100); + } + } + }, true); // Deep watch to detect array changes + }; + + // Select a video from the list + this.selectVideo = function(videoId) { + // If clicking on the same video that's already selected and expanded, do nothing + if (that.selectedVideoId === videoId && that.expandedVideoId === videoId) { + return; + } + + // If switching to a different video, trigger autosave for the currently expanded video + if (that.expandedVideoId && that.expandedVideoId !== videoId) { + $scope.$broadcast("cds.deposit.project.saveAll"); + } + + that.selectedVideoId = videoId; + that.loadingVideo = videoId; + + // Small delay to show loading state + $timeout(function() { + that.expandedVideoId = videoId; + that.loadingVideo = null; + + // Emit event to notify parent components + $scope.$emit('cds.video.selected', videoId); + }, 300); + }; + + // Get video status for display (cached) + this.getVideoStatus = function(video) { + var videoId = video._deposit.id; + var cached = that._statusCache.get(videoId); + if (cached) return cached; + + var result; + if (!video._cds || !video._cds.state) { + result = { status: 'draft', label: 'Draft', class: 'label-default' }; + } else { + var state = video._cds.state; + var anyFailed = Object.values(state).some(s => s === 'FAILURE'); + var anyRunning = Object.values(state).some(s => s === 'STARTED' || s === 'PENDING'); + var allSuccess = Object.values(state).every(s => s === 'SUCCESS'); + + if (anyFailed) { + result = { status: 'failed', label: 'Processing Failed', class: 'label-danger' }; + } else if (anyRunning) { + result = { status: 'processing', label: 'Processing', class: 'label-warning' }; + } else if (allSuccess) { + result = { status: 'ready', label: 'Ready to Publish', class: 'label-success' }; + } else { + result = { status: 'draft', label: 'Draft', class: 'label-default' }; + } + } + + that._statusCache.set(videoId, result); + return result; + }; + + // Check if video is published + this.isVideoPublished = function(video) { + return video._deposit.status === 'published'; + }; + + // Get video item CSS classes + this.getVideoItemClasses = function(video) { + var classes = []; + + if (that.isSelected(video._deposit.id)) { + classes.push('active'); + } + + if (that.isLoading(video._deposit.id)) { + classes.push('loading'); + } + + if (that.isVideoPublished(video)) { + classes.push('video-published'); + } else { + classes.push('video-editing'); + } + + return classes.join(' '); + }; + + // Get video title for display (cached) + this.getVideoTitle = function(video) { + var videoId = video._deposit.id; + var cached = that._titleCache.get(videoId); + if (cached) return cached; + + var result; + if (video.title && video.title.title) { + result = video.title.title; + } else if (video._files && video._files.length > 0) { + // Try to get title from the main video file + var mainFile = video._files.find(f => f.context_type === 'master'); + if (mainFile) { + result = mainFile.key || 'Untitled Video'; + } else { + result = video._files[0].key || 'Untitled Video'; + } + } else { + result = 'Untitled Video'; + } + + that._titleCache.set(videoId, result); + return result; + }; + + // Get video thumbnail or placeholder + this.getVideoThumbnail = function(video) { + // Try to find frame from files + if (video._files) { + var frameFile = video._files.find(f => f.key && f.key.includes('frame-')); + if (frameFile && frameFile.links && frameFile.links.self) { + return frameFile.links.self + '?width=120&height=68'; + } + } + + // Return placeholder + return '/static/img/video-placeholder.svg'; + }; + + + // Check if video is selected + this.isSelected = function(videoId) { + return that.selectedVideoId === videoId; + }; + + // Check if video is expanded + this.isExpanded = function(videoId) { + return that.expandedVideoId === videoId; + }; + + // Check if video is loading + this.isLoading = function(videoId) { + return that.loadingVideo === videoId; + }; + + // Get progress for processing videos + this.getProcessingProgress = function(video) { + if (!video._cds || !video._cds.state) return 0; + + var tasks = Object.keys(video._cds.state); + var completedTasks = tasks.filter(task => + video._cds.state[task] === 'SUCCESS' || video._cds.state[task] === 'FAILURE' + ).length; + + return Math.round((completedTasks / tasks.length) * 100); + }; + + // Get video description for display + this.getVideoDescription = function(video) { + // Try different possible paths for description + if (video.description) { + if (typeof video.description === 'string') { + return video.description; + } else if (video.description.description) { + return video.description.description; + } + } + + // Try abstract field as fallback + if (video.abstract) { + if (typeof video.abstract === 'string') { + return video.abstract; + } else if (video.abstract.summary) { + return video.abstract.summary; + } + } + + // Debug: log the video object structure (can be removed later) + console.log('Video object for description debugging:', video); + + // Fallback message + return 'No description available'; + }; + + // Track expanded descriptions + this.expandedDescriptions = new Set(); + + // Toggle description expansion + this.toggleDescription = function(videoId, event) { + // Stop event from bubbling to prevent video selection + if (event) { + event.stopPropagation(); + } + + if (that.expandedDescriptions.has(videoId)) { + that.expandedDescriptions.delete(videoId); + } else { + that.expandedDescriptions.add(videoId); + } + }; + + // Check if description is expanded + this.isDescriptionExpanded = function(videoId) { + return that.expandedDescriptions.has(videoId); + }; + + // Filter videos based on status + this.getFilteredVideos = function() { + if (!that.videos) return []; + + switch (that.statusFilter) { + case 'published': + return that.videos.filter(function(video) { + return that.isVideoPublished(video); + }); + case 'draft': + return that.videos.filter(function(video) { + return !that.isVideoPublished(video); + }); + default: + return that.videos; + } + }; + + // Set status filter + this.setStatusFilter = function(filter) { + that.statusFilter = filter; + + // If current selected video is not in filtered results, clear selection + var filteredVideos = that.getFilteredVideos(); + var selectedVideoInFilter = filteredVideos.find(function(video) { + return video._deposit.id === that.selectedVideoId; + }); + + if (!selectedVideoInFilter && filteredVideos.length > 0) { + // Auto-select first video in filtered results + that.selectedVideoId = filteredVideos[0]._deposit.id; + $timeout(function() { + that.expandedVideoId = filteredVideos[0]._deposit.id; + }, 100); + } else if (filteredVideos.length === 0) { + // Clear selection if no videos match filter + that.selectedVideoId = null; + that.expandedVideoId = null; + } + }; + + // Get filter counts + this.getFilterCounts = function() { + if (!that.videos) return { all: 0, published: 0, draft: 0 }; + + var published = that.videos.filter(function(video) { + return that.isVideoPublished(video); + }).length; + + var draft = that.videos.length - published; + + return { + all: that.videos.length, + published: published, + draft: draft + }; + }; +} + +cdsVideoListCtrl.$inject = ['$scope', '$timeout']; + +function cdsVideoList() { + return { + transclude: true, + bindings: { + videos: '=', + maxNumberOfVideos: '<', + isPublished: '&' + }, + require: { + cdsDepositsCtrl: '^cdsDeposits' + }, + controller: cdsVideoListCtrl, + template: ` +
    + +
    +
    +
    +

    + + Videos +

    +
    +
    +
    + + +
    +
    + +
    +
    + +
    +
    + Filter by Status +
    +
    +
    + + + +
    +
    +
    + +
    + Select a video to edit +
    +
    +
    +
    +
    + Video thumbnail +
    + +
    +
    +
    +
    +
    +
    + {{ $ctrl.getVideoTitle(video) }} +
    +
    + Video #{{ $index + 1 }} +
    +
    +
    +
    + + Published + + + View + +
    +
    + + {{ $ctrl.getVideoStatus(video).label }} + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + {{ $ctrl.getVideoDescription(video) }} +

    +
    + Show more + Show less +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + + Maximum {{$ctrl.maxNumberOfVideos}} videos reached +
    +
    +
    +
    + + +
    +
    +
    +
    + +

    Select a video to edit

    +

    Choose a video from the list to view and edit its details

    +
    +
    + +
    +
    +
    +
    + + +
    +
    +

    + + {{ $ctrl.getVideoTitle(video) }} + #{{ $index + 1 }} +

    +
    + +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    + +

    No videos yet

    +

    Upload video files to get started

    +
    + + +
    + +

    No videos match the current filter

    +

    + No published videos found. + No draft videos found. +

    + +
    + + +
    +
    + ` + }; +} + +angular.module('cdsDeposit.components').component('cdsVideoList', cdsVideoList()); \ No newline at end of file diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js b/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js index 8ec43af26..dc7e72852 100644 --- a/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js +++ b/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js @@ -39,7 +39,7 @@ import { getCookie } from "../getCookie"; * @description * CDS record controller. */ -function cdsRecordController($scope, $sce, $http, $timeout) { +function cdsRecordController($scope, $sce, $http, $timeout, $filter) { // Parameters // Assign the controller to `vm` @@ -59,6 +59,10 @@ function cdsRecordController($scope, $sce, $http, $timeout) { $scope.filteredTranscript = []; $scope.selectedTranscriptLanguage = null; $scope.transcriptSearch = ""; + $scope.chapters = []; + $scope.showChapters = true; + $scope.showAllChapters = false; + $scope.activeTab = 'chapters'; // Default to chapters tab const REQUEST_HEADERS = { "Content-Type": "application/json", @@ -178,6 +182,24 @@ function cdsRecordController($scope, $sce, $http, $timeout) { console.warn("No subtitle file found."); } }); + + // Use chapters from API or parse from description as fallback + if (record.metadata.chapters && record.metadata.chapters.length > 0) { + $scope.chapters = record.metadata.chapters; + } else if (record.metadata.description) { + $scope.chapters = $scope.parseChapters(record.metadata.description); + } + + // Set default active tab based on what's available (prioritize chapters) + const hasTranscripts = (record.metadata._files || []).some(f => + f.context_type === 'subtitle' && f.content_type === 'vtt' + ); + + if ($scope.chapters.length > 0) { + $scope.activeTab = 'chapters'; + } else if (hasTranscripts) { + $scope.activeTab = 'transcript'; + } }; $scope.setTranscriptLanguage = function (lang) { @@ -261,6 +283,125 @@ function cdsRecordController($scope, $sce, $http, $timeout) { return `${minutes}:${paddedSecs}`; }; + $scope.parseChapters = function (description) { + if (!description) return []; + + // Regex pattern to match timestamp formats: 0:00, 00:00, 0:00:00, 00:00:00 + const pattern = /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{2})\s*[-\s]*(.+?)(?=\n|$)/gm; + const chapters = []; + let match; + + while ((match = pattern.exec(description)) !== null) { + const [, timestampStr, title] = match; + + // Parse timestamp to seconds + const timeParts = timestampStr.split(':'); + let totalSeconds; + + if (timeParts.length === 2) { // MM:SS format + const [minutes, seconds] = timeParts.map(Number); + totalSeconds = minutes * 60 + seconds; + } else if (timeParts.length === 3) { // HH:MM:SS format + const [hours, minutes, seconds] = timeParts.map(Number); + totalSeconds = hours * 3600 + minutes * 60 + seconds; + } else { + continue; + } + + // Clean up title + const cleanTitle = title.trim(); + if (cleanTitle) { + chapters.push({ + timestamp: timestampStr, + seconds: totalSeconds, + title: cleanTitle + }); + } + } + + // Sort chapters by timestamp + chapters.sort((a, b) => a.seconds - b.seconds); + return chapters; + }; + + $scope.toggleChapters = function () { + $scope.showChapters = !$scope.showChapters; + }; + + $scope.setActiveTab = function (tab) { + $scope.activeTab = tab; + }; + + $scope.toggleInThisVideoFromMain = function () { + $scope.showTranscript = !$scope.showTranscript; + + // If opening, scroll to the "In this video" section and set chapters tab + if ($scope.showTranscript && $scope.chapters.length > 0) { + $scope.activeTab = 'chapters'; + setTimeout(function () { + const el = document.getElementById('transcriptionsSection'); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, 100); + } + }; + + $scope.processDescriptionWithClickableTimestamps = function (description) { + if (!description) return description; + + // Regex pattern to match timestamp formats: 0:00, 00:00, 0:00:00, 00:00:00 + const pattern = /(\d{1,2}:(?:\d{1,2}:)?\d{2})/g; + + return description.replace(pattern, function(match) { + // Parse timestamp to seconds for the seek function + const timeParts = match.split(':'); + let totalSeconds; + + if (timeParts.length === 2) { + const [minutes, seconds] = timeParts.map(Number); + totalSeconds = minutes * 60 + seconds; + } else if (timeParts.length === 3) { + const [hours, minutes, seconds] = timeParts.map(Number); + totalSeconds = hours * 3600 + minutes * 60 + seconds; + } else { + return match; // Return unchanged if invalid format + } + + // Return clickable timestamp using onclick for ng-bind-html compatibility + return `${match}`; + }); + }; + + $scope.getChapterFrame = function (chapter) { + if (!$scope.record || !chapter) return null; + + // Use the findMaster filter to get the master file (this filter is defined in cds/module.js) + const master = $filter('findMaster')($scope.record); + + if (!master || !master.frame) return null; + + // Look for a frame with filename that matches chapter timestamp + // Chapter frames are named like "chapter-{seconds}.jpg" + const expectedFrameName = `chapter-${chapter.seconds}.jpg`; + + const chapterFrame = master.frame.find(frame => + frame.key === expectedFrameName + ); + + return chapterFrame || null; + }; + + $scope.cleanHtmlFromTitle = function (title) { + if (!title) return title; + + // Remove HTML tags and clean up whitespace for display purposes only + let cleanTitle = title.replace(/<[^>]+>/g, ' '); + cleanTitle = cleanTitle.replace(/\s+/g, ' ').trim(); + + return cleanTitle; + }; + /** * Trust iframe url * @memberof cdsRecordController @@ -383,7 +524,7 @@ function cdsRecordController($scope, $sce, $http, $timeout) { $scope.$on("cds.record.loading.stop", cdsRecordLoadingStop); } -cdsRecordController.$inject = ["$scope", "$sce", "$http", "$timeout"]; +cdsRecordController.$inject = ["$scope", "$sce", "$http", "$timeout", "$filter"]; //////////// diff --git a/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss b/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss index c923bc8f4..9ffdf55c9 100644 --- a/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss +++ b/cds/modules/theme/assets/bootstrap3/scss/cds/cds.scss @@ -62,9 +62,9 @@ html, body { } .cds-deposit-panel { - max-width: 1000px; - margin-left: auto; - margin-right: auto; + // max-width: 1000px; + // margin-left: auto; + // margin-right: auto; .is-sticky { // the height of the navbar margin-top: 65px !important; @@ -438,6 +438,1109 @@ a.cds-anchor:hover{ background-color: #fff; } +// Expand container width for deposit pages +.cds-max-fluid-width { + max-width: 95% !important; + margin: 0 auto; +} + +// Override container constraints for deposit edit pages +body.cds-deposit-edit-page { + .container-fluid.cds-max-fluid-width { + max-width: 98% !important; + width: 98% !important; + margin: 0 auto; + padding: 0 15px; + + // For larger desktop monitors (1440px+), use contained layout + @media (min-width: 1440px) { + max-width: 1350px !important; + width: 1350px !important; + padding: 0 30px; + } + + // For very large screens (1920px+), more contained + @media (min-width: 1920px) { + max-width: 1600px !important; + width: 1600px !important; + padding: 0 40px; + } + } +} + +// Wide container for video deposit interface +.cds-video-deposit-container { + // Full width up to larger desktop screens (MacBook Pro 13" and similar) + max-width: 98%; + margin: 0 auto; + padding: 0 15px 50px 15px; // Added bottom padding to prevent footer overlap + + // For larger desktop monitors (1440px+), use contained layout + @media (min-width: 1440px) { + max-width: 1350px; + margin: 0 auto; + padding: 0 30px 50px 30px; + } + + // For very large screens (1920px+), more contained + @media (min-width: 1920px) { + max-width: 1600px; + margin: 0 auto; + padding: 0 40px 50px 40px; + } + + // Ensure project section uses full width efficiently + .cds-deposit-panel-project { + .panel-body { + padding: 20px 30px; + + // Better spacing for project form layout + .row { + margin-left: -20px; + margin-right: -20px; + + .col-sm-6 { + padding-left: 20px; + padding-right: 20px; + } + + .col-sm-12 { + padding-left: 20px; + padding-right: 20px; + } + } + + // Improve form field spacing in project + .form-group { + margin-bottom: 20px; + + label { + font-weight: 500; + margin-bottom: 8px; + } + + .form-control { + padding: 8px 12px; + font-size: 14px; + } + } + + // Better button spacing + .btn { + margin: 5px; + border-radius: 6px; + font-weight: 500; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + } + + &.btn-success { + background: #5cb85c; + border-color: #4cae4c; + color: #fff; + } + + &.btn-primary { + background: #337ab7; + border-color: #2e6da4; + color: #fff; + } + + &.btn-warning { + background: #f0ad4e; + border-color: #eea236; + color: #fff; + } + + &.btn-sm { + padding: 6px 12px; + } + } + + // Improve help text spacing + .text-muted { + margin-top: 15px; + line-height: 1.5; + } + } + + // Project header styling to match video list aesthetic + .panel-heading { + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + padding: 15px 30px; + + .text-muted { + font-size: 16px; + font-weight: 500; + color: #495057; + + i { + margin-right: 8px; + } + } + + .pull-right { + .btn { + margin-left: 8px; + border-radius: 6px; + font-weight: 500; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + } + + &.btn-success { + background: #5cb85c; + border-color: #4cae4c; + color: #fff; + } + + &.btn-primary { + background: #337ab7; + border-color: #2e6da4; + color: #fff; + } + + &.btn-warning { + background: #f0ad4e; + border-color: #eea236; + color: #fff; + } + } + } + } + } + + // Improve uploader section styling within project + .cds-deposit-box { + margin: 20px 0; + border: 2px dashed #dee2e6; + border-radius: 8px; + background: #f8f9fa; + transition: all 0.3s ease; + + &:hover { + border-color: #adb5bd; + background: #f1f3f4; + } + + .cds-deposit-box-upload-wrapper { + padding: 40px 20px; + + .cds-deposit-box-upload-icon { + color: #6c757d; + } + + .cds-deposit-box-upload-title h3 { + color: #495057; + font-weight: 500; + margin-bottom: 10px; + } + + .cds-deposit-box-upload-description { + color: #6c757d; + font-size: 14px; + } + } + } +} + +// Video List Sidebar Component Styles +.cds-video-list-sidebar { + // Make sure we use full width + width: 100%; + + .video-list-header { + border-bottom: 1px solid #e9ecef; + padding-bottom: 15px; + margin-bottom: 20px; + + @media (max-width: 768px) { + .col-md-8, .col-md-4 { + width: 100%; + text-align: left !important; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + .video-sidebar-layout { + min-height: calc(100vh - 300px); + margin-bottom: 50px; + + // Adjust proportions to prevent tab wrapping + .col-lg-4, .col-md-5 { + // Reduce sidebar width more to give tabs space + @media (min-width: 992px) { + width: 28% !important; // Further reduced from 30% + padding-right: 5px; // Reduce right padding + } + @media (min-width: 768px) and (max-width: 991px) { + width: 32% !important; // Further reduced from 35% + padding-right: 5px; + } + } + + .col-lg-8, .col-md-7 { + // Increase main content width more + @media (min-width: 992px) { + width: 72% !important; // Increased from 70% + padding-left: 5px; // Reduce left padding + } + @media (min-width: 768px) and (max-width: 991px) { + width: 68% !important; // Increased from 65% + padding-left: 5px; + } + } + + // Responsive adjustments for mobile + @media (max-width: 767px) { + .col-lg-4, .col-md-5 { + width: 100%; + margin-bottom: 20px; + } + .col-lg-8, .col-md-7 { + width: 100%; + } + } + + // Left sidebar + .video-sidebar { + background: #f8f9fa; + border-right: 1px solid #e9ecef; + height: auto; + min-height: 650px; // Header + 5 videos + upload area + display: flex; + flex-direction: column; + + // Status Filter Section + .video-sidebar-filter { + padding: 15px; + border-bottom: 1px solid #e9ecef; + background: #fff; + flex-shrink: 0; + + .filter-header { + margin-bottom: 10px; + + small { + font-weight: 500; + color: #495057; + + i { + margin-right: 5px; + color: #6c757d; + } + } + } + + .filter-buttons { + .btn-group { + width: 100%; + display: flex; + + .filter-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; + padding: 8px 4px; + font-size: 11px; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; + overflow: hidden; + + i { + margin-bottom: 2px; + font-size: 12px; + } + + .badge { + background-color: #6c757d; + font-size: 9px; + padding: 1px 4px; + border-radius: 8px; + margin-top: 2px; + min-width: 16px; + text-align: center; + } + + &.btn-primary .badge { + background-color: #fff; + color: #007bff; + } + + &.btn-success .badge { + background-color: #fff; + color: #28a745; + } + + &.btn-warning .badge { + background-color: #fff; + color: #ffc107; + } + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + // Responsive text adjustments + @media (max-width: 1200px) { + font-size: 10px; + padding: 6px 2px; + + i { + font-size: 11px; + } + } + } + } + } + } + + .video-sidebar-header { + padding: 15px; + border-bottom: 1px solid #e9ecef; + background: #fff; + flex-shrink: 0; + } + + .video-sidebar-list { + flex: 1; + overflow-y: auto; + padding: 10px 0; + max-height: 550px; // Height for approximately 5 videos (110px per video) + min-height: 550px; + + .video-list-item { + cursor: pointer; + padding: 12px 15px; + border-bottom: 1px solid #e9ecef; + transition: all 0.2s ease; + border-left: 3px solid transparent; + min-height: 120px; // Increased height to accommodate expanded descriptions + display: flex; + align-items: flex-start; // Changed from center to flex-start + + &:hover { + background-color: #fff; + border-left-color: #ddd; + } + + &.active { + background-color: #e7f3ff; + border-left-color: #007bff; + + .video-title { + color: #007bff; + font-weight: 600; + } + } + + &.loading { + opacity: 0.7; + } + + // Published video styling + &.video-published { + background-color: #e8f5e9; + border-left-color: #28a745; + + .video-title { + color: #155724; + } + + &:hover { + background-color: #d4edda; + border-left-color: #28a745; + } + + &.active { + background-color: #c3e6cb; + border-left-color: #28a745; + + .video-title { + color: #155724; + font-weight: 600; + } + } + } + + // Editing (draft) video styling + &.video-editing { + background-color: #fff3cd; + border-left-color: #fd7e14; + + &:hover { + background-color: #ffeaa7; + border-left-color: #fd7e14; + } + + &.active { + background-color: #ffe082; + border-left-color: #fd7e14; + + .video-title { + color: #8a4800; + font-weight: 600; + } + } + } + + .video-item-content { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + } + + .video-thumbnail-sidebar { + position: relative; + flex-shrink: 0; + align-self: flex-start; // Keep thumbnail at top + margin-top: 8px; // Add some top margin for alignment + + img { + width: 80px; + height: 45px; + object-fit: cover; + border-radius: 4px; + border: 1px solid #e9ecef; + } + + .loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: white; + } + } + + .video-item-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; + + .video-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + + .video-title-section { + flex: 1; + min-width: 0; + + .video-title { + margin: 0 0 4px 0; + font-size: 13px; + font-weight: 500; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 2.6em; + } + + .video-meta { + .video-number { + font-size: 10px; + color: #6c757d; + font-weight: 500; + } + } + } + + .video-actions { + flex-shrink: 0; + display: flex; + align-items: flex-end; + flex-direction: column; + gap: 4px; + min-width: 80px; + text-align: right; + + .video-status-published { + display: flex; + align-items: flex-end; + flex-direction: column; + gap: 4px; + width: 100%; + + .label { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + align-self: flex-end; + } + + .visit-video-btn { + font-size: 9px; + padding: 3px 8px; + border-radius: 3px; + text-decoration: none; + background: #007bff; + border-color: #007bff; + color: #fff; + transition: all 0.2s ease; + align-self: flex-end; + + &:hover { + background: #0056b3; + border-color: #0056b3; + color: #fff; + text-decoration: none; + transform: translateY(-1px); + box-shadow: 0 1px 3px rgba(0,0,0,0.15); + } + + i { + margin-right: 2px; + font-size: 8px; + } + } + } + + .video-status-editing { + width: 100%; + text-align: right; + + .label { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + } + } + } + } + + .video-progress-section { + .progress-xs { + height: 3px; + margin: 0; + background-color: #e9ecef; + border-radius: 2px; + + .progress-bar { + line-height: 3px; + } + } + } + + .video-description-section { + .video-description { + position: relative; + + .description-text { + margin: 0; + font-size: 11px; + line-height: 1.4; + color: #6c757d; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 2.8em; + transition: max-height 0.3s ease; + + &.no-description { + color: #adb5bd; + font-style: italic; + font-size: 10px; + } + } + + &.expanded .description-text { + display: block; + -webkit-line-clamp: unset; + max-height: none; + overflow: visible; + } + + .description-toggle-text { + margin-top: 4px; + cursor: pointer; + + .show-more, + .show-less { + font-size: 10px; + color: #007bff; + font-weight: 500; + text-decoration: underline; + transition: color 0.2s ease; + + &:hover { + color: #0056b3; + text-decoration: none; + } + } + } + } + } + } + } + } + + // Upload area in sidebar (using original style) + .video-sidebar-upload { + margin-top: 10px; + + .upload-divider { + height: 1px; + background: #e9ecef; + margin: 15px 0; + } + + .sidebar-upload-box { + margin: 0 15px 15px 15px; + + .cds-deposit-box-upload-wrapper { + padding: 25px 15px; + + .cds-deposit-box-upload-icon { + font-size: 20px; + margin-bottom: 10px; + + i { + color: $btn-info-bg; + } + } + + .cds-deposit-box-upload-title h5 { + color: $gray-dark; + margin-bottom: 8px; + font-size: 16px; + } + + .cds-deposit-box-upload-description { + color: $gray; + font-size: 13px; + margin: 0; + } + + &:hover { + .cds-deposit-box-upload-icon i { + color: $primary-color; + } + } + } + } + } + + // Max videos message in sidebar + .video-sidebar-max-message { + margin-top: 10px; + + .upload-divider { + height: 1px; + background: #e9ecef; + margin: 15px 0; + } + + .max-videos-message { + padding: 12px 15px; + margin: 0 15px; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + font-size: 12px; + text-align: center; + color: #856404; + + i { + margin-right: 6px; + color: #f39c12; + } + } + } + } + + // Right editor area + .video-editor-area { + padding-left: 10px; // Reduced from 20px to 10px + height: calc(100vh - 300px); + min-height: 500px; + margin-bottom: 30px; + + // Make forms use full height without scrollbar + .video-editor-content { + height: 100%; + + .video-details-container { + height: 100%; + + // Video form container + .panel.panel-default { + height: 100%; + display: flex; + flex-direction: column; + + .panel-body { + flex: 1; + overflow: visible; + padding: 20px; + + // Tab content should fill available space + .tab-content { + height: calc(100% - 60px); // Account for tab nav height + + .tab-pane { + height: 100%; + overflow-y: auto; + padding: 10px 0; + + // Form sections + .row { + margin-bottom: 15px; + + .col-sm-6, .col-sm-12 { + .form-group { + margin-bottom: 15px; + + .form-control { + border-radius: 4px; + } + + // Textarea adjustments + textarea.form-control { + min-height: 80px; + resize: vertical; + } + } + } + } + } + } + + // Tab navigation + .nav-tabs { + border-bottom: 1px solid #ddd; + margin-bottom: 10px; + flex-shrink: 0; + + // Ensure tabs don't wrap and have enough space + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + white-space: nowrap; + + li { + flex-shrink: 0; + + a { + white-space: nowrap; + padding: 8px 12px; + font-size: 13px; + + // Ensure Admin tab and others fit properly + @media (min-width: 768px) { + padding: 10px 16px; + font-size: 14px; + } + } + } + } + } + } + } + } + + @media (max-width: 768px) { + padding-left: 0; + margin-top: 20px; + } + + .video-editor-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: #f8f9fa; + border: 2px dashed #dee2e6; + border-radius: 8px; + transition: all 0.3s ease; + + &:hover { + border-color: #adb5bd; + } + } + + .video-editor-header { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border: 1px solid #dee2e6; + border-bottom: 2px solid #007bff; + padding: 18px 25px; + border-radius: 6px 6px 0 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 20px; + + + .video-header-content { + display: flex; + justify-content: space-between; + align-items: center; + + h4 { + margin: 0; + font-size: 18px; + font-weight: 600; + flex: 1; + color: #495057; + + i.fa-video-camera { + color: #007bff; + margin-right: 8px; + } + + small { + font-weight: 500; + color: #6c757d; + background: #e9ecef; + padding: 2px 8px; + border-radius: 12px; + margin-left: 10px; + font-size: 12px; + } + } + + .video-header-actions { + flex-shrink: 0; + margin-left: 15px; + + // Style action buttons for better integration + .btn { + margin-left: 8px; + border-radius: 6px; + font-weight: 500; + padding: 8px 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + } + + &.btn-success { + background: #5cb85c; + border-color: #4cae4c; + color: #fff; + } + + &.btn-primary { + background: #337ab7; + border-color: #2e6da4; + color: #fff; + } + + &.btn-warning { + background: #f0ad4e; + border-color: #eea236; + color: #fff; + } + + &.btn-sm { + padding: 6px 12px; + font-size: 13px; + } + } + } + } + } + + .video-editor-content { + border: 1px solid #e9ecef; + border-radius: 0 0 4px 4px; + background: #fff; + + .video-details-container { + padding: 20px; + + // Reset panel styles to integrate better - but preserve form functionality + > .cds-deposit-panel { + margin-bottom: 0; + border: none; + box-shadow: none; + + // Keep the panel heading but style it for better integration + > .panel-heading[hl-sticky] { + background: #f8f9fa; + border: none; + border-bottom: 1px solid #e9ecef; + padding: 10px 15px; + margin: -20px -20px 20px -20px; + + // Hide the redundant video title and info, but keep actions + .text-muted { + display: none; + } + + // Keep actions visible and properly positioned + .pull-right { + display: block; + float: right; + + // Style action buttons for better integration + .btn { + margin-left: 5px; + border-radius: 4px; + + &.btn-success, &.btn-primary { + font-weight: 500; + } + + &.btn-sm { + padding: 5px 10px; + font-size: 12px; + } + } + } + } + } + + // Keep form panels intact for proper field rendering + .panel.panel-default { + border: 1px solid #ddd; + + .panel-heading { + display: block !important; + background-color: #f5f5f5; + padding: 10px 15px; + } + + .panel-body { + padding: 15px; + } + } + + // Ensure form controls work properly + .form-control { + display: block; + width: 100%; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + } + + // Fix for CKEditor and textarea in the editor area + textarea.form-control { + height: auto; + min-height: 80px; + resize: vertical; + width: 100%; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + } + + // Ensure CKEditor instances render properly + .cke { + border: 1px solid #ccc; + } + } + } + } + } + + // Legacy compact view styles (for backward compatibility) + .video-list-compact { + .video-list-item { + cursor: pointer; + transition: all 0.2s ease; + border-left: 3px solid transparent; + + &:hover { + background-color: #f8f9fa; + border-left-color: #ddd; + } + + &.active { + background-color: #e7f3ff; + border-left-color: #007bff; + + .list-group-item-heading { + color: #007bff; + font-weight: 600; + } + } + + &.loading { + opacity: 0.7; + } + } + + .video-thumbnail { + position: relative; + + .loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: white; + } + } + + .progress-xs { + height: 4px; + margin-bottom: 0; + + .progress-bar { + line-height: 4px; + } + } + } + + // Empty state styling + .py-40 { + padding: 40px 0; + } +} + .cds-pills-center { display: flex; justify-content: center; @@ -1122,6 +2225,79 @@ div[cds-search-results] { background-color: $gray-lighter; } +// Chapter styling +.chapter-item { + transition: background-color 0.2s ease; + + &:hover { + background-color: #e8f4fd; + } + + &:last-child { + border-bottom: none !important; + } +} + +.chapter-timestamp { + color: #2196F3; + font-weight: 600; +} + +// Main chapters section styling +.cds-detail-chapters { + .chapter-item-main { + &:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transform: translateY(-1px); + } + + .chapter-timestamp-main { + .badge { + font-family: monospace; + } + } + + .chapter-title-main { + font-size: 14px; + line-height: 1.4; + } + } + + // Responsive adjustments + @media (max-width: 768px) { + .chapter-item-main { + margin-bottom: 10px; + + div[style*="display: flex"] { + flex-direction: column !important; + align-items: flex-start !important; + + .chapter-thumbnail { + margin-bottom: 8px; + margin-right: 0 !important; + } + + .chapter-info { + .chapter-timestamp-main { + margin-bottom: 5px; + } + } + } + } + } + + // Chapter thumbnail styling + .chapter-thumbnail { + img { + transition: transform 0.2s ease; + } + + &:hover img { + transform: scale(1.05); + } + } +} + .transcription-button { border-radius: 9px !important; border: 1px solid $cds-primary-color !important; @@ -1135,4 +2311,426 @@ div[cds-search-results] { .transcription-close:hover { cursor: pointer; +} + +// Transcription section alignment with video player +.cds-detail-video { + // When transcription is active, ensure proper alignment + .container-fluid .row { + margin-left: 0; + margin-right: 0; + align-items: flex-start; + // Add top margin to account for fixed header + margin-top: 20px; + + // Video column when transcription is active + .col-md-8 { + padding-left: 0; + padding-right: 15px; + + .cds-video-iframe { + // Reset all margins to ensure perfect alignment + margin: 0; + padding: 0; + + // Use more stable sizing that works better with zoom + width: 100%; + + // Fallback for browsers without aspect-ratio support + height: 56.25vw; // 16:9 aspect ratio fallback + + // Modern browsers with aspect-ratio support + aspect-ratio: 16/9; + height: auto; + + max-width: 100%; + max-height: 80vh; + + // Responsive adjustments for smaller screens + @media (max-width: 991px) { + height: 56.25vw; // Fallback + aspect-ratio: 16/9; + height: auto; // Override for modern browsers + max-height: 60vh; + } + + iframe { + width: 100% !important; + height: 100% !important; + max-width: 100%; + max-height: 100%; + margin: 0; + padding: 0; + vertical-align: top; + } + } + } + + // Transcription column + .col-md-4 { + padding-left: 15px; + padding-right: 0; + + // Ensure transcription section aligns with video top + #transcriptionsSection { + margin: 0 !important; + padding: 0; + height: auto; + min-height: 400px; + overflow-y: auto; + border-top: none; + position: relative; + z-index: 10; + background: #fff; + + // Override ALL inherited spacing classes + &.mt-20, &.mb-30, &.pt-10, &.pb-10 { + margin: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + // Responsive height adjustment + @media (max-width: 991px) { + height: auto; + max-height: 400px; + margin-top: 20px; + } + + // Transcription content styling + .panel { + max-height: calc(100% - 200px); + + .panel-body { + max-height: inherit; + + .list-unstyled { + .transcript-line { + padding: 8px 12px; + margin-bottom: 0; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f8f9fa; + } + + &.active { + background-color: #e3f2fd; + border-left: 3px solid #2196f3; + } + + strong { + color: #666; + font-size: 11px; + } + + .cds-deposit-frame-thumbnail-label { + margin-top: 4px; + font-size: 13px; + line-height: 1.4; + color: #333; + } + } + } + } + } + + // Search input styling + .form-control { + border-radius: 4px; + border: 1px solid #ddd; + font-size: 14px; + + &:focus { + border-color: #2196f3; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1); + } + } + + // Language select styling + #languageSelect { + border-radius: 4px; + border: 1px solid #ddd; + font-size: 14px; + } + + // Header styling + h3 { + color: #333; + font-size: 18px; + margin: 0; + padding: 0 0 10px 0; + background: #fff; + position: relative; + z-index: 15; + border-bottom: 1px solid #eee; + + .fa-close { + opacity: 0.7; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } + } + } + + // Remove ALL spacing from transcription container and children + .pb-20, .pt-10, .mt-20, .mb-30, .px-20 { + margin: 0 !important; + padding: 10px !important; + } + + // Ensure the first child element (header) has no extra margin + > div:first-child { + margin: 0 !important; + padding: 10px !important; + } + + // Force container to start at top + > * { + &:first-child { + margin-top: 0 !important; + padding-top: 10px !important; + } + } + } + } + } +} + +// Video Keywords styling +.video-keywords-row { + margin-top: 5px; + display: flex; + flex-wrap: wrap; + gap: 3px; + + .keyword-pill { + font-size: 10px; + padding: 2px 6px; + margin: 0; + border-radius: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 80px; + } + + .keyword-pill-more { + font-size: 10px; + padding: 2px 6px; + margin: 0; + border-radius: 10px; + background-color: #5bc0de; + border-color: #46b8da; + } +} + +// Video detail chapters and transcript styles +.chapters-main-horizontal, +.chapters-main-horizontal-additional { + &::-webkit-scrollbar { + height: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + + &:hover { + background: #a8a8a8; + } + } +} + +// Transcript line styling +.transcript-line { + transition: all 0.2s ease; + + &:hover { + background-color: #f8f9fa !important; + border-left: 3px solid #e3f2fd; + padding-left: 11px !important; + } + + &.active { + background-color: #e3f2fd !important; + border-left: 4px solid #2196F3; + padding-left: 10px !important; + box-shadow: 0 2px 4px rgba(33, 150, 243, 0.1); + + .transcript-timestamp { + color: #1976D2 !important; + font-weight: 700 !important; + } + + .transcript-text { + color: #333 !important; + font-weight: 500 !important; + } + } +} + +// In this video panel styles +.in-this-video-panel { + .tabs-container { + margin-bottom: 15px; + margin-top: 10px; + + .nav-tabs { + border-bottom: 1px solid #ddd; + + a { + padding: 10px 14px; + font-size: 14px; + } + } + } + + .search-container { + margin-bottom: 15px; + + .form-control { + font-size: 14px; + } + } + + .content-container { + max-height: 300px; + overflow-y: auto; + margin-bottom: 15px; + } + + .language-selector { + border-top: 1px solid #eee; + padding-top: 15px; + margin-bottom: 0; + + label { + font-size: 13px; + color: #666; + margin-bottom: 6px; + } + + .form-control { + font-size: 14px; + } + } +} + +// Chapter item styles +.chapter-item { + cursor: pointer; + padding: 12px; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f8f9fa; + } + + .chapter-content { + display: flex; + align-items: center; + + .chapter-thumbnail { + min-width: 80px; + margin-right: 12px; + + .thumbnail-container { + width: 80px; + height: 45px; + border-radius: 4px; + overflow: hidden; + background-color: #f5f5f5; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .thumbnail-placeholder { + width: 100%; + height: 100%; + background-color: #e9ecef; + display: flex; + align-items: center; + justify-content: center; + + .fa { + color: #6c757d; + font-size: 16px; + } + } + } + } + + .chapter-info { + flex: 1; + + .chapter-title { + font-size: 15px; + font-weight: 500; + line-height: 1.3; + margin-bottom: 4px; + } + + .chapter-timestamp { + font-size: 14px; + color: #2196F3; + font-weight: 600; + } + } + } +} + +// Transcript item styles +.transcript-item { + cursor: pointer; + padding: 8px 10px; + border-radius: 3px; + margin-bottom: 6px; + + .transcript-timestamp { + font-size: 14px; + color: #2196F3; + font-weight: 600; + margin-bottom: 4px; + } + + .transcript-text { + font-size: 15px; + line-height: 1.4; + } +} + +// Responsive styles for chapters +@media (max-width: 768px) { + .chapter-item-horizontal-main { + width: 150px !important; + } + + .chapter-thumbnail-main-horizontal { + width: 126px !important; + height: 71px !important; + } +} + +@media (max-width: 480px) { + .chapter-item-horizontal-main { + width: 130px !important; + } + + .chapter-thumbnail-main-horizontal { + width: 106px !important; + height: 60px !important; + } } \ No newline at end of file diff --git a/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss b/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss index 896bee114..3f0071a30 100644 --- a/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss +++ b/cds/modules/theme/assets/bootstrap3/scss/cds_previewer/video.scss @@ -92,4 +92,5 @@ video { .theoplayer-skin.light:hover .vjs-big-play-button:after, .theoplayer-skin.light .vjs-big-play-button:focus:after { opacity: 0; -} \ No newline at end of file +} + diff --git a/cds/modules/theme/static/img/video-placeholder.svg b/cds/modules/theme/static/img/video-placeholder.svg new file mode 100644 index 000000000..04f08a244 --- /dev/null +++ b/cds/modules/theme/static/img/video-placeholder.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/recent-changes.md b/recent-changes.md new file mode 100644 index 000000000..1c9408e3d --- /dev/null +++ b/recent-changes.md @@ -0,0 +1,77 @@ +# Recent Changes Summary + +## Last 15 Commits (Latest to Oldest) + +### 799ae1f7 - claude: add chapters via deposit description +- Added chapter functionality through deposit description + +### 635b1fd1 - claude: fix IOS fullscreen issue +- Fixed fullscreen video playback issues on iOS devices + +### e5b856f6 - claude: remove toggleable layout and keep new filtered list +- Removed toggleable layout option +- Maintained new filtered list functionality + +### 2aeedeab - claude: add video list filters and related_identifiers field +- Added filtering capabilities to video lists +- Introduced related_identifiers field + +### e6399b2f - claude: fix delete video timing issue +- Resolved timing problems when deleting videos + +### 33455871 - claude: improve polling mechanism for status updates +- Enhanced polling system for better status update handling + +### 95e53e4f - remove CLAUDE.md +- Removed CLAUDE.md file from repository + +### 355f3120 - gitignore: CLAUDE.md +- Added CLAUDE.md to gitignore + +### 16e5158d - claude: refine deposit videos list and transcription css +- Improved styling for deposit videos list +- Enhanced transcription CSS + +### 124c1370 - claude: fix toggling on side panel and display keywords +- Fixed side panel toggle functionality +- Improved keyword display + +### f44eb4cf - claude: add list views for video editing +- Added list view functionality for video editing interface + +### 6432e385 - claude: deposit form performance optimizations +- Optimized deposit form performance + +### 262c8c9b - detail: fix toggle transcript css +- Fixed CSS issues with transcript toggle + +### 1adb60c2 - detail: fix transcript filtering +- Resolved transcript filtering issues + +### 3241f6ea - templates: rename related_indico_section to related_event_section and remove Indico from section label +- Renamed related_indico_section to related_event_section +- Removed Indico branding from section labels + +## Summary Categories + +### UI/UX Improvements +- iOS fullscreen fixes +- List view enhancements +- Side panel and keyword display improvements +- CSS refinements for transcription and deposit lists + +### Feature Additions +- Chapter functionality via deposit descriptions +- Video list filtering capabilities +- Related identifiers field +- List views for video editing + +### Performance & Technical +- Deposit form performance optimizations +- Improved polling mechanism for status updates +- Video deletion timing fixes +- Transcript filtering improvements + +### Configuration & Maintenance +- CLAUDE.md file management +- Template naming updates (Indico → Event) \ No newline at end of file