Compare commits
No commits in common. "main" and "v6.4.0" have entirely different histories.
50
.github/workflows/docker-build-push.yml
vendored
@ -15,18 +15,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta for base image
|
||||
id: meta-base
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# List of Docker images to use as base name for tags
|
||||
images: |
|
||||
mediacms/mediacms
|
||||
# Generate Docker tags based on the following events/attributes
|
||||
# Set latest tag for default branch
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
|
||||
type=semver,pattern={{version}}
|
||||
@ -40,39 +37,16 @@ jobs:
|
||||
org.opencontainers.image.source=https://github.com/mediacms-io/mediacms
|
||||
org.opencontainers.image.licenses=AGPL-3.0
|
||||
|
||||
- name: Docker meta for full image
|
||||
id: meta-full
|
||||
uses: docker/metadata-action@v4
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
images: |
|
||||
mediacms/mediacms
|
||||
tags: |
|
||||
type=raw,value=full,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
|
||||
type=semver,pattern={{version}}-full
|
||||
type=semver,pattern={{major}}.{{minor}}-full
|
||||
type=semver,pattern={{major}}-full
|
||||
labels: |
|
||||
org.opencontainers.image.title=MediaCMS Full
|
||||
org.opencontainers.image.description=MediaCMS is a modern, fully featured open source video and media CMS, written in Python/Django and React, featuring a REST API. This is the full version with additional dependencies.
|
||||
org.opencontainers.image.vendor=MediaCMS
|
||||
org.opencontainers.image.url=https://mediacms.io/
|
||||
org.opencontainers.image.source=https://github.com/mediacms-io/mediacms
|
||||
org.opencontainers.image.licenses=AGPL-3.0
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push full image
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
target: full
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-full.outputs.tags }}
|
||||
labels: ${{ steps.meta-full.outputs.labels }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
target: base
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-base.outputs.tags }}
|
||||
labels: ${{ steps.meta-base.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
6
.gitignore
vendored
@ -5,7 +5,6 @@ media_files/original/
|
||||
media_files/hls/
|
||||
media_files/chunks/
|
||||
media_files/uploads/
|
||||
media_files/tinymce_media/
|
||||
postgres_data/
|
||||
celerybeat-schedule
|
||||
logs/
|
||||
@ -30,8 +29,3 @@ static/video_editor/videos/sample-video-37s.mp4
|
||||
.DS_Store
|
||||
static/video_editor/videos/sample-video-10m.mp4
|
||||
static/video_editor/videos/sample-video-10s.mp4
|
||||
frontend-tools/video-js/public/videos/sample-video-white.mp4
|
||||
frontend-tools/video-editor/client/public/videos/sample-video.mp3
|
||||
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
||||
static/chapters_editor/videos/sample-video.mp3
|
||||
static/video_editor/videos/sample-video.mp3
|
||||
|
||||
@ -1,3 +1 @@
|
||||
/templates/cms/*
|
||||
/templates/*.html
|
||||
*.scss
|
||||
*
|
||||
21
.prettierrc
@ -1,21 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.css", "*.scss"],
|
||||
"options": {
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
77
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM python:3.13.5-slim-bookworm AS build-image
|
||||
FROM python:3.13.5-bookworm AS build-image
|
||||
|
||||
# Install system dependencies needed for downloading and extracting
|
||||
RUN apt-get update -y && \
|
||||
@ -14,7 +14,7 @@ RUN mkdir -p ffmpeg-tmp && \
|
||||
cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \
|
||||
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
|
||||
|
||||
# Install Bento4 in the specified location
|
||||
# Install Bento4 in the specified location
|
||||
RUN mkdir -p /home/mediacms.io/bento4 && \
|
||||
wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d /home/mediacms.io/bento4 && \
|
||||
@ -23,8 +23,8 @@ RUN mkdir -p /home/mediacms.io/bento4 && \
|
||||
rm -rf /home/mediacms.io/bento4/docs && \
|
||||
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
|
||||
############ BASE RUNTIME IMAGE ############
|
||||
FROM python:3.13.5-slim-bookworm AS base
|
||||
############ RUNTIME IMAGE ############
|
||||
FROM python:3.13.5-bookworm AS runtime_image
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
@ -34,47 +34,13 @@ ENV CELERY_APP='cms'
|
||||
ENV VIRTUAL_ENV=/home/mediacms.io
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# Install system dependencies first
|
||||
# Install runtime system dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
supervisor \
|
||||
nginx \
|
||||
imagemagick \
|
||||
procps \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
zlib1g-dev \
|
||||
zlib1g \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
libpq-dev \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up virtualenv first
|
||||
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && \
|
||||
cd /home/mediacms.io && \
|
||||
python3 -m venv $VIRTUAL_ENV
|
||||
|
||||
# Copy requirements files
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
|
||||
# Install Python dependencies using pip (within virtualenv)
|
||||
ARG DEVELOPMENT_MODE=False
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --no-binary lxml --no-binary xmlsec -r requirements.txt && \
|
||||
if [ "$DEVELOPMENT_MODE" = "True" ]; then \
|
||||
echo "Installing development dependencies..." && \
|
||||
uv pip install -r requirements-dev.txt; \
|
||||
fi && \
|
||||
apt-get purge -y --auto-remove \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libpq-dev
|
||||
apt-get install --no-install-recommends supervisor nginx imagemagick procps pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt-get purge --auto-remove && \
|
||||
apt-get clean
|
||||
|
||||
# Copy ffmpeg and Bento4 from build image
|
||||
COPY --from=build-image /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
@ -82,11 +48,28 @@ COPY --from=build-image /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||
COPY --from=build-image /usr/local/bin/qt-faststart /usr/local/bin/qt-faststart
|
||||
COPY --from=build-image /home/mediacms.io/bento4 /home/mediacms.io/bento4
|
||||
|
||||
# Set up virtualenv
|
||||
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && \
|
||||
cd /home/mediacms.io && \
|
||||
python3 -m venv $VIRTUAL_ENV
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
|
||||
ARG DEVELOPMENT_MODE=False
|
||||
|
||||
RUN pip install --no-cache-dir --no-binary lxml,xmlsec -r requirements.txt && \
|
||||
if [ "$DEVELOPMENT_MODE" = "True" ]; then \
|
||||
echo "Installing development dependencies..." && \
|
||||
pip install --no-cache-dir -r requirements-dev.txt; \
|
||||
fi
|
||||
|
||||
# Copy application files
|
||||
COPY . /home/mediacms.io/mediacms
|
||||
WORKDIR /home/mediacms.io/mediacms
|
||||
|
||||
# required for sprite thumbnail generation for large video files
|
||||
|
||||
COPY deploy/docker/policy.xml /etc/ImageMagick-6/policy.xml
|
||||
|
||||
# Set process control environment variables
|
||||
@ -103,11 +86,3 @@ RUN chmod +x ./deploy/docker/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
|
||||
CMD ["./deploy/docker/start.sh"]
|
||||
|
||||
############ FULL IMAGE ############
|
||||
FROM base AS full
|
||||
COPY requirements-full.txt ./
|
||||
RUN mkdir -p /root/.cache/ && \
|
||||
chmod go+rwx /root/ && \
|
||||
chmod go+rwx /root/.cache/
|
||||
RUN uv pip install -r requirements-full.txt
|
||||
@ -25,12 +25,11 @@ A demo is available at https://demo.mediacms.io
|
||||
- **Complete control over your data**: host it yourself!
|
||||
- **Modern technologies**: Django/Python/Celery, React.
|
||||
- **Support for multiple publishing workflows**: public, private, unlisted and custom
|
||||
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
|
||||
- **Automatic transcription**: through integration with Whisper running locally
|
||||
- **Multiple media types support**: video, audio, image, pdf
|
||||
- **Multiple media classification options**: categories, tags and custom
|
||||
- **Multiple media sharing options**: social media share, videos embed code generation
|
||||
- **Video Trimmer**: trim video, replace, save as new or create segments
|
||||
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
|
||||
- **SAML support**: with ability to add mappings to system roles and groups
|
||||
- **Easy media searching**: enriched with live search functionality
|
||||
- **Playlists for audio and video content**: create playlists, add and reorder content
|
||||
@ -49,7 +48,7 @@ A demo is available at https://demo.mediacms.io
|
||||
|
||||
## Example cases
|
||||
|
||||
- **Universities, schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
|
||||
- **Schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
|
||||
- **Organization sensitive content.** In cases where content is sensitive and cannot be uploaded to external sites.
|
||||
- **Build a great community.** MediaCMS can be customized (URLs, logos, fonts, aesthetics) so that you create a highly customized video portal for your community!
|
||||
- **Personal portal.** Organize, categorize and host your content the way you prefer.
|
||||
@ -84,7 +83,6 @@ For a small to medium installation, with a few hours of video uploaded daily, an
|
||||
|
||||
In terms of disk space, think of what the needs will be. A general rule is to multiply by three the size of the expected uploaded videos (since the system keeps original versions, encoded versions plus HLS), so if you receive 1G of videos daily and maintain all of them, you should consider a 1T disk across a year (1G * 3 * 365).
|
||||
|
||||
In order to support automatic transcriptions through Whisper, consider more CPUs.
|
||||
|
||||
## Installation / Maintanance
|
||||
|
||||
@ -112,7 +110,7 @@ This software uses the following list of awesome technologies: Python, Django, D
|
||||
|
||||
|
||||
## Who is using it
|
||||
- **Multiple Universities** for hosting educational videos
|
||||
|
||||
- **Cinemata** non-profit media, technology and culture organization - https://cinemata.org
|
||||
- **Critical Commons** public media archive and fair use advocacy network - https://criticalcommons.org
|
||||
- **American Association of Gynecologic Laparoscopists** - https://surgeryu.org/
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
|
||||
|
||||
class ApprovalBackend(ModelBackend):
|
||||
def user_can_authenticate(self, user):
|
||||
can_authenticate = super().user_can_authenticate(user)
|
||||
if can_authenticate and settings.USERS_NEEDS_TO_BE_APPROVED and not user.is_superuser:
|
||||
return getattr(user, 'is_approved', False)
|
||||
return can_authenticate
|
||||
@ -34,7 +34,6 @@ INSTALLED_APPS = [
|
||||
"allauth.socialaccount.providers.saml",
|
||||
"saml_auth.apps.SamlAuthConfig",
|
||||
"corsheaders",
|
||||
"tinymce",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class ApprovalMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if settings.USERS_NEEDS_TO_BE_APPROVED and request.user.is_authenticated and not request.user.is_superuser and not getattr(request.user, 'is_approved', False):
|
||||
allowed_paths = [
|
||||
reverse('approval_required'),
|
||||
reverse('account_logout'),
|
||||
]
|
||||
if request.path not in allowed_paths:
|
||||
if request.path.startswith('/api/'):
|
||||
return JsonResponse({'detail': 'User account not approved.'}, status=403)
|
||||
return redirect('approval_required')
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
@ -1,22 +1,14 @@
|
||||
from django.conf import settings
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from files.methods import (
|
||||
is_mediacms_editor,
|
||||
is_mediacms_manager,
|
||||
user_allowed_to_upload,
|
||||
)
|
||||
from files.methods import is_mediacms_editor, is_mediacms_manager
|
||||
|
||||
|
||||
class IsAuthorizedToAdd(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
if not user_allowed_to_upload(request):
|
||||
raise PermissionDenied("You don't have permission to upload media, or have reached max number of media uploads.")
|
||||
|
||||
return True
|
||||
return user_allowed_to_upload(request)
|
||||
|
||||
|
||||
class IsAuthorizedToAddComment(permissions.BasePermission):
|
||||
@ -63,6 +55,26 @@ class IsUserOrEditor(permissions.BasePermission):
|
||||
return obj.user == request.user
|
||||
|
||||
|
||||
def user_allowed_to_upload(request):
|
||||
"""Any custom logic for whether a user is allowed
|
||||
to upload content lives here
|
||||
"""
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
|
||||
if settings.CAN_ADD_MEDIA == "all":
|
||||
return True
|
||||
elif settings.CAN_ADD_MEDIA == "email_verified":
|
||||
if request.user.email_is_verified:
|
||||
return True
|
||||
elif settings.CAN_ADD_MEDIA == "advancedUser":
|
||||
if request.user.advancedUser:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def user_allowed_to_comment(request):
|
||||
"""Any custom logic for whether a user is allowed
|
||||
to comment lives here
|
||||
|
||||
100
cms/settings.py
@ -105,23 +105,6 @@ USE_L10N = True
|
||||
USE_TZ = True
|
||||
SITE_ID = 1
|
||||
|
||||
# these are the portal logos (dark and light)
|
||||
# set new paths for svg or png if you want to override
|
||||
# svg has priority over png, so if you want to use
|
||||
# custom pngs and not svgs, remove the lines with svgs
|
||||
# or set as empty strings
|
||||
# example:
|
||||
# PORTAL_LOGO_DARK_SVG = ""
|
||||
# PORTAL_LOGO_LIGHT_SVG = ""
|
||||
# place the files on static/images folder
|
||||
PORTAL_LOGO_DARK_SVG = "/static/images/logo_dark.svg"
|
||||
PORTAL_LOGO_DARK_PNG = "/static/images/logo_dark.png"
|
||||
PORTAL_LOGO_LIGHT_SVG = "/static/images/logo_light.svg"
|
||||
PORTAL_LOGO_LIGHT_PNG = "/static/images/logo_dark.png"
|
||||
|
||||
# paths to extra css files to be included, eg "/static/css/custom.css"
|
||||
# place css inside static/css folder
|
||||
EXTRA_CSS_PATHS = []
|
||||
# protection agains anonymous users
|
||||
# per ip address limit, for actions as like/dislike/report
|
||||
TIME_TO_ACTION_ANONYMOUS = 10 * 60
|
||||
@ -145,10 +128,6 @@ USERS_CAN_SELF_REGISTER = True
|
||||
|
||||
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"]
|
||||
|
||||
# by default users do not need to be approved. If this is set to True, then new users
|
||||
# will have to be approved before they can login successfully
|
||||
USERS_NEEDS_TO_BE_APPROVED = False
|
||||
|
||||
# Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"]
|
||||
# Empty list disables.
|
||||
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = []
|
||||
@ -247,7 +226,7 @@ POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY = ""
|
||||
# only in case where unlisted workflow is used and no commentary
|
||||
# exists
|
||||
|
||||
CANNOT_ADD_MEDIA_MESSAGE = "User cannot add media, or maximum number of media uploads has been reached."
|
||||
CANNOT_ADD_MEDIA_MESSAGE = ""
|
||||
|
||||
# mp4hls command, part of Bento4
|
||||
MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls"
|
||||
@ -306,7 +285,6 @@ INSTALLED_APPS = [
|
||||
"drf_yasg",
|
||||
"allauth.socialaccount.providers.saml",
|
||||
"saml_auth.apps.SamlAuthConfig",
|
||||
"tinymce",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -490,49 +468,9 @@ LANGUAGES = [
|
||||
|
||||
LANGUAGE_CODE = 'en' # default language
|
||||
|
||||
TINYMCE_DEFAULT_CONFIG = {
|
||||
"theme": "silver",
|
||||
"height": 500,
|
||||
"resize": "both",
|
||||
"menubar": "file edit view insert format tools table help",
|
||||
"menu": {
|
||||
"format": {
|
||||
"title": "Format",
|
||||
"items": "blocks | bold italic underline strikethrough superscript subscript code | " "fontfamily fontsize align lineheight | " "forecolor backcolor removeformat",
|
||||
},
|
||||
},
|
||||
"plugins": "advlist,autolink,autosave,lists,link,image,charmap,print,preview,anchor,"
|
||||
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,directionality,"
|
||||
"code,help,wordcount,emoticons,file,image,media",
|
||||
"toolbar": "undo redo | code preview | blocks | "
|
||||
"bold italic | alignleft aligncenter "
|
||||
"alignright alignjustify ltr rtl | bullist numlist outdent indent | "
|
||||
"removeformat | restoredraft help | image media",
|
||||
"branding": False, # remove branding
|
||||
"promotion": False, # remove promotion
|
||||
"body_class": "page-main-inner custom-page-wrapper", # class of the body element in tinymce
|
||||
"block_formats": "Paragraph=p; Heading 1=h1; Heading 2=h2; Heading 3=h3;",
|
||||
"formats": { # customize h2 to always have emphasis-large class
|
||||
"h2": {"block": "h2", "classes": "emphasis-large"},
|
||||
},
|
||||
"font_size_formats": "16px 18px 24px 32px",
|
||||
"images_upload_url": "/tinymce/upload/",
|
||||
"images_upload_handler": "tinymce.views.upload_image",
|
||||
"automatic_uploads": True,
|
||||
"file_picker_types": "image",
|
||||
"paste_data_images": True,
|
||||
"paste_as_text": False,
|
||||
"paste_enable_default_filters": True,
|
||||
"paste_word_valid_elements": "b,strong,i,em,h1,h2,h3,h4,h5,h6,p,br,a,ul,ol,li",
|
||||
"paste_retain_style_properties": "all",
|
||||
"paste_remove_styles": False,
|
||||
"paste_merge_formats": True,
|
||||
"sandbox_iframes": False,
|
||||
}
|
||||
|
||||
SPRITE_NUM_SECS = 10
|
||||
# number of seconds for sprite image.
|
||||
# If you plan to change this, you must also follow the instructions on admins_docs.md
|
||||
# If you plan to change this, you must also follow the instructions on admin_docs.md
|
||||
# to change the equivalent value in ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and then re-build frontend
|
||||
|
||||
# how many images will be shown on the slideshow
|
||||
@ -563,34 +501,9 @@ ALLOW_CUSTOM_MEDIA_URLS = False
|
||||
# Whether to allow anonymous users to list all users
|
||||
ALLOW_ANONYMOUS_USER_LISTING = True
|
||||
|
||||
# Who can see the members page
|
||||
# valid choices are all, editors, admins
|
||||
CAN_SEE_MEMBERS_PAGE = "all"
|
||||
|
||||
# Maximum number of media a user can upload
|
||||
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100
|
||||
|
||||
# ffmpeg options
|
||||
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||
|
||||
# If 'all' is in the list, no check is performed
|
||||
ALLOWED_MEDIA_UPLOAD_TYPES = ["video", "audio", "image", "pdf"]
|
||||
|
||||
# transcription options
|
||||
# the mediacms-full docker image needs to be used in order to be able to use transcription
|
||||
# if you are using the mediacms-full image, change USE_WHISPER_TRANSCRIBE to True
|
||||
USE_WHISPER_TRANSCRIBE = False
|
||||
|
||||
# by default all users can request a video to be transcribed. If you want to
|
||||
# allow only editors, set this to False
|
||||
USER_CAN_TRANSCRIBE_VIDEO = True
|
||||
|
||||
# Whisper transcribe options - https://github.com/openai/whisper
|
||||
WHISPER_MODEL = "base"
|
||||
|
||||
# show a custom text in the sidebar footer, otherwise the default will be shown if this is empty
|
||||
SIDEBAR_FOOTER_TEXT = ""
|
||||
|
||||
try:
|
||||
# keep a local_settings.py file for local overrides
|
||||
from .local_settings import * # noqa
|
||||
@ -632,12 +545,3 @@ except ImportError:
|
||||
if GLOBAL_LOGIN_REQUIRED:
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
|
||||
|
||||
|
||||
if USERS_NEEDS_TO_BE_APPROVED:
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'cms.auth_backends.ApprovalBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
)
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
||||
|
||||
@ -30,7 +30,6 @@ urlpatterns = [
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||
path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||
path("tinymce/", include("tinymce.urls")),
|
||||
]
|
||||
|
||||
admin.site.site_header = "MediaCMS Admin"
|
||||
|
||||
@ -1 +1 @@
|
||||
VERSION = "7.0.1-beta.8"
|
||||
VERSION = "6.4.0"
|
||||
|
||||
@ -30,8 +30,7 @@ fi
|
||||
|
||||
# We should do this only for folders that have a different owner, since it is an expensive operation
|
||||
# Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934
|
||||
# Exclude package-lock.json files that may not exist or be removed during frontend setup
|
||||
find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json" \) -exec chown www-data:$TARGET_GID {} + 2>/dev/null || true
|
||||
find /home/mediacms.io/mediacms ! \( -path "*.git*" \) -exec chown www-data:$TARGET_GID {} +
|
||||
|
||||
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
# This script builds the video editor package and deploys the frontend assets to the static directory.
|
||||
# How to run: sh deploy/scripts/build_and_deploy.sh
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
@ -13,21 +12,9 @@ cd frontend-tools/video-editor
|
||||
yarn build:django
|
||||
cd ../../
|
||||
|
||||
# Build chapter editor package
|
||||
echo "Building chapters editor package..."
|
||||
cd frontend-tools/chapters-editor
|
||||
yarn build:django
|
||||
cd ../../
|
||||
|
||||
# Build video js package
|
||||
echo "Building video js package..."
|
||||
cd frontend-tools/video-js
|
||||
yarn build:django
|
||||
cd ../../
|
||||
|
||||
# Run npm build in the frontend container
|
||||
echo "Building frontend assets..."
|
||||
docker compose -f docker-compose/docker-compose-dev-updated.yaml exec frontend npm run dist
|
||||
docker compose -f docker-compose-dev.yaml exec frontend npm run dist
|
||||
|
||||
# Copy static assets to the static directory
|
||||
echo "Copying static assets..."
|
||||
@ -35,6 +22,6 @@ cp -r frontend/dist/static/* static/
|
||||
|
||||
# Restart the web service
|
||||
echo "Restarting web service..."
|
||||
docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web
|
||||
docker compose -f docker-compose-dev.yaml restart web
|
||||
|
||||
echo "Build and deployment completed successfully!"
|
||||
@ -5,7 +5,6 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: base
|
||||
args:
|
||||
- DEVELOPMENT_MODE=True
|
||||
image: mediacms/mediacms-dev:latest
|
||||
@ -85,5 +84,6 @@ services:
|
||||
ENABLE_NGINX: 'no'
|
||||
ENABLE_CELERY_BEAT: 'no'
|
||||
ENABLE_MIGRATIONS: 'no'
|
||||
DEVELOPMENT_MODE: True
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
celery_worker:
|
||||
image: mediacms/mediacms:full
|
||||
@ -33,35 +33,55 @@ services:
|
||||
volumes:
|
||||
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
|
||||
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
|
||||
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
|
||||
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
|
||||
- npm_cache:/home/node/.npm
|
||||
- npm_global:/home/node/.npm-global
|
||||
working_dir: /home/mediacms.io/mediacms/frontend/
|
||||
command: >
|
||||
bash -c "
|
||||
echo 'Checking dependencies...' &&
|
||||
if [ ! -f node_modules/.install-complete ]; then
|
||||
echo 'First-time setup or dependencies changed, installing...' &&
|
||||
npm install --legacy-peer-deps --cache /home/node/.npm &&
|
||||
echo 'Setting up npm global directory...' &&
|
||||
mkdir -p /home/node/.npm-global &&
|
||||
chown -R node:node /home/node/.npm-global &&
|
||||
echo 'Setting up permissions...' &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend &&
|
||||
echo 'Cleaning up node_modules...' &&
|
||||
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
||||
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
||||
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
|
||||
echo 'Switching to node user...' &&
|
||||
su node -c '
|
||||
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
|
||||
echo \"Setting up frontend...\" &&
|
||||
rm -f package-lock.json &&
|
||||
rm -f packages/player/package-lock.json &&
|
||||
rm -f packages/scripts/package-lock.json &&
|
||||
echo \"Installing dependencies...\" &&
|
||||
npm install --legacy-peer-deps &&
|
||||
echo \"Setting up workspaces...\" &&
|
||||
npm install -g npm@latest &&
|
||||
cd packages/scripts &&
|
||||
npm install --legacy-peer-deps --cache /home/node/.npm &&
|
||||
npm install --legacy-peer-deps &&
|
||||
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
|
||||
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
|
||||
npm install tslib@2.6.2 --save --legacy-peer-deps &&
|
||||
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
|
||||
npm install --legacy-peer-deps &&
|
||||
npm run build &&
|
||||
cd ../.. &&
|
||||
touch node_modules/.install-complete &&
|
||||
echo 'Dependencies installed successfully'
|
||||
else
|
||||
echo 'Dependencies already installed, skipping installation...' &&
|
||||
if [ ! -d packages/scripts/dist ]; then
|
||||
echo 'Building scripts package...' &&
|
||||
cd packages/scripts &&
|
||||
cd packages/player &&
|
||||
npm install --legacy-peer-deps &&
|
||||
npm run build &&
|
||||
cd ../..
|
||||
fi
|
||||
fi &&
|
||||
echo 'Starting development server...' &&
|
||||
cd ../.. &&
|
||||
echo \"Starting development server...\" &&
|
||||
npm run start
|
||||
"
|
||||
'"
|
||||
env_file:
|
||||
- ${PWD}/frontend/.env
|
||||
environment:
|
||||
- NPM_CONFIG_PREFIX=/home/node/.npm-global
|
||||
ports:
|
||||
- "8088:8088"
|
||||
depends_on:
|
||||
@ -120,5 +140,6 @@ services:
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
player_node_modules:
|
||||
scripts_node_modules:
|
||||
npm_cache:
|
||||
npm_global:
|
||||
|
||||
@ -26,9 +26,6 @@
|
||||
- [23. SAML setup](#23-saml-setup)
|
||||
- [24. Identity Providers setup](#24-identity-providers-setup)
|
||||
- [25. Custom urls](#25-custom-urls)
|
||||
- [26. Allowed files](#26-allowed-files)
|
||||
- [27. User upload limits](#27-user-upload-limits)
|
||||
- [28. Whisper Transcribe for Automatic Subtitles](#28-whisper-transcribe-for-automatic-subtitles)
|
||||
|
||||
|
||||
## 1. Welcome
|
||||
@ -125,12 +122,6 @@ migrations_1 | Created admin user with password: gwg1clfkwf
|
||||
|
||||
or if you have set the ADMIN_PASSWORD variable on docker-compose file you have used (example `docker-compose.yaml`), that variable will be set as the admin user's password
|
||||
|
||||
`Note`: if you want to use the automatic transcriptions, you have to do one of the following:
|
||||
* either use the docker-compose.full.yaml, so in this case run `docker-compose -f docker-compose.yaml -f docker-compose.full.yaml up`
|
||||
* or edit the docker-compose.yaml file and set the image for the celery_worker service as mediacms/mediacms:full instead of mediacms/mediacms:latest
|
||||
|
||||
Plus set variable `USE_WHISPER_TRANSCRIBE = True` in the settings.py file
|
||||
|
||||
### Update
|
||||
|
||||
Get latest MediaCMS image and stop/start containers
|
||||
@ -240,12 +231,7 @@ Docker Compose installation: edit `deploy/docker/local_settings.py`, make a chan
|
||||
|
||||
### 5.1 Change portal logo
|
||||
|
||||
Find the default svg files for the white theme on `static/images/logo_dark.svg` and for the dark theme on `static/images/logo_light.svg`
|
||||
You can specify new svg paths to override by editing the `PORTAL_LOGO_DARK_SVG` and `PORTAL_LOGO_LIGHT_SVG` variables in `settings.py`.
|
||||
|
||||
You can also use custom pngs, by setting the variables `PORTAL_LOGO_DARK_PNG` and `PORTAL_LOGO_LIGHT_PNG` in `settings.py`. The svg files have priority over png files, so if both are set, svg files will be used.
|
||||
|
||||
In any case, make sure the files are placed on the static/images folder.
|
||||
Set a new svg file for the white theme (`static/images/logo_dark.svg`) or the dark theme (`static/images/logo_light.svg`)
|
||||
|
||||
### 5.2 Set global portal title
|
||||
|
||||
@ -525,20 +511,6 @@ ALLOW_ANONYMOUS_USER_LISTING = False
|
||||
When set to False, only logged-in users will be able to access the user listing API endpoint.
|
||||
|
||||
|
||||
### 5.27 Control who can see the members page
|
||||
|
||||
By default `CAN_SEE_MEMBERS_PAGE = "all"` means that all registered users can see the members page. Other valid options are:
|
||||
|
||||
- **editors**, only MediaCMS editors can view the page
|
||||
- **admins**, only MediaCMS admins can view the page
|
||||
|
||||
|
||||
### 5.28 Require user approval on registration
|
||||
|
||||
By default, users do not require approval, so they can login immediately after registration (if registration is open). However, if the parameter `USERS_NEEDS_TO_BE_APPROVED` is set to `True`, they will first have to have their accounts approved by an administrator before they can successfully sign in.
|
||||
Administrators can approve users through the following ways: 1. through Django administration, 2. through the users management page, 3. through editing the profile page directly. In all cases, set 'Is approved' to True.
|
||||
|
||||
|
||||
## 6. Manage pages
|
||||
to be written
|
||||
|
||||
@ -983,8 +955,6 @@ Select the SAML Configurations tab, create a new one and set:
|
||||
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
|
||||
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS
|
||||
|
||||
A full SAML deployment with [EntraID guide and troubleshooting steps is available here.](./saml_entraid_setup.md). This guide can be used as reference for other IDPs too.
|
||||
|
||||
## 24. Identity Providers setup
|
||||
|
||||
A separate Django app identity_providers has been added in order to facilitate a number of configurations related to different identity providers. If this is enabled, it gives the following options:
|
||||
@ -1006,35 +976,3 @@ Visiting the admin, you will see the Identity Providers tab and you can add one.
|
||||
## 25. Custom urls
|
||||
To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py
|
||||
This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this.
|
||||
|
||||
## 26. Allowed files
|
||||
MediaCMS performs identification attempts on new file uploads and only allows certain file types specified in the `ALLOWED_MEDIA_UPLOAD_TYPES` setting. By default, only ["video", "audio", "image", "pdf"] files are allowed.
|
||||
|
||||
When a file is not identified as one of these allowed types, the file gets removed from the system and there's an entry indicating that this is not a supported media type.
|
||||
|
||||
If you want to change the allowed file types, edit the `ALLOWED_MEDIA_UPLOAD_TYPES` list in your `settings.py` or `local_settings.py` file. If 'all' is specified in this list, no check is performed and all files are allowed.
|
||||
|
||||
## 27. User upload limits
|
||||
MediaCMS allows you to set a maximum number of media files that each user can upload. This is controlled by the `NUMBER_OF_MEDIA_USER_CAN_UPLOAD` setting in `settings.py` or `local_settings.py`. By default, this is set to 100 media items per user.
|
||||
|
||||
When a user reaches this limit, they will no longer be able to upload new media until they delete some of their existing content. This limit applies regardless of the user's role or permissions in the system.
|
||||
|
||||
To change the maximum number of uploads allowed per user, modify the `NUMBER_OF_MEDIA_USER_CAN_UPLOAD` value in your settings file:
|
||||
|
||||
```
|
||||
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 5
|
||||
```
|
||||
|
||||
## 28. Whisper Transcribe for Automatic Subtitles
|
||||
MediaCMS can integrate with OpenAI's Whisper to automatically generate subtitles for your media files. This feature is useful for making your content more accessible.
|
||||
|
||||
### How it works
|
||||
When the whisper transcribe task is triggered for a media file, MediaCMS runs the `whisper` command-line tool to process the audio and generate a subtitle file in VTT format. The generated subtitles are then associated with the media and are available under the "automatic" language option.
|
||||
|
||||
### Configuration
|
||||
|
||||
Transcription functionality is available only for the Docker installation. To enable this feature, you must either use the `docker-compose.full.yaml` file, as it contains an image with the necessary requirements, or you can also set that celery_worker service is usine mediacms:full image instead of mediacms:latest. Then you also have to set the setting: `USE_WHISPER_TRANSCRIBE = True` in your local_settings.py file.
|
||||
|
||||
By default, all users have the ability to send a request for a video to be transcribed, as well as transcribed and translated to English. If you wish to change this behavior, you can edit the `settings.py` file and set `USER_CAN_TRANSCRIBE_VIDEO=False`.
|
||||
|
||||
The transcription uses the base model of Whisper speech-to-text by default. However, you can change the model by editing the `WHISPER_MODEL` setting in `settings.py`.
|
||||
|
||||
@ -1,315 +0,0 @@
|
||||
# Integrating Microsoft Entra ID (formerly Azure AD) with MediaCMS via SAML Authentication
|
||||
|
||||
This guide provides step-by-step instructions on how to configure Microsoft Entra ID as a SAML Identity Provider (IdP) for MediaCMS, an open-source content management system. The goal is to enable single sign-on (SSO) authentication for users in a secure and scalable way.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Prerequisites](#prerequisites)
|
||||
3. [Step 1: Configure MediaCMS for SAML](#step-1-configure-mediacms-for-saml)
|
||||
4. [Step 2: Register MediaCMS as an Enterprise App in Entra ID](#step-2-register-mediacms-as-an-enterprise-app-in-entra-id)
|
||||
5. [Step 3: Configure SAML Settings in Entra ID](#step-3-configure-saml-settings-in-entra-id)
|
||||
6. [Step 4: Configure SAML Settings in MediaCMS](#step-4-configure-saml-settings-in-mediacms)
|
||||
7. [Step 5: Allow Users or Groups to Log Into the Application](#step-5-allow-users-or-groups-to-log-into-the-application)
|
||||
8. [Step 6: Test and Validate Login Flow](#step-6-test-and-validate-login-flow)
|
||||
9. [Troubleshooting](#troubleshooting)
|
||||
10. [Resources](#resources)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
MediaCMS supports SAML 2.0 authentication by acting as a Service Provider (SP). By integrating with Microsoft Entra ID, organizations can allow users to authenticate using their existing enterprise credentials.
|
||||
|
||||
In our particular deployment of MediaCMS, the application is hosted internally with no direct inbound access from the public Internet. As an internal company application, it was essential to integrate it with our existing authentication systems and provide a seamless single sign-on experience. This is where the SAML protocol shines.
|
||||
|
||||
One of the major advantages of SAML authentication is that all communication between the Identity Provider (IdP) — in this case, Microsoft Entra ID — and the Service Provider (SP) — MediaCMS — is brokered entirely by the end user's browser. The browser initiates the authentication flow, communicates securely with Microsoft’s login portal, receives the identity assertion, and then passes it back to the internal MediaCMS server.
|
||||
|
||||
This architecture enables the MediaCMS server to remain isolated from the Internet while still participating in a modern and seamless federated login experience.
|
||||
|
||||
Even though the deployment method outlined in this tutorial is for EntraID on an isolated MediaCMS server, the same steps and general information could be applied to another authentication SAML provider/identity provider on a non-isolated system.
|
||||
|
||||
> **Note**: This guide assumes you are running MediaCMS with Django backend and that the `django-allauth` library is enabled and configured.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before beginning, ensure the following:
|
||||
|
||||
* You have administrator access to both MediaCMS and Microsoft Entra ID (Azure portal).
|
||||
* MediaCMS is installed and accessible via HTTPS, with a valid SSL certificate.
|
||||
* Your MediaCMS installation has SAML support enabled (via `django-allauth`).
|
||||
* You have a dedicated domain or subdomain for MediaCMS (e.g., `https://<MyMediaCMS.MyDomain.com>`).
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Configure MediaCMS for SAML
|
||||
|
||||
The first step in enabling SAML authentication is to modify the `local_settings.py` (for Docker: `./deploy/docker/local_settings.py`) file of your MediaCMS deployment. Add the following configuration block to enable SAML support, role-based access control (RBAC), and enforce secure communication settings:
|
||||
|
||||
```python
|
||||
USE_RBAC = True
|
||||
USE_SAML = True
|
||||
USE_IDENTITY_PROVIDERS = True
|
||||
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SECURE_SSL_REDIRECT = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"saml": {
|
||||
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These settings enable SAML authentication, configure MediaCMS to respect role-based access, and apply important headers and cookie policies for secure browser handling — all of which are necessary for the SAML flow to function properly.
|
||||
|
||||
> ⚠️ **Important**: After updating the `local_settings.py` file, you must restart your MediaCMS service (e.g., by rebooting the Docker container) in order for the changes to take effect. This step must be completed before proceeding to the next configuration stage.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Register MediaCMS as an Enterprise App in Entra ID
|
||||
|
||||
To begin the integration process on the Microsoft Entra ID (formerly Azure AD) side, follow the steps below to register MediaCMS as a new Enterprise Application.
|
||||
|
||||
### 1. Navigate to Enterprise Applications
|
||||
|
||||
* Log in to your [Azure Portal](https://portal.azure.com).
|
||||
* Navigate to **Enterprise Applications**.
|
||||
|
||||
> *Note: This guide assumes you already have an existing Azure tenant and Entra ID configured with users and groups.*
|
||||
|
||||
### 2. Create a New Application
|
||||
|
||||
* Click the **+ New Application** button.
|
||||
* On the next screen, choose **Create your own application**.
|
||||
* Enter a name for the application (e.g., `MediaCMS`).
|
||||
* Under "What are you looking to do with your application?", select **Integrate any other application you don't find in the gallery (Non-gallery)**.
|
||||
* Click **Create**.
|
||||
|
||||
After a few moments, Azure will create the new application and redirect you to its configuration page.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Configure SAML Settings in Entra ID
|
||||
|
||||
### 1. Configure SAML-Based Single Sign-On
|
||||
|
||||
* From the application overview page, in the left-hand menu under **Manage**, click **Single sign-on**.
|
||||
* You will be prompted to choose a sign-on method. Select **SAML**.
|
||||
|
||||
### 2. Choose a Client ID Name
|
||||
|
||||
Before filling out the SAML configuration, you must decide on a client ID name. This name will uniquely identify your SAML integration and appear in your login URL.
|
||||
|
||||
* Choose a name that is descriptive and easy to remember (e.g., `mediacms_entraid`).
|
||||
* You will use this name in both MediaCMS and Entra ID configuration settings.
|
||||
|
||||
### 3. Fill Out Basic SAML Configuration
|
||||
|
||||
Now input the following values under the **Basic SAML Configuration** section:
|
||||
|
||||
| Field | Value |
|
||||
| -------------------------- | --------------------------------------------------------------------- |
|
||||
| **Identifier (Entity ID)** | `https://<MyMediaCMS.MyDomain.com>/saml/metadata/` |
|
||||
| **Reply URL (ACS URL)** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/acs/` |
|
||||
| **Sign-on URL** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/login/` |
|
||||
| **Relay State (Optional)** | `https://<MyMediaCMS.MyDomain.com>/` |
|
||||
| **Logout URL (Optional)** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/sls/` |
|
||||
|
||||
> 🔐 Replace `<MyClientID>` with your own chosen client ID if different.
|
||||
|
||||
Once these fields are filled in, save your configuration.
|
||||
|
||||
Keep the Azure Enterprise single sign-on configuration window up, as we are now going to configure some of the details from this Azure page into our MediaCMS system.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Configure SAML Settings in MediaCMS
|
||||
|
||||
In MediaCMS, start by logging into the back-end administrative web page. You will now have new options under the left-hand menu bar.
|
||||
|
||||
### 1. Add Login Option
|
||||
|
||||
* Navigate to **Identity Providers → Login Options**.
|
||||
|
||||
* Click **Add Login Option**.
|
||||
|
||||
* Give the login option a title. This title can be anything you like but it will appear to the end-user when they select a method of logging in, so ensure the name is clear. (e.g., `EntraID-SSO`).
|
||||
|
||||
* Set the **Login URL** to the same Sign-on URL:
|
||||
|
||||
```
|
||||
https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/login/
|
||||
```
|
||||
|
||||
* Leave the ordering at `0` if you have no other authentication methods.
|
||||
|
||||
* Ensure the **Active** box is checked to make this an active login method.
|
||||
|
||||
* Click **Save** to continue.
|
||||
|
||||
### 2. Add ID Provider
|
||||
|
||||
* Navigate to **Identity Providers → ID Providers**.
|
||||
* Click **Add ID Provider**.
|
||||
|
||||
Back in your Azure Enterprise application configuration window (at the bottom of the Single Sign-On configuration menu), find your application-specific details. They will look like the following example:
|
||||
|
||||
```
|
||||
Example unique AppID: 123456ab-1234-12ab-ab12-abc123abc123
|
||||
The unique AppID is automatically generated when you create the application.
|
||||
|
||||
-- Example URLs --
|
||||
Login URL: https://login.microsoftonline.com/123456ab-1234-12ab-ab12-abc123abc123/saml2
|
||||
Microsoft Entra Identifier: https://sts.windows.net/123456ab-1234-12ab-ab12-abc123abc123/
|
||||
Logout URL: https://login.microsoftonline.com/123456ab-1234-12ab-ab12-abc123abc123/saml2
|
||||
```
|
||||
|
||||
Back in MediaCMS's new ID Provider window, under the **General** tab:
|
||||
|
||||
* **Protocol**: `saml` (all lowercase)
|
||||
* **Provider ID**: The Microsoft Entra Identifier (as shown above), the whole URL.
|
||||
* **IDP Configuration Name**: Any unique name (e.g., `EntraID`)
|
||||
* **Client ID**: The exact same client ID you used earlier when configuring EntraID (e.g., `mediacms_entraid`).
|
||||
* **Sites**: Add all the sites you want this login to appear on (e.g., all of them)
|
||||
|
||||
Click **Save and Continue**, then go to the **SAML Configuration** tab.
|
||||
|
||||
On the **SAML Configuration** tab:
|
||||
|
||||
* **SSO URL**: Use the same Logon URL from EntraID example listed above.
|
||||
|
||||
* **SLO URL**: Use the Logout URL from EntraID example listed above.
|
||||
|
||||
* **SP Metadata URL**:
|
||||
|
||||
```
|
||||
https://<MyMediaCMS.MyDomain.com>/saml/metadata/
|
||||
```
|
||||
|
||||
* **IdP ID**: Use the same Microsoft Entra Identifier URL as listed above.
|
||||
|
||||
#### LDP Certificate
|
||||
|
||||
Back in Azure's Enterprise Application page (SAML certificates section), download the **Base64 Certificate**, open it in a text editor, and copy the contents into the **LDP Certificate** setting inside of MediaCMS.
|
||||
|
||||
### 3. Configure Identity Mappings
|
||||
|
||||
Map the identity attributes that Entra ID will provide to MediaCMS. Even though only UID is specified as mandatory, Entra ID will not work unless all of these details are filled in(YES, you must type NA in the fields; you cannot leave anything blank. You will get 500 errors if this is not done). You can use the exact settings below:
|
||||
|
||||
| Field | Value |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| **Uid** | `http://schemas.microsoft.com/identity/claims/objectidentifier` |
|
||||
| **Name** | `http://schemas.microsoft.com/identity/claims/displayname` |
|
||||
| **Email** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` |
|
||||
| **Groups** | `NA` |
|
||||
| **First name** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` |
|
||||
| **Last name** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` |
|
||||
| **User logo** | `NA` |
|
||||
| **Role** | `NA` |
|
||||
|
||||
> ℹ️ Groups and Role can be changed or remapped inside the Azure Enterprise Application under **Attributes and Claims**.
|
||||
|
||||
Check the **Verified Email** box (since EntraID will verify the user for you). While setting up, you can enable **Save SAML Response Log** for troubleshooting purposes.
|
||||
|
||||
Finally, click **Save** to finish adding the new ID provider.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Allow Users or Groups to Log Into the Application
|
||||
|
||||
Back inside Azure AD, within your MediaCMS Enterprise Application, you must assign users or groups that are allowed to use the MediaCMS authentication sign-on.
|
||||
|
||||
### 1. Navigate to Users and Groups
|
||||
|
||||
* Open the Azure Portal and go to your **MediaCMS Enterprise Application**.
|
||||
* In the left-hand **Manage** menu, click **Users and Groups**.
|
||||
|
||||
### 2. Assign Users or Groups
|
||||
|
||||
* Add individual users or groups of users who are allowed to use the EntraID authentication method with MediaCMS.
|
||||
* In this example, the application was provided to all registered users inside of EntraID by using the special group **All Users**, which grants any registered user in the tenant access to MediaCMS.
|
||||
|
||||
> ⚠️ **Important**: Nested groups will not work. All users must be directly assigned to the group you are giving permission to. If a group contains another group, the users of the nested group will not inherit the permissions to use this application from the parent group.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Test and Validate Login Flow
|
||||
|
||||
At this point, you should go to your MediaCMS webpage and attempt to log in using the authentication method that you have just set up.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you're experiencing logon issues, it is helpful to first review the SAML authentication data directly.
|
||||
|
||||
1. Go to MediaCMS's login page. It should redirect you to Microsoft's login page.
|
||||
2. Before completing the Microsoft authentication, open Firefox or Chrome Developer Tools (press **F12**) and navigate to the **Network** tab.
|
||||
3. Enable **Persistent Logging**.
|
||||
4. Complete the Microsoft authentication steps on your page (including two-factor authentication if enabled).
|
||||
|
||||
On the final step of the authentication (usually after entering a code and confirming "Stay signed in?"), you will see several POST requests going back to your MediaCMS server URL. Find the POST request that is going to your MediaCMS server's Assertion Consumer Service (ACS) URL, which will look like this:
|
||||
|
||||
```
|
||||
https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/acs/
|
||||
```
|
||||
|
||||
Inside the request section of the Network tab, you will see a **Form Data** field labeled **SAMLResponse**, which contains a Base64-encoded XML string of your authenticated assertion from EntraID.
|
||||
|
||||
* Click into the data field of the SAML response so you can highlight and copy all of the Base64-encoded text.
|
||||
* You can then take this Base64-encoded text to a tool like [CyberChef](https://gchq.github.io/CyberChef/) and use the **From Base64** decoder and **XML Beautify** to reveal the XML-formatted SAML response.
|
||||
|
||||
This decoded XML contains all the assertion and token details passed back to MediaCMS. You can use this information to troubleshoot any issues or misconfigurations that arise.
|
||||
|
||||
You can also confirm your MediaCMS server has the SAML authentication settings correct by opening a private browsing window and navigating to the following URL, which will output the current XML data that your MediaCMS server is configured with:
|
||||
|
||||
```
|
||||
https://<MyMediaCMS.MyDomain.com>/saml/metadata/
|
||||
```
|
||||
|
||||
You can use the returned XML data from this URL to confirm that MediaCMS is configured appropriately as expected and is providing the correct information to the identity provider.
|
||||
|
||||
### Infinite Redirect Loop
|
||||
|
||||
Another issue you might encounter is an **infinite redirect loop**. This can happen when global login is enforced and local user login is disabled.
|
||||
|
||||
**Symptoms:** The system continuously redirects between the homepage and the login URL.
|
||||
|
||||
**Root Cause:** With global login required and local login disabled, Django attempts to redirect users to the default local login page. Since that login method is unavailable, users are bounced back to the homepage, triggering the same redirect logic again — resulting in a loop.
|
||||
|
||||
**Solution:** Specify the correct SAML authentication URL in your local settings. For example:
|
||||
|
||||
* "Login Option" URL configured for EntraID in MediaCMS:
|
||||
|
||||
```
|
||||
https://<MyDomainName>/accounts/saml/mediacms_entraid/login/
|
||||
```
|
||||
|
||||
* Add the following line to `./deploy/docker/local_settings.py`:
|
||||
|
||||
```python
|
||||
LOGIN_URL = "/accounts/saml/mediacms_entraid/login/"
|
||||
```
|
||||
|
||||
This change ensures Django uses the proper SAML login route, breaking the redirect loop and allowing authentication via EntraID as intended.
|
||||
|
||||
> **Note:** The `LOGIN_URL` setting works because we are using the Django AllAuth module to perform the SAML authentication. If you review the AllAuth Django configuration settings, you will find that this is a setting, among other settings, that you can set inside of your local settings file that Django will pick up when using the AllAuth module. You can review the module documentation at the following URL for more details and additional settings that can be set through AllAuth via `local_settings.py`: [https://django-allauth.readthedocs.io/en/latest/account/configuration.html](https://django-allauth.readthedocs.io/en/latest/account/configuration.html)
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
* [MediaCMS SAML Docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#24-identity-providers-setup)
|
||||
* [Enable SAML single sign-on for an enterprise application](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/add-application-portal-setup-sso)
|
||||
* [Django AllAuth](https://django-allauth.readthedocs.io/en/latest/index.html)
|
||||
|
||||
---
|
||||
|
||||
*This documentation is a work-in-progress and will be updated as further steps are dictated or completed.*
|
||||
@ -3,7 +3,6 @@ from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from rbac.models import RBACGroup
|
||||
|
||||
@ -14,11 +13,8 @@ from .models import (
|
||||
Encoding,
|
||||
Language,
|
||||
Media,
|
||||
Page,
|
||||
Subtitle,
|
||||
Tag,
|
||||
TinyMCEMedia,
|
||||
TranscriptionRequest,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
|
||||
@ -223,47 +219,14 @@ class EncodingAdmin(admin.ModelAdmin):
|
||||
has_file.short_description = "Has file"
|
||||
|
||||
|
||||
class TranscriptionRequestAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class PageAdminForm(forms.ModelForm):
|
||||
description = forms.CharField(widget=TinyMCE())
|
||||
|
||||
def clean_description(self):
|
||||
content = self.cleaned_data['description']
|
||||
# Add sandbox attribute to all iframes
|
||||
content = content.replace('<iframe ', '<iframe sandbox="allow-scripts allow-same-origin allow-presentation" ')
|
||||
return content
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class PageAdmin(admin.ModelAdmin):
|
||||
form = PageAdminForm
|
||||
|
||||
|
||||
@admin.register(TinyMCEMedia)
|
||||
class TinyMCEMediaAdmin(admin.ModelAdmin):
|
||||
list_display = ['original_filename', 'file_type', 'uploaded_at', 'user']
|
||||
list_filter = ['file_type', 'uploaded_at']
|
||||
search_fields = ['original_filename']
|
||||
readonly_fields = ['uploaded_at']
|
||||
date_hierarchy = 'uploaded_at'
|
||||
|
||||
|
||||
admin.site.register(EncodeProfile, EncodeProfileAdmin)
|
||||
admin.site.register(Comment, CommentAdmin)
|
||||
admin.site.register(Media, MediaAdmin)
|
||||
admin.site.register(Encoding, EncodingAdmin)
|
||||
admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(Page, PageAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
admin.site.register(Subtitle, SubtitleAdmin)
|
||||
admin.site.register(Language, LanguageAdmin)
|
||||
admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin)
|
||||
admin.site.register(TranscriptionRequest, TranscriptionRequestAdmin)
|
||||
|
||||
Media._meta.app_config.verbose_name = "Media"
|
||||
|
||||
@ -12,12 +12,6 @@ def stuff(request):
|
||||
ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/')
|
||||
ret["DEFAULT_THEME"] = settings.DEFAULT_THEME
|
||||
ret["PORTAL_NAME"] = settings.PORTAL_NAME
|
||||
|
||||
ret["PORTAL_LOGO_DARK_SVG"] = getattr(settings, 'PORTAL_LOGO_DARK_SVG', "")
|
||||
ret["PORTAL_LOGO_DARK_PNG"] = getattr(settings, 'PORTAL_LOGO_DARK_PNG', "")
|
||||
ret["PORTAL_LOGO_LIGHT_SVG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_SVG', "")
|
||||
ret["PORTAL_LOGO_LIGHT_PNG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_PNG', "")
|
||||
ret["EXTRA_CSS_PATHS"] = getattr(settings, 'EXTRA_CSS_PATHS', [])
|
||||
ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION
|
||||
ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN
|
||||
ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED
|
||||
@ -32,22 +26,10 @@ def stuff(request):
|
||||
ret["UPLOAD_MAX_SIZE"] = settings.UPLOAD_MAX_SIZE
|
||||
ret["UPLOAD_MAX_FILES_NUMBER"] = settings.UPLOAD_MAX_FILES_NUMBER
|
||||
ret["PRE_UPLOAD_MEDIA_MESSAGE"] = settings.PRE_UPLOAD_MEDIA_MESSAGE
|
||||
ret["SIDEBAR_FOOTER_TEXT"] = settings.SIDEBAR_FOOTER_TEXT
|
||||
ret["POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY"] = settings.POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY
|
||||
ret["IS_MEDIACMS_ADMIN"] = request.user.is_superuser
|
||||
ret["IS_MEDIACMS_EDITOR"] = is_mediacms_editor(request.user)
|
||||
ret["IS_MEDIACMS_MANAGER"] = is_mediacms_manager(request.user)
|
||||
ret["USERS_NEEDS_TO_BE_APPROVED"] = settings.USERS_NEEDS_TO_BE_APPROVED
|
||||
|
||||
can_see_members_page = False
|
||||
if request.user.is_authenticated:
|
||||
if settings.CAN_SEE_MEMBERS_PAGE == "all":
|
||||
can_see_members_page = True
|
||||
elif settings.CAN_SEE_MEMBERS_PAGE == "editors" and is_mediacms_editor(request.user):
|
||||
can_see_members_page = True
|
||||
elif settings.CAN_SEE_MEMBERS_PAGE == "admins" and request.user.is_superuser:
|
||||
can_see_members_page = True
|
||||
ret["CAN_SEE_MEMBERS_PAGE"] = can_see_members_page
|
||||
ret["ALLOW_RATINGS"] = settings.ALLOW_RATINGS
|
||||
ret["ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY"] = settings.ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY
|
||||
ret["VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE"] = settings.VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import HTML, Field, Layout, Submit
|
||||
from crispy_forms.layout import Field, Layout, Submit
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
@ -35,7 +35,7 @@ class MediaMetadataForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"new_tags": MultipleSelect(),
|
||||
"description": forms.Textarea(attrs={'rows': 4}),
|
||||
"add_date": forms.DateTimeInput(attrs={'type': 'datetime-local', 'step': '1'}, format='%Y-%m-%dT%H:%M:%S'),
|
||||
"add_date": forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
|
||||
}
|
||||
labels = {
|
||||
@ -118,7 +118,14 @@ class MediaPublishForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
|
||||
fields = (
|
||||
"category",
|
||||
"state",
|
||||
"featured",
|
||||
"reported_times",
|
||||
"is_reviewed",
|
||||
"allow_download",
|
||||
)
|
||||
|
||||
widgets = {
|
||||
"category": MultipleSelect(),
|
||||
@ -127,7 +134,6 @@ class MediaPublishForm(forms.ModelForm):
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if not is_mediacms_editor(user):
|
||||
for field in ["featured", "reported_times", "is_reviewed"]:
|
||||
self.fields[field].disabled = True
|
||||
@ -214,95 +220,16 @@ class MediaPublishForm(forms.ModelForm):
|
||||
return media
|
||||
|
||||
|
||||
class WhisperSubtitlesForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Media
|
||||
fields = (
|
||||
"allow_whisper_transcribe",
|
||||
"allow_whisper_transcribe_and_translate",
|
||||
)
|
||||
labels = {
|
||||
"allow_whisper_transcribe": "Transcription",
|
||||
"allow_whisper_transcribe_and_translate": "English Translation",
|
||||
}
|
||||
help_texts = {
|
||||
"allow_whisper_transcribe": "",
|
||||
"allow_whisper_transcribe_and_translate": "",
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(WhisperSubtitlesForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.allow_whisper_transcribe:
|
||||
self.fields['allow_whisper_transcribe'].widget.attrs['readonly'] = True
|
||||
self.fields['allow_whisper_transcribe'].widget.attrs['disabled'] = True
|
||||
if self.instance.allow_whisper_transcribe_and_translate:
|
||||
self.fields['allow_whisper_transcribe_and_translate'].widget.attrs['readonly'] = True
|
||||
self.fields['allow_whisper_transcribe_and_translate'].widget.attrs['disabled'] = True
|
||||
|
||||
both_readonly = self.instance.allow_whisper_transcribe and self.instance.allow_whisper_transcribe_and_translate
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
CustomField('allow_whisper_transcribe'),
|
||||
CustomField('allow_whisper_transcribe_and_translate'),
|
||||
)
|
||||
|
||||
if not both_readonly:
|
||||
self.helper.layout.append(FormActions(Submit('submit_whisper', 'Submit', css_class='primaryAction')))
|
||||
else:
|
||||
# Optional: Add a disabled button with explanatory text
|
||||
self.helper.layout.append(
|
||||
FormActions(Submit('submit_whisper', 'Submit', css_class='primaryAction', disabled=True), HTML('<small class="text-muted">Cannot submit - both options are already enabled</small>'))
|
||||
)
|
||||
|
||||
def clean_allow_whisper_transcribe(self):
|
||||
# Ensure the field value doesn't change if it was originally True
|
||||
if self.instance and self.instance.allow_whisper_transcribe:
|
||||
return self.instance.allow_whisper_transcribe
|
||||
return self.cleaned_data['allow_whisper_transcribe']
|
||||
|
||||
def clean_allow_whisper_transcribe_and_translate(self):
|
||||
# Ensure the field value doesn't change if it was originally True
|
||||
if self.instance and self.instance.allow_whisper_transcribe_and_translate:
|
||||
return self.instance.allow_whisper_transcribe_and_translate
|
||||
return self.cleaned_data['allow_whisper_transcribe_and_translate']
|
||||
|
||||
|
||||
class SubtitleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Subtitle
|
||||
fields = ["language", "subtitle_file"]
|
||||
|
||||
labels = {
|
||||
"subtitle_file": "Upload Caption File",
|
||||
}
|
||||
help_texts = {
|
||||
"subtitle_file": "SubRip (.srt) and WebVTT (.vtt) are supported file formats.",
|
||||
}
|
||||
|
||||
def __init__(self, media_item, *args, **kwargs):
|
||||
super(SubtitleForm, self).__init__(*args, **kwargs)
|
||||
self.instance.media = media_item
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
CustomField('subtitle_file'),
|
||||
CustomField('language'),
|
||||
)
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Submit', css_class='primaryAction')))
|
||||
self.fields["subtitle_file"].help_text = "SubRip (.srt) and WebVTT (.vtt) are supported file formats."
|
||||
self.fields["subtitle_file"].label = "Subtitle or Closed Caption File"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.instance.user = self.instance.media.user
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "تشغيل تلقائي",
|
||||
"About": "حول",
|
||||
"Add a ": "أضف ",
|
||||
"Browse your files": "تصفح ملفاتك",
|
||||
"COMMENT": "تعليق",
|
||||
"Categories": "الفئات",
|
||||
"Category": "الفئة",
|
||||
"Change Language": "تغيير اللغة",
|
||||
"Change password": "تغيير كلمة المرور",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "انقر على 'بدء التسجيل' واختر الشاشة أو علامة التبويب المراد تسجيلها. بمجرد الانتهاء من التسجيل، انقر على 'إيقاف التسجيل'، وسيتم تحميل التسجيل.",
|
||||
"Comment": "تعليق",
|
||||
"Comments": "تعليقات",
|
||||
"Comments are disabled": "التعليقات معطلة",
|
||||
"Contact": "اتصل",
|
||||
"DELETE MEDIA": "حذف الوسائط",
|
||||
"DOWNLOAD": "تحميل",
|
||||
"Drag and drop files": "سحب وإفلات الملفات",
|
||||
"EDIT MEDIA": "تعديل الوسائط",
|
||||
"EDIT PROFILE": "تعديل الملف الشخصي",
|
||||
"EDIT SUBTITLE": "تعديل الترجمة",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "قوائم التشغيل",
|
||||
"Playlists": "قوائم التشغيل",
|
||||
"Powered by": "مدعوم من",
|
||||
"Publish": "نشر",
|
||||
"Published on": "نشر في",
|
||||
"Recommended": "موصى به",
|
||||
"Record Screen": "تسجيل الشاشة",
|
||||
"Register": "تسجيل",
|
||||
"SAVE": "حفظ",
|
||||
"SEARCH": "بحث",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "اختر",
|
||||
"Sign in": "تسجيل الدخول",
|
||||
"Sign out": "تسجيل الخروج",
|
||||
"Start Recording": "بدء التسجيل",
|
||||
"Stop Recording": "إيقاف التسجيل",
|
||||
"Subtitle was added": "تمت إضافة الترجمة",
|
||||
"Subtitles": "ترجمات",
|
||||
"Tags": "العلامات",
|
||||
"Terms": "الشروط",
|
||||
"This works in Chrome, Safari and Edge browsers.": "هذا يعمل في متصفحات Chrome و Safari و Edge.",
|
||||
"Trim": "قص",
|
||||
"UPLOAD": "رفع",
|
||||
"Up next": "التالي",
|
||||
"Upload": "رفع",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "التحميلات",
|
||||
"VIEW ALL": "عرض الكل",
|
||||
"View all": "عرض الكل",
|
||||
"View media": "عرض الوسائط",
|
||||
"comment": "تعليق",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "هو نظام إدارة محتوى فيديو ووسائط مفتوح المصدر وحديث ومتكامل. تم تطويره لتلبية احتياجات المنصات الويب الحديثة لمشاهدة ومشاركة الوسائط",
|
||||
"media in category": "وسائط في الفئة",
|
||||
"media in tag": "وسائط في العلامة",
|
||||
"or": "أو",
|
||||
"view": "عرض",
|
||||
"views": "مشاهدات",
|
||||
"yet": "بعد",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "স্বয়ংক্রিয় প্লে",
|
||||
"About": "সম্পর্কে",
|
||||
"Add a ": "যোগ করুন",
|
||||
"Browse your files": "আপনার ফাইল ব্রাউজ করুন",
|
||||
"COMMENT": "মন্তব্য",
|
||||
"Categories": "বিভাগসমূহ",
|
||||
"Category": "বিভাগ",
|
||||
"Change Language": "ভাষা পরিবর্তন করুন",
|
||||
"Change password": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'রেকর্ডিং শুরু করুন'-এ ক্লিক করুন এবং রেকর্ড করার জন্য স্ক্রিন বা ট্যাব নির্বাচন করুন। রেকর্ডিং শেষ হলে, 'রেকর্ডিং বন্ধ করুন'-এ ক্লিক করুন এবং রেকর্ডিং আপলোড হয়ে যাবে।",
|
||||
"Comment": "মন্তব্য",
|
||||
"Comments": "মন্তব্যসমূহ",
|
||||
"Comments are disabled": "মন্তব্য নিষ্ক্রিয় করা হয়েছে",
|
||||
"Contact": "যোগাযোগ",
|
||||
"DELETE MEDIA": "মিডিয়া মুছুন",
|
||||
"DOWNLOAD": "ডাউনলোড",
|
||||
"Drag and drop files": "ফাইল টেনে আনুন",
|
||||
"EDIT MEDIA": "মিডিয়া সম্পাদনা করুন",
|
||||
"EDIT PROFILE": "প্রোফাইল সম্পাদনা করুন",
|
||||
"EDIT SUBTITLE": "সাবটাইটেল সম্পাদনা করুন",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "প্লেলিস্ট",
|
||||
"Playlists": "প্লেলিস্ট",
|
||||
"Powered by": "দ্বারা চালিত",
|
||||
"Publish": "প্রকাশ করুন",
|
||||
"Published on": "প্রকাশিত",
|
||||
"Recommended": "প্রস্তাবিত",
|
||||
"Record Screen": "স্ক্রিন রেকর্ড করুন",
|
||||
"Register": "নিবন্ধন করুন",
|
||||
"SAVE": "সংরক্ষণ করুন",
|
||||
"SEARCH": "অনুসন্ধান",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "নির্বাচন করুন",
|
||||
"Sign in": "সাইন ইন করুন",
|
||||
"Sign out": "সাইন আউট করুন",
|
||||
"Start Recording": "রেকর্ডিং শুরু করুন",
|
||||
"Stop Recording": "রেকর্ডিং বন্ধ করুন",
|
||||
"Subtitle was added": "সাবটাইটেল যোগ করা হয়েছে",
|
||||
"Subtitles": "সাবটাইটেল",
|
||||
"Tags": "ট্যাগ",
|
||||
"Terms": "শর্তাবলী",
|
||||
"This works in Chrome, Safari and Edge browsers.": "এটি ক্রোম, সাফারি এবং এজ ব্রাউজারে কাজ করে।",
|
||||
"Trim": "ছাঁটাই",
|
||||
"UPLOAD": "আপলোড করুন",
|
||||
"Up next": "পরবর্তী",
|
||||
"Upload": "আপলোড করুন",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "আপলোডসমূহ",
|
||||
"VIEW ALL": "সব দেখুন",
|
||||
"View all": "সব দেখুন",
|
||||
"View media": "মিডিয়া দেখুন",
|
||||
"comment": "মন্তব্য",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "একটি আধুনিক, সম্পূর্ণ বৈশিষ্ট্যযুক্ত ওপেন সোর্স ভিডিও এবং মিডিয়া CMS। এটি আধুনিক ওয়েব প্ল্যাটফর্মের জন্য মিডিয়া দেখার এবং শেয়ার করার প্রয়োজন মেটাতে তৈরি করা হয়েছে",
|
||||
"media in category": "বিভাগে মিডিয়া",
|
||||
"media in tag": "ট্যাগে মিডিয়া",
|
||||
"or": "অথবা",
|
||||
"view": "দেখুন",
|
||||
"views": "দেখা হয়েছে",
|
||||
"yet": "এখনও",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "Automatisk afspilning",
|
||||
"About": "Om",
|
||||
"Add a ": "Tilføj en ",
|
||||
"Browse your files": "Gennemse dine filer",
|
||||
"COMMENT": "KOMMENTAR",
|
||||
"Categories": "Kategorier",
|
||||
"Category": "Kategori",
|
||||
"Change Language": "Skift sprog",
|
||||
"Change password": "Skift adgangskode",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klik på 'Start optagelse' og vælg den skærm eller fane, du vil optage. Når optagelsen er færdig, skal du klikke på 'Stop optagelse', og optagelsen vil blive uploadet.",
|
||||
"Comment": "Kommentar",
|
||||
"Comments": "Kommentarer",
|
||||
"Comments are disabled": "Kommentarer er slået fra",
|
||||
"Contact": "Kontakt",
|
||||
"DELETE MEDIA": "SLET MEDIE",
|
||||
"DOWNLOAD": "HENT",
|
||||
"Drag and drop files": "Træk og slip filer",
|
||||
"EDIT MEDIA": "REDIGER MEDIE",
|
||||
"EDIT PROFILE": "REDIGER PROFIL",
|
||||
"EDIT SUBTITLE": "REDIGER UNDERTEKSTER",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "PLAYLISTER",
|
||||
"Playlists": "Playlister",
|
||||
"Powered by": "Drevet af",
|
||||
"Publish": "Udgiv",
|
||||
"Published on": "Udgivet på",
|
||||
"Recommended": "Anbefalet",
|
||||
"Record Screen": "Optag skærm",
|
||||
"Register": "Registrer",
|
||||
"SAVE": "GEM",
|
||||
"SEARCH": "SØG",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Vælg",
|
||||
"Sign in": "Log ind",
|
||||
"Sign out": "Log ud",
|
||||
"Start Recording": "Start optagelse",
|
||||
"Stop Recording": "Stop optagelse",
|
||||
"Subtitle was added": "Undertekster tilføjet",
|
||||
"Subtitles": "Undertekster",
|
||||
"Tags": "Tags",
|
||||
"Terms": "Vilkår",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Dette virker i Chrome, Safari og Edge browsere.",
|
||||
"Trim": "Beskær",
|
||||
"UPLOAD": "UPLOAD",
|
||||
"Up next": "Næste",
|
||||
"Upload": "Upload",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Uploads",
|
||||
"VIEW ALL": "SE ALLE",
|
||||
"View all": "Se alle",
|
||||
"View media": "Se medie",
|
||||
"comment": "kommentar",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "er et moderne, fuldt udstyret open source video og medie CMS. Det er udviklet til at imødekomme behovene for moderne webplatforme til visning og deling af medier.",
|
||||
"media in category": "medier i kategori",
|
||||
"media in tag": "medier i tag",
|
||||
"or": "eller",
|
||||
"view": "visning",
|
||||
"views": "visninger",
|
||||
"yet": "endnu",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "Automatische Wiedergabe",
|
||||
"About": "Über",
|
||||
"Add a ": "Hinzufügen eines ",
|
||||
"Browse your files": "Durchsuchen Sie Ihre Dateien",
|
||||
"COMMENT": "KOMMENTAR",
|
||||
"Categories": "Kategorien",
|
||||
"Category": "Kategorie",
|
||||
"Change Language": "Sprache ändern",
|
||||
"Change password": "Passwort ändern",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klicken Sie auf 'Aufnahme starten' und wählen Sie den Bildschirm oder Tab aus, den Sie aufnehmen möchten. Sobald die Aufnahme beendet ist, klicken Sie auf 'Aufnahme beenden', und die Aufnahme wird hochgeladen.",
|
||||
"Comment": "Kommentar",
|
||||
"Comments": "Kommentare",
|
||||
"Comments are disabled": "Kommentare sind deaktiviert",
|
||||
"Contact": "Kontakt",
|
||||
"DELETE MEDIA": "MEDIEN LÖSCHEN",
|
||||
"DOWNLOAD": "HERUNTERLADEN",
|
||||
"Drag and drop files": "Dateien per Drag & Drop verschieben",
|
||||
"EDIT MEDIA": "MEDIEN BEARBEITEN",
|
||||
"EDIT PROFILE": "PROFIL BEARBEITEN",
|
||||
"EDIT SUBTITLE": "UNTERTITEL BEARBEITEN",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "PLAYLISTS",
|
||||
"Playlists": "Playlists",
|
||||
"Powered by": "Bereitgestellt von",
|
||||
"Publish": "Veröffentlichen",
|
||||
"Published on": "Veröffentlicht am",
|
||||
"Recommended": "Empfohlen",
|
||||
"Record Screen": "Bildschirm aufnehmen",
|
||||
"Register": "Registrieren",
|
||||
"SAVE": "SPEICHERN",
|
||||
"SEARCH": "SUCHE",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Auswählen",
|
||||
"Sign in": "Anmelden",
|
||||
"Sign out": "Abmelden",
|
||||
"Start Recording": "Aufnahme starten",
|
||||
"Stop Recording": "Aufnahme stoppen",
|
||||
"Subtitle was added": "Untertitel wurde hinzugefügt",
|
||||
"Subtitles": "Untertitel",
|
||||
"Tags": "Tags",
|
||||
"Terms": "Bedingungen",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Dies funktioniert in den Browsern Chrome, Safari und Edge.",
|
||||
"Trim": "Trimmen",
|
||||
"UPLOAD": "HOCHLADEN",
|
||||
"Up next": "Als nächstes",
|
||||
"Upload": "Hochladen",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Uploads",
|
||||
"VIEW ALL": "ALLE ANZEIGEN",
|
||||
"View all": "Alle anzeigen",
|
||||
"View media": "Medien anzeigen",
|
||||
"comment": "Kommentar",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "ist ein modernes, voll ausgestattetes Open-Source-Video- und Medien-CMS. Es wurde entwickelt, um den Anforderungen moderner Webplattformen für das Ansehen und Teilen von Medien gerecht zu werden",
|
||||
"media in category": "Medien in Kategorie",
|
||||
"media in tag": "Medien in Tag",
|
||||
"or": "oder",
|
||||
"view": "Ansicht",
|
||||
"views": "Ansichten",
|
||||
"yet": "noch",
|
||||
|
||||
@ -3,33 +3,30 @@ translation_strings = {
|
||||
"AUTOPLAY": "Αυτόματη αναπαραγωγή",
|
||||
"About": "Σχετικά",
|
||||
"Add a ": "Προσθέστε ένα ",
|
||||
"Browse your files": "Περιήγηση στα αρχεία σας",
|
||||
"COMMENT": "ΣΧΟΛΙΟ",
|
||||
"Categories": "Κατηγορίες",
|
||||
"Category": "Κατηγορία",
|
||||
"Change Language": "Αλλαγή Γλώσσας",
|
||||
"Change password": "Αλλαγή κωδικού",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Κάντε κλικ στο 'Έναρξη εγγραφής' και επιλέξτε την οθόνη ή την καρτέλα για εγγραφή. Μόλις ολοκληρωθεί η εγγραφή, κάντε κλικ στο 'Διακοπή εγγραφής' και η εγγραφή θα μεταφορτωθεί.",
|
||||
"Comment": "Σχόλιο",
|
||||
"Comments": "Σχόλια",
|
||||
"Comments are disabled": "Τα σχόλια είναι απενεργοποιημένα",
|
||||
"Contact": "Επικοινωνία",
|
||||
"DELETE MEDIA": "ΔΙΑΓΡΑΦΗ ΑΡΧΕΙΟΥ",
|
||||
"DOWNLOAD": "ΚΑΤΕΒΑΣΜΑ",
|
||||
"Drag and drop files": "Σύρετε και αποθέστε αρχεία",
|
||||
"EDIT MEDIA": "ΕΠΕΞΕΡΓΑΣΙΑ ΑΡΧΕΙΟΥ",
|
||||
"EDIT PROFILE": "ΕΠΕΞΕΡΓΑΣΙΑ ΠΡΟΦΙΛ",
|
||||
"EDIT SUBTITLE": "ΕΠΕΞΕΡΓΑΣΙΑ ΥΠΟΤΙΤΛΩΝ",
|
||||
"Edit media": "Επεξεργασία αρχείου",
|
||||
"Edit profile": "Επεξεργασία προφίλ",
|
||||
"Edit profile": "Επεξεργασία προφιλ",
|
||||
"Edit subtitle": "Επεξεργασία υποτίτλων",
|
||||
"Featured": "Επιλεγμένα",
|
||||
"Go": "Μετάβαση",
|
||||
"Go": "Πήγαινε",
|
||||
"History": "Ιστορικό",
|
||||
"Home": "Αρχική",
|
||||
"Language": "Γλώσσα",
|
||||
"Latest": "Πρόσφατα",
|
||||
"Liked media": "Αγαπημένα αρχεία",
|
||||
"Liked media": "Αγαπημένα",
|
||||
"Manage comments": "Διαχείριση σχολίων",
|
||||
"Manage media": "Διαχείριση αρχείων",
|
||||
"Manage users": "Διαχείριση χρηστών",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "ΛΙΣΤΕΣ",
|
||||
"Playlists": "Λίστες",
|
||||
"Powered by": "Υποστηρίζεται από το",
|
||||
"Publish": "Δημοσίευση",
|
||||
"Published on": "Δημοσιεύτηκε στις",
|
||||
"Recommended": "Προτεινόμενα",
|
||||
"Record Screen": "Καταγραφή οθόνης",
|
||||
"Register": "Εγγραφή",
|
||||
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
|
||||
"SEARCH": "ΑΝΑΖΗΤΗΣΗ",
|
||||
@ -59,27 +54,20 @@ translation_strings = {
|
||||
"Select": "Επιλογή",
|
||||
"Sign in": "Σύνδεση",
|
||||
"Sign out": "Αποσύνδεση",
|
||||
"Start Recording": "Έναρξη εγγραφής",
|
||||
"Stop Recording": "Διακοπή εγγραφής",
|
||||
"Subtitle was added": "Οι υπότιτλοι προστέθηκαν",
|
||||
"Subtitles": "Υπότιτλοι",
|
||||
"Tags": "Ετικέτες",
|
||||
"Terms": "Όροι",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Αυτό λειτουργεί σε προγράμματα περιήγησης Chrome, Safari και Edge.",
|
||||
"Trim": "Περικοπή",
|
||||
"UPLOAD": "ΑΝΕΒΑΣΜΑ",
|
||||
"Up next": "Επόμενο",
|
||||
"Upload": "Ανέβασμα",
|
||||
"Upload": "Ανέβασμα αρχείου",
|
||||
"Upload media": "Ανέβασμα αρχείων",
|
||||
"Uploads": "Ανεβάσματα",
|
||||
"VIEW ALL": "ΔΕΣ ΤΑ ΟΛΑ",
|
||||
"View all": "Δες τα όλα",
|
||||
"View media": "Προβολή αρχείου",
|
||||
"View all": "Δές τα όλα",
|
||||
"comment": "σχόλιο",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "είναι ένα σύγχρονο, πλήρως λειτουργικό ανοιχτού κώδικα CMS βίντεο και πολυμέσων. Αναπτύχθηκε για να καλύψει τις ανάγκες των σύγχρονων πλατφορμών ιστού για την προβολή και την κοινοποίηση πολυμέσων",
|
||||
"media in category": "αρχεία στην κατηγορία",
|
||||
"media in tag": "αρχεία με ετικέτα",
|
||||
"or": "ή",
|
||||
"view": "προβολή",
|
||||
"views": "προβολές",
|
||||
"yet": "ακόμα",
|
||||
@ -94,10 +82,10 @@ replacement_strings = {
|
||||
"Jul": "Ιουλ",
|
||||
"Jun": "Ιουν",
|
||||
"Mar": "Μαρ",
|
||||
"May": "Μάι",
|
||||
"May": "Μαϊ",
|
||||
"Nov": "Νοε",
|
||||
"Oct": "Οκτ",
|
||||
"Sep": "Σεπ",
|
||||
"Sep": "Σεπτ",
|
||||
"day ago": "μέρα πριν",
|
||||
"days ago": "μέρες πριν",
|
||||
"hour ago": "ώρα πριν",
|
||||
|
||||
@ -2,20 +2,17 @@ translation_strings = {
|
||||
"ABOUT": "",
|
||||
"AUTOPLAY": "",
|
||||
"Add a ": "",
|
||||
"Browse your files": "",
|
||||
"COMMENT": "",
|
||||
"Categories": "",
|
||||
"Category": "",
|
||||
"Change Language": "",
|
||||
"Change password": "",
|
||||
"About": "",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "",
|
||||
"Comment": "",
|
||||
"Comments": "",
|
||||
"Comments are disabled": "",
|
||||
"Contact": "",
|
||||
"DELETE MEDIA": "",
|
||||
"Drag and drop files": "",
|
||||
"DOWNLOAD": "",
|
||||
"EDIT MEDIA": "",
|
||||
"EDIT PROFILE": "",
|
||||
@ -45,28 +42,21 @@ translation_strings = {
|
||||
"PLAYLISTS": "",
|
||||
"Playlists": "",
|
||||
"Powered by": "",
|
||||
"Publish": "",
|
||||
"Published on": "",
|
||||
"Recommended": "",
|
||||
"Record Screen": "",
|
||||
"Register": "",
|
||||
"SAVE": "",
|
||||
"SEARCH": "",
|
||||
"SHARE": "",
|
||||
"SHOW MORE": "",
|
||||
"SUBMIT": "",
|
||||
"Subtitles": "",
|
||||
"Search": "",
|
||||
"Select": "",
|
||||
"Sign in": "",
|
||||
"Sign out": "",
|
||||
"Start Recording": "",
|
||||
"Stop Recording": "",
|
||||
"Subtitle was added": "",
|
||||
"Tags": "",
|
||||
"Terms": "",
|
||||
"This works in Chrome, Safari and Edge browsers.": "",
|
||||
"Trim": "",
|
||||
"UPLOAD": "",
|
||||
"Up next": "",
|
||||
"Upload": "",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "",
|
||||
"VIEW ALL": "",
|
||||
"View all": "",
|
||||
"View media": "",
|
||||
"comment": "",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "",
|
||||
"media in category": "",
|
||||
"media in tag": "",
|
||||
"or": "",
|
||||
"view": "",
|
||||
"views": "",
|
||||
"yet": "",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "Reproducción automática",
|
||||
"About": "Acerca de",
|
||||
"Add a ": "Agregar un ",
|
||||
"Browse your files": "Explorar sus archivos",
|
||||
"COMMENT": "COMENTARIO",
|
||||
"Categories": "Categorías",
|
||||
"Category": "Categoría",
|
||||
"Change Language": "Cambiar idioma",
|
||||
"Change password": "Cambiar contraseña",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Haga clic en 'Iniciar grabación' y seleccione la pantalla o pestaña para grabar. Una vez finalizada la grabación, haga clic en 'Detener grabación' y la grabación se subirá.",
|
||||
"Comment": "Comentario",
|
||||
"Comments": "Comentarios",
|
||||
"Comments are disabled": "Los comentarios están deshabilitados",
|
||||
"Contact": "Contacto",
|
||||
"DELETE MEDIA": "ELIMINAR MEDIOS",
|
||||
"DOWNLOAD": "DESCARGAR",
|
||||
"Drag and drop files": "Arrastre y suelte archivos",
|
||||
"EDIT MEDIA": "EDITAR MEDIOS",
|
||||
"EDIT PROFILE": "EDITAR PERFIL",
|
||||
"EDIT SUBTITLE": "EDITAR SUBTÍTULO",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "LISTAS DE REPRODUCCIÓN",
|
||||
"Playlists": "Listas de reproducción",
|
||||
"Powered by": "Desarrollado por",
|
||||
"Publish": "Publicar",
|
||||
"Published on": "Publicado en",
|
||||
"Recommended": "Recomendado",
|
||||
"Record Screen": "Grabar pantalla",
|
||||
"Register": "Registrarse",
|
||||
"SAVE": "GUARDAR",
|
||||
"SEARCH": "BUSCAR",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Seleccionar",
|
||||
"Sign in": "Iniciar sesión",
|
||||
"Sign out": "Cerrar sesión",
|
||||
"Start Recording": "Iniciar grabación",
|
||||
"Stop Recording": "Detener grabación",
|
||||
"Subtitle was added": "El subtítulo fue agregado",
|
||||
"Subtitles": "Subtítulos",
|
||||
"Tags": "Etiquetas",
|
||||
"Terms": "Términos",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Esto funciona en los navegadores Chrome, Safari y Edge.",
|
||||
"Trim": "Recortar",
|
||||
"UPLOAD": "SUBIR",
|
||||
"Up next": "A continuación",
|
||||
"Upload": "Subir",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Subidas",
|
||||
"VIEW ALL": "VER TODO",
|
||||
"View all": "Ver todo",
|
||||
"View media": "Ver medios",
|
||||
"comment": "comentario",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "es un CMS de video y medios de código abierto, moderno y completamente equipado. Está desarrollado para satisfacer las necesidades de las plataformas web modernas para ver y compartir medios",
|
||||
"media in category": "medios en la categoría",
|
||||
"media in tag": "medios en la etiqueta",
|
||||
"or": "o",
|
||||
"view": "vista",
|
||||
"views": "vistas",
|
||||
"yet": "aún",
|
||||
|
||||
@ -3,21 +3,18 @@ translation_strings = {
|
||||
"AUTOPLAY": "Lecture automatique",
|
||||
"About": "À propos",
|
||||
"Add a": "Ajouter un",
|
||||
"Add a ": "Ajouter un ",
|
||||
"Browse your files": "Parcourir vos fichiers",
|
||||
"Add a ": "",
|
||||
"COMMENT": "COMMENTAIRE",
|
||||
"Categories": "Catégories",
|
||||
"Category": "Catégorie",
|
||||
"Change Language": "Changer de langue",
|
||||
"Change password": "Changer le mot de passe",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Cliquez sur 'Démarrer l'enregistrement' et sélectionnez l'écran ou l'onglet à enregistrer. Une fois l'enregistrement terminé, cliquez sur 'Arrêter l'enregistrement', et l'enregistrement sera téléversé.",
|
||||
"Comment": "Commentaire",
|
||||
"Comments": "Commentaires",
|
||||
"Comments are disabled": "Les commentaires sont désactivés",
|
||||
"Contact": "Contact",
|
||||
"DELETE MEDIA": "SUPPRIMER LE MÉDIA",
|
||||
"DOWNLOAD": "TÉLÉCHARGER",
|
||||
"Drag and drop files": "Glisser-déposer des fichiers",
|
||||
"EDIT MEDIA": "MODIFIER LE MÉDIA",
|
||||
"EDIT PROFILE": "MODIFIER LE PROFIL",
|
||||
"EDIT SUBTITLE": "MODIFIER LE SOUS-TITRE",
|
||||
@ -46,10 +43,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "PLAYLISTS",
|
||||
"Playlists": "Playlists",
|
||||
"Powered by": "Propulsé par",
|
||||
"Publish": "Publier",
|
||||
"Published on": "Publié le",
|
||||
"Recommended": "Recommandé",
|
||||
"Record Screen": "Enregistrer l'écran",
|
||||
"Register": "S'inscrire",
|
||||
"SAVE": "ENREGISTRER",
|
||||
"SEARCH": "RECHERCHER",
|
||||
@ -60,14 +55,9 @@ translation_strings = {
|
||||
"Select": "Sélectionner",
|
||||
"Sign in": "Se connecter",
|
||||
"Sign out": "Se déconnecter",
|
||||
"Start Recording": "Commencer l'enregistrement",
|
||||
"Stop Recording": "Arrêter l'enregistrement",
|
||||
"Subtitle was added": "Le sous-titre a été ajouté",
|
||||
"Subtitles": "Sous-titres",
|
||||
"Tags": "Tags",
|
||||
"Terms": "Conditions",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Cela fonctionne dans les navigateurs Chrome, Safari et Edge.",
|
||||
"Trim": "Couper",
|
||||
"UPLOAD": "TÉLÉCHARGER",
|
||||
"Up next": "À suivre",
|
||||
"Upload": "Télécharger",
|
||||
@ -75,12 +65,10 @@ translation_strings = {
|
||||
"Uploads": "Téléchargements",
|
||||
"VIEW ALL": "VOIR TOUT",
|
||||
"View all": "Voir tout",
|
||||
"View media": "Voir le média",
|
||||
"comment": "commentaire",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "est un CMS vidéo et média open source moderne et complet. Il est développé pour répondre aux besoins des plateformes web modernes pour la visualisation et le partage de médias",
|
||||
"media in category": "média dans la catégorie",
|
||||
"media in tag": "média dans le tag",
|
||||
"or": "ou",
|
||||
"view": "vue",
|
||||
"views": "vues",
|
||||
"yet": "encore",
|
||||
|
||||
@ -1,116 +1,104 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "על אודות",
|
||||
"AUTOPLAY": "ניגון אוטומטי",
|
||||
"About": "על אודות",
|
||||
"Add a ": "הוסף",
|
||||
"Browse your files": "עיין בקבצים שלך",
|
||||
"COMMENT": "תגובה",
|
||||
"Categories": "קטגוריות",
|
||||
"Category": "קטגוריה",
|
||||
"Change Language": "שנה שפה",
|
||||
"Change password": "שנה סיסמה",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "לחץ על 'התחל הקלטה' ובחר את המסך או הכרטיסייה להקלטה. לאחר סיום ההקלטה, לחץ על 'עצור הקלטה', וההקלטה תועלה.",
|
||||
"Comment": "תגובה",
|
||||
"Comments": "תגובות",
|
||||
"Comments are disabled": "התגובות מושבתות",
|
||||
"Contact": "צור קשר",
|
||||
"DELETE MEDIA": "מחק מדיה",
|
||||
"DOWNLOAD": "הורד",
|
||||
"Drag and drop files": "גרור ושחרר קבצים",
|
||||
"EDIT MEDIA": "ערוך מדיה",
|
||||
"EDIT PROFILE": "ערוך פרופיל",
|
||||
"EDIT SUBTITLE": "ערוך כתוביות",
|
||||
"Edit media": "ערוך מדיה",
|
||||
"Edit profile": "ערוך פרופיל",
|
||||
"Edit subtitle": "ערוך כתוביות",
|
||||
"Featured": "מומלצים",
|
||||
"Go": "בצע",
|
||||
"History": "היסטוריה",
|
||||
"Home": "דף הבית",
|
||||
"Language": "שפה",
|
||||
"Latest": "העדכונים האחרונים",
|
||||
"Liked media": "מדיה שאהבתי",
|
||||
"Manage comments": "ניהול תגובות",
|
||||
"Manage media": "ניהול מדיה",
|
||||
"Manage users": "ניהול משתמשים",
|
||||
"Media": "מדיה",
|
||||
"Media was edited": "המדיה נערכה",
|
||||
"Members": "משתמשים",
|
||||
"My media": "המדיה שלי",
|
||||
"My playlists": "הפלייליסטים שלי",
|
||||
"No": "לא",
|
||||
"No comment yet": "עדיין אין תגובות",
|
||||
"No comments yet": "עדיין אין תגובות",
|
||||
"No results for": "אין תוצאות עבור",
|
||||
"PLAYLISTS": "פלייליסטים",
|
||||
"Playlists": "פלייליסטים",
|
||||
"Powered by": "מופעל על ידי",
|
||||
"Publish": "פרסם",
|
||||
"Published on": "פורסם בתאריך",
|
||||
"Recommended": "מומלץ",
|
||||
"Record Screen": "הקלטת מסך",
|
||||
"Register": "הרשמה",
|
||||
"SAVE": "שמור",
|
||||
"SEARCH": "חפש",
|
||||
"SHARE": "שתף",
|
||||
"SHOW MORE": "הצג עוד",
|
||||
"SUBMIT": "שלח",
|
||||
"Search": "חפש",
|
||||
"Select": "בחר",
|
||||
"Sign in": "התחבר",
|
||||
"Sign out": "התנתק",
|
||||
"Start Recording": "התחל הקלטה",
|
||||
"Stop Recording": "עצור הקלטה",
|
||||
"Subtitle was added": "הכתובית נוספה",
|
||||
"Subtitles": "כתוביות",
|
||||
"Tags": "תגיות",
|
||||
"Terms": "תנאים",
|
||||
"This works in Chrome, Safari and Edge browsers.": "זה עובד בדפדפני Chrome, Safari ו-Edge.",
|
||||
"Trim": "גזירה",
|
||||
"UPLOAD": "העלה",
|
||||
"Up next": "הבא בתור",
|
||||
"Upload": "העלה",
|
||||
"Upload media": "העלה מדיה",
|
||||
"Uploads": "העלאות",
|
||||
"VIEW ALL": "הצג הכל",
|
||||
"View all": "הצג הכל",
|
||||
"View media": "צפה במדיה",
|
||||
"comment": "תגובה",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "מערכת ניהול מדיה ווידאו מודרנית, פתוחה ומלאה בפיצ׳רים. פותחה כדי לענות על הצרכים של פלטפורמות אינטרנט מודרניות לצפייה ושיתוף מדיה.",
|
||||
"media in category": "מדיה בקטגוריה",
|
||||
"media in tag": "מדיה בתגית",
|
||||
"or": "או",
|
||||
"view": "צפיות",
|
||||
"views": "צפיות",
|
||||
"yet": "עדיין",
|
||||
'ABOUT': 'על אודות',
|
||||
'AUTOPLAY': 'ניגון אוטומטי',
|
||||
'About': 'על אודות',
|
||||
'Add a ': 'הוסף',
|
||||
'COMMENT': 'תגובה',
|
||||
'Categories': 'קטגוריות',
|
||||
'Category': 'קטגוריה',
|
||||
'Change Language': 'שנה שפה',
|
||||
'Change password': 'שנה סיסמה',
|
||||
'Comment': 'תגובה',
|
||||
'Comments': 'תגובות',
|
||||
'Comments are disabled': 'התגובות מושבתות',
|
||||
'Contact': 'צור קשר',
|
||||
'DELETE MEDIA': 'מחק מדיה',
|
||||
'DOWNLOAD': 'הורד',
|
||||
'EDIT MEDIA': 'ערוך מדיה',
|
||||
'EDIT PROFILE': 'ערוך פרופיל',
|
||||
'EDIT SUBTITLE': 'ערוך כתוביות',
|
||||
'Edit media': 'ערוך מדיה',
|
||||
'Edit profile': 'ערוך פרופיל',
|
||||
'Edit subtitle': 'ערוך כתוביות',
|
||||
'Featured': 'מומלצים',
|
||||
'Go': 'בצע', # in context of "execution"
|
||||
'History': 'היסטוריה',
|
||||
'Home': 'דף הבית',
|
||||
'Language': 'שפה',
|
||||
'Latest': 'העדכונים האחרונים',
|
||||
'Liked media': 'מדיה שאהבתי',
|
||||
'Manage comments': 'ניהול תגובות',
|
||||
'Manage media': 'ניהול מדיה',
|
||||
'Manage users': 'ניהול משתמשים',
|
||||
'Media': 'מדיה',
|
||||
'Media was edited': 'המדיה נערכה',
|
||||
'Members': 'משתמשים',
|
||||
'My media': 'המדיה שלי',
|
||||
'My playlists': 'הפלייליסטים שלי',
|
||||
'No': 'לא', # in context of "no comments", etc.
|
||||
'No comment yet': 'עדיין אין תגובות',
|
||||
'No comments yet': 'עדיין אין תגובות',
|
||||
'No results for': 'אין תוצאות עבור',
|
||||
'PLAYLISTS': 'פלייליסטים',
|
||||
'Playlists': 'פלייליסטים',
|
||||
'Powered by': 'מופעל על ידי',
|
||||
'Published on': 'פורסם בתאריך',
|
||||
'Recommended': 'מומלץ',
|
||||
'Register': 'הרשמה',
|
||||
'SAVE': 'שמור',
|
||||
'SEARCH': 'חפש',
|
||||
'SHARE': 'שתף',
|
||||
'SHOW MORE': 'הצג עוד',
|
||||
'SUBMIT': 'שלח',
|
||||
'Search': 'חפש',
|
||||
'Select': 'בחר',
|
||||
'Sign in': 'התחבר',
|
||||
'Sign out': 'התנתק',
|
||||
'Subtitle was added': 'הכתובית נוספה',
|
||||
'Tags': 'תגיות',
|
||||
'Terms': 'תנאים',
|
||||
'UPLOAD': 'העלה',
|
||||
'Up next': 'הבא בתור',
|
||||
'Upload': 'העלה',
|
||||
'Upload media': 'העלה מדיה',
|
||||
'Uploads': 'העלאות',
|
||||
'VIEW ALL': 'הצג הכל',
|
||||
'View all': 'הצג הכל',
|
||||
'comment': 'תגובה',
|
||||
'is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media': 'מערכת ניהול מדיה ווידאו מודרנית, פתוחה ומלאה בפיצ׳רים. פותחה כדי לענות על הצרכים של פלטפורמות אינטרנט מודרניות לצפייה ושיתוף מדיה.',
|
||||
'media in category': 'מדיה בקטגוריה',
|
||||
'media in tag': 'מדיה בתגית',
|
||||
'view': 'צפיות',
|
||||
'views': 'צפיות',
|
||||
'yet': 'עדיין',
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
"Apr": "אפריל",
|
||||
"Aug": "אוגוסט",
|
||||
"Dec": "דצמבר",
|
||||
"Feb": "פברואר",
|
||||
"Jan": "ינואר",
|
||||
"Jul": "יולי",
|
||||
"Jun": "יוני",
|
||||
"Mar": "מרץ",
|
||||
"May": "מאי",
|
||||
"Nov": "נובמבר",
|
||||
"Oct": "אוקטובר",
|
||||
"Sep": "ספטמבר",
|
||||
"day ago": "לפני יום",
|
||||
"days ago": "לפני ימים",
|
||||
"hour ago": "לפני שעה",
|
||||
"hours ago": "לפני שעות",
|
||||
"just now": "הרגע",
|
||||
"minute ago": "לפני דקה",
|
||||
"minutes ago": "לפני דקות",
|
||||
"month ago": "לפני חודש",
|
||||
"months ago": "לפני חודשים",
|
||||
"second ago": "לפני שנייה",
|
||||
"seconds ago": "לפני שניות",
|
||||
"week ago": "לפני שבוע",
|
||||
"weeks ago": "לפני שבועות",
|
||||
"year ago": "לפני שנה",
|
||||
"years ago": "לפני שנים",
|
||||
'Apr': 'אפריל',
|
||||
'Aug': 'אוגוסט',
|
||||
'Dec': 'דצמבר',
|
||||
'Feb': 'פברואר',
|
||||
'Jan': 'ינואר',
|
||||
'Jul': 'יולי',
|
||||
'Jun': 'יוני',
|
||||
'Mar': 'מרץ',
|
||||
'May': 'מאי',
|
||||
'Nov': 'נובמבר',
|
||||
'Oct': 'אוקטובר',
|
||||
'Sep': 'ספטמבר',
|
||||
'day ago': 'לפני יום',
|
||||
'days ago': 'לפני ימים',
|
||||
'hour ago': 'לפני שעה',
|
||||
'hours ago': 'לפני שעות',
|
||||
'just now': 'הרגע',
|
||||
'minute ago': 'לפני דקה',
|
||||
'minutes ago': 'לפני דקות',
|
||||
'month ago': 'לפני חודש',
|
||||
'months ago': 'לפני חודשים',
|
||||
'second ago': 'לפני שנייה',
|
||||
'seconds ago': 'לפני שניות',
|
||||
'week ago': 'לפני שבוע',
|
||||
'weeks ago': 'לפני שבועות',
|
||||
'year ago': 'לפני שנה',
|
||||
'years ago': 'לפני שנים',
|
||||
}
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "स्वतः चलाएं",
|
||||
"About": "के बारे में",
|
||||
"Add a ": "जोड़ें",
|
||||
"Browse your files": "अपनी फ़ाइलें ब्राउज़ करें",
|
||||
"COMMENT": "टिप्पणी",
|
||||
"Categories": "श्रेणियाँ",
|
||||
"Category": "श्रेणी",
|
||||
"Change Language": "भाषा बदलें",
|
||||
"Change password": "पासवर्ड बदलें",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'रिकॉर्डिंग प्रारंभ करें' पर क्लिक करें और रिकॉर्ड करने के लिए स्क्रीन या टैब का चयन करें। रिकॉर्डिंग समाप्त होने के बाद, 'रिकॉर्डिंग रोकें' पर क्लिक करें, और रिकॉर्डिंग अपलोड हो जाएगी।",
|
||||
"Comment": "टिप्पणी",
|
||||
"Comments": "टिप्पणियाँ",
|
||||
"Comments are disabled": "टिप्पणियाँ अक्षम हैं",
|
||||
"Contact": "संपर्क करें",
|
||||
"DELETE MEDIA": "मीडिया हटाएं",
|
||||
"DOWNLOAD": "डाउनलोड करें",
|
||||
"Drag and drop files": "फ़ाइलें खींचें और छोड़ें",
|
||||
"EDIT MEDIA": "मीडिया संपादित करें",
|
||||
"EDIT PROFILE": "प्रोफ़ाइल संपादित करें",
|
||||
"EDIT SUBTITLE": "उपशीर्षक संपादित करें",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "प्लेलिस्ट",
|
||||
"Playlists": "प्लेलिस्ट",
|
||||
"Powered by": "द्वारा संचालित",
|
||||
"Publish": "प्रकाशित करें",
|
||||
"Published on": "पर प्रकाशित",
|
||||
"Recommended": "अनुशंसित",
|
||||
"Record Screen": "स्क्रीन रिकॉर्ड करें",
|
||||
"Register": "पंजीकरण करें",
|
||||
"SAVE": "सहेजें",
|
||||
"SEARCH": "खोजें",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "चुनें",
|
||||
"Sign in": "साइन इन करें",
|
||||
"Sign out": "साइन आउट करें",
|
||||
"Start Recording": "रिकॉर्डिंग प्रारंभ करें",
|
||||
"Stop Recording": "रिकॉर्डिंग रोकें",
|
||||
"Subtitle was added": "उपशीर्षक जोड़ा गया",
|
||||
"Subtitles": "उपशीर्षक",
|
||||
"Tags": "टैग",
|
||||
"Terms": "शर्तें",
|
||||
"This works in Chrome, Safari and Edge browsers.": "यह क्रोम, सफारी और एज ब्राउज़र में काम करता है।",
|
||||
"Trim": "छांटें",
|
||||
"UPLOAD": "अपलोड करें",
|
||||
"Up next": "अगला",
|
||||
"Upload": "अपलोड करें",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "अपलोड",
|
||||
"VIEW ALL": "सभी देखें",
|
||||
"View all": "सभी देखें",
|
||||
"View media": "मीडिया देखें",
|
||||
"comment": "टिप्पणी",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "एक आधुनिक, पूर्ण विशेषताओं वाला ओपन सोर्स वीडियो और मीडिया CMS है। इसे मीडिया देखने और साझा करने के लिए आधुनिक वेब प्लेटफार्मों की आवश्यकताओं को पूरा करने के लिए विकसित किया गया है",
|
||||
"media in category": "श्रेणी में मीडिया",
|
||||
"media in tag": "टैग में मीडिया",
|
||||
"or": "या",
|
||||
"view": "देखें",
|
||||
"views": "दृश्य",
|
||||
"yet": "अभी तक",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "PUTAR OTOMATIS",
|
||||
"About": "Tentang",
|
||||
"Add a ": "Tambahkan ",
|
||||
"Browse your files": "Jelajahi file Anda",
|
||||
"COMMENT": "KOMENTAR",
|
||||
"Categories": "Kategori",
|
||||
"Category": "Kategori",
|
||||
"Change Language": "Ganti Bahasa",
|
||||
"Change password": "Ganti kata sandi",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klik 'Mulai Merekam' dan pilih layar atau tab untuk merekam. Setelah perekaman selesai, klik 'Hentikan Perekaman,' dan rekaman akan diunggah.",
|
||||
"Comment": "Komentar",
|
||||
"Comments": "Komentar",
|
||||
"Comments are disabled": "Komentar dinonaktifkan",
|
||||
"Contact": "Kontak",
|
||||
"DELETE MEDIA": "HAPUS MEDIA",
|
||||
"DOWNLOAD": "UNDUH",
|
||||
"Drag and drop files": "Seret dan lepas file",
|
||||
"EDIT MEDIA": "EDIT MEDIA",
|
||||
"EDIT PROFILE": "EDIT PROFIL",
|
||||
"EDIT SUBTITLE": "EDIT SUBTITLE",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "DAFTAR PUTAR",
|
||||
"Playlists": "Daftar putar",
|
||||
"Powered by": "Didukung oleh",
|
||||
"Publish": "Terbitkan",
|
||||
"Published on": "Diterbitkan pada",
|
||||
"Recommended": "Direkomendasikan",
|
||||
"Record Screen": "Rekam Layar",
|
||||
"Register": "Daftar",
|
||||
"SAVE": "SIMPAN",
|
||||
"SEARCH": "CARI",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Pilih",
|
||||
"Sign in": "Masuk",
|
||||
"Sign out": "Keluar",
|
||||
"Start Recording": "Mulai Merekam",
|
||||
"Stop Recording": "Hentikan Perekaman",
|
||||
"Subtitle was added": "Subtitle telah ditambahkan",
|
||||
"Subtitles": "Subtitel",
|
||||
"Tags": "Tag",
|
||||
"Terms": "Ketentuan",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Ini berfungsi di browser Chrome, Safari, dan Edge.",
|
||||
"Trim": "Potong",
|
||||
"UPLOAD": "UNGGAH",
|
||||
"Up next": "Selanjutnya",
|
||||
"Upload": "Unggah",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Unggahan",
|
||||
"VIEW ALL": "LIHAT SEMUA",
|
||||
"View all": "Lihat semua",
|
||||
"View media": "Lihat media",
|
||||
"comment": "komentar",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "adalah CMS video dan media open source yang modern dan lengkap. Ini dikembangkan untuk memenuhi kebutuhan platform web modern untuk menonton dan berbagi media",
|
||||
"media in category": "media dalam kategori",
|
||||
"media in tag": "media dalam tag",
|
||||
"or": "atau",
|
||||
"view": "lihat",
|
||||
"views": "tampilan",
|
||||
"yet": "belum",
|
||||
|
||||
@ -4,20 +4,17 @@ translation_strings = {
|
||||
"About": "Su di noi",
|
||||
"Add a": "Aggiungi un",
|
||||
"Add a ": "Aggiungi un ",
|
||||
"Browse your files": "Sfoglia i tuoi file",
|
||||
"COMMENT": "COMMENTA",
|
||||
"Categories": "Categorie",
|
||||
"Category": "Categoria",
|
||||
"Change Language": "Cambia lingua",
|
||||
"Change password": "Cambia password",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Fai clic su 'Avvia registrazione' e seleziona lo schermo o la scheda da registrare. Una volta terminata la registrazione, fai clic su 'Interrompi registrazione' e la registrazione verrà caricata.",
|
||||
"Comment": "Commento",
|
||||
"Comments": "Commenti",
|
||||
"Comments are disabled": "I commenti sono disabilitati",
|
||||
"Contact": "Contatti",
|
||||
"DELETE MEDIA": "ELIMINA MEDIA",
|
||||
"DOWNLOAD": "SCARICA",
|
||||
"Drag and drop files": "Trascina e rilascia i file",
|
||||
"EDIT MEDIA": "MODIFICA IL MEDIA",
|
||||
"EDIT PROFILE": "MODIFICA IL PROFILO",
|
||||
"EDIT SUBTITLE": "MODIFICA I SOTTOTITOLI",
|
||||
@ -46,10 +43,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "PLAYLIST",
|
||||
"Playlists": "Playlist",
|
||||
"Powered by": "Powered by",
|
||||
"Publish": "Pubblica",
|
||||
"Published on": "Pubblicato il",
|
||||
"Recommended": "Raccomandati",
|
||||
"Record Screen": "Registra schermo",
|
||||
"Register": "Registrati",
|
||||
"SAVE": "SALVA",
|
||||
"SEARCH": "CERCA",
|
||||
@ -60,14 +55,9 @@ translation_strings = {
|
||||
"Select": "Seleziona",
|
||||
"Sign in": "Login",
|
||||
"Sign out": "Logout",
|
||||
"Start Recording": "Inizia registrazione",
|
||||
"Stop Recording": "Interrompi registrazione",
|
||||
"Subtitle was added": "I sottotitoli sono stati aggiunti",
|
||||
"Subtitles": "Sottotitoli",
|
||||
"Tags": "Tag",
|
||||
"Terms": "Termini e condizioni",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Questo funziona nei browser Chrome, Safari e Edge.",
|
||||
"Trim": "Taglia",
|
||||
"UPLOAD": "CARICA",
|
||||
"Up next": "A seguire",
|
||||
"Upload": "Carica",
|
||||
@ -75,12 +65,10 @@ translation_strings = {
|
||||
"Uploads": "Caricamenti",
|
||||
"VIEW ALL": "MOSTRA TUTTI",
|
||||
"View all": "Mostra tutti",
|
||||
"View media": "Visualizza media",
|
||||
"comment": "commento",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "è un CMS per media open source moderno e completo. È stato sviluppato per rispondere per venire incontro alle esigenze delle moderne piattaforme web di visualizzazione e condivisione media",
|
||||
"media in category": "media nella categoria",
|
||||
"media in tag": "media con tag",
|
||||
"or": "o",
|
||||
"view": "visualizzazione",
|
||||
"views": "visualizzazioni",
|
||||
"yet": "ancora",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "自動再生",
|
||||
"About": "約",
|
||||
"Add a ": "追加",
|
||||
"Browse your files": "ファイルを参照",
|
||||
"COMMENT": "コメント",
|
||||
"Categories": "カテゴリー",
|
||||
"Category": "カテゴリー",
|
||||
"Change Language": "言語を変更",
|
||||
"Change password": "パスワードを変更",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "「録画開始」をクリックして、録画する画面またはタブを選択します。録画が終了したら、「録画停止」をクリックすると、録画がアップロードされます。",
|
||||
"Comment": "コメント",
|
||||
"Comments": "コメント",
|
||||
"Comments are disabled": "コメントは無効です",
|
||||
"Contact": "連絡先",
|
||||
"DELETE MEDIA": "メディアを削除",
|
||||
"DOWNLOAD": "ダウンロード",
|
||||
"Drag and drop files": "ファイルをドラッグアンドドロップ",
|
||||
"EDIT MEDIA": "メディアを編集",
|
||||
"EDIT PROFILE": "プロフィールを編集",
|
||||
"EDIT SUBTITLE": "字幕を編集",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "プレイリスト",
|
||||
"Playlists": "プレイリスト",
|
||||
"Powered by": "提供",
|
||||
"Publish": "公開",
|
||||
"Published on": "公開日",
|
||||
"Recommended": "おすすめ",
|
||||
"Record Screen": "画面を録画",
|
||||
"Register": "登録",
|
||||
"SAVE": "保存",
|
||||
"SEARCH": "検索",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "選択",
|
||||
"Sign in": "サインイン",
|
||||
"Sign out": "サインアウト",
|
||||
"Start Recording": "録画開始",
|
||||
"Stop Recording": "録画停止",
|
||||
"Subtitle was added": "字幕が追加されました",
|
||||
"Subtitles": "字幕",
|
||||
"Tags": "タグ",
|
||||
"Terms": "利用規約",
|
||||
"This works in Chrome, Safari and Edge browsers.": "これはChrome、Safari、Edgeブラウザで動作します。",
|
||||
"Trim": "トリム",
|
||||
"UPLOAD": "アップロード",
|
||||
"Up next": "次に再生",
|
||||
"Upload": "アップロード",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "アップロード",
|
||||
"VIEW ALL": "すべて表示",
|
||||
"View all": "すべて表示",
|
||||
"View media": "メディアを見る",
|
||||
"comment": "コメント",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "は、現代のウェブプラットフォームのニーズに応えるために開発された、最新のフル機能のオープンソースビデオおよびメディアCMSです。",
|
||||
"media in category": "カテゴリー内のメディア",
|
||||
"media in tag": "タグ内のメディア",
|
||||
"or": "または",
|
||||
"view": "ビュー",
|
||||
"views": "ビュー",
|
||||
"yet": "まだ",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "자동 재생",
|
||||
"About": "정보",
|
||||
"Add a ": "추가",
|
||||
"Browse your files": "파일 찾아보기",
|
||||
"COMMENT": "댓글",
|
||||
"Categories": "카테고리",
|
||||
"Category": "카테고리",
|
||||
"Change Language": "언어 변경",
|
||||
"Change password": "비밀번호 변경",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'녹화 시작'을 클릭하고 녹화할 화면이나 탭을 선택하세요. 녹화가 끝나면 '녹화 중지'를 클릭하면 녹화 파일이 업로드됩니다.",
|
||||
"Comment": "댓글",
|
||||
"Comments": "댓글",
|
||||
"Comments are disabled": "댓글이 비활성화되었습니다",
|
||||
"Contact": "연락처",
|
||||
"DELETE MEDIA": "미디어 삭제",
|
||||
"DOWNLOAD": "다운로드",
|
||||
"Drag and drop files": "파일을 끌어다 놓기",
|
||||
"EDIT MEDIA": "미디어 편집",
|
||||
"EDIT PROFILE": "프로필 편집",
|
||||
"EDIT SUBTITLE": "자막 편집",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "재생 목록",
|
||||
"Playlists": "재생 목록",
|
||||
"Powered by": "제공",
|
||||
"Publish": "게시",
|
||||
"Published on": "게시일",
|
||||
"Recommended": "추천",
|
||||
"Record Screen": "화면 녹화",
|
||||
"Register": "등록",
|
||||
"SAVE": "저장",
|
||||
"SEARCH": "검색",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "선택",
|
||||
"Sign in": "로그인",
|
||||
"Sign out": "로그아웃",
|
||||
"Start Recording": "녹화 시작",
|
||||
"Stop Recording": "녹화 중지",
|
||||
"Subtitle was added": "자막이 추가되었습니다",
|
||||
"Subtitles": "자막",
|
||||
"Tags": "태그",
|
||||
"Terms": "약관",
|
||||
"This works in Chrome, Safari and Edge browsers.": "이 기능은 Chrome, Safari 및 Edge 브라우저에서 작동합니다.",
|
||||
"Trim": "자르기",
|
||||
"UPLOAD": "업로드",
|
||||
"Up next": "다음",
|
||||
"Upload": "업로드",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "업로드",
|
||||
"VIEW ALL": "모두 보기",
|
||||
"View all": "모두 보기",
|
||||
"View media": "미디어 보기",
|
||||
"comment": "댓글",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "현대적인, 완전한 기능을 갖춘 오픈 소스 비디오 및 미디어 CMS입니다. 미디어를 시청하고 공유하기 위한 현대 웹 플랫폼의 요구를 충족시키기 위해 개발되었습니다",
|
||||
"media in category": "카테고리의 미디어",
|
||||
"media in tag": "태그의 미디어",
|
||||
"or": "또는",
|
||||
"view": "보기",
|
||||
"views": "조회수",
|
||||
"yet": "아직",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "AUTOMATISCH AFSPELEN",
|
||||
"About": "Over",
|
||||
"Add a ": "Voeg een ",
|
||||
"Browse your files": "Blader door uw bestanden",
|
||||
"COMMENT": "REACTIE",
|
||||
"Categories": "Categorieën",
|
||||
"Category": "Categorie",
|
||||
"Change Language": "Taal wijzigen",
|
||||
"Change password": "Wachtwoord wijzigen",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Klik op 'Opname starten' en selecteer het scherm of tabblad dat u wilt opnemen. Zodra de opname is voltooid, klikt u op 'Opname stoppen' en de opname wordt geüpload.",
|
||||
"Comment": "Reactie",
|
||||
"Comments": "Reacties",
|
||||
"Comments are disabled": "Reacties zijn uitgeschakeld",
|
||||
"Contact": "Contact",
|
||||
"DELETE MEDIA": "MEDIA VERWIJDEREN",
|
||||
"DOWNLOAD": "DOWNLOADEN",
|
||||
"Drag and drop files": "Sleep bestanden en zet ze neer",
|
||||
"EDIT MEDIA": "MEDIA BEWERKEN",
|
||||
"EDIT PROFILE": "PROFIEL BEWERKEN",
|
||||
"EDIT SUBTITLE": "ONDERTITEL BEWERKEN",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "AFSPEELLIJSTEN",
|
||||
"Playlists": "Afspeellijsten",
|
||||
"Powered by": "Aangedreven door",
|
||||
"Publish": "Publiceren",
|
||||
"Published on": "Gepubliceerd op",
|
||||
"Recommended": "Aanbevolen",
|
||||
"Record Screen": "Scherm opnemen",
|
||||
"Register": "Registreren",
|
||||
"SAVE": "OPSLAAN",
|
||||
"SEARCH": "ZOEKEN",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Selecteer",
|
||||
"Sign in": "Inloggen",
|
||||
"Sign out": "Uitloggen",
|
||||
"Start Recording": "Opname starten",
|
||||
"Stop Recording": "Opname stoppen",
|
||||
"Subtitle was added": "Ondertitel is toegevoegd",
|
||||
"Subtitles": "Ondertitels",
|
||||
"Tags": "Tags",
|
||||
"Terms": "Voorwaarden",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Dit werkt in Chrome, Safari en Edge browsers.",
|
||||
"Trim": "Bijsnijden",
|
||||
"UPLOAD": "UPLOADEN",
|
||||
"Up next": "Hierna",
|
||||
"Upload": "Uploaden",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Uploads",
|
||||
"VIEW ALL": "BEKIJK ALLES",
|
||||
"View all": "Bekijk alles",
|
||||
"View media": "Media bekijken",
|
||||
"comment": "reactie",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "is een modern, volledig uitgerust open source video- en media-CMS. Het is ontwikkeld om te voldoen aan de behoeften van moderne webplatforms voor het bekijken en delen van media",
|
||||
"media in category": "media in categorie",
|
||||
"media in tag": "media in tag",
|
||||
"or": "of",
|
||||
"view": "bekijk",
|
||||
"views": "weergaven",
|
||||
"yet": "nog",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "REPRODUÇÃO AUTOMÁTICA",
|
||||
"About": "Sobre",
|
||||
"Add a ": "Adicionar um ",
|
||||
"Browse your files": "Procurar seus arquivos",
|
||||
"COMMENT": "COMENTÁRIO",
|
||||
"Categories": "Categorias",
|
||||
"Category": "Categoria",
|
||||
"Change Language": "Mudar idioma",
|
||||
"Change password": "Mudar senha",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Clique em 'Iniciar gravação' e selecione a tela ou guia para gravar. Quando a gravação terminar, clique em 'Parar gravação' e a gravação será enviada.",
|
||||
"Comment": "Comentário",
|
||||
"Comments": "Comentários",
|
||||
"Comments are disabled": "Comentários estão desativados",
|
||||
"Contact": "Contato",
|
||||
"DELETE MEDIA": "EXCLUIR MÍDIA",
|
||||
"DOWNLOAD": "BAIXAR",
|
||||
"Drag and drop files": "Arraste e solte arquivos",
|
||||
"EDIT MEDIA": "EDITAR MÍDIA",
|
||||
"EDIT PROFILE": "EDITAR PERFIL",
|
||||
"EDIT SUBTITLE": "EDITAR LEGENDA",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "PLAYLISTS",
|
||||
"Playlists": "Playlists",
|
||||
"Powered by": "Desenvolvido por",
|
||||
"Publish": "Publicar",
|
||||
"Published on": "Publicado em",
|
||||
"Recommended": "Recomendado",
|
||||
"Record Screen": "Gravar tela",
|
||||
"Register": "Registrar",
|
||||
"SAVE": "SALVAR",
|
||||
"SEARCH": "PESQUISAR",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Selecionar",
|
||||
"Sign in": "Entrar",
|
||||
"Sign out": "Sair",
|
||||
"Start Recording": "Iniciar Gravação",
|
||||
"Stop Recording": "Parar Gravação",
|
||||
"Subtitle was added": "Legenda foi adicionada",
|
||||
"Subtitles": "Legendas",
|
||||
"Tags": "Tags",
|
||||
"Terms": "Termos",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Isso funciona nos navegadores Chrome, Safari e Edge.",
|
||||
"Trim": "Cortar",
|
||||
"UPLOAD": "CARREGAR",
|
||||
"Up next": "A seguir",
|
||||
"Upload": "Carregar",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Uploads",
|
||||
"VIEW ALL": "VER TODOS",
|
||||
"View all": "Ver todos",
|
||||
"View media": "Ver mídia",
|
||||
"comment": "comentário",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "é um CMS de vídeo e mídia de código aberto, moderno e completo. Foi desenvolvido para atender às necessidades das plataformas web modernas para visualização e compartilhamento de mídia",
|
||||
"media in category": "mídia na categoria",
|
||||
"media in tag": "mídia na tag",
|
||||
"or": "ou",
|
||||
"view": "visualização",
|
||||
"views": "visualizações",
|
||||
"yet": "ainda",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "Автовоспроизведение",
|
||||
"About": "О",
|
||||
"Add a ": "Добавить ",
|
||||
"Browse your files": "Просмотреть файлы",
|
||||
"COMMENT": "КОММЕНТАРИЙ",
|
||||
"Categories": "Категории",
|
||||
"Category": "Категория",
|
||||
"Change Language": "Изменить язык",
|
||||
"Change password": "Изменить пароль",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Нажмите 'Начать запись' и выберите экран или вкладку для записи. После окончания записи нажмите 'Остановить запись', и запись будет загружена.",
|
||||
"Comment": "Комментарий",
|
||||
"Comments": "Комментарии",
|
||||
"Comments are disabled": "Комментарии отключены",
|
||||
"Contact": "Контакт",
|
||||
"DELETE MEDIA": "УДАЛИТЬ МЕДИА",
|
||||
"DOWNLOAD": "СКАЧАТЬ",
|
||||
"Drag and drop files": "Перетащите файлы",
|
||||
"EDIT MEDIA": "РЕДАКТИРОВАТЬ МЕДИА",
|
||||
"EDIT PROFILE": "РЕДАКТИРОВАТЬ ПРОФИЛЬ",
|
||||
"EDIT SUBTITLE": "РЕДАКТИРОВАТЬ СУБТИТРЫ",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "ПЛЕЙЛИСТЫ",
|
||||
"Playlists": "Плейлисты",
|
||||
"Powered by": "Работает на",
|
||||
"Publish": "Опубликовать",
|
||||
"Published on": "Опубликовано",
|
||||
"Recommended": "Рекомендуемое",
|
||||
"Record Screen": "Запись экрана",
|
||||
"Register": "Регистрация",
|
||||
"SAVE": "СОХРАНИТЬ",
|
||||
"SEARCH": "ПОИСК",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Выбрать",
|
||||
"Sign in": "Войти",
|
||||
"Sign out": "Выйти",
|
||||
"Start Recording": "Начать запись",
|
||||
"Stop Recording": "Остановить запись",
|
||||
"Subtitle was added": "Субтитры были добавлены",
|
||||
"Subtitles": "Субтитры",
|
||||
"Tags": "Теги",
|
||||
"Terms": "Условия",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Это работает в браузерах Chrome, Safari и Edge.",
|
||||
"Trim": "Обрезать",
|
||||
"UPLOAD": "ЗАГРУЗИТЬ",
|
||||
"Up next": "Далее",
|
||||
"Upload": "Загрузить",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Загрузки",
|
||||
"VIEW ALL": "ПОКАЗАТЬ ВСЕ",
|
||||
"View all": "Показать все",
|
||||
"View media": "Просмотр медиа",
|
||||
"comment": "комментарий",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "это современная, полнофункциональная система управления видео и медиа с открытым исходным кодом. Она разработана для удовлетворения потребностей современных веб-платформ для просмотра и обмена медиа",
|
||||
"media in category": "медиа в категории",
|
||||
"media in tag": "медиа в теге",
|
||||
"or": "или",
|
||||
"view": "просмотр",
|
||||
"views": "просмотры",
|
||||
"yet": "еще",
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "O NAS",
|
||||
"AUTOPLAY": "SAMODEJNO PREDVAJANJE",
|
||||
"About": "O nas",
|
||||
"Add a ": "Dodaj ",
|
||||
"Browse your files": "Prebrskaj datoteke",
|
||||
"COMMENT": "KOMENTAR",
|
||||
"Categories": "Kategorije",
|
||||
"Category": "Kategorija",
|
||||
"Change Language": "Spremeni jezik",
|
||||
"Change password": "Spremeni geslo",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "Kliknite 'Začni snemanje' in izberite zaslon ali zavihek za snemanje. Ko je snemanje končano, kliknite 'Ustavi snemanje' in posnetek bo naložen.",
|
||||
"About": "O nas",
|
||||
"Comment": "Komentar",
|
||||
"Comments": "Komentarji",
|
||||
"Comments are disabled": "Komentarji so onemogočeni",
|
||||
"Contact": "Kontakt",
|
||||
"DELETE MEDIA": "IZBRIŠI MEDIJ",
|
||||
"DOWNLOAD": "PRENESI",
|
||||
"Drag and drop files": "Povleci in spusti datoteke",
|
||||
"EDIT MEDIA": "UREDI MEDIJ",
|
||||
"EDIT PROFILE": "UREDI PROFIL",
|
||||
"EDIT SUBTITLE": "UREDI PODNAPISE",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "SEZNAMI PREDVAJANJA",
|
||||
"Playlists": "Seznami predvajanja",
|
||||
"Powered by": "Poganja",
|
||||
"Publish": "Objavi",
|
||||
"Published on": "Objavljeno",
|
||||
"Recommended": "Priporočeno",
|
||||
"Record Screen": "Snemanje zaslona",
|
||||
"Register": "Registracija",
|
||||
"SAVE": "SHRANI",
|
||||
"SEARCH": "ISKANJE",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Izberi",
|
||||
"Sign in": "Prijava",
|
||||
"Sign out": "Odjava",
|
||||
"Start Recording": "Začni snemanje",
|
||||
"Stop Recording": "Ustavi snemanje",
|
||||
"Subtitle was added": "Podnapisi so bili dodani",
|
||||
"Subtitles": "Podnapisi",
|
||||
"Tags": "Oznake",
|
||||
"Terms": "Pogoji",
|
||||
"This works in Chrome, Safari and Edge browsers.": "To deluje v brskalnikih Chrome, Safari in Edge.",
|
||||
"Trim": "Obreži",
|
||||
"UPLOAD": "NALOŽI",
|
||||
"Up next": "Naslednji",
|
||||
"Upload": "Naloži",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Naloženi",
|
||||
"VIEW ALL": "PRIKAŽI VSE",
|
||||
"View all": "Prikaži vse",
|
||||
"View media": "Ogled medija",
|
||||
"comment": "komentar",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "je moderni, popolnoma opremljen odprtokodni video in medijski CMS. Razvit je za potrebe sodobnih spletnih platform za ogled in deljenje medijev",
|
||||
"media in category": "mediji v kategoriji",
|
||||
"media in tag": "mediji z oznako",
|
||||
"or": "ali",
|
||||
"view": "ogled",
|
||||
"views": "ogledi",
|
||||
"yet": "še",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "OTOMATİK OYNATMA",
|
||||
"About": "Hakkında",
|
||||
"Add a ": "Ekle ",
|
||||
"Browse your files": "Dosyalarınıza göz atın",
|
||||
"COMMENT": "YORUM",
|
||||
"Categories": "Kategoriler",
|
||||
"Category": "Kategori",
|
||||
"Change Language": "Dili Değiştir",
|
||||
"Change password": "Şifreyi Değiştir",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'Kaydı Başlat'a tıklayın ve kaydedilecek ekranı veya sekmeyi seçin. Kayıt bittiğinde, 'Kaydı Durdur'a tıklayın ve kayıt yüklenecektir.",
|
||||
"Comment": "Yorum",
|
||||
"Comments": "Yorumlar",
|
||||
"Comments are disabled": "Yorumlar devre dışı",
|
||||
"Contact": "İletişim",
|
||||
"DELETE MEDIA": "MEDYAYI SİL",
|
||||
"DOWNLOAD": "İNDİR",
|
||||
"Drag and drop files": "Dosyaları sürükleyip bırakın",
|
||||
"EDIT MEDIA": "MEDYAYI DÜZENLE",
|
||||
"EDIT PROFILE": "PROFİLİ DÜZENLE",
|
||||
"EDIT SUBTITLE": "ALT YAZIYI DÜZENLE",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "ÇALMA LİSTELERİ",
|
||||
"Playlists": "Çalma listeleri",
|
||||
"Powered by": "Tarafından desteklenmektedir",
|
||||
"Publish": "Yayınla",
|
||||
"Published on": "Yayınlanma tarihi",
|
||||
"Recommended": "Önerilen",
|
||||
"Record Screen": "Ekranı Kaydet",
|
||||
"Register": "Kayıt Ol",
|
||||
"SAVE": "KAYDET",
|
||||
"SEARCH": "ARA",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "Seç",
|
||||
"Sign in": "Giriş Yap",
|
||||
"Sign out": "Çıkış Yap",
|
||||
"Start Recording": "Kaydı Başlat",
|
||||
"Stop Recording": "Kaydı Durdur",
|
||||
"Subtitle was added": "Alt yazı eklendi",
|
||||
"Subtitles": "Altyazılar",
|
||||
"Tags": "Etiketler",
|
||||
"Terms": "Şartlar",
|
||||
"This works in Chrome, Safari and Edge browsers.": "Bu, Chrome, Safari ve Edge tarayıcılarında çalışır.",
|
||||
"Trim": "Kırp",
|
||||
"UPLOAD": "YÜKLE",
|
||||
"Up next": "Sıradaki",
|
||||
"Upload": "Yükle",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "Yüklemeler",
|
||||
"VIEW ALL": "HEPSİNİ GÖR",
|
||||
"View all": "Hepsini gör",
|
||||
"View media": "Medyayı Görüntüle",
|
||||
"comment": "yorum",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "modern, tam özellikli açık kaynaklı bir video ve medya CMS'sidir. Medya izleme ve paylaşma ihtiyaçlarını karşılamak için geliştirilmiştir",
|
||||
"media in category": "kategorideki medya",
|
||||
"media in tag": "etiketteki medya",
|
||||
"or": "veya",
|
||||
"view": "görünüm",
|
||||
"views": "görünümler",
|
||||
"yet": "henüz",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "خودکار پلے",
|
||||
"About": "کے بارے میں",
|
||||
"Add a ": "شامل کریں",
|
||||
"Browse your files": "اپنی فائلیں براؤز کریں",
|
||||
"COMMENT": "تبصرہ",
|
||||
"Categories": "اقسام",
|
||||
"Category": "قسم",
|
||||
"Change Language": "زبان تبدیل کریں",
|
||||
"Change password": "پاس ورڈ تبدیل کریں",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "'ریکارڈنگ شروع کریں' پر کلک کریں اور ریکارڈ کرنے کے لیے اسکرین یا ٹیب منتخب کریں۔ ریکارڈنگ مکمل ہونے کے بعد، 'ریکارڈنگ بند کریں' پر کلک کریں، اور ریکارڈنگ اپ لوڈ ہو جائے گی۔",
|
||||
"Comment": "تبصرہ",
|
||||
"Comments": "تبصرے",
|
||||
"Comments are disabled": "تبصرے غیر فعال ہیں",
|
||||
"Contact": "رابطہ کریں",
|
||||
"DELETE MEDIA": "میڈیا حذف کریں",
|
||||
"DOWNLOAD": "ڈاؤن لوڈ",
|
||||
"Drag and drop files": "فائلیں گھسیٹیں اور چھوڑیں",
|
||||
"EDIT MEDIA": "میڈیا ترمیم کریں",
|
||||
"EDIT PROFILE": "پروفائل ترمیم کریں",
|
||||
"EDIT SUBTITLE": "سب ٹائٹل ترمیم کریں",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "پلے لسٹس",
|
||||
"Playlists": "پلے لسٹس",
|
||||
"Powered by": "کے ذریعہ تقویت یافتہ",
|
||||
"Publish": "شائع کریں",
|
||||
"Published on": "پر شائع ہوا",
|
||||
"Recommended": "تجویز کردہ",
|
||||
"Record Screen": "اسکرین ریکارڈ کریں",
|
||||
"Register": "رجسٹر کریں",
|
||||
"SAVE": "محفوظ کریں",
|
||||
"SEARCH": "تلاش کریں",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "منتخب کریں",
|
||||
"Sign in": "سائن ان کریں",
|
||||
"Sign out": "سائن آؤٹ کریں",
|
||||
"Start Recording": "ریکارڈنگ شروع کریں",
|
||||
"Stop Recording": "ریکارڈنگ روکیں",
|
||||
"Subtitle was added": "سب ٹائٹل شامل کیا گیا",
|
||||
"Subtitles": "سب ٹائٹلز",
|
||||
"Tags": "ٹیگز",
|
||||
"Terms": "شرائط",
|
||||
"This works in Chrome, Safari and Edge browsers.": "یہ کروم، سفاری اور ایج براؤزرز میں کام کرتا ہے۔",
|
||||
"Trim": "تراشیں",
|
||||
"UPLOAD": "اپ لوڈ کریں",
|
||||
"Up next": "اگلا",
|
||||
"Upload": "اپ لوڈ کریں",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "اپ لوڈز",
|
||||
"VIEW ALL": "سب دیکھیں",
|
||||
"View all": "سب دیکھیں",
|
||||
"View media": "میڈیا دیکھیں",
|
||||
"comment": "تبصرہ",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "ایک جدید، مکمل خصوصیات والا اوپن سورس ویڈیو اور میڈیا CMS ہے۔ یہ جدید ویب پلیٹ فارمز کی ضروریات کو پورا کرنے کے لئے تیار کیا گیا ہے تاکہ میڈیا دیکھنے اور شیئر کرنے کے لئے",
|
||||
"media in category": "زمرے میں میڈیا",
|
||||
"media in tag": "ٹیگ میں میڈیا",
|
||||
"or": "یا",
|
||||
"view": "دیکھیں",
|
||||
"views": "دیکھے گئے",
|
||||
"yet": "ابھی تک",
|
||||
|
||||
@ -3,20 +3,17 @@ translation_strings = {
|
||||
"AUTOPLAY": "自动播放",
|
||||
"About": "关于",
|
||||
"Add a ": "添加一个",
|
||||
"Browse your files": "浏览文件",
|
||||
"COMMENT": "评论",
|
||||
"Categories": "分类",
|
||||
"Category": "类别",
|
||||
"Change Language": "更改语言",
|
||||
"Change password": "更改密码",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "点击“开始录制”并选择要录制的屏幕或标签页。录制完成后,点击“停止录制”,录制内容将被上传。",
|
||||
"Comment": "评论",
|
||||
"Comments": "评论",
|
||||
"Comments are disabled": "评论已禁用",
|
||||
"Contact": "联系",
|
||||
"DELETE MEDIA": "删除媒体",
|
||||
"DOWNLOAD": "下载",
|
||||
"Drag and drop files": "拖放文件",
|
||||
"EDIT MEDIA": "编辑媒体",
|
||||
"EDIT PROFILE": "编辑个人资料",
|
||||
"EDIT SUBTITLE": "编辑字幕",
|
||||
@ -45,10 +42,8 @@ translation_strings = {
|
||||
"PLAYLISTS": "播放列表",
|
||||
"Playlists": "播放列表",
|
||||
"Powered by": "由...提供技术支持",
|
||||
"Publish": "发布",
|
||||
"Published on": "发布于",
|
||||
"Recommended": "推荐",
|
||||
"Record Screen": "录制屏幕",
|
||||
"Register": "注册",
|
||||
"SAVE": "保存",
|
||||
"SEARCH": "搜索",
|
||||
@ -59,14 +54,9 @@ translation_strings = {
|
||||
"Select": "选择",
|
||||
"Sign in": "登录",
|
||||
"Sign out": "登出",
|
||||
"Start Recording": "开始录制",
|
||||
"Stop Recording": "停止录制",
|
||||
"Subtitle was added": "字幕已添加",
|
||||
"Subtitles": "字幕",
|
||||
"Tags": "标签",
|
||||
"Terms": "条款",
|
||||
"This works in Chrome, Safari and Edge browsers.": "此功能适用于 Chrome、Safari 和 Edge 浏览器。",
|
||||
"Trim": "修剪",
|
||||
"UPLOAD": "上传",
|
||||
"Up next": "接下来",
|
||||
"Upload": "上传",
|
||||
@ -74,12 +64,10 @@ translation_strings = {
|
||||
"Uploads": "上传",
|
||||
"VIEW ALL": "查看全部",
|
||||
"View all": "查看全部",
|
||||
"View media": "查看媒体",
|
||||
"comment": "评论",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "是一个现代化、功能齐全的开源视频和媒体CMS。它是为了满足现代网络平台观看和分享媒体的需求而开发的",
|
||||
"media in category": "类别中的媒体",
|
||||
"media in tag": "标签中的媒体",
|
||||
"or": "或",
|
||||
"view": "查看",
|
||||
"views": "查看",
|
||||
"yet": "还",
|
||||
|
||||
@ -1,116 +1,104 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "關於",
|
||||
"AUTOPLAY": "自動播放",
|
||||
"About": "關於",
|
||||
"Add a ": "新增",
|
||||
"Browse your files": "瀏覽您的檔案",
|
||||
"COMMENT": "留言",
|
||||
"Categories": "分類",
|
||||
"Category": "分類",
|
||||
"Change Language": "切換語言",
|
||||
"Change password": "變更密碼",
|
||||
"Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded.": "點擊「開始錄製」並選擇要錄製的螢幕或分頁。錄製完成後,點擊「停止錄製」,錄製的內容將會上傳。",
|
||||
"Comment": "留言",
|
||||
"Comments": "留言",
|
||||
"Comments are disabled": "留言功能已關閉",
|
||||
"Contact": "聯絡資訊",
|
||||
"DELETE MEDIA": "刪除影片",
|
||||
"DOWNLOAD": "下載",
|
||||
"Drag and drop files": "拖放檔案",
|
||||
"EDIT MEDIA": "編輯影片",
|
||||
"EDIT PROFILE": "編輯個人資料",
|
||||
"EDIT SUBTITLE": "編輯字幕",
|
||||
"Edit media": "編輯影片",
|
||||
"Edit profile": "編輯個人資料",
|
||||
"Edit subtitle": "編輯字幕",
|
||||
"Featured": "精選內容",
|
||||
"Go": "執行",
|
||||
"History": "觀看紀錄",
|
||||
"Home": "首頁",
|
||||
"Language": "語言",
|
||||
"Latest": "最新內容",
|
||||
"Liked media": "我喜歡的影片",
|
||||
"Manage comments": "留言管理",
|
||||
"Manage media": "媒體管理",
|
||||
"Manage users": "使用者管理",
|
||||
"Media": "媒體",
|
||||
"Media was edited": "媒體已更新",
|
||||
"Members": "會員",
|
||||
"My media": "我的媒體",
|
||||
"My playlists": "我的播放清單",
|
||||
"No": "無",
|
||||
"No comment yet": "尚無留言",
|
||||
"No comments yet": "尚未有留言",
|
||||
"No results for": "查無相關結果:",
|
||||
"PLAYLISTS": "播放清單",
|
||||
"Playlists": "播放清單",
|
||||
"Powered by": "技術提供為",
|
||||
"Publish": "發布",
|
||||
"Published on": "發布日期為",
|
||||
"Recommended": "推薦內容",
|
||||
"Record Screen": "螢幕錄製",
|
||||
"Register": "註冊",
|
||||
"SAVE": "儲存",
|
||||
"SEARCH": "搜尋",
|
||||
"SHARE": "分享",
|
||||
"SHOW MORE": "顯示更多",
|
||||
"SUBMIT": "送出",
|
||||
"Search": "搜尋",
|
||||
"Select": "選擇",
|
||||
"Sign in": "登入",
|
||||
"Sign out": "登出",
|
||||
"Start Recording": "開始錄製",
|
||||
"Stop Recording": "停止錄製",
|
||||
"Subtitle was added": "字幕已新增",
|
||||
"Subtitles": "字幕",
|
||||
"Tags": "標籤",
|
||||
"Terms": "使用條款",
|
||||
"This works in Chrome, Safari and Edge browsers.": "此功能適用於 Chrome、Safari 和 Edge 瀏覽器。",
|
||||
"Trim": "修剪",
|
||||
"UPLOAD": "上傳",
|
||||
"Up next": "即將播放",
|
||||
"Upload": "上傳",
|
||||
"Upload media": "上傳媒體",
|
||||
"Uploads": "上傳內容",
|
||||
"VIEW ALL": "查看全部",
|
||||
"View all": "瀏覽全部",
|
||||
"View media": "查看媒體",
|
||||
"comment": "留言",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "這是一個現代化且功能完整的開源影音內容管理系統,專為現代網路平台的觀賞與分享需求所打造。",
|
||||
"media in category": "此分類下的媒體",
|
||||
"media in tag": "此標籤下的媒體",
|
||||
"or": "或者",
|
||||
"view": "次觀看",
|
||||
"views": "次觀看",
|
||||
"yet": " ",
|
||||
'ABOUT': '關於',
|
||||
'AUTOPLAY': '自動播放',
|
||||
'About': '關於',
|
||||
'Add a ': '新增',
|
||||
'COMMENT': '留言',
|
||||
'Categories': '分類',
|
||||
'Category': '分類',
|
||||
'Change Language': '切換語言',
|
||||
'Change password': '變更密碼',
|
||||
'Comment': '留言',
|
||||
'Comments': '留言',
|
||||
'Comments are disabled': '留言功能已關閉',
|
||||
'Contact': '聯絡資訊',
|
||||
'DELETE MEDIA': '刪除影片',
|
||||
'DOWNLOAD': '下載',
|
||||
'EDIT MEDIA': '編輯影片',
|
||||
'EDIT PROFILE': '編輯個人資料',
|
||||
'EDIT SUBTITLE': '編輯字幕',
|
||||
'Edit media': '編輯影片',
|
||||
'Edit profile': '編輯個人資料',
|
||||
'Edit subtitle': '編輯字幕',
|
||||
'Featured': '精選內容',
|
||||
'Go': '執行', # in context of "execution"
|
||||
'History': '觀看紀錄',
|
||||
'Home': '首頁',
|
||||
'Language': '語言',
|
||||
'Latest': '最新內容',
|
||||
'Liked media': '我喜歡的影片',
|
||||
'Manage comments': '留言管理',
|
||||
'Manage media': '媒體管理',
|
||||
'Manage users': '使用者管理',
|
||||
'Media': '媒體',
|
||||
'Media was edited': '媒體已更新',
|
||||
'Members': '會員',
|
||||
'My media': '我的媒體',
|
||||
'My playlists': '我的播放清單',
|
||||
'No': '無', # in context of "no comments", etc.
|
||||
'No comment yet': '尚無留言',
|
||||
'No comments yet': '尚未有留言',
|
||||
'No results for': '查無相關結果:',
|
||||
'PLAYLISTS': '播放清單',
|
||||
'Playlists': '播放清單',
|
||||
'Powered by': '技術提供為',
|
||||
'Published on': '發布日期為',
|
||||
'Recommended': '推薦內容',
|
||||
'Register': '註冊',
|
||||
'SAVE': '儲存',
|
||||
'SEARCH': '搜尋',
|
||||
'SHARE': '分享',
|
||||
'SHOW MORE': '顯示更多',
|
||||
'SUBMIT': '送出',
|
||||
'Search': '搜尋',
|
||||
'Select': '選擇',
|
||||
'Sign in': '登入',
|
||||
'Sign out': '登出',
|
||||
'Subtitle was added': '字幕已新增',
|
||||
'Tags': '標籤',
|
||||
'Terms': '使用條款',
|
||||
'UPLOAD': '上傳',
|
||||
'Up next': '即將播放',
|
||||
'Upload': '上傳',
|
||||
'Upload media': '上傳媒體',
|
||||
'Uploads': '上傳內容',
|
||||
'VIEW ALL': '查看全部',
|
||||
'View all': '瀏覽全部',
|
||||
'comment': '留言',
|
||||
'is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media': '這是一個現代化且功能完整的開源影音內容管理系統,專為現代網路平台的觀賞與分享需求所打造。',
|
||||
'media in category': '此分類下的媒體',
|
||||
'media in tag': '此標籤下的媒體',
|
||||
'view': '次觀看',
|
||||
'views': '次觀看',
|
||||
'yet': ' ', # no such usage in this language,
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
"Apr": "四月",
|
||||
"Aug": "八月",
|
||||
"Dec": "十二月",
|
||||
"Feb": "二月",
|
||||
"Jan": "一月",
|
||||
"Jul": "七月",
|
||||
"Jun": "六月",
|
||||
"Mar": "三月",
|
||||
"May": "五月",
|
||||
"Nov": "十一月",
|
||||
"Oct": "十月",
|
||||
"Sep": "九月",
|
||||
"day ago": "天前",
|
||||
"days ago": "天前",
|
||||
"hour ago": "小時前",
|
||||
"hours ago": "小時前",
|
||||
"just now": "剛剛",
|
||||
"minute ago": "分鐘前",
|
||||
"minutes ago": "分鐘前",
|
||||
"month ago": "個月前",
|
||||
"months ago": "個月前",
|
||||
"second ago": "秒前",
|
||||
"seconds ago": "秒前",
|
||||
"week ago": "週前",
|
||||
"weeks ago": "週前",
|
||||
"year ago": "年前",
|
||||
"years ago": "年前",
|
||||
'Apr': '四月',
|
||||
'Aug': '八月',
|
||||
'Dec': '十二月',
|
||||
'Feb': '二月',
|
||||
'Jan': '一月',
|
||||
'Jul': '七月',
|
||||
'Jun': '六月',
|
||||
'Mar': '三月',
|
||||
'May': '五月',
|
||||
'Nov': '十一月',
|
||||
'Oct': '十月',
|
||||
'Sep': '九月',
|
||||
'day ago': '天前',
|
||||
'days ago': '天前',
|
||||
'hour ago': '小時前',
|
||||
'hours ago': '小時前',
|
||||
'just now': '剛剛',
|
||||
'minute ago': '分鐘前',
|
||||
'minutes ago': '分鐘前',
|
||||
'month ago': '個月前',
|
||||
'months ago': '個月前',
|
||||
'second ago': '秒前',
|
||||
'seconds ago': '秒前',
|
||||
'week ago': '週前',
|
||||
'weeks ago': '週前',
|
||||
'year ago': '年前',
|
||||
'years ago': '年前',
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from drf_yasg import openapi as openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
@ -221,13 +219,6 @@ class UserList(APIView):
|
||||
elif role == "editor":
|
||||
qs = qs.filter(is_editor=True)
|
||||
|
||||
if settings.USERS_NEEDS_TO_BE_APPROVED:
|
||||
is_approved = request.GET.get("is_approved")
|
||||
if is_approved == "true":
|
||||
qs = qs.filter(is_approved=True)
|
||||
elif is_approved == "false":
|
||||
qs = qs.filter(Q(is_approved=False) | Q(is_approved__isnull=True))
|
||||
|
||||
users = qs.order_by(f"{ordering}{sort_by}")
|
||||
|
||||
paginator = pagination_class()
|
||||
|
||||
@ -401,44 +401,6 @@ def clean_comment(raw_comment):
|
||||
return cleaned_comment
|
||||
|
||||
|
||||
def user_allowed_to_upload(request):
|
||||
"""Any custom logic for whether a user is allowed
|
||||
to upload content lives here
|
||||
"""
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
if is_mediacms_editor(request.user):
|
||||
return True
|
||||
|
||||
# Check if user has reached the maximum number of uploads
|
||||
if hasattr(settings, 'NUMBER_OF_MEDIA_USER_CAN_UPLOAD'):
|
||||
if models.Media.objects.filter(user=request.user).count() >= settings.NUMBER_OF_MEDIA_USER_CAN_UPLOAD:
|
||||
return False
|
||||
|
||||
if settings.CAN_ADD_MEDIA == "all":
|
||||
return True
|
||||
elif settings.CAN_ADD_MEDIA == "email_verified":
|
||||
if request.user.email_is_verified:
|
||||
return True
|
||||
elif settings.CAN_ADD_MEDIA == "advancedUser":
|
||||
if request.user.advancedUser:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_transcribe_video(user):
|
||||
"""Checks if a user can transcribe a video."""
|
||||
if not getattr(settings, 'USE_WHISPER_TRANSCRIBE', False):
|
||||
return False
|
||||
|
||||
if is_mediacms_editor(user):
|
||||
return True
|
||||
if getattr(settings, 'USER_CAN_TRANSCRIBE_VIDEO', False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def kill_ffmpeg_process(filepath):
|
||||
"""Kill ffmpeg process that is processing a specific file
|
||||
|
||||
@ -604,7 +566,7 @@ def handle_video_chapters(media, chapters):
|
||||
else:
|
||||
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
||||
|
||||
return {'chapters': media.chapter_data}
|
||||
return media.chapter_data
|
||||
|
||||
|
||||
def change_media_owner(media_id, new_user):
|
||||
@ -644,9 +606,3 @@ def copy_media(media_id):
|
||||
None
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def is_media_allowed_type(media):
|
||||
if "all" in settings.ALLOWED_MEDIA_UPLOAD_TYPES:
|
||||
return True
|
||||
return media.media_type in settings.ALLOWED_MEDIA_UPLOAD_TYPES
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-08-31 08:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0011_mediapermission'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='language',
|
||||
name='code',
|
||||
field=models.CharField(help_text='language code', max_length=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='media',
|
||||
name='allow_whisper_transcribe',
|
||||
field=models.BooleanField(default=False, verbose_name='Transcribe auto-detected language'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='media',
|
||||
name='allow_whisper_transcribe_and_translate',
|
||||
field=models.BooleanField(default=False, verbose_name='Transcribe auto-detected language and translate to English'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TranscriptionRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('add_date', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('fail', 'Fail'), ('success', 'Success')], db_index=True, default='pending', max_length=20)),
|
||||
('translate_to_english', models.BooleanField(default=False)),
|
||||
('logs', models.TextField(blank=True, null=True)),
|
||||
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transcriptionrequests', to='files.media')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,42 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-21 11:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0012_media_allow_whisper_transcribe_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Page',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(max_length=200, unique=True)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('add_date', models.DateTimeField(auto_now_add=True)),
|
||||
('edit_date', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TinyMCEMedia',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(upload_to='tinymce_media/')),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('file_type', models.CharField(choices=[('image', 'Image'), ('media', 'Media')], max_length=10)),
|
||||
('original_filename', models.CharField(max_length=255)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'TinyMCE Media',
|
||||
'verbose_name_plural': 'TinyMCE Media',
|
||||
'ordering': ['-uploaded_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -4,10 +4,9 @@ from .comment import Comment # noqa: F401
|
||||
from .encoding import EncodeProfile, Encoding # noqa: F401
|
||||
from .license import License # noqa: F401
|
||||
from .media import Media, MediaPermission # noqa: F401
|
||||
from .page import Page, TinyMCEMedia # noqa: F401
|
||||
from .playlist import Playlist, PlaylistMedia # noqa: F401
|
||||
from .rating import Rating, RatingCategory # noqa: F401
|
||||
from .subtitle import Language, Subtitle, TranscriptionRequest # noqa: F401
|
||||
from .subtitle import Language, Subtitle # noqa: F401
|
||||
from .utils import CODECS # noqa: F401
|
||||
from .utils import ENCODE_EXTENSIONS # noqa: F401
|
||||
from .utils import ENCODE_EXTENSIONS_KEYS # noqa: F401
|
||||
|
||||
@ -138,7 +138,7 @@ class Tag(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.title = helpers.get_alphanumeric_only(self.title)
|
||||
self.title = self.title[:100]
|
||||
self.title = self.title[:99]
|
||||
super(Tag, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
||||
@ -23,7 +23,6 @@ from imagekit.processors import ResizeToFit
|
||||
from .. import helpers
|
||||
from ..stop_words import STOP_WORDS
|
||||
from .encoding import EncodeProfile, Encoding
|
||||
from .subtitle import TranscriptionRequest
|
||||
from .utils import (
|
||||
ENCODE_RESOLUTIONS_KEYS,
|
||||
MEDIA_ENCODING_STATUS,
|
||||
@ -206,9 +205,6 @@ class Media(models.Model):
|
||||
|
||||
views = models.IntegerField(db_index=True, default=1)
|
||||
|
||||
allow_whisper_transcribe = models.BooleanField("Transcribe auto-detected language", default=False)
|
||||
allow_whisper_transcribe_and_translate = models.BooleanField("Transcribe auto-detected language and translate to English", default=False)
|
||||
|
||||
# keep track if media file has changed, on saves
|
||||
__original_media_file = None
|
||||
__original_thumbnail_time = None
|
||||
@ -235,8 +231,6 @@ class Media(models.Model):
|
||||
self.__original_media_file = self.media_file
|
||||
self.__original_thumbnail_time = self.thumbnail_time
|
||||
self.__original_uploaded_poster = self.uploaded_poster
|
||||
self.__original_allow_whisper_transcribe = self.allow_whisper_transcribe
|
||||
self.__original_allow_whisper_transcribe_and_translate = self.allow_whisper_transcribe_and_translate
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.title:
|
||||
@ -245,7 +239,7 @@ class Media(models.Model):
|
||||
strip_text_items = ["title", "description"]
|
||||
for item in strip_text_items:
|
||||
setattr(self, item, strip_tags(getattr(self, item, None)))
|
||||
self.title = self.title[:100]
|
||||
self.title = self.title[:99]
|
||||
|
||||
# if thumbnail_time specified, keep up to single digit
|
||||
if self.thumbnail_time:
|
||||
@ -277,17 +271,6 @@ class Media(models.Model):
|
||||
if self.thumbnail_time != self.__original_thumbnail_time:
|
||||
self.__original_thumbnail_time = self.thumbnail_time
|
||||
self.set_thumbnail(force=True)
|
||||
|
||||
transcription_changed = (
|
||||
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
||||
)
|
||||
|
||||
if transcription_changed and self.media_type == "video":
|
||||
self.transcribe_function()
|
||||
|
||||
# Update the original values for next comparison
|
||||
self.__original_allow_whisper_transcribe = self.allow_whisper_transcribe
|
||||
self.__original_allow_whisper_transcribe_and_translate = self.allow_whisper_transcribe_and_translate
|
||||
else:
|
||||
# media is going to be created now
|
||||
# after media is saved, post_save signal will call media_init function
|
||||
@ -314,26 +297,6 @@ class Media(models.Model):
|
||||
thumbnail_name = helpers.get_file_name(self.uploaded_poster.path)
|
||||
self.uploaded_thumbnail.save(content=myfile, name=thumbnail_name)
|
||||
|
||||
def transcribe_function(self):
|
||||
to_transcribe = False
|
||||
to_transcribe_and_translate = False
|
||||
|
||||
if self.allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate:
|
||||
if self.allow_whisper_transcribe and not TranscriptionRequest.objects.filter(media=self, translate_to_english=False).exists():
|
||||
to_transcribe = True
|
||||
|
||||
if self.allow_whisper_transcribe_and_translate and not TranscriptionRequest.objects.filter(media=self, translate_to_english=True).exists():
|
||||
to_transcribe_and_translate = True
|
||||
|
||||
from .. import tasks
|
||||
|
||||
if to_transcribe:
|
||||
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
|
||||
if to_transcribe_and_translate:
|
||||
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
|
||||
|
||||
def update_search_vector(self):
|
||||
"""
|
||||
Update SearchVector field of SearchModel using raw SQL
|
||||
@ -373,15 +336,6 @@ class Media(models.Model):
|
||||
video duration, encode
|
||||
"""
|
||||
self.set_media_type()
|
||||
from ..methods import is_media_allowed_type
|
||||
|
||||
if not is_media_allowed_type(self):
|
||||
helpers.rm_file(self.media_file.path)
|
||||
if self.state == "public":
|
||||
self.state = "unlisted"
|
||||
self.save(update_fields=["state"])
|
||||
return False
|
||||
|
||||
if self.media_type == "video":
|
||||
self.set_thumbnail(force=True)
|
||||
if settings.DO_NOT_TRANSCODE_VIDEO:
|
||||
@ -630,7 +584,7 @@ class Media(models.Model):
|
||||
|
||||
@property
|
||||
def trim_video_url(self):
|
||||
if self.media_type not in ["video", "audio"]:
|
||||
if self.media_type not in ["video"]:
|
||||
return None
|
||||
|
||||
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
||||
@ -642,7 +596,7 @@ class Media(models.Model):
|
||||
|
||||
@property
|
||||
def trim_video_path(self):
|
||||
if self.media_type not in ["video", "audio"]:
|
||||
if self.media_type not in ["video"]:
|
||||
return None
|
||||
|
||||
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class Page(models.Model):
|
||||
slug = models.SlugField(max_length=200, unique=True)
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
edit_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("get_page", args=[str(self.slug)])
|
||||
|
||||
|
||||
class TinyMCEMedia(models.Model):
|
||||
file = models.FileField(upload_to='tinymce_media/')
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
file_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=(
|
||||
('image', 'Image'),
|
||||
('media', 'Media'),
|
||||
),
|
||||
)
|
||||
original_filename = models.CharField(max_length=255)
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'TinyMCE Media'
|
||||
verbose_name_plural = 'TinyMCE Media'
|
||||
ordering = ['-uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.original_filename} ({self.file_type})"
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.file.url
|
||||
@ -64,7 +64,7 @@ class Playlist(models.Model):
|
||||
strip_text_items = ["title", "description"]
|
||||
for item in strip_text_items:
|
||||
setattr(self, item, strip_tags(getattr(self, item, None)))
|
||||
self.title = self.title[:100]
|
||||
self.title = self.title[:99]
|
||||
|
||||
if not self.friendly_token:
|
||||
while True:
|
||||
|
||||
@ -6,7 +6,7 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from .. import helpers
|
||||
from .utils import MEDIA_ENCODING_STATUS, subtitles_file_path
|
||||
from .utils import subtitles_file_path
|
||||
|
||||
|
||||
class Language(models.Model):
|
||||
@ -14,7 +14,7 @@ class Language(models.Model):
|
||||
to be used with Subtitles
|
||||
"""
|
||||
|
||||
code = models.CharField(max_length=30, help_text="language code")
|
||||
code = models.CharField(max_length=12, help_text="language code")
|
||||
|
||||
title = models.CharField(max_length=100, help_text="language code")
|
||||
|
||||
@ -70,15 +70,3 @@ class Subtitle(models.Model):
|
||||
else:
|
||||
raise Exception("Could not convert to srt")
|
||||
return True
|
||||
|
||||
|
||||
class TranscriptionRequest(models.Model):
|
||||
# Whisper transcription request
|
||||
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="transcriptionrequests")
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending", db_index=True)
|
||||
translate_to_english = models.BooleanField(default=False)
|
||||
logs = models.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Transcription request for {self.media.title} - {self.status}"
|
||||
|
||||
@ -12,19 +12,40 @@ class VideoChapterData(models.Model):
|
||||
class Meta:
|
||||
unique_together = ['media']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from .. import tasks
|
||||
|
||||
is_new = self.pk is None
|
||||
if is_new or (not is_new and self._check_data_changed()):
|
||||
super().save(*args, **kwargs)
|
||||
tasks.produce_video_chapters.delay(self.pk)
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _check_data_changed(self):
|
||||
if self.pk:
|
||||
old_instance = VideoChapterData.objects.get(pk=self.pk)
|
||||
return old_instance.data != self.data
|
||||
return False
|
||||
|
||||
@property
|
||||
def chapter_data(self):
|
||||
# ensure response is consistent
|
||||
data = []
|
||||
if self.data and isinstance(self.data, list):
|
||||
for item in self.data:
|
||||
if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"):
|
||||
chapter_item = {
|
||||
'startTime': item.get("startTime"),
|
||||
'endTime': item.get("endTime"),
|
||||
'chapterTitle': item.get("chapterTitle"),
|
||||
if item.get("start") and item.get("title"):
|
||||
thumbnail = item.get("thumbnail")
|
||||
if thumbnail:
|
||||
thumbnail = helpers.url_from_path(thumbnail)
|
||||
else:
|
||||
thumbnail = "static/images/chapter_default.jpg"
|
||||
data.append(
|
||||
{
|
||||
"start": item.get("start"),
|
||||
"title": item.get("title"),
|
||||
"thumbnail": thumbnail,
|
||||
}
|
||||
data.append(chapter_item)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
104
files/tasks.py
@ -46,12 +46,10 @@ from .models import (
|
||||
Category,
|
||||
EncodeProfile,
|
||||
Encoding,
|
||||
Language,
|
||||
Media,
|
||||
Rating,
|
||||
Subtitle,
|
||||
Tag,
|
||||
TranscriptionRequest,
|
||||
VideoChapterData,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
|
||||
@ -467,67 +465,6 @@ def encode_media(
|
||||
return success
|
||||
|
||||
|
||||
@task(name="whisper_transcribe", queue="long_tasks", soft_time_limit=60 * 60 * 2)
|
||||
def whisper_transcribe(friendly_token, translate_to_english=False):
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=friendly_token)
|
||||
except: # noqa
|
||||
logger.info(f"failed to get media {friendly_token}")
|
||||
return False
|
||||
|
||||
request = TranscriptionRequest.objects.filter(media=media, status="pending", translate_to_english=translate_to_english).first()
|
||||
if not request:
|
||||
logger.info(f"No pending transcription request for media {friendly_token}")
|
||||
return False
|
||||
|
||||
if translate_to_english:
|
||||
language = Language.objects.filter(code="whisper-translation").first()
|
||||
if not language:
|
||||
language = Language.objects.create(code="whisper-translation", title="English Translation")
|
||||
else:
|
||||
language = Language.objects.filter(code="whisper").first()
|
||||
if not language:
|
||||
language = Language.objects.create(code="whisper", title="Transcription")
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(media.media_file.path))
|
||||
request.status = "running"
|
||||
request.save(update_fields=["status"])
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
|
||||
video_file_path = get_file_name(media.media_file.name)
|
||||
video_file_path = '.'.join(video_file_path.split('.')[:-1]) # needed by whisper without the extension
|
||||
subtitle_name = f"{video_file_path}.vtt"
|
||||
output_name = f"{tmpdirname}/{subtitle_name}"
|
||||
|
||||
cmd = f"whisper /home/mediacms.io/mediacms/media_files/{media.media_file.name} --model {settings.WHISPER_MODEL} --output_dir {tmpdirname}"
|
||||
if translate_to_english:
|
||||
cmd += " --task translate"
|
||||
|
||||
logger.info(f"Whisper transcribe: ready to run command {cmd}")
|
||||
|
||||
start_time = datetime.now()
|
||||
ret = run_command(cmd, cwd=cwd) # noqa
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
if os.path.exists(output_name):
|
||||
subtitle = Subtitle.objects.create(media=media, user=media.user, language=language)
|
||||
|
||||
with open(output_name, 'rb') as f:
|
||||
subtitle.subtitle_file.save(subtitle_name, File(f))
|
||||
|
||||
request.status = "success"
|
||||
request.logs = f"Transcription took {duration:.2f} seconds." # noqa
|
||||
request.save(update_fields=["status", "logs"])
|
||||
return True
|
||||
|
||||
request.status = "fail"
|
||||
request.logs = f"Transcription failed after {duration:.2f} seconds. Error: {ret.get('error')}" # noqa
|
||||
request.save(update_fields=["status", "logs"])
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@task(name="produce_sprite_from_video", queue="long_tasks")
|
||||
def produce_sprite_from_video(friendly_token):
|
||||
"""Produces a sprites file for a video, uses ffmpeg"""
|
||||
@ -949,6 +886,45 @@ def update_encoding_size(encoding_id):
|
||||
return False
|
||||
|
||||
|
||||
@task(name="produce_video_chapters", queue="short_tasks")
|
||||
def produce_video_chapters(chapter_id):
|
||||
# this is not used
|
||||
return False
|
||||
chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
|
||||
if not chapter_object:
|
||||
return False
|
||||
|
||||
media = chapter_object.media
|
||||
video_path = media.media_file.path
|
||||
output_folder = media.video_chapters_folder
|
||||
|
||||
chapters = chapter_object.data
|
||||
|
||||
width = 336
|
||||
height = 188
|
||||
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
|
||||
results = []
|
||||
|
||||
for i, chapter in enumerate(chapters):
|
||||
timestamp = chapter["start"]
|
||||
title = chapter["title"]
|
||||
|
||||
output_filename = f"thumbnail_{i:02d}.jpg" # noqa
|
||||
output_path = os.path.join(output_folder, output_filename)
|
||||
|
||||
command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
|
||||
ret = run_command(command) # noqa
|
||||
if os.path.exists(output_path) and get_file_type(output_path) == "image":
|
||||
results.append({"start": timestamp, "title": title, "thumbnail": output_path})
|
||||
|
||||
chapter_object.data = results
|
||||
chapter_object.save(update_fields=["data"])
|
||||
return True
|
||||
|
||||
|
||||
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
|
||||
def post_trim_action(friendly_token):
|
||||
"""Perform post-processing actions after video trimming
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from .models import TinyMCEMedia
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def upload_image(request):
|
||||
if not (request.user.is_authenticated and request.user.is_active and request.user.is_staff and request.user.is_superuser):
|
||||
return JsonResponse({'error': 'Admin access required'}, status=403)
|
||||
|
||||
if request.method == "POST":
|
||||
file_obj = request.FILES.get('file')
|
||||
if file_obj:
|
||||
# Create a new TinyMCEMedia instance for the image
|
||||
media = TinyMCEMedia(file=file_obj, file_type='image', original_filename=file_obj.name, user=request.user if request.user.is_authenticated else None)
|
||||
media.save()
|
||||
|
||||
return JsonResponse({'location': media.url})
|
||||
return JsonResponse({'error': 'Invalid request'}, status=400)
|
||||
@ -4,11 +4,9 @@ from django.conf.urls import include
|
||||
from django.conf.urls.static import static
|
||||
from django.urls import path, re_path
|
||||
|
||||
from . import management_views, tinymce_handlers, views
|
||||
from . import management_views, views
|
||||
from .feeds import IndexRSSFeed, SearchRSSFeed
|
||||
|
||||
friendly_token = r"(?P<friendly_token>[\w\-_]*)"
|
||||
|
||||
urlpatterns = [
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
re_path(r"^$", views.index),
|
||||
@ -30,12 +28,12 @@ urlpatterns = [
|
||||
re_path(r"^latest$", views.latest_media),
|
||||
re_path(r"^members", views.members, name="members"),
|
||||
re_path(
|
||||
rf"^playlist/{friendly_token}$",
|
||||
r"^playlist/(?P<friendly_token>[\w]*)$",
|
||||
views.view_playlist,
|
||||
name="get_playlist",
|
||||
),
|
||||
re_path(
|
||||
rf"^playlists/{friendly_token}$",
|
||||
r"^playlists/(?P<friendly_token>[\w]*)$",
|
||||
views.view_playlist,
|
||||
name="get_playlist",
|
||||
),
|
||||
@ -43,7 +41,6 @@ urlpatterns = [
|
||||
re_path(r"^recommended$", views.recommended_media),
|
||||
path("rss/", IndexRSSFeed()),
|
||||
re_path("^rss/search", SearchRSSFeed()),
|
||||
re_path(r"^record_screen", views.record_screen, name="record_screen"),
|
||||
re_path(r"^search", views.search, name="search"),
|
||||
re_path(r"^scpublisher", views.upload_media, name="upload_media"),
|
||||
re_path(r"^tags", views.tags, name="tags"),
|
||||
@ -56,7 +53,7 @@ urlpatterns = [
|
||||
re_path(r"^api/v1/media$", views.MediaList.as_view()),
|
||||
re_path(r"^api/v1/media/$", views.MediaList.as_view()),
|
||||
re_path(
|
||||
rf"^api/v1/media/{friendly_token}$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w\-_]*)$",
|
||||
views.MediaDetail.as_view(),
|
||||
name="api_get_media",
|
||||
),
|
||||
@ -67,32 +64,32 @@ urlpatterns = [
|
||||
),
|
||||
re_path(r"^api/v1/search$", views.MediaSearch.as_view()),
|
||||
re_path(
|
||||
rf"^api/v1/media/{friendly_token}/actions$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/actions$",
|
||||
views.MediaActions.as_view(),
|
||||
),
|
||||
re_path(
|
||||
rf"^api/v1/media/{friendly_token}/chapters$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/chapters$",
|
||||
views.video_chapters,
|
||||
),
|
||||
re_path(
|
||||
rf"^api/v1/media/{friendly_token}/trim_video$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/trim_video$",
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
re_path(
|
||||
rf"^api/v1/media/{friendly_token}/comments$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/comments$",
|
||||
views.CommentDetail.as_view(),
|
||||
),
|
||||
re_path(
|
||||
rf"^api/v1/media/{friendly_token}/comments/(?P<uid>[\w-]*)$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/comments/(?P<uid>[\w-]*)$",
|
||||
views.CommentDetail.as_view(),
|
||||
),
|
||||
re_path(r"^api/v1/playlists$", views.PlaylistList.as_view()),
|
||||
re_path(r"^api/v1/playlists/$", views.PlaylistList.as_view()),
|
||||
re_path(
|
||||
rf"^api/v1/playlists/{friendly_token}$",
|
||||
r"^api/v1/playlists/(?P<friendly_token>[\w]*)$",
|
||||
views.PlaylistDetail.as_view(),
|
||||
name="api_get_playlist",
|
||||
),
|
||||
@ -108,15 +105,8 @@ urlpatterns = [
|
||||
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
|
||||
re_path(r"^manage/media$", views.manage_media, name="manage_media"),
|
||||
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
||||
# Media uploads in ADMIN created pages
|
||||
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
|
||||
re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
|
||||
if settings.USERS_NEEDS_TO_BE_APPROVED:
|
||||
urlpatterns.append(re_path(r"^approval_required/", views.approval_required, name="approval_required"))
|
||||
|
||||
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
|
||||
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
# Import all views for backward compatibility
|
||||
|
||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||
from .categories import CategoryList, TagList # noqa: F401
|
||||
from .comments import CommentDetail, CommentList # noqa: F401
|
||||
@ -11,7 +10,6 @@ from .media import MediaList # noqa: F401
|
||||
from .media import MediaSearch # noqa: F401
|
||||
from .pages import about # noqa: F401
|
||||
from .pages import add_subtitle # noqa: F401
|
||||
from .pages import approval_required # noqa: F401
|
||||
from .pages import categories # noqa: F401
|
||||
from .pages import contact # noqa: F401
|
||||
from .pages import edit_chapters # noqa: F401
|
||||
@ -20,7 +18,6 @@ from .pages import edit_subtitle # noqa: F401
|
||||
from .pages import edit_video # noqa: F401
|
||||
from .pages import embed_media # noqa: F401
|
||||
from .pages import featured_media # noqa: F401
|
||||
from .pages import get_page # noqa: F401
|
||||
from .pages import history # noqa: F401
|
||||
from .pages import index # noqa: F401
|
||||
from .pages import latest_media # noqa: F401
|
||||
@ -31,7 +28,6 @@ from .pages import manage_users # noqa: F401
|
||||
from .pages import members # noqa: F401
|
||||
from .pages import publish_media # noqa: F401
|
||||
from .pages import recommended_media # noqa: F401
|
||||
from .pages import record_screen # noqa: F401
|
||||
from .pages import search # noqa: F401
|
||||
from .pages import setlanguage # noqa: F401
|
||||
from .pages import sitemap # noqa: F401
|
||||
|
||||
@ -132,7 +132,7 @@ class MediaList(APIView):
|
||||
elif author_param:
|
||||
user_queryset = User.objects.all()
|
||||
user = get_object_or_404(user_queryset, username=author_param)
|
||||
if self.request.user == user or is_mediacms_editor(self.request.user):
|
||||
if self.request.user == user:
|
||||
media = Media.objects.filter(user=user).prefetch_related("user").order_by("-add_date")
|
||||
else:
|
||||
media = self._get_media_queryset(request, user)
|
||||
@ -159,7 +159,6 @@ class MediaList(APIView):
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Add new media
|
||||
|
||||
serializer = MediaSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
media_file = request.data["media_file"]
|
||||
@ -175,13 +174,13 @@ class MediaBulkUserActions(APIView):
|
||||
parser_classes = (JSONParser,)
|
||||
|
||||
@swagger_auto_schema(
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=['media_ids', 'action'],
|
||||
properties={
|
||||
'media_ids': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING), description="List of media IDs"),
|
||||
'action': openapi.Schema(
|
||||
manual_parameters=[
|
||||
openapi.Parameter(name='media_ids', in_=openapi.IN_FORM, type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING), required=True, description="List of media IDs"),
|
||||
openapi.Parameter(
|
||||
name='action',
|
||||
in_=openapi.IN_FORM,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=True,
|
||||
description="Action to perform",
|
||||
enum=[
|
||||
"enable_comments",
|
||||
@ -196,15 +195,19 @@ class MediaBulkUserActions(APIView):
|
||||
"copy_media",
|
||||
],
|
||||
),
|
||||
'playlist_ids': openapi.Schema(
|
||||
openapi.Parameter(
|
||||
name='playlist_ids',
|
||||
in_=openapi.IN_FORM,
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
required=False,
|
||||
description="List of playlist IDs (required for add_to_playlist and remove_from_playlist actions)",
|
||||
),
|
||||
'state': openapi.Schema(type=openapi.TYPE_STRING, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"]),
|
||||
'owner': openapi.Schema(type=openapi.TYPE_STRING, description="New owner username (required for change_owner action)"),
|
||||
},
|
||||
openapi.Parameter(
|
||||
name='state', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"]
|
||||
),
|
||||
openapi.Parameter(name='owner', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="New owner username (required for change_owner action)"),
|
||||
],
|
||||
tags=['Media'],
|
||||
operation_summary='Perform bulk actions on media',
|
||||
operation_description='Perform various bulk actions on multiple media items at once',
|
||||
|
||||
@ -8,8 +8,8 @@ from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from cms.permissions import user_allowed_to_upload
|
||||
from cms.version import VERSION
|
||||
from files.methods import user_allowed_to_upload
|
||||
from users.models import User
|
||||
|
||||
from .. import helpers
|
||||
@ -19,62 +19,26 @@ from ..forms import (
|
||||
MediaMetadataForm,
|
||||
MediaPublishForm,
|
||||
SubtitleForm,
|
||||
WhisperSubtitlesForm,
|
||||
)
|
||||
from ..frontend_translations import translate_string
|
||||
from ..helpers import get_alphanumeric_only
|
||||
from ..methods import (
|
||||
can_transcribe_video,
|
||||
create_video_trim_request,
|
||||
get_user_or_session,
|
||||
handle_video_chapters,
|
||||
is_media_allowed_type,
|
||||
is_mediacms_editor,
|
||||
)
|
||||
from ..models import Category, Media, Page, Playlist, Subtitle, Tag, VideoTrimRequest
|
||||
from ..models import Category, Media, Playlist, Subtitle, Tag, VideoTrimRequest
|
||||
from ..tasks import save_user_action, video_trim_task
|
||||
|
||||
|
||||
def get_page(request, slug):
|
||||
context = {}
|
||||
page = Page.objects.filter(slug=slug).first()
|
||||
if page:
|
||||
context["page"] = page
|
||||
else:
|
||||
return render(request, "404.html", context)
|
||||
return render(request, "cms/page.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def record_screen(request):
|
||||
"""Record screen view"""
|
||||
|
||||
context = {}
|
||||
context["can_add"] = user_allowed_to_upload(request)
|
||||
can_upload_exp = settings.CANNOT_ADD_MEDIA_MESSAGE
|
||||
context["can_upload_exp"] = can_upload_exp
|
||||
|
||||
return render(request, "cms/record_screen.html", context)
|
||||
|
||||
|
||||
def about(request):
|
||||
"""About view"""
|
||||
|
||||
page = Page.objects.filter(slug="about").first()
|
||||
if page:
|
||||
context = {}
|
||||
context["page"] = page
|
||||
return render(request, "cms/page.html", context)
|
||||
|
||||
context = {"VERSION": VERSION}
|
||||
return render(request, "cms/about.html", context)
|
||||
|
||||
|
||||
def approval_required(request):
|
||||
"""User needs approval view"""
|
||||
return render(request, "cms/user_needs_approval.html", {})
|
||||
|
||||
|
||||
def setlanguage(request):
|
||||
"""Set Language view"""
|
||||
|
||||
@ -89,7 +53,6 @@ def add_subtitle(request):
|
||||
friendly_token = request.GET.get("m", "").strip()
|
||||
if not friendly_token:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
@ -97,41 +60,24 @@ def add_subtitle(request):
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
# Initialize variables
|
||||
form = None
|
||||
whisper_form = None
|
||||
show_whisper_form = can_transcribe_video(request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
if 'submit' in request.POST:
|
||||
form = SubtitleForm(media, request.POST, request.FILES, prefix="form")
|
||||
form = SubtitleForm(media, request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
subtitle = form.save()
|
||||
new_subtitle = Subtitle.objects.filter(id=subtitle.id).first()
|
||||
try:
|
||||
subtitle.convert_to_srt()
|
||||
messages.add_message(request, messages.INFO, "Caption was added!")
|
||||
new_subtitle.convert_to_srt()
|
||||
messages.add_message(request, messages.INFO, "Subtitle was added!")
|
||||
return HttpResponseRedirect(subtitle.media.get_absolute_url())
|
||||
except Exception as e: # noqa
|
||||
subtitle.delete()
|
||||
except: # noqa: E722
|
||||
new_subtitle.delete()
|
||||
error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files."
|
||||
form.add_error("subtitle_file", error_msg)
|
||||
|
||||
elif 'submit_whisper' in request.POST and show_whisper_form:
|
||||
whisper_form = WhisperSubtitlesForm(request.user, request.POST, instance=media, prefix="whisper_form")
|
||||
if whisper_form.is_valid():
|
||||
whisper_form.save()
|
||||
messages.add_message(request, messages.INFO, "Request for transcription was sent")
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
# GET request or form invalid
|
||||
if form is None:
|
||||
form = SubtitleForm(media_item=media, prefix="form")
|
||||
|
||||
if show_whisper_form and whisper_form is None:
|
||||
whisper_form = WhisperSubtitlesForm(request.user, instance=media, prefix="whisper_form")
|
||||
|
||||
else:
|
||||
form = SubtitleForm(media_item=media)
|
||||
subtitles = media.subtitles.all()
|
||||
context = {"media_object": media, "form": form, "subtitles": subtitles, "whisper_form": whisper_form}
|
||||
context = {"media": media, "form": form, "subtitles": subtitles}
|
||||
return render(request, "cms/add_subtitle.html", context)
|
||||
|
||||
|
||||
@ -168,7 +114,7 @@ def edit_subtitle(request):
|
||||
elif request.method == "POST":
|
||||
confirm = request.GET.get("confirm", "").strip()
|
||||
if confirm == "true":
|
||||
messages.add_message(request, messages.INFO, "Caption was deleted")
|
||||
messages.add_message(request, messages.INFO, "Subtitle was deleted")
|
||||
redirect_url = subtitle.media.get_absolute_url()
|
||||
subtitle.delete()
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
@ -177,7 +123,7 @@ def edit_subtitle(request):
|
||||
with open(subtitle.subtitle_file.path, "w") as ff:
|
||||
ff.write(subtitle_text)
|
||||
|
||||
messages.add_message(request, messages.INFO, "Caption was edited")
|
||||
messages.add_message(request, messages.INFO, "Subtitle was edited")
|
||||
return HttpResponseRedirect(subtitle.media.get_absolute_url())
|
||||
return render(request, "cms/edit_subtitle.html", context)
|
||||
|
||||
@ -244,6 +190,8 @@ def history(request):
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def video_chapters(request, friendly_token):
|
||||
# this is not ready...
|
||||
return False
|
||||
if not request.method == "POST":
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
@ -256,26 +204,20 @@ def video_chapters(request, friendly_token):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
try:
|
||||
request_data = json.loads(request.body)
|
||||
data = request_data.get("chapters")
|
||||
if data is None:
|
||||
return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
|
||||
|
||||
data = json.loads(request.body)["chapters"]
|
||||
chapters = []
|
||||
for _, chapter_data in enumerate(data):
|
||||
start_time = chapter_data.get('startTime')
|
||||
end_time = chapter_data.get('endTime')
|
||||
chapter_title = chapter_data.get('chapterTitle')
|
||||
if start_time and end_time and chapter_title:
|
||||
start_time = chapter_data.get('start')
|
||||
title = chapter_data.get('title')
|
||||
if start_time and title:
|
||||
chapters.append(
|
||||
{
|
||||
'startTime': start_time,
|
||||
'endTime': end_time,
|
||||
'chapterTitle': chapter_title,
|
||||
'start': start_time,
|
||||
'title': title,
|
||||
}
|
||||
)
|
||||
except Exception as e: # noqa
|
||||
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with startTime, endTime, chapterTitle'}, status=400)
|
||||
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400)
|
||||
|
||||
ret = handle_video_chapters(media, chapters)
|
||||
|
||||
@ -296,10 +238,6 @@ def edit_media(request):
|
||||
|
||||
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not is_media_allowed_type(media):
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
if request.method == "POST":
|
||||
form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media)
|
||||
if form.is_valid():
|
||||
@ -362,6 +300,8 @@ def publish_media(request):
|
||||
@login_required
|
||||
def edit_chapters(request):
|
||||
"""Edit chapters"""
|
||||
# not implemented yet
|
||||
return False
|
||||
friendly_token = request.GET.get("m", "").strip()
|
||||
if not friendly_token:
|
||||
return HttpResponseRedirect("/")
|
||||
@ -373,11 +313,10 @@ def edit_chapters(request):
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
chapters = media.chapter_data
|
||||
return render(
|
||||
request,
|
||||
"cms/edit_chapters.html",
|
||||
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters},
|
||||
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token},
|
||||
)
|
||||
|
||||
|
||||
@ -429,7 +368,7 @@ def edit_video(request):
|
||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if media.media_type not in ["video", "audio"]:
|
||||
if not media.media_type == "video":
|
||||
messages.add_message(request, messages.INFO, "Media is not video")
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
@ -541,12 +480,6 @@ def manage_comments(request):
|
||||
def members(request):
|
||||
"""List members view"""
|
||||
|
||||
if settings.CAN_SEE_MEMBERS_PAGE == "editors" and not is_mediacms_editor(request.user):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if settings.CAN_SEE_MEMBERS_PAGE == "admins" and not request.user.is_superuser:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
context = {}
|
||||
return render(request, "cms/members.html", context)
|
||||
|
||||
@ -641,10 +574,9 @@ def view_media(request):
|
||||
video_msg = "Media encoding hasn't started yet. Attempting to show the original video file"
|
||||
if media.encoding_status == "running":
|
||||
video_msg = "Media encoding is under processing. Attempting to show the original video file"
|
||||
if video_msg and media.user == request.user:
|
||||
if video_msg:
|
||||
messages.add_message(request, messages.INFO, video_msg)
|
||||
|
||||
context["is_media_allowed_type"] = is_media_allowed_type(media)
|
||||
return render(request, "cms/media.html", context)
|
||||
|
||||
|
||||
|
||||
15
frontend-tools/chapters-editor/.gitignore
vendored
@ -1,15 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
server/public
|
||||
vite.config.ts.*
|
||||
*.tar.gz
|
||||
yt.readme.md
|
||||
client/public/videos/sample-video.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-37s.mp4
|
||||
videos/sample-video-37s.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-1.mp4
|
||||
client/public/videos/sample-video-10m.mp4
|
||||
client/public/videos/sample-video-10s.mp4
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.configPath": ".prettierrc"
|
||||
}
|
||||
@ -1,255 +0,0 @@
|
||||
# MediaCMS Chapters Editor
|
||||
|
||||
A modern browser-based chapter editing tool built with React and TypeScript that integrates with MediaCMS. The Chapters Editor allows users to create, manage, and edit video chapters with precise timing controls and an intuitive timeline interface.
|
||||
|
||||
## Features
|
||||
|
||||
- 📑 Create and manage video chapters with custom titles
|
||||
- ⏱️ Precise timestamp controls for chapter start and end points
|
||||
- ✂️ Split chapters and reorganize content
|
||||
- 👁️ Preview chapters with jump-to navigation
|
||||
- 🔄 Undo/redo support for all editing operations
|
||||
- 🏷️ Chapter metadata editing (titles, descriptions)
|
||||
- 💾 Save chapter data directly to MediaCMS
|
||||
- 🎯 Timeline-based chapter visualization
|
||||
- 📱 Responsive design for desktop and mobile
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Educational Content**: Add chapters to lectures and tutorials for better navigation
|
||||
- **Entertainment**: Create chapters for movies, shows, or long-form content
|
||||
- **Documentation**: Organize training videos and documentation with logical sections
|
||||
- **Accessibility**: Improve content accessibility with structured navigation
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
|
||||
- Yarn or npm package manager
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Navigate to the Chapters Editor directory
|
||||
cd frontend-tools/chapters-editor
|
||||
|
||||
# Install dependencies with Yarn
|
||||
yarn install
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The Chapters Editor can be run in two modes:
|
||||
|
||||
### Standalone Development Mode
|
||||
|
||||
This starts a local development server with hot reloading:
|
||||
|
||||
```bash
|
||||
# Start the development server with Yarn
|
||||
yarn dev
|
||||
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend-only Development Mode
|
||||
|
||||
If you want to work only on the frontend with MediaCMS backend:
|
||||
|
||||
```bash
|
||||
# Start frontend-only development with Yarn
|
||||
yarn dev:frontend
|
||||
|
||||
# Or with npm
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### For MediaCMS Integration
|
||||
|
||||
To build the Chapters Editor for integration with MediaCMS:
|
||||
|
||||
```bash
|
||||
# Build for Django integration with Yarn
|
||||
yarn build:django
|
||||
|
||||
# Or with npm
|
||||
npm run build:django
|
||||
```
|
||||
|
||||
This will compile the editor and place the output in the MediaCMS static directory.
|
||||
|
||||
### Standalone Build
|
||||
|
||||
To build the editor as a standalone application:
|
||||
|
||||
```bash
|
||||
# Build for production with Yarn
|
||||
yarn build
|
||||
|
||||
# Or with npm
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy the Chapters Editor, you can use the build and deploy script (recommended):
|
||||
|
||||
```bash
|
||||
# Run the deployment script
|
||||
sh deploy/scripts/build_and_deploy.sh
|
||||
```
|
||||
|
||||
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
|
||||
|
||||
You can also deploy manually after building:
|
||||
|
||||
```bash
|
||||
# With Yarn
|
||||
yarn deploy
|
||||
|
||||
# Or with npm
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/client` - Frontend React application
|
||||
- `/src` - Source code
|
||||
- `/components` - React components for chapter editing
|
||||
- `/hooks` - Custom React hooks for chapter management
|
||||
- `/lib` - Utility functions and helpers
|
||||
- `/services` - API services for MediaCMS integration
|
||||
- `/styles` - CSS and style definitions
|
||||
- `/shared` - Shared TypeScript types and utilities
|
||||
|
||||
## API Integration
|
||||
|
||||
The Chapters Editor interfaces with MediaCMS through a set of API endpoints for:
|
||||
|
||||
- Retrieving video metadata and existing chapters
|
||||
- Saving chapter data (timestamps, titles, descriptions)
|
||||
- Validating chapter structure and timing
|
||||
- Integration with MediaCMS user permissions
|
||||
|
||||
### Chapter Data Format
|
||||
|
||||
Chapters are stored in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"chapters": [
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "Introduction",
|
||||
"startTime": 0,
|
||||
"endTime": 120,
|
||||
"description": "Opening remarks and overview"
|
||||
},
|
||||
{
|
||||
"id": "chapter-2",
|
||||
"title": "Main Content",
|
||||
"startTime": 120,
|
||||
"endTime": 600,
|
||||
"description": "Core educational material"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Code Formatting
|
||||
|
||||
To automatically format all source files using [Prettier](https://prettier.io):
|
||||
|
||||
```bash
|
||||
# Format all code in the src directory
|
||||
npx prettier --write client/src/
|
||||
|
||||
# Or format specific file types
|
||||
npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
|
||||
```
|
||||
|
||||
You can also add this as a script in `package.json`:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"format": "prettier --write client/src/"
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite to ensure Chapters Editor functionality:
|
||||
|
||||
```bash
|
||||
# Run tests with Yarn
|
||||
yarn test
|
||||
|
||||
# Or with npm
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
yarn test:watch
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/chapter-enhancement`
|
||||
3. Make your changes and add tests
|
||||
4. Run the formatter: `yarn format`
|
||||
5. Run tests: `yarn test`
|
||||
6. Commit your changes: `git commit -m "Add chapter enhancement"`
|
||||
7. Push to the branch: `git push origin feature/chapter-enhancement`
|
||||
8. Submit a pull request
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Chapter timestamps not saving**: Ensure the MediaCMS backend API is accessible and user has proper permissions.
|
||||
|
||||
**Timeline not displaying correctly**: Check browser console for JavaScript errors and ensure video file is properly loaded.
|
||||
|
||||
**Performance issues with long videos**: The editor is optimized for videos up to 2 hours. For longer content, consider splitting into multiple files.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug mode for detailed logging:
|
||||
|
||||
```bash
|
||||
# Start with debug logging
|
||||
DEBUG=true yarn dev
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Chromium 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the same license as MediaCMS. See the main MediaCMS repository for license details.
|
||||
@ -1,34 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Chapters Editor</title>
|
||||
<!-- Add meta tag to help iOS devices render as desktop -->
|
||||
<script>
|
||||
// Try to detect iOS
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
if (isIOS) {
|
||||
// Replace viewport meta tag with one optimized for desktop view
|
||||
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
||||
if (viewportMeta) {
|
||||
viewportMeta.setAttribute(
|
||||
'content',
|
||||
'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
|
||||
);
|
||||
}
|
||||
|
||||
// Add a class to the HTML element for iOS-specific styles
|
||||
document.documentElement.classList.add('ios-device');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chapters-editor-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 695 KiB |
@ -1,186 +0,0 @@
|
||||
import { formatDetailedTime } from './lib/timeUtils';
|
||||
import logger from './lib/logger';
|
||||
import VideoPlayer from '@/components/VideoPlayer';
|
||||
import TimelineControls from '@/components/TimelineControls';
|
||||
import EditingTools from '@/components/EditingTools';
|
||||
import ClipSegments from '@/components/ClipSegments';
|
||||
import MobilePlayPrompt from '@/components/IOSPlayPrompt';
|
||||
import useVideoChapters from '@/hooks/useVideoChapters';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
selectedSegmentId,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
toggleMute,
|
||||
handleSegmentUpdate,
|
||||
handleChapterSave,
|
||||
handleSelectedSegmentChange,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
initializeSafariIfNeeded,
|
||||
} = useVideoChapters();
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
// If already playing, just pause the video
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
logger.debug('Video paused');
|
||||
return;
|
||||
}
|
||||
|
||||
// Safari: Try to initialize if needed before playing
|
||||
if (duration === 0) {
|
||||
const initialized = await initializeSafariIfNeeded();
|
||||
if (initialized) {
|
||||
// Wait a moment for initialization to complete
|
||||
setTimeout(() => handlePlay(), 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start playing - no boundary checking, play through entire timeline
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
logger.debug('Continuous playback started from:', formatDetailedTime(video.currentTime));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error playing video:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't handle keyboard shortcuts if user is typing in an input field
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Space':
|
||||
event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
|
||||
handlePlay();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
// Use the video element's current time directly to avoid stale state
|
||||
const newTime = Math.max(videoRef.current.currentTime - 10, 0);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
// Use the video element's current time directly to avoid stale state
|
||||
const newTime = Math.min(videoRef.current.currentTime + 10, duration);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handlePlay, handleMobileSafeSeek, duration, videoRef]);
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
onPlayPause={handlePlay}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onPlay={handlePlay}
|
||||
isPlaying={isPlaying}
|
||||
canUndo={historyPosition > 0}
|
||||
canRedo={historyPosition < history.length - 1}
|
||||
/>
|
||||
|
||||
{/* Timeline Controls */}
|
||||
<TimelineControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
thumbnails={[]}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
splitPoints={splitPoints}
|
||||
zoomLevel={zoomLevel}
|
||||
clipSegments={clipSegments}
|
||||
selectedSegmentId={selectedSegmentId}
|
||||
onSelectedSegmentChange={handleSelectedSegmentChange}
|
||||
onSegmentUpdate={handleSegmentUpdate}
|
||||
onChapterSave={handleChapterSave}
|
||||
onTrimStartChange={handleTrimStartChange}
|
||||
onTrimEndChange={handleTrimEndChange}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
videoRef={videoRef}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
isIOSUninitialized={isMobile && !videoInitialized}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
onPlayPause={handlePlay}
|
||||
/>
|
||||
|
||||
{/* Clip Segments */}
|
||||
<ClipSegments segments={clipSegments} selectedSegmentId={selectedSegmentId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@ -1,6 +0,0 @@
|
||||
// Import the audio poster image as a module
|
||||
// Vite will handle this and provide the correct URL
|
||||
import audioPosterJpg from '../../public/audio-poster.jpg';
|
||||
|
||||
export const AUDIO_POSTER_URL = audioPosterJpg;
|
||||
|
||||
@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 832 B |
@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 813 B |
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 818 B |
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 597 B |
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 611 B |
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 439 B |
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 439 B |
@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 359 B |
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(30, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 412 B |
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(28, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 411 B |
@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 359 B |
@ -1,93 +0,0 @@
|
||||
import { formatTime, formatLongTime } from '@/lib/timeUtils';
|
||||
import '../styles/ClipSegments.css';
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
chapterTitle: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface ClipSegmentsProps {
|
||||
segments: Segment[];
|
||||
selectedSegmentId?: number | null;
|
||||
}
|
||||
|
||||
const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
|
||||
// Sort segments by startTime
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Handle delete segment click
|
||||
const handleDeleteSegment = (segmentId: number) => {
|
||||
// Create and dispatch the delete event
|
||||
const deleteEvent = new CustomEvent('delete-segment', {
|
||||
detail: { segmentId },
|
||||
});
|
||||
document.dispatchEvent(deleteEvent);
|
||||
};
|
||||
|
||||
// Generate the same color background for a segment as shown in the timeline
|
||||
const getSegmentColorClass = (index: number) => {
|
||||
// Return CSS class based on index modulo 8
|
||||
// This matches the CSS nth-child selectors in the timeline
|
||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||
};
|
||||
|
||||
// Get selected segment
|
||||
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
|
||||
|
||||
return (
|
||||
<div className="clip-segments-container">
|
||||
<h3 className="clip-segments-title">Chapters</h3>
|
||||
|
||||
{sortedSegments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.id}
|
||||
className={`segment-item ${getSegmentColorClass(index)} ${selectedSegmentId === segment.id ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="segment-content">
|
||||
<div className="segment-info">
|
||||
<div className="segment-title">
|
||||
{segment.chapterTitle ? (
|
||||
<span className="chapter-title">{segment.chapterTitle}</span>
|
||||
) : (
|
||||
<span className="default-title">Chapter {index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="segment-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
<div className="segment-duration">
|
||||
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<button
|
||||
className="delete-button"
|
||||
aria-label="Delete Segment"
|
||||
data-tooltip="Delete this segment"
|
||||
onClick={() => handleDeleteSegment(segment.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">
|
||||
No chapters created yet. Use the split button to create chapter segments.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClipSegments;
|
||||
@ -1,219 +0,0 @@
|
||||
import '../styles/EditingTools.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPlay: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
const EditingTools = ({
|
||||
onSplit,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlay,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isPlaying = false,
|
||||
}: EditingToolsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsSmallScreen(window.innerWidth <= 640);
|
||||
};
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
}, []);
|
||||
|
||||
// Handle play button click with iOS fix
|
||||
const handlePlay = () => {
|
||||
// Ensure lastSeekedPosition is used when play is clicked
|
||||
if (typeof window !== 'undefined') {
|
||||
logger.debug('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition);
|
||||
}
|
||||
|
||||
// Call the original handler
|
||||
onPlay();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editing-tools-container">
|
||||
<div className="flex-container single-row">
|
||||
{/* Left side - Play buttons group */}
|
||||
<div className="button-group play-buttons-group">
|
||||
|
||||
{/* Play Preview button */}
|
||||
{/* <button
|
||||
className="button preview-button"
|
||||
onClick={onPreview}
|
||||
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* Standard Play button */}
|
||||
<button
|
||||
className="button play-button"
|
||||
onClick={handlePlay}
|
||||
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Pause</span>
|
||||
<span className="short-text">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play</span>
|
||||
<span className="short-text">Play</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Segments Playback message (replaces play button during segments playback) */}
|
||||
{/* {isPlayingSegments && !isSmallScreen && (
|
||||
<div className="segments-playback-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Preview mode message (replaces play button) */}
|
||||
{/* {isPreviewMode && (
|
||||
<div className="preview-mode-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{/* Right side - Editing tools */}
|
||||
<div className="button-group secondary">
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Undo"
|
||||
data-tooltip="Undo last action"
|
||||
disabled={!canUndo}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</svg>
|
||||
<span className="button-text">Undo</span>
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Redo"
|
||||
data-tooltip="Redo last undone action"
|
||||
disabled={!canRedo}
|
||||
onClick={onRedo}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</svg>
|
||||
<span className="button-text">Redo</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={onReset}
|
||||
data-tooltip="Reset to full video"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="reset-text">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditingTools;
|
||||
@ -1,60 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import '../styles/IOSPlayPrompt.css';
|
||||
|
||||
interface MobilePlayPromptProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check if the device is mobile or Safari browser
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
// More comprehensive check for mobile/tablet devices
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
};
|
||||
|
||||
// Only show for mobile devices
|
||||
const isMobile = checkIsMobile();
|
||||
setIsVisible(isMobile);
|
||||
}, []);
|
||||
|
||||
// Close the prompt when video plays
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => {
|
||||
// Just close the prompt when video plays
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
const handlePlayClick = () => {
|
||||
onPlay();
|
||||
// Prompt will be closed by the play event handler
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-play-prompt-overlay">
|
||||
<div className="mobile-play-prompt">
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobilePlayPrompt;
|
||||
@ -1,197 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { formatTime } from '@/lib/timeUtils';
|
||||
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||
import '../styles/IOSVideoPlayer.css';
|
||||
|
||||
interface IOSVideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clean up intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get the video source URL from the main player
|
||||
useEffect(() => {
|
||||
let url = '';
|
||||
if (videoRef.current && videoRef.current.querySelector('source')) {
|
||||
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
||||
if (source && source.src) {
|
||||
url = source.src;
|
||||
}
|
||||
} else {
|
||||
// Fallback to sample video if needed
|
||||
url = '/videos/sample-video.mp4';
|
||||
}
|
||||
setVideoUrl(url);
|
||||
|
||||
// Check if the media is an audio file and set poster image
|
||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
|
||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms increment when button is held
|
||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
|
||||
// Setup continuous adjustment
|
||||
incrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous increment
|
||||
const stopIncrement = () => {
|
||||
if (incrementIntervalRef.current) {
|
||||
clearInterval(incrementIntervalRef.current);
|
||||
incrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms decrement when button is held
|
||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
|
||||
// Setup continuous adjustment
|
||||
decrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous decrement
|
||||
const stopDecrement = () => {
|
||||
if (decrementIntervalRef.current) {
|
||||
clearInterval(decrementIntervalRef.current);
|
||||
decrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ios-video-player-container">
|
||||
{/* Current Time / Duration Display */}
|
||||
<div className="ios-time-display mb-2">
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={jumpBackward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
-15s
|
||||
</button>
|
||||
<button
|
||||
onClick={jumpForward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
+15s
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* iOS Fine Control Buttons */}
|
||||
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
||||
<button
|
||||
onMouseDown={startDecrement}
|
||||
onTouchStart={startDecrement}
|
||||
onMouseUp={stopDecrement}
|
||||
onMouseLeave={stopDecrement}
|
||||
onTouchEnd={stopDecrement}
|
||||
onTouchCancel={stopDecrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
-50ms
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={startIncrement}
|
||||
onTouchStart={startIncrement}
|
||||
onMouseUp={stopIncrement}
|
||||
onMouseLeave={stopIncrement}
|
||||
onTouchEnd={stopIncrement}
|
||||
onTouchCancel={stopIncrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
+50ms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ios-note mt-2 text-xs text-gray-500">
|
||||
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IOSVideoPlayer;
|
||||
@ -1,74 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import '../styles/Modal.css';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
// Close modal when Escape key is pressed
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
// Disable body scrolling when modal is open
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Handle click outside the modal content to close it
|
||||
const handleClickOutside = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close-button" onClick={onClose} aria-label="Close modal" style={{ minWidth: '24px', minHeight: '24px' }}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">{children}</div>
|
||||
|
||||
{actions && <div className="modal-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@ -1,492 +0,0 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
||||
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||
import logger from '../lib/logger';
|
||||
import '../styles/VideoPlayer.css';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isMuted?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onToggleMute?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isMuted = false,
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
||||
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({
|
||||
x: 0,
|
||||
});
|
||||
const [tooltipTime, setTooltipTime] = useState(0);
|
||||
|
||||
const sampleVideoUrl =
|
||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
||||
|
||||
// Check if the media is an audio file
|
||||
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
|
||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
|
||||
|
||||
// Detect iOS device and Safari browser
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
const checkSafari = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||
};
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
// Store Safari detection globally for other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).isSafari = checkSafari();
|
||||
}
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== 'undefined') {
|
||||
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
||||
setHasInitialized(wasInitialized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update initialized state when video plays
|
||||
useEffect(() => {
|
||||
if (isPlaying && !hasInitialized) {
|
||||
setHasInitialized(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
}
|
||||
}
|
||||
}, [isPlaying, hasInitialized]);
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
video.setAttribute('x-webkit-airplay', 'allow');
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug('Video play event fired');
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug('Video pause event fired');
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
||||
|
||||
// Save current time to lastPosition when it changes (from external seeking)
|
||||
useEffect(() => {
|
||||
setLastPosition(currentTime);
|
||||
}, [currentTime]);
|
||||
|
||||
// Jump 10 seconds forward
|
||||
const handleForward = () => {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Jump 10 seconds backward
|
||||
const handleBackward = () => {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// Handle start of progress bar dragging
|
||||
const handleProgressDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
// Get initial position
|
||||
handleProgressDrag(e);
|
||||
|
||||
// Set up document-level event listeners for mouse movement and release
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressDrag(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Handle progress dragging for both mouse and touch events
|
||||
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({
|
||||
x: e.clientX,
|
||||
});
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle touch events for progress bar
|
||||
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
||||
if (!progressRef.current || !e.touches[0]) return;
|
||||
e.preventDefault();
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
// Get initial position using touch
|
||||
handleProgressTouchMove(e);
|
||||
|
||||
// Set up document-level event listeners for touch movement and release
|
||||
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressTouchMove(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
// Handle touch dragging on progress bar
|
||||
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
// Get the touch coordinates
|
||||
const touch = 'touches' in e ? e.touches[0] : null;
|
||||
if (!touch) return;
|
||||
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * touchPosition;
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({
|
||||
x: touch.clientX,
|
||||
});
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
// Store position for iOS Safari
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle click on progress bar (for non-drag interactions)
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If we're already dragging, don't handle the click
|
||||
if (isDraggingProgress) return;
|
||||
|
||||
if (progressRef.current) {
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle toggling fullscreen
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click on video to play/pause
|
||||
const handleVideoClick = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// If the video is paused, we want to play it
|
||||
if (video.paused) {
|
||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
||||
logger.debug('iOS: Explicitly setting position before play:', lastPosition);
|
||||
|
||||
// First, seek to the position
|
||||
video.currentTime = lastPosition;
|
||||
|
||||
// Use a small timeout to ensure seeking is complete before play
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Try to play with proper promise handling
|
||||
videoRef.current
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug(
|
||||
'iOS: Play started successfully at position:',
|
||||
videoRef.current?.currentTime
|
||||
);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('iOS: Error playing video:', err);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug('Normal: Play started successfully');
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error playing video:', err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If playing, pause and update state
|
||||
video.pause();
|
||||
onPlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
controls={false}
|
||||
muted={isMuted}
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
{/* Safari fallback for audio files */}
|
||||
<source src={sampleVideoUrl} type="audio/mp4" />
|
||||
<source src={sampleVideoUrl} type="audio/mpeg" />
|
||||
<p>Your browser doesn't support HTML5 video or audio.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
{isIOS && !hasInitialized && !isPlaying && (
|
||||
<div className="ios-first-play-indicator">
|
||||
<div className="ios-play-message">Tap Play to initialize video controls</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
<div className="video-time-display">
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with enhanced dragging */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressDragStart}
|
||||
onTouchStart={handleProgressTouchStart}
|
||||
>
|
||||
<div
|
||||
className="video-progress-fill"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="video-scrubber"
|
||||
style={{
|
||||
left: `${progressPercentage}%`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Floating time tooltip when dragging */}
|
||||
{isDraggingProgress && (
|
||||
<div
|
||||
className="video-time-tooltip"
|
||||
style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{formatDetailedTime(tooltipTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls - Mute and Fullscreen buttons */}
|
||||
<div className="video-controls-buttons">
|
||||
{/* Mute/Unmute Button */}
|
||||
{onToggleMute && (
|
||||
<button
|
||||
className="mute-button"
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
onClick={onToggleMute}
|
||||
data-tooltip={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
aria-label="Fullscreen"
|
||||
onClick={handleFullscreen}
|
||||
data-tooltip="Toggle fullscreen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
@ -1,796 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--primary: 207 90% 54%;
|
||||
--primary-foreground: 211 100% 99%;
|
||||
--secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Player Styles */
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-current-time {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.video-progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.video-scrubber {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
/* Play/Pause indicator for video player */
|
||||
.video-player-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.play-pause-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Only show play/pause indicator on hover */
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Timeline Styles */
|
||||
.timeline-scroll-container {
|
||||
height: 6rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
height: calc(100% + 10px);
|
||||
width: 2px;
|
||||
background-color: red;
|
||||
z-index: 100; /* Highest z-index to stay on top of everything */
|
||||
pointer-events: none; /* Allow clicks to pass through to segments underneath */
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
|
||||
}
|
||||
|
||||
.trim-line-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
width: 8px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
cursor: ew-resize;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.trim-handle.left {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.trim-handle.right {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.split-point {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Clip Segment styles */
|
||||
.clip-segment {
|
||||
position: absolute;
|
||||
height: 95%;
|
||||
top: 0;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-blend-mode: soft-light;
|
||||
/* Border is now set in the color-specific rules */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.1s;
|
||||
/* Original z-index for stacking order based on segment ID */
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* No background colors for segments, just borders with 2-color scheme */
|
||||
.clip-segment:nth-child(odd),
|
||||
.segment-color-1,
|
||||
.segment-color-3,
|
||||
.segment-color-5,
|
||||
.segment-color-7 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
|
||||
}
|
||||
.clip-segment:nth-child(even),
|
||||
.segment-color-2,
|
||||
.segment-color-4,
|
||||
.segment-color-6,
|
||||
.segment-color-8 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
|
||||
}
|
||||
|
||||
.clip-segment:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.clip-segment:active {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.clip-segment.selected {
|
||||
border-width: 3px; /* Make border thicker instead of changing color */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 25;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.clip-segment-info {
|
||||
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
|
||||
color: #000000; /* Pure black text */
|
||||
padding: 6px 8px;
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-radius: 4px 4px 0 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.clip-segment-name {
|
||||
font-weight: bold;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-time {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-duration {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
background: rgba(179, 217, 255, 0.4); /* Light blue background */
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.clip-segment-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
cursor: ew-resize;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clip-segment-handle::after {
|
||||
content: "↔";
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.clip-segment-handle.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle:hover {
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
/* Zoom Slider */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
margin-bottom: 0px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip]:hover::before,
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips (simple hover labels) on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]::before,
|
||||
[data-tooltip]::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for buttons with disabled state */
|
||||
button[disabled][data-tooltip]::before,
|
||||
button[disabled][data-tooltip]::after {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Custom tooltip for action buttons - completely different approach */
|
||||
.tooltip-action-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before,
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
/* Reset standard tooltip styles first */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Position below the button */
|
||||
bottom: -35px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
content: "";
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
|
||||
|
||||
/* Position the arrow */
|
||||
bottom: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.tooltip-action-btn:hover[data-tooltip]::before,
|
||||
.tooltip-action-btn:hover[data-tooltip]::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure tooltip container has proper space */
|
||||
|
||||
/* Segment tooltip styles */
|
||||
.segment-tooltip {
|
||||
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
color: #000000; /* Pure black text */
|
||||
border-radius: 4px;
|
||||
padding: 6px; /* Regular padding now that we have custom tooltips */
|
||||
min-width: 140px; /* Increased width to accommodate the new delete button */
|
||||
z-index: 1000; /* Increased z-index */
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.segment-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 20px !important;
|
||||
}
|
||||
|
||||
.tooltip-action-btn:hover {
|
||||
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Adjust for the hand icons specifically */
|
||||
.tooltip-action-btn.set-in svg,
|
||||
.tooltip-action-btn.set-out svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
/* Empty space tooltip styling */
|
||||
.empty-space-tooltip {
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px;
|
||||
z-index: 50;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-space-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 8px 8px 0;
|
||||
border-style: solid;
|
||||
border-color: white transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment {
|
||||
width: auto;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tooltip-btn-text {
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.icon-new-segment {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Zoom dropdown styling */
|
||||
.zoom-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.zoom-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
.zoom-dropdown {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.zoom-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.zoom-option:hover {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.zoom-option.selected {
|
||||
background-color: rgba(0, 123, 255, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Save buttons styling */
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
background-color: rgba(0, 123, 255, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(0, 123, 255, 1);
|
||||
}
|
||||
|
||||
.save-copy-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
}
|
||||
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Time navigation input styling */
|
||||
.time-nav-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
width: 150px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.time-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.time-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Timeline navigation and zoom controls responsiveness */
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* Allow wrapping on smaller screens */
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Media queries for responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timeline header styling */
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-weight: bold;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.timeline-title-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.current-time,
|
||||
.duration-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-code {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.timeline-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-button,
|
||||
.save-copy-button {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-dropdown-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
/**
|
||||
* A consistent logger utility that only outputs debug messages in development
|
||||
* but always shows errors, warnings, and info messages.
|
||||
*/
|
||||
const logger = {
|
||||
/**
|
||||
* Logs debug messages only in development environment
|
||||
*/
|
||||
debug: (...args: any[]) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Always logs error messages
|
||||
*/
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
|
||||
/**
|
||||
* Always logs warning messages
|
||||
*/
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
|
||||
/**
|
||||
* Always logs info messages
|
||||
*/
|
||||
info: (...args: any[]) => console.info(...args),
|
||||
};
|
||||
|
||||
export default logger;
|
||||
@ -1,51 +0,0 @@
|
||||
import { QueryClient, QueryFunction } from '@tanstack/react-query';
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: data ? { 'Content-Type': 'application/json' } : {},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = 'returnNull' | 'throw';
|
||||
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: 'throw' }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
||||
*/
|
||||
export const formatDetailedTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return '00:00:00.000';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||
|
||||
const formattedHours = String(hours).padStart(2, '0');
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
|
||||
const formattedMilliseconds = String(milliseconds).padStart(3, '0');
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
||||
*/
|
||||
export const formatTime = (seconds: number): string => {
|
||||
// Use the detailed format instead of the old MM:SS format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
||||
*/
|
||||
export const formatLongTime = (seconds: number): string => {
|
||||
// Use the detailed format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Generate a solid color background for a segment
|
||||
* Returns a CSS color based on the segment position
|
||||
*/
|
||||
export const generateSolidColor = (time: number, duration: number): string => {
|
||||
// Use the time position to create different colors
|
||||
// This gives each segment a different color without needing an image
|
||||
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
|
||||
|
||||
// Calculate color based on position
|
||||
// Use an extremely light blue-based color palette
|
||||
const hue = 210; // Blue base
|
||||
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
|
||||
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
@ -1,39 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: '',
|
||||
mediaId: '',
|
||||
posterUrl: ''
|
||||
};
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MEDIA_DATA: {
|
||||
videoUrl: string;
|
||||
mediaId: string;
|
||||
posterUrl?: string;
|
||||
};
|
||||
seekToFunction?: (time: number) => void;
|
||||
lastSeekedPosition: number;
|
||||
}
|
||||
}
|
||||
|
||||
// Mount the components when the DOM is ready
|
||||
const mountComponents = () => {
|
||||
const chaptersEditorContainer = document.getElementById('chapters-editor-root');
|
||||
if (chaptersEditorContainer) {
|
||||
const chaptersEditorRoot = createRoot(chaptersEditorContainer);
|
||||
chaptersEditorRoot.render(<App />);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
// API service for video trimming operations
|
||||
import logger from '../lib/logger';
|
||||
|
||||
// Helper function to simulate delay
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Auto-save interface
|
||||
interface AutoSaveRequest {
|
||||
chapters: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
chapterTitle?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface AutoSaveResponse {
|
||||
success: boolean;
|
||||
status?: string;
|
||||
timestamp: string;
|
||||
chapters?: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
chapterTitle: string;
|
||||
}[];
|
||||
updated_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Auto-save API function
|
||||
export const autoSaveVideo = async (mediaId: string, data: AutoSaveRequest): Promise<AutoSaveResponse> => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/media/${mediaId}/chapters`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
logger.debug('response', response);
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status
|
||||
if (response.status === 404) {
|
||||
// If endpoint not ready (404), return mock success response
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
};
|
||||
} else {
|
||||
// Handle other error responses
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: errorData.error || 'Auto-save failed (videoApi.ts)',
|
||||
};
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Auto-save failed (videoApi.ts)',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
|
||||
// Check if the response has the expected format
|
||||
return {
|
||||
success: true,
|
||||
timestamp: jsonResponse.updated_at || new Date().toISOString(),
|
||||
...jsonResponse,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// For any fetch errors, return mock success response
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
success: true,
|
||||
timestamp: timestamp,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -1,338 +0,0 @@
|
||||
#chapters-editor-root {
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
.clip-segments-container {
|
||||
margin-top: 1rem;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.clip-segments-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.clip-segments-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground, #333);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.save-chapters-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
&.has-changes {
|
||||
background-color: #10b981;
|
||||
animation: pulse-green 2s infinite;
|
||||
}
|
||||
|
||||
&.has-changes:hover {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-green {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #10b981;
|
||||
}
|
||||
50% {
|
||||
background-color: #34d399;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-editor {
|
||||
background-color: #f8fafc;
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.chapter-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.chapter-editor-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.chapter-editor-segment {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
background-color: #e5e7eb;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.chapter-title-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-editor-info {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.segment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.segment-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.default-title {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.segment-time {
|
||||
font-size: 0.75rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-duration {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
display: inline-block;
|
||||
background-color: #f3f4f6;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 0.375rem;
|
||||
color: #4b5563;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
min-width: auto;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: rgba(51, 51, 51, 0.7);
|
||||
}
|
||||
|
||||
.segment-color-1 {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.segment-color-2 {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
.segment-color-3 {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.segment-color-4 {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.segment-color-5 {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
.segment-color-6 {
|
||||
background-color: rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
.segment-color-7 {
|
||||
background-color: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
.segment-color-8 {
|
||||
background-color: rgba(250, 204, 21, 0.15);
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.clip-segments-header {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.save-chapters-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chapter-editor-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chapter-editor-segment {
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,397 +0,0 @@
|
||||
#chapters-editor-root {
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.editing-tools-container {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Show full text on larger screens, hide short text */
|
||||
.full-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.short-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reset text always visible by default */
|
||||
.reset-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.play-buttons-group {
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto; /* Don't expand to fill space */
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto; /* Push to right edge */
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-right: 1px solid #d1d5db;
|
||||
height: 1.5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Style for play buttons with highlight effect */
|
||||
.play-button,
|
||||
.preview-button {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
/* Greyed out play button when segments are playing */
|
||||
.play-button.greyed-out {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Highlighted stop button with blue pulse on small screens */
|
||||
.segments-button.highlighted-stop {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
animation: bluePulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bluePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Completely disable ALL hover effects for play buttons */
|
||||
.play-button:hover:not(:disabled),
|
||||
.preview-button:hover:not(:disabled) {
|
||||
/* Reset everything to prevent any changes */
|
||||
color: inherit !important;
|
||||
transform: none !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: auto !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.play-button svg,
|
||||
.preview-button svg {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
/* Make sure SVG scales with the button but doesn't change layout */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add responsive button text class */
|
||||
.button-text {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Media queries for the editing tools */
|
||||
@media (max-width: 992px) {
|
||||
/* Hide text for undo/redo buttons on medium screens */
|
||||
.button-group.secondary .button-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Keep all buttons in a single row, make them more compact */
|
||||
.flex-container.single-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Keep font size consistent regardless of screen size */
|
||||
.preview-button,
|
||||
.play-button {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Prevent container overflow on mobile */
|
||||
.editing-tools-container {
|
||||
padding: 0.75rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* At this breakpoint, make preview button text shorter */
|
||||
.preview-button {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Switch to short text versions */
|
||||
.full-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.short-text {
|
||||
display: inline;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
/* Hide reset text */
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ensure buttons stay in correct position */
|
||||
.button-group.play-buttons-group {
|
||||
flex: initial;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-group.secondary {
|
||||
flex: initial;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Reduce button sizes on mobile */
|
||||
.button-group button {
|
||||
padding: 0.375rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.button-group button svg {
|
||||
height: 1.125rem;
|
||||
width: 1.125rem;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
/* Keep single row, left-align play buttons, right-align controls */
|
||||
.flex-container.single-row {
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Fix left-align for play buttons */
|
||||
.button-group.play-buttons-group {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Fix right-align for editing controls */
|
||||
.button-group.secondary {
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Reduce button padding to fit more easily */
|
||||
.button-group button {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens - maintain layout but reduce further */
|
||||
@media (max-width: 480px) {
|
||||
.editing-tools-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-group.play-buttons-group,
|
||||
.button-group.secondary {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none; /* Hide divider on very small screens */
|
||||
}
|
||||
|
||||
/* Even smaller buttons on very small screens */
|
||||
.button-group button {
|
||||
padding: 0.125rem;
|
||||
}
|
||||
|
||||
.button-group button svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Hide all button text on very small screens */
|
||||
.button-text,
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Portrait orientation specific fixes */
|
||||
@media (max-width: 640px) and (orientation: portrait) {
|
||||
.editing-tools-container {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Ensure button groups don't overflow */
|
||||
.button-group {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.button-group.play-buttons-group {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.button-group.secondary {
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
.ios-notification {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background-color: #fffdeb;
|
||||
border-bottom: 1px solid #e2e2e2;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 10px;
|
||||
animation: slide-down 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ios-notification-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ios-notification-icon {
|
||||
flex-shrink: 0;
|
||||
color: #0066cc;
|
||||
margin-right: 15px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.ios-notification-message {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ios-notification-message h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.ios-notification-message p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.ios-notification-message ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.ios-notification-message li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ios-notification-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.ios-notification-close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Desktop mode button styling */
|
||||
.ios-mode-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ios-desktop-mode-btn {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ios-desktop-mode-btn:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.ios-desktop-mode-btn:active {
|
||||
background-color: #004499;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.ios-or {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0 0 6px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.ios-notification {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.ios-notification-close {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make sure this notification has better visibility on smaller screens */
|
||||
@media (max-width: 480px) {
|
||||
.ios-notification-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.ios-notification-message h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ios-notification-message p,
|
||||
.ios-notification-message ol {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add iOS-specific styles when in desktop mode */
|
||||
html.ios-device {
|
||||
/* Force the content to be rendered at desktop width */
|
||||
min-width: 1024px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
html.ios-device .ios-control-btn {
|
||||
/* Make buttons easier to tap in desktop mode */
|
||||
min-height: 44px;
|
||||
}
|
||||