Compare commits

..

8 Commits

Author SHA1 Message Date
Yiannis Christodoulou
94e88de33d build assets 2025-11-03 23:57:43 +02:00
Yiannis Christodoulou
54c8ee9d94 fix: Audio file / media: desktop Safari, and on iPhone Safari, Chrome and FireFox: Not possible to trim audio
closes 144
2025-11-03 23:55:49 +02:00
Yiannis Christodoulou
d90d99a682 build assets 2025-11-03 23:43:02 +02:00
Yiannis Christodoulou
30461f8cfd fix: Remove fake data from related videos
closes 146
2025-11-03 23:41:45 +02:00
Yiannis Christodoulou
c5804c49f2 build assets 2025-11-03 23:15:51 +02:00
Yiannis Christodoulou
344ad7dda1 fix: Autoplay button missing in vertical mode on mobile 2025-11-03 22:47:49 +02:00
Markos Gogoulos
3e79f5a558 fixes 2025-10-29 15:25:08 +02:00
Markos Gogoulos
a320375e16 Bulk actions support
3wtv
2025-10-28 15:24:29 +02:00
191 changed files with 6326 additions and 20641 deletions

View File

@@ -1,69 +1,2 @@
# Node.js/JavaScript dependencies and artifacts node_modules
**/node_modules npm-debug.log
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/.yarn/cache
**/.yarn/unplugged
**/package-lock.json
**/.npm
**/.cache
**/.parcel-cache
**/dist
**/build
**/*.tsbuildinfo
# Python bytecode and cache
**/__pycache__
**/*.py[cod]
**/*$py.class
**/*.so
**/.Python
**/pip-log.txt
**/pip-delete-this-directory.txt
**/.pytest_cache
**/.coverage
**/htmlcov
**/.tox
**/.mypy_cache
**/.ruff_cache
# Version control
**/.git
**/.gitignore
**/.gitattributes
# IDE and editor files
**/.DS_Store
**/.vscode
**/.idea
**/*.swp
**/*.swo
**/*~
# Logs and runtime files
**/logs
**/*.log
**/celerybeat-schedule*
**/.env
**/.env.*
# Media files and data directories (should not be in image)
media_files/**
postgres_data/**
pids/**
# Static files collected at runtime
static_collected/**
# Documentation and development files
**/.github
**/CHANGELOG.md
# Test files and directories
**/tests
**/test_*.py
**/*_test.py
# Frontend build artifacts (built separately)
frontend/dist/**

View File

@@ -1,42 +0,0 @@
name: Frontend build and test
on:
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
strategy:
matrix:
os: [ubuntu-latest]
node: [20]
runs-on: ${{ matrix.os }}
name: '${{ matrix.os }} - node v${{ matrix.node }}'
permissions:
contents: read
defaults:
run:
working-directory: ./frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm install
- name: Build script
run: npm run dist
- name: Test script
run: npm run test

View File

@@ -1,22 +0,0 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
environment: dev
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,47 +0,0 @@
name: Semantic Release
on:
push:
branches:
- main
permissions:
contents: write
issues: write
jobs:
semantic-release:
runs-on: ubuntu-latest
environment: dev
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup SSH
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.GA_DEPLOY_KEY }}
# use SSH url to ensure git commit using a deploy key bypasses the main
# branch protection rule
- name: Configure Git for SSH Push
run: git remote set-url origin "git@github.com:${{ github.repository }}.git"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install Dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Run Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@@ -6,9 +6,8 @@ media_files/hls/
media_files/chunks/ media_files/chunks/
media_files/uploads/ media_files/uploads/
media_files/tinymce_media/ media_files/tinymce_media/
media_files/userlogos/
postgres_data/ postgres_data/
celerybeat-schedule* celerybeat-schedule
logs/ logs/
pids/ pids/
static/admin/ static/admin/
@@ -20,8 +19,8 @@ static/drf-yasg
cms/local_settings.py cms/local_settings.py
deploy/docker/local_settings.py deploy/docker/local_settings.py
yt.readme.md yt.readme.md
# Node.js dependencies (covers all node_modules directories, including frontend-tools) /frontend-tools/video-editor/node_modules
**/node_modules/ /frontend-tools/video-editor/client/node_modules
/static_collected /static_collected
/frontend-tools/video-editor-v1 /frontend-tools/video-editor-v1
frontend-tools/.DS_Store frontend-tools/.DS_Store
@@ -36,4 +35,3 @@ frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-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/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3 static/video_editor/videos/sample-video.mp3
templates/todo-MS4.md

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pycqa/flake8 - repo: https://github.com/pycqa/flake8
rev: 6.1.0 rev: 6.0.0
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort

View File

@@ -1,4 +1,3 @@
/templates/cms/* /templates/cms/*
/templates/*.html /templates/*.html
*.scss *.scss
/frontend/

View File

@@ -1,100 +0,0 @@
{
"branches": [
"main"
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "chore",
"hidden": true
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "style",
"hidden": true
},
{
"type": "refactor",
"section": "Refactors"
},
{
"type": "perf",
"section": "Performance"
},
{
"type": "test",
"hidden": true
},
{
"type": "depr",
"section": "Deprecations"
}
]
}
}
],
[
"semantic-release-replace-plugin",
{
"replacements": [
{
"files": [
"package.json"
],
"from": "\"version\": \".*\"",
"to": "\"version\": \"${nextRelease.version}\"",
"results": [
{
"file": "package.json",
"hasChanged": true,
"numMatches": 1,
"numReplacements": 1
}
],
"countMatches": true
}
]
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md",
"changelogTitle": "# Changelog"
}
],
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}

View File

@@ -1,43 +0,0 @@
# Changelog
## [7.6.0](https://github.com/mediacms-io/mediacms/compare/v7.5.0...v7.6.0) (2026-02-07)
### Features
* Create SECURITY.md ([#1485](https://github.com/mediacms-io/mediacms/issues/1485)) ([11449c2](https://github.com/mediacms-io/mediacms/commit/11449c2187d0f450b86915d88f92595a1825e4cf))
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)
### Features
* bump version ([36d815c](https://github.com/mediacms-io/mediacms/commit/36d815c0cfbe21d3136541d410d545742b9ebecd))
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)
### Features
* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](https://github.com/mediacms-io/mediacms/commit/74952f68d79bc67617edb38eac62d2f5e7457565))
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)
### Features
* add package json for semantic release ([b405a04](https://github.com/mediacms-io/mediacms/commit/b405a04e346ca81b7d3f4e099eb984e7785cdd0f))
* add semantic release github actions ([76a27ae](https://github.com/mediacms-io/mediacms/commit/76a27ae25609178c1bd47c947b9f1a082c791d61))
* frontend unit tests ([1c15880](https://github.com/mediacms-io/mediacms/commit/1c15880ae3ef1ce77f53d5b473dfc0cc448b4977))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](https://github.com/mediacms-io/mediacms/commit/223e87073f7d5e44130c9976854cac670db0ae66))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](https://github.com/mediacms-io/mediacms/commit/d9b1d6cab1d2bdfc16f799a0a27b64313e2e0d22))
* semantic release ([b76282f](https://github.com/mediacms-io/mediacms/commit/b76282f9e465a39c2da5e9a22184d1db23de3f56))
### Bug Fixes
* add delay to task creation ([1b3cdfd](https://github.com/mediacms-io/mediacms/commit/1b3cdfd302abc5e69ebe01ca52b5091f3b24c0b2))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](https://github.com/mediacms-io/mediacms/commit/90331f3b4a2a5737de9dd75ab45c096944813c42))
* adjust poster url for audio ([01912ea](https://github.com/mediacms-io/mediacms/commit/01912ea1f99ef43793a65712539d6264f1f6410f))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](https://github.com/mediacms-io/mediacms/commit/cd7dd4f72c9f0bac466c680f686a9ecfdd3a38dd))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](https://github.com/mediacms-io/mediacms/commit/5eb6fafb8c6928b8bc3fe5f0c7af315273f78a55))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](https://github.com/mediacms-io/mediacms/commit/ba2c31b1e65b7f508dee598b1f2d86f01f9bf036))
### Documentation
* update page link ([aeef828](https://github.com/mediacms-io/mediacms/commit/aeef8284bfba2a9a7f69c684f96c54f0e0e0cf92))

View File

@@ -69,7 +69,7 @@ Copyright Markos Gogoulos.
## Support and paid services ## Support and paid services
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Checkout our [services page](https://mediacms.io/#services/) for more information. We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
### Commercial Hostings ### Commercial Hostings
**Elestio** **Elestio**

View File

@@ -1,54 +0,0 @@
# Security Policy
Thank you for helping improve the security of MediaCMS.
We take security vulnerabilities seriously and appreciate responsible disclosure.
---
## Reporting a Vulnerability
If you discover a security vulnerability in MediaCMS, **please do not open a public GitHub issue**.
Instead, report it using one of the following methods:
- **GitHub Security Advisories (preferred)**
Use the "Report a vulnerability" feature in this repository.
- **Contact Form**
Submit details via the official contact page:
https://mediacms.io/contact/
Please include as much of the following information as possible:
- Affected version(s)
- Detailed description of the issue
- Steps to reproduce (PoC if available)
- Impact assessment (e.g. RCE, XSS, privilege escalation)
- Any potential mitigations you are aware of
---
## Supported Versions
Security updates are provided for the **latest stable release** of MediaCMS.
Older versions may not receive security patches.
---
## Disclosure Policy
- We aim to acknowledge reports within **7 days**
- We aim to provide a fix or mitigation within **90 days**, depending on severity
- Please allow us time to investigate before any public disclosure
We follow responsible disclosure practices and will coordinate disclosure timelines when appropriate.
---
## Recognition
At this time, MediaCMS does not operate a formal bug bounty program.
However, we are happy to acknowledge valid security reports in release notes or advisories (with your permission).
---
Thank you for helping keep MediaCMS secure.

View File

@@ -563,8 +563,7 @@ ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False ALLOW_CUSTOM_MEDIA_URLS = False
ALLOW_MEDIA_REPLACEMENT = False # Whether to allow anonymous users to list all users
ALLOW_ANONYMOUS_USER_LISTING = True ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page # Who can see the members page

View File

@@ -1 +1 @@
VERSION = "7.5" VERSION = "7.1.0"

View File

@@ -58,7 +58,6 @@ def stuff(request):
ret["USE_RBAC"] = settings.USE_RBAC ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
ret["ALLOW_MEDIA_REPLACEMENT"] = getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False)
ret["VERSION"] = VERSION ret["VERSION"] = VERSION
if request.user.is_superuser: if request.user.is_superuser:

View File

@@ -6,7 +6,6 @@ from django.conf import settings
from .methods import get_next_state, is_mediacms_editor from .methods import get_next_state, is_mediacms_editor
from .models import MEDIA_STATES, Category, Media, Subtitle from .models import MEDIA_STATES, Category, Media, Subtitle
from .widgets import CategoryModalWidget
class CustomField(Field): class CustomField(Field):
@@ -122,18 +121,13 @@ class MediaPublishForm(forms.ModelForm):
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download") fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
widgets = { widgets = {
"category": CategoryModalWidget(), "category": MultipleSelect(),
} }
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.user = user self.user = user
super(MediaPublishForm, self).__init__(*args, **kwargs) super(MediaPublishForm, self).__init__(*args, **kwargs)
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
self.has_rbac_categories = self.instance.category.filter(is_rbac_category=True).exists() if self.instance.pk else False
self.is_shared = self.has_custom_permissions or self.has_rbac_categories
self.actual_state = self.instance.state if self.instance.pk else None
if not is_mediacms_editor(user): if not is_mediacms_editor(user):
for field in ["featured", "reported_times", "is_reviewed"]: for field in ["featured", "reported_times", "is_reviewed"]:
self.fields[field].disabled = True self.fields[field].disabled = True
@@ -146,13 +140,6 @@ class MediaPublishForm(forms.ModelForm):
valid_states.append(self.instance.state) valid_states.append(self.instance.state)
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states] self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
if self.is_shared:
current_choices = list(self.fields["state"].choices)
current_choices.insert(0, ("shared", "Shared"))
self.fields["state"].choices = current_choices
self.fields["state"].initial = "shared"
self.initial["state"] = "shared"
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields: if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user): if is_mediacms_editor(user):
pass pass
@@ -191,76 +178,34 @@ class MediaPublishForm(forms.ModelForm):
state = cleaned_data.get("state") state = cleaned_data.get("state")
categories = cleaned_data.get("category") categories = cleaned_data.get("category")
if self.is_shared and state != "shared": if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
self.fields['confirm_state'].widget = forms.CheckboxInput()
state_index = None
for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i
break
if state_index is not None:
layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'):
if state == 'private':
error_parts = []
if self.has_rbac_categories:
rbac_cat_titles = self.instance.category.filter(is_rbac_category=True).values_list('title', flat=True)
error_parts.append(f"shared with users that have access to categories: {', '.join(rbac_cat_titles)}")
if self.has_custom_permissions:
error_parts.append("shared by me with other users (visible in 'Shared by me' page)")
error_message = f"I understand that changing to Private will remove all sharing. Currently this media is {' and '.join(error_parts)}. All this sharing will be removed."
self.add_error('confirm_state', error_message)
else:
error_message = f"I understand that changing to {state.title()} will maintain existing sharing settings."
self.add_error('confirm_state', error_message)
elif state in ['private', 'unlisted']:
custom_permissions = self.instance.permissions.exists()
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True) rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories or custom_permissions:
if rbac_categories and state in ['private', 'unlisted']:
# Make the confirm_state field visible and add it to the layout
self.fields['confirm_state'].widget = forms.CheckboxInput() self.fields['confirm_state'].widget = forms.CheckboxInput()
# add it after the state field
state_index = None state_index = None
for i, layout_item in enumerate(self.helper.layout): for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state': if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i state_index = i
break break
if state_index is not None: if state_index:
layout_items = list(self.helper.layout) layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state')) layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items) self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'): if not cleaned_data.get('confirm_state'):
if rbac_categories: error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to categories: {', '.join(rbac_categories)}"
self.add_error('confirm_state', error_message) self.add_error('confirm_state', error_message)
if custom_permissions:
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
self.add_error('confirm_state', error_message)
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
if state == "shared":
cleaned_data["state"] = self.actual_state
return cleaned_data return cleaned_data
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
data = self.cleaned_data data = self.cleaned_data
state = data.get("state") state = data.get("state")
# If transitioning from shared to private, remove all sharing
if self.is_shared and state == 'private' and data.get('confirm_state'):
# Remove all custom permissions
self.instance.permissions.all().delete()
# Remove RBAC categories
rbac_cats = self.instance.category.filter(is_rbac_category=True)
self.instance.category.remove(*rbac_cats)
if state != self.initial["state"]: if state != self.initial["state"]:
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state) self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
@@ -387,35 +332,3 @@ class ContactForm(forms.Form):
if user.is_authenticated: if user.is_authenticated:
self.fields.pop("name") self.fields.pop("name")
self.fields.pop("from_email") self.fields.pop("from_email")
class ReplaceMediaForm(forms.Form):
new_media_file = forms.FileField(
required=True,
label="New Media File",
help_text="Select a new file to replace the current media",
)
def __init__(self, media_instance, *args, **kwargs):
self.media_instance = media_instance
super(ReplaceMediaForm, self).__init__(*args, **kwargs)
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('new_media_file'),
)
self.helper.layout.append(FormActions(Submit('submit', 'Replace Media', css_class='primaryAction')))
def clean_new_media_file(self):
file = self.cleaned_data.get("new_media_file", False)
if file:
if file.size > settings.UPLOAD_MAX_SIZE:
max_size_mb = settings.UPLOAD_MAX_SIZE / (1024 * 1024)
raise forms.ValidationError(f"File too large. Maximum size: {max_size_mb:.0f}MB")
return file

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "إزالة من القائمة", "Remove from list": "إزالة من القائمة",
"Remove tag": "إزالة العلامة", "Remove tag": "إزالة العلامة",
"Remove user": "إزالة المستخدم", "Remove user": "إزالة المستخدم",
"Replace": "",
"SAVE": "حفظ", "SAVE": "حفظ",
"SEARCH": "بحث", "SEARCH": "بحث",
"SHARE": "مشاركة", "SHARE": "مشاركة",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "সংরক্ষণ করুন", "SAVE": "সংরক্ষণ করুন",
"SEARCH": "অনুসন্ধান", "SEARCH": "অনুসন্ধান",
"SHARE": "শেয়ার করুন", "SHARE": "শেয়ার করুন",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Fjern fra liste", "Remove from list": "Fjern fra liste",
"Remove tag": "Fjern tag", "Remove tag": "Fjern tag",
"Remove user": "Fjern bruger", "Remove user": "Fjern bruger",
"Replace": "",
"SAVE": "GEM", "SAVE": "GEM",
"SEARCH": "SØG", "SEARCH": "SØG",
"SHARE": "DEL", "SHARE": "DEL",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Aus Liste entfernen", "Remove from list": "Aus Liste entfernen",
"Remove tag": "Tag entfernen", "Remove tag": "Tag entfernen",
"Remove user": "Benutzer entfernen", "Remove user": "Benutzer entfernen",
"Replace": "",
"SAVE": "SPEICHERN", "SAVE": "SPEICHERN",
"SEARCH": "SUCHE", "SEARCH": "SUCHE",
"SHARE": "TEILEN", "SHARE": "TEILEN",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Αφαίρεση από λίστα", "Remove from list": "Αφαίρεση από λίστα",
"Remove tag": "Αφαίρεση ετικέτας", "Remove tag": "Αφαίρεση ετικέτας",
"Remove user": "Αφαίρεση χρήστη", "Remove user": "Αφαίρεση χρήστη",
"Replace": "",
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ", "SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
"SEARCH": "ΑΝΑΖΗΤΗΣΗ", "SEARCH": "ΑΝΑΖΗΤΗΣΗ",
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ", "SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",

View File

@@ -165,7 +165,6 @@ translation_strings = {
"Recommended": "", "Recommended": "",
"Record Screen": "", "Record Screen": "",
"Register": "", "Register": "",
"Replace": "",
"Remove category": "", "Remove category": "",
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Eliminar de la lista", "Remove from list": "Eliminar de la lista",
"Remove tag": "Eliminar etiqueta", "Remove tag": "Eliminar etiqueta",
"Remove user": "Eliminar usuario", "Remove user": "Eliminar usuario",
"Replace": "",
"SAVE": "GUARDAR", "SAVE": "GUARDAR",
"SEARCH": "BUSCAR", "SEARCH": "BUSCAR",
"SHARE": "COMPARTIR", "SHARE": "COMPARTIR",

View File

@@ -163,7 +163,6 @@ translation_strings = {
"Remove from list": "Supprimer de la liste", "Remove from list": "Supprimer de la liste",
"Remove tag": "Supprimer le tag", "Remove tag": "Supprimer le tag",
"Remove user": "Supprimer l'utilisateur", "Remove user": "Supprimer l'utilisateur",
"Replace": "",
"SAVE": "ENREGISTRER", "SAVE": "ENREGISTRER",
"SEARCH": "RECHERCHER", "SEARCH": "RECHERCHER",
"SHARE": "PARTAGER", "SHARE": "PARTAGER",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "שמור", "SAVE": "שמור",
"SEARCH": "חפש", "SEARCH": "חפש",
"SHARE": "שתף", "SHARE": "שתף",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "सूची से हटाएं", "Remove from list": "सूची से हटाएं",
"Remove tag": "टैग हटाएं", "Remove tag": "टैग हटाएं",
"Remove user": "उपयोगकर्ता हटाएं", "Remove user": "उपयोगकर्ता हटाएं",
"Replace": "",
"SAVE": "सहेजें", "SAVE": "सहेजें",
"SEARCH": "खोजें", "SEARCH": "खोजें",
"SHARE": "साझा करें", "SHARE": "साझा करें",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Hapus dari daftar", "Remove from list": "Hapus dari daftar",
"Remove tag": "Hapus tag", "Remove tag": "Hapus tag",
"Remove user": "Hapus pengguna", "Remove user": "Hapus pengguna",
"Replace": "",
"SAVE": "SIMPAN", "SAVE": "SIMPAN",
"SEARCH": "CARI", "SEARCH": "CARI",
"SHARE": "BAGIKAN", "SHARE": "BAGIKAN",

View File

@@ -163,7 +163,6 @@ translation_strings = {
"Remove from list": "Rimuovi dalla lista", "Remove from list": "Rimuovi dalla lista",
"Remove tag": "Rimuovi tag", "Remove tag": "Rimuovi tag",
"Remove user": "Rimuovi utente", "Remove user": "Rimuovi utente",
"Replace": "",
"SAVE": "SALVA", "SAVE": "SALVA",
"SEARCH": "CERCA", "SEARCH": "CERCA",
"SHARE": "CONDIVIDI", "SHARE": "CONDIVIDI",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "リストから削除", "Remove from list": "リストから削除",
"Remove tag": "タグを削除", "Remove tag": "タグを削除",
"Remove user": "ユーザーを削除", "Remove user": "ユーザーを削除",
"Replace": "",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "検索", "SEARCH": "検索",
"SHARE": "共有", "SHARE": "共有",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "목록에서 제거", "Remove from list": "목록에서 제거",
"Remove tag": "태그 제거", "Remove tag": "태그 제거",
"Remove user": "사용자 제거", "Remove user": "사용자 제거",
"Replace": "",
"SAVE": "저장", "SAVE": "저장",
"SEARCH": "검색", "SEARCH": "검색",
"SHARE": "공유", "SHARE": "공유",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Verwijderen uit lijst", "Remove from list": "Verwijderen uit lijst",
"Remove tag": "Tag verwijderen", "Remove tag": "Tag verwijderen",
"Remove user": "Gebruiker verwijderen", "Remove user": "Gebruiker verwijderen",
"Replace": "",
"SAVE": "OPSLAAN", "SAVE": "OPSLAAN",
"SEARCH": "ZOEKEN", "SEARCH": "ZOEKEN",
"SHARE": "DELEN", "SHARE": "DELEN",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Remover da lista", "Remove from list": "Remover da lista",
"Remove tag": "Remover tag", "Remove tag": "Remover tag",
"Remove user": "Remover usuário", "Remove user": "Remover usuário",
"Replace": "",
"SAVE": "SALVAR", "SAVE": "SALVAR",
"SEARCH": "PESQUISAR", "SEARCH": "PESQUISAR",
"SHARE": "COMPARTILHAR", "SHARE": "COMPARTILHAR",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Удалить из списка", "Remove from list": "Удалить из списка",
"Remove tag": "Удалить тег", "Remove tag": "Удалить тег",
"Remove user": "Удалить пользователя", "Remove user": "Удалить пользователя",
"Replace": "",
"SAVE": "СОХРАНИТЬ", "SAVE": "СОХРАНИТЬ",
"SEARCH": "ПОИСК", "SEARCH": "ПОИСК",
"SHARE": "ПОДЕЛИТЬСЯ", "SHARE": "ПОДЕЛИТЬСЯ",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Odstrani s seznama", "Remove from list": "Odstrani s seznama",
"Remove tag": "Odstrani oznako", "Remove tag": "Odstrani oznako",
"Remove user": "Odstrani uporabnika", "Remove user": "Odstrani uporabnika",
"Replace": "",
"SAVE": "SHRANI", "SAVE": "SHRANI",
"SEARCH": "ISKANJE", "SEARCH": "ISKANJE",
"SHARE": "DELI", "SHARE": "DELI",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "Listeden kaldır", "Remove from list": "Listeden kaldır",
"Remove tag": "Etiketi kaldır", "Remove tag": "Etiketi kaldır",
"Remove user": "Kullanıcıyı kaldır", "Remove user": "Kullanıcıyı kaldır",
"Replace": "",
"SAVE": "KAYDET", "SAVE": "KAYDET",
"SEARCH": "ARA", "SEARCH": "ARA",
"SHARE": "PAYLAŞ", "SHARE": "PAYLAŞ",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "فہرست سے ہٹائیں", "Remove from list": "فہرست سے ہٹائیں",
"Remove tag": "ٹیگ ہٹائیں", "Remove tag": "ٹیگ ہٹائیں",
"Remove user": "صارف ہٹائیں", "Remove user": "صارف ہٹائیں",
"Replace": "",
"SAVE": "محفوظ کریں", "SAVE": "محفوظ کریں",
"SEARCH": "تلاش کریں", "SEARCH": "تلاش کریں",
"SHARE": "شیئر کریں", "SHARE": "شیئر کریں",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "搜索", "SEARCH": "搜索",
"SHARE": "分享", "SHARE": "分享",

View File

@@ -162,7 +162,6 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "儲存", "SAVE": "儲存",
"SEARCH": "搜尋", "SEARCH": "搜尋",
"SHARE": "分享", "SHARE": "分享",

View File

@@ -910,9 +910,7 @@ def trim_video_method(media_file_path, timestamps_list):
return False return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
# Detect input file extension to preserve original format output_file = os.path.join(temp_dir, "output.mp4")
_, input_ext = os.path.splitext(media_file_path)
output_file = os.path.join(temp_dir, f"output{input_ext}")
segment_files = [] segment_files = []
for i, item in enumerate(timestamps_list): for i, item in enumerate(timestamps_list):
@@ -922,7 +920,7 @@ def trim_video_method(media_file_path, timestamps_list):
# For single timestamp, we can use the output file directly # For single timestamp, we can use the output file directly
# For multiple timestamps, we need to create segment files # For multiple timestamps, we need to create segment files
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}{input_ext}") segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file] cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]

View File

@@ -272,16 +272,12 @@ def show_related_media_content(media, request, limit):
category = media.category.first() category = media.category.first()
if category: if category:
q_category = Q(listable=True, category=category) q_category = Q(listable=True, category=category)
# Fix: Ensure slice index is never negative q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
remaining = max(0, limit - len(m))
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
m = list(itertools.chain(m, q_res)) m = list(itertools.chain(m, q_res))
if len(m) < limit: if len(m) < limit:
q_generic = Q(listable=True) q_generic = Q(listable=True)
# Fix: Ensure slice index is never negative q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
remaining = max(0, limit - len(m))
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
m = list(itertools.chain(m, q_res)) m = list(itertools.chain(m, q_res))
m = list(set(m[:limit])) # remove duplicates m = list(set(m[:limit])) # remove duplicates
@@ -494,6 +490,7 @@ def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
state=helpers.get_default_state(user=original_media.user), state=helpers.get_default_state(user=original_media.user),
is_reviewed=original_media.is_reviewed, is_reviewed=original_media.is_reviewed,
encoding_status=original_media.encoding_status, encoding_status=original_media.encoding_status,
listable=original_media.listable,
add_date=timezone.now(), add_date=timezone.now(),
video_height=original_media.video_height, video_height=original_media.video_height,
size=original_media.size, size=original_media.size,
@@ -669,8 +666,11 @@ def change_media_owner(media_id, new_user):
media.user = new_user media.user = new_user
media.save(update_fields=["user"]) media.save(update_fields=["user"])
# Optimize: Update any related permissions in bulk instead of loop # Update any related permissions
models.MediaPermission.objects.filter(media=media).update(owner_user=new_user) media_permissions = models.MediaPermission.objects.filter(media=media)
for permission in media_permissions:
permission.owner_user = new_user
permission.save(update_fields=["owner_user"])
# remove any existing permissions for the new user, since they are now owner # remove any existing permissions for the new user, since they are now owner
models.MediaPermission.objects.filter(media=media, user=new_user).delete() models.MediaPermission.objects.filter(media=media, user=new_user).delete()
@@ -713,6 +713,7 @@ def copy_media(media):
state=helpers.get_default_state(user=media.user), state=helpers.get_default_state(user=media.user),
is_reviewed=media.is_reviewed, is_reviewed=media.is_reviewed,
encoding_status=media.encoding_status, encoding_status=media.encoding_status,
listable=media.listable,
add_date=timezone.now(), add_date=timezone.now(),
) )

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.2.6 on 2025-12-16 14:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0013_page_tinymcemedia'),
]
operations = [
migrations.AlterModelOptions(
name='subtitle',
options={'ordering': ['language__title'], 'verbose_name': 'Caption', 'verbose_name_plural': 'Captions'},
),
migrations.AlterModelOptions(
name='transcriptionrequest',
options={'verbose_name': 'Caption Request', 'verbose_name_plural': 'Caption Requests'},
),
migrations.AlterModelOptions(
name='videotrimrequest',
options={'verbose_name': 'Trim Request', 'verbose_name_plural': 'Trim Requests'},
),
]

View File

@@ -91,7 +91,7 @@ class Category(models.Model):
if self.listings_thumbnail: if self.listings_thumbnail:
return self.listings_thumbnail return self.listings_thumbnail
# Optimize: Use first() directly instead of exists() + first() (saves one query) if Media.objects.filter(category=self, state="public").exists():
media = Media.objects.filter(category=self, state="public").order_by("-views").first() media = Media.objects.filter(category=self, state="public").order_by("-views").first()
if media: if media:
return media.thumbnail_url return media.thumbnail_url

View File

@@ -270,9 +270,7 @@ class Media(models.Model):
if self.media_file != self.__original_media_file: if self.media_file != self.__original_media_file:
# set this otherwise gets to infinite loop # set this otherwise gets to infinite loop
self.__original_media_file = self.media_file self.__original_media_file = self.media_file
from .. import tasks self.media_init()
tasks.media_init.apply_async(args=[self.friendly_token], countdown=5)
# for video files, if user specified a different time # for video files, if user specified a different time
# to automatically grub thumbnail # to automatically grub thumbnail
@@ -284,7 +282,7 @@ class Media(models.Model):
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
) )
if transcription_changed and self.media_type in ["video", "audio"]: if transcription_changed and self.media_type == "video":
self.transcribe_function() self.transcribe_function()
# Update the original values for next comparison # Update the original values for next comparison
@@ -331,17 +329,10 @@ class Media(models.Model):
if to_transcribe: if to_transcribe:
TranscriptionRequest.objects.create(media=self, translate_to_english=False) TranscriptionRequest.objects.create(media=self, translate_to_english=False)
tasks.whisper_transcribe.apply_async( tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
args=[self.friendly_token, False],
countdown=10,
)
if to_transcribe_and_translate: if to_transcribe_and_translate:
TranscriptionRequest.objects.create(media=self, translate_to_english=True) TranscriptionRequest.objects.create(media=self, translate_to_english=True)
tasks.whisper_transcribe.apply_async( tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
args=[self.friendly_token, True],
countdown=10,
)
def update_search_vector(self): def update_search_vector(self):
""" """
@@ -419,11 +410,6 @@ class Media(models.Model):
self.media_type = "image" self.media_type = "image"
elif kind == "pdf": elif kind == "pdf":
self.media_type = "pdf" self.media_type = "pdf"
elif kind == "audio":
self.media_type = "audio"
elif kind == "video":
self.media_type = "video"
if self.media_type in ["image", "pdf"]: if self.media_type in ["image", "pdf"]:
self.encoding_status = "success" self.encoding_status = "success"
else: else:
@@ -777,8 +763,6 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_thumbnail.path) return helpers.url_from_path(self.uploaded_thumbnail.path)
if self.thumbnail: if self.thumbnail:
return helpers.url_from_path(self.thumbnail.path) return helpers.url_from_path(self.thumbnail.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property
@@ -792,9 +776,6 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_poster.path) return helpers.url_from_path(self.uploaded_poster.path)
if self.poster: if self.poster:
return helpers.url_from_path(self.poster.path) return helpers.url_from_path(self.poster.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property

View File

@@ -101,17 +101,10 @@ class MediaSerializer(serializers.ModelSerializer):
class SingleMediaSerializer(serializers.ModelSerializer): class SingleMediaSerializer(serializers.ModelSerializer):
user = serializers.ReadOnlyField(source="user.username") user = serializers.ReadOnlyField(source="user.username")
url = serializers.SerializerMethodField() url = serializers.SerializerMethodField()
is_shared = serializers.SerializerMethodField()
def get_url(self, obj): def get_url(self, obj):
return self.context["request"].build_absolute_uri(obj.get_absolute_url()) return self.context["request"].build_absolute_uri(obj.get_absolute_url())
def get_is_shared(self, obj):
"""Check if media has custom permissions or RBAC categories"""
custom_permissions = obj.permissions.exists()
rbac_categories = obj.category.filter(is_rbac_category=True).exists()
return custom_permissions or rbac_categories
class Meta: class Meta:
model = Media model = Media
read_only_fields = ( read_only_fields = (
@@ -140,7 +133,6 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"edit_date", "edit_date",
"media_type", "media_type",
"state", "state",
"is_shared",
"duration", "duration",
"thumbnail_url", "thumbnail_url",
"poster_url", "poster_url",

View File

@@ -625,18 +625,6 @@ def create_hls(friendly_token):
return True return True
@task(name="media_init", queue="short_tasks")
def media_init(friendly_token):
try:
media = Media.objects.get(friendly_token=friendly_token)
except: # noqa
logger.info("failed to get media with friendly_token %s" % friendly_token)
return False
media.media_init()
return True
@task(name="check_running_states", queue="short_tasks") @task(name="check_running_states", queue="short_tasks")
def check_running_states(): def check_running_states():
# Experimental - unused # Experimental - unused

View File

@@ -20,7 +20,6 @@ urlpatterns = [
re_path(r"^contact$", views.contact, name="contact"), re_path(r"^contact$", views.contact, name="contact"),
re_path(r"^publish", views.publish_media, name="publish_media"), re_path(r"^publish", views.publish_media, name="publish_media"),
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"), re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
re_path(r"^replace_media", views.replace_media, name="replace_media"),
re_path(r"^edit_video", views.edit_video, name="edit_video"), re_path(r"^edit_video", views.edit_video, name="edit_video"),
re_path(r"^edit", views.edit_media, name="edit_media"), re_path(r"^edit", views.edit_media, name="edit_media"),
re_path(r"^embed", views.embed_media, name="get_embed"), re_path(r"^embed", views.embed_media, name="get_embed"),
@@ -111,7 +110,7 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"), re_path(r"^manage/users$", views.manage_users, name="manage_users"),
# Media uploads in ADMIN created pages # Media uploads in ADMIN created pages
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"), re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
re_path(r"^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605 re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -32,7 +32,6 @@ from .pages import members # noqa: F401
from .pages import publish_media # noqa: F401 from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401 from .pages import recommended_media # noqa: F401
from .pages import record_screen # noqa: F401 from .pages import record_screen # noqa: F401
from .pages import replace_media # noqa: F401
from .pages import search # noqa: F401 from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401 from .pages import setlanguage # noqa: F401
from .pages import sitemap # noqa: F401 from .pages import sitemap # noqa: F401

View File

@@ -74,8 +74,10 @@ class MediaList(APIView):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return base_queryset.filter(base_filters) return base_queryset.filter(base_filters)
conditions = base_filters # Build OR conditions for authenticated users
conditions = base_filters # Start with listable media
# Add user permissions
permission_filter = {'user': request.user} permission_filter = {'user': request.user}
if user: if user:
permission_filter['owner_user'] = user permission_filter['owner_user'] = user
@@ -86,6 +88,7 @@ class MediaList(APIView):
perm_conditions &= Q(user=user) perm_conditions &= Q(user=user)
conditions |= perm_conditions conditions |= perm_conditions
# Add RBAC conditions
if getattr(settings, 'USE_RBAC', False): if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member() rbac_categories = request.user.get_rbac_categories_as_member()
rbac_conditions = Q(category__in=rbac_categories) rbac_conditions = Q(category__in=rbac_categories)
@@ -96,6 +99,7 @@ class MediaList(APIView):
return base_queryset.filter(conditions).distinct() return base_queryset.filter(conditions).distinct()
def get(self, request, format=None): def get(self, request, format=None):
# Show media
# authenticated users can see: # authenticated users can see:
# All listable media (public access) # All listable media (public access)
@@ -114,6 +118,7 @@ class MediaList(APIView):
publish_state = params.get('publish_state', '').strip() publish_state = params.get('publish_state', '').strip()
query = params.get("q", "").strip().lower() query = params.get("q", "").strip().lower()
# Handle combined sort options (e.g., title_asc, views_desc)
parsed_combined = False parsed_combined = False
if sort_by and '_' in sort_by: if sort_by and '_' in sort_by:
parts = sort_by.rsplit('_', 1) parts = sort_by.rsplit('_', 1)
@@ -226,25 +231,20 @@ class MediaList(APIView):
elif duration == '60-120': elif duration == '60-120':
media = media.filter(duration__gte=3600) media = media.filter(duration__gte=3600)
if publish_state: if publish_state and publish_state in ['private', 'public', 'unlisted']:
if publish_state == 'shared':
# Filter media that have custom permissions OR RBAC categories
shared_conditions = Q(permissions__isnull=False) | Q(category__is_rbac_category=True)
media = media.filter(shared_conditions).distinct()
elif publish_state in ['private', 'public', 'unlisted']:
media = media.filter(state=publish_state) media = media.filter(state=publish_state)
if not already_sorted: if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}") media = media.order_by(f"{ordering}{sort_by}")
media = media[:1000] media = media[:1000] # limit to 1000 results
paginator = pagination_class() paginator = pagination_class()
page = paginator.paginate_queryset(media, request) page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request}) serializer = MediaSerializer(page, many=True, context={"request": request})
# Collect all unique tags from the current page results
tags_set = set() tags_set = set()
for media_obj in page: for media_obj in page:
for tag in media_obj.tags.all(): for tag in media_obj.tags.all():
@@ -354,23 +354,28 @@ class MediaBulkUserActions(APIView):
}, },
) )
def post(self, request, format=None): def post(self, request, format=None):
# Check if user is authenticated
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED) return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
# Get required parameters
media_ids = request.data.get('media_ids', []) media_ids = request.data.get('media_ids', [])
action = request.data.get('action') action = request.data.get('action')
# Validate required parameters
if not media_ids: if not media_ids:
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
if not action: if not action:
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
# Get media objects owned by the user
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids) media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
if not media: if not media:
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
# Process based on action
if action == "enable_comments": if action == "enable_comments":
media.update(enable_comments=True) media.update(enable_comments=True)
return Response({"detail": f"Comments enabled for {media.count()} media items"}) return Response({"detail": f"Comments enabled for {media.count()} media items"})
@@ -441,10 +446,12 @@ class MediaBulkUserActions(APIView):
if state not in valid_states: if state not in valid_states:
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
# Check if user can set public state
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public": if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
if state == "public": if state == "public":
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
# Update media state
for m in media: for m in media:
m.state = state m.state = state
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True: if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
@@ -488,6 +495,8 @@ class MediaBulkUserActions(APIView):
if ownership_type not in valid_ownership_types: if ownership_type not in valid_ownership_types:
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
# Find users who have the permission on ALL media items (intersection)
media_count = media.count() media_count = media.count()
users = ( users = (
@@ -514,6 +523,7 @@ class MediaBulkUserActions(APIView):
if not usernames: if not usernames:
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
# Get valid users from the provided usernames
users = User.objects.filter(username__in=usernames) users = User.objects.filter(username__in=usernames)
if not users.exists(): if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
@@ -538,17 +548,22 @@ class MediaBulkUserActions(APIView):
if not usernames: if not usernames:
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
# Get valid users from the provided usernames
users = User.objects.filter(username__in=usernames) users = User.objects.filter(username__in=usernames)
if not users.exists(): if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
# Delete MediaPermission objects matching the criteria
MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete() MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
return Response({"detail": "Action succeeded"}) return Response({"detail": "Action succeeded"})
elif action == "playlist_membership": elif action == "playlist_membership":
# Find playlists that contain ALL the selected media (intersection)
media_count = media.count() media_count = media.count()
# Query playlists owned by user that contain these media
results = list( results = list(
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media) Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
.values('id', 'friendly_token', 'title') .values('id', 'friendly_token', 'title')
@@ -559,15 +574,21 @@ class MediaBulkUserActions(APIView):
return Response({'results': results}) return Response({'results': results})
elif action == "category_membership": elif action == "category_membership":
# Find categories that contain ALL the selected media (intersection)
media_count = media.count() media_count = media.count()
# Query categories that contain these media
results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count)) results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
return Response({'results': results}) return Response({'results': results})
elif action == "tag_membership": elif action == "tag_membership":
# Find tags that contain ALL the selected media (intersection)
media_count = media.count() media_count = media.count()
# Query tags that contain these media
results = list(Tag.objects.filter(media__in=media).values('title').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count)) results = list(Tag.objects.filter(media__in=media).values('title').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
return Response({'results': results}) return Response({'results': results})
@@ -584,6 +605,7 @@ class MediaBulkUserActions(APIView):
added_count = 0 added_count = 0
for category in categories: for category in categories:
for m in media: for m in media:
# Add media to category (ManyToMany relationship)
if not m.category.filter(uid=category.uid).exists(): if not m.category.filter(uid=category.uid).exists():
m.category.add(category) m.category.add(category)
added_count += 1 added_count += 1
@@ -602,6 +624,7 @@ class MediaBulkUserActions(APIView):
removed_count = 0 removed_count = 0
for category in categories: for category in categories:
for m in media: for m in media:
# Remove media from category (ManyToMany relationship)
if m.category.filter(uid=category.uid).exists(): if m.category.filter(uid=category.uid).exists():
m.category.remove(category) m.category.remove(category)
removed_count += 1 removed_count += 1
@@ -620,6 +643,7 @@ class MediaBulkUserActions(APIView):
added_count = 0 added_count = 0
for tag in tags: for tag in tags:
for m in media: for m in media:
# Add media to tag (ManyToMany relationship)
if not m.tags.filter(title=tag.title).exists(): if not m.tags.filter(title=tag.title).exists():
m.tags.add(tag) m.tags.add(tag)
added_count += 1 added_count += 1
@@ -638,6 +662,7 @@ class MediaBulkUserActions(APIView):
removed_count = 0 removed_count = 0
for tag in tags: for tag in tags:
for m in media: for m in media:
# Remove media from tag (ManyToMany relationship)
if m.tags.filter(title=tag.title).exists(): if m.tags.filter(title=tag.title).exists():
m.tags.remove(tag) m.tags.remove(tag)
removed_count += 1 removed_count += 1
@@ -804,14 +829,13 @@ class MediaDetail(APIView):
serializer = MediaSerializer(media, data=request.data, context={"request": request}) serializer = MediaSerializer(media, data=request.data, context={"request": request})
if serializer.is_valid(): if serializer.is_valid():
serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'): # if request.data.get('media_file'):
# media_file = request.data["media_file"] # media_file = request.data["media_file"]
# media.state = helpers.get_default_state(request.user)
# media.listable = False
# serializer.save(user=request.user, media_file=media_file) # serializer.save(user=request.user, media_file=media_file)
# else: # else:
# serializer.save(user=request.user) # serializer.save(user=request.user)
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -1,5 +1,4 @@
import json import json
import os
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -19,7 +18,6 @@ from ..forms import (
EditSubtitleForm, EditSubtitleForm,
MediaMetadataForm, MediaMetadataForm,
MediaPublishForm, MediaPublishForm,
ReplaceMediaForm,
SubtitleForm, SubtitleForm,
WhisperSubtitlesForm, WhisperSubtitlesForm,
) )
@@ -365,76 +363,6 @@ def publish_media(request):
) )
@login_required
def replace_media(request):
"""Replace media file"""
if not getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False):
return HttpResponseRedirect("/")
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("/")
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 = ReplaceMediaForm(media, request.POST, request.FILES)
if form.is_valid():
new_media_file = form.cleaned_data.get("new_media_file")
media.encodings.all().delete()
if media.thumbnail:
helpers.rm_file(media.thumbnail.path)
media.thumbnail = None
if media.poster:
helpers.rm_file(media.poster.path)
media.poster = None
if media.uploaded_thumbnail:
helpers.rm_file(media.uploaded_thumbnail.path)
media.uploaded_thumbnail = None
if media.uploaded_poster:
helpers.rm_file(media.uploaded_poster.path)
media.uploaded_poster = None
if media.sprites:
helpers.rm_file(media.sprites.path)
media.sprites = None
if media.preview_file_path:
helpers.rm_file(media.preview_file_path)
media.preview_file_path = ""
if media.hls_file:
hls_dir = os.path.dirname(media.hls_file)
helpers.rm_dir(hls_dir)
media.hls_file = ""
media.media_file = new_media_file
media.listable = False
media.state = helpers.get_default_state(request.user)
media.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media file was replaced successfully"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = ReplaceMediaForm(media)
return render(
request,
"cms/replace_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required @login_required
def edit_chapters(request): def edit_chapters(request):
"""Edit chapters""" """Edit chapters"""

View File

@@ -1,39 +0,0 @@
import json
from django import forms
from django.utils.safestring import mark_safe
class CategoryModalWidget(forms.SelectMultiple):
"""Two-panel category selector with modal"""
class Media:
css = {'all': ('css/category_modal.css',)}
js = ('js/category_modal.js',)
def render(self, name, value, attrs=None, renderer=None):
# Get all categories as JSON
categories = []
for opt_value, opt_label in self.choices:
if opt_value: # Skip empty choice
categories.append({'id': str(opt_value), 'title': str(opt_label)})
all_categories_json = json.dumps(categories)
selected_ids_json = json.dumps([str(v) for v in (value or [])])
html = f'''<div class="category-widget" data-name="{name}">
<div class="category-content">
<div class="category-panel">
<input type="text" class="category-search" placeholder="Search categories...">
<div class="category-list scrollable" data-panel="left"></div>
</div>
<div class="category-panel">
<h3>Selected Categories</h3>
<div class="category-list scrollable" data-panel="right"></div>
</div>
</div>
<div class="hidden-inputs"></div>
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json}}}</script>
</div>'''
return mark_safe(html)

View File

@@ -150,11 +150,6 @@ const App = () => {
canRedo={historyPosition < history.length - 1} canRedo={historyPosition < history.length - 1}
/> />
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Add Chapters</h2>
</div>
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}

View File

@@ -28,9 +28,9 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 20 // Return CSS class based on index modulo 8
// This matches the CSS classes for up to 20 segments // This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 20) + 1}`; return `segment-default-color segment-color-${(index % 8) + 1}`;
}; };
// Get selected segment // Get selected segment
@@ -65,8 +65,8 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
<div className="segment-actions"> <div className="segment-actions">
<button <button
className="delete-button" className="delete-button"
aria-label="Delete Chapter" aria-label="Delete Segment"
data-tooltip="Delete this chapter" data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)} onClick={() => handleDeleteSegment(segment.id)}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -13,7 +13,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined); const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
const [isAudioFile, setIsAudioFile] = useState(false);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -42,13 +41,12 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
setVideoUrl(url); setVideoUrl(url);
// Check if the media is an audio file and set poster image // Check if the media is an audio file and set poster image
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null; const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
setIsAudioFile(audioFile);
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None" // Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || ''; const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== ''; const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined)); setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@@ -130,21 +128,10 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
</span> </span>
</div> </div>
{/* Video container with persistent background for audio files */}
<div className="ios-video-wrapper">
{/* Persistent background image for audio files (Safari fix) */}
{isAudioFile && posterImage && (
<div
className="ios-audio-poster-background"
style={{ backgroundImage: `url(${posterImage})` }}
aria-hidden="true"
/>
)}
{/* iOS-optimized Video Element with Native Controls */} {/* iOS-optimized Video Element with Native Controls */}
<video <video
ref={(ref) => setIosVideoRef(ref)} ref={(ref) => setIosVideoRef(ref)}
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`} className="w-full rounded-md"
src={videoUrl} src={videoUrl}
controls controls
playsInline playsInline
@@ -157,7 +144,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
<source src={videoUrl} type="video/mp4" /> <source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>
</video> </video>
</div>
{/* iOS Video Skip Controls */} {/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4"> <div className="ios-skip-controls mt-3 flex justify-center gap-4">

View File

@@ -177,16 +177,7 @@ const TimelineControls = ({
const [isAutoSaving, setIsAutoSaving] = useState(false); const [isAutoSaving, setIsAutoSaving] = useState(false);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null); const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const clipSegmentsRef = useRef(clipSegments); const clipSegmentsRef = useRef(clipSegments);
// Track when a drag just ended to prevent Safari from triggering clicks after drag
const dragJustEndedRef = useRef<boolean>(false);
const dragEndTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Helper function to detect Safari browser
const isSafari = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
};
// Keep clipSegmentsRef updated // Keep clipSegmentsRef updated
useEffect(() => { useEffect(() => {
@@ -277,8 +268,13 @@ const TimelineControls = ({
// Update editing title when selected segment changes // Update editing title when selected segment changes
useEffect(() => { useEffect(() => {
if (selectedSegment) { if (selectedSegment) {
// Always show the chapter title in the textarea, whether it's default or custom // Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
setEditingChapterTitle(selectedSegment.chapterTitle || ''); const isDefaultChapterName = selectedSegment.chapterTitle &&
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
// If it's a default name, show empty string so placeholder appears
// If it's a custom title, show the actual title
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
} else { } else {
setEditingChapterTitle(''); setEditingChapterTitle('');
} }
@@ -876,12 +872,6 @@ const TimelineControls = ({
logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current); logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
clearTimeout(autoSaveTimerRef.current); clearTimeout(autoSaveTimerRef.current);
} }
// Clear any pending drag end timeout
if (dragEndTimeoutRef.current) {
clearTimeout(dragEndTimeoutRef.current);
dragEndTimeoutRef.current = null;
}
}; };
}, [scheduleAutoSave]); }, [scheduleAutoSave]);
@@ -1099,20 +1089,16 @@ const TimelineControls = ({
}; };
// Helper function to calculate available space for a new segment // Helper function to calculate available space for a new segment
const calculateAvailableSpace = (startTime: number, segmentsOverride?: Segment[]): number => { const calculateAvailableSpace = (startTime: number): number => {
// Always return at least 0.1 seconds to ensure tooltip shows // Always return at least 0.1 seconds to ensure tooltip shows
const MIN_SPACE = 0.1; const MIN_SPACE = 0.1;
// Use override segments if provided, otherwise use ref to get latest segments
// This ensures we always have the most up-to-date segments, especially important for Safari
const segmentsToUse = segmentsOverride || clipSegmentsRef.current;
// Determine the amount of available space: // Determine the amount of available space:
// 1. Check remaining space until the end of video // 1. Check remaining space until the end of video
const remainingDuration = Math.max(0, duration - startTime); const remainingDuration = Math.max(0, duration - startTime);
// 2. Find the next segment (if any) // 2. Find the next segment (if any)
const sortedSegments = [...segmentsToUse].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Find the next and previous segments // Find the next and previous segments
const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime); const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime);
@@ -1128,6 +1114,14 @@ const TimelineControls = ({
availableSpace = duration - startTime; availableSpace = duration - startTime;
} }
// Log the space calculation for debugging
logger.debug('Space calculation:', {
position: formatDetailedTime(startTime),
nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)),
});
// Always return at least MIN_SPACE to ensure tooltip shows // Always return at least MIN_SPACE to ensure tooltip shows
return Math.max(MIN_SPACE, availableSpace); return Math.max(MIN_SPACE, availableSpace);
}; };
@@ -1136,11 +1130,8 @@ const TimelineControls = ({
const updateTooltipForPosition = (currentPosition: number) => { const updateTooltipForPosition = (currentPosition: number) => {
if (!timelineRef.current) return; if (!timelineRef.current) return;
// Use ref to get latest segments to avoid stale state issues
const currentSegments = clipSegmentsRef.current;
// Find if we're in a segment at the current position with a small tolerance // Find if we're in a segment at the current position with a small tolerance
const segmentAtPosition = currentSegments.find((seg) => { const segmentAtPosition = clipSegments.find((seg) => {
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime; const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001; const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001;
const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001; const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001;
@@ -1148,7 +1139,7 @@ const TimelineControls = ({
}); });
// Find the next and previous segments // Find the next and previous segments
const sortedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition); const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition); const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition);
@@ -1158,13 +1149,21 @@ const TimelineControls = ({
setShowEmptySpaceTooltip(false); setShowEmptySpaceTooltip(false);
} else { } else {
// We're in a cutaway area // We're in a cutaway area
// Calculate available space for new segment using current segments // Calculate available space for new segment
const availableSpace = calculateAvailableSpace(currentPosition, currentSegments); const availableSpace = calculateAvailableSpace(currentPosition);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// Always show empty space tooltip // Always show empty space tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Log position info for debugging
logger.debug('Cutaway position:', {
current: formatDetailedTime(currentPosition),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
availableSpace: formatDetailedTime(availableSpace),
});
} }
// Update tooltip position // Update tooltip position
@@ -1194,12 +1193,6 @@ const TimelineControls = ({
if (!timelineRef.current || !scrollContainerRef.current) return; if (!timelineRef.current || !scrollContainerRef.current) return;
// Safari-specific fix: Ignore clicks that happen immediately after a drag operation
// Safari fires click events after drag ends, which can cause issues with stale state
if (isSafari() && dragJustEndedRef.current) {
return;
}
// If on mobile device and video hasn't been initialized, don't handle timeline clicks // If on mobile device and video hasn't been initialized, don't handle timeline clicks
if (isIOSUninitialized) { if (isIOSUninitialized) {
return; return;
@@ -1207,6 +1200,7 @@ const TimelineControls = ({
// Check if video is globally playing before the click // Check if video is globally playing before the click
const wasPlaying = videoRef.current && !videoRef.current.paused; const wasPlaying = videoRef.current && !videoRef.current.paused;
logger.debug('Video was playing before timeline click:', wasPlaying);
// Reset continuation flag when clicking on timeline - ensures proper boundary detection // Reset continuation flag when clicking on timeline - ensures proper boundary detection
setContinuePastBoundary(false); setContinuePastBoundary(false);
@@ -1227,6 +1221,14 @@ const TimelineControls = ({
const newTime = position * duration; const newTime = position * duration;
// Log the position for debugging
logger.debug(
'Timeline clicked at:',
formatDetailedTime(newTime),
'distance from end:',
formatDetailedTime(duration - newTime)
);
// Store position globally for iOS Safari (this is critical for first-time visits) // Store position globally for iOS Safari (this is critical for first-time visits)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.lastSeekedPosition = newTime; window.lastSeekedPosition = newTime;
@@ -1239,12 +1241,8 @@ const TimelineControls = ({
setClickedTime(newTime); setClickedTime(newTime);
setDisplayTime(newTime); setDisplayTime(newTime);
// Use ref to get latest segments to avoid stale state issues, especially in Safari
// Safari can fire click events immediately after drag before React re-renders
const currentSegments = clipSegmentsRef.current;
// Find if we clicked in a segment with a small tolerance for boundaries // Find if we clicked in a segment with a small tolerance for boundaries
const segmentAtClickedTime = currentSegments.find((seg) => { const segmentAtClickedTime = clipSegments.find((seg) => {
// Standard check for being inside a segment // Standard check for being inside a segment
const isInside = newTime >= seg.startTime && newTime <= seg.endTime; const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
// Additional checks for being exactly at the start or end boundary (with small tolerance) // Additional checks for being exactly at the start or end boundary (with small tolerance)
@@ -1265,7 +1263,7 @@ const TimelineControls = ({
if (isPlayingSegments && wasPlaying) { if (isPlayingSegments && wasPlaying) {
// Update the current segment index if we clicked into a segment // Update the current segment index if we clicked into a segment
if (segmentAtClickedTime) { if (segmentAtClickedTime) {
const orderedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime); const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id); const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
if (targetSegmentIndex !== -1) { if (targetSegmentIndex !== -1) {
@@ -1318,9 +1316,8 @@ const TimelineControls = ({
// We're in a cutaway area - always show tooltip // We're in a cutaway area - always show tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
// Calculate the available space for a new segment using current segments from ref // Calculate the available space for a new segment
// This ensures we use the latest segments even if React hasn't re-rendered yet const availableSpace = calculateAvailableSpace(newTime);
const availableSpace = calculateAvailableSpace(newTime, currentSegments);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// Calculate and set tooltip position correctly for zoomed timeline // Calculate and set tooltip position correctly for zoomed timeline
@@ -1342,6 +1339,18 @@ const TimelineControls = ({
// Always show the empty space tooltip in cutaway areas // Always show the empty space tooltip in cutaway areas
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Log the cutaway area details
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime);
logger.debug('Clicked in cutaway area:', {
position: formatDetailedTime(newTime),
availableSpace: formatDetailedTime(availableSpace),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
});
} }
} }
}; };
@@ -1494,10 +1503,6 @@ const TimelineControls = ({
return seg; return seg;
}); });
// Update the ref immediately during drag to ensure we always have latest segments
// This is critical for Safari which may fire events before React re-renders
clipSegmentsRef.current = updatedSegments;
// Create a custom event to update the segments WITHOUT recording in history during drag // Create a custom event to update the segments WITHOUT recording in history during drag
const updateEvent = new CustomEvent('update-segments', { const updateEvent = new CustomEvent('update-segments', {
detail: { detail: {
@@ -1582,26 +1587,6 @@ const TimelineControls = ({
return seg; return seg;
}); });
// CRITICAL: Update the ref immediately with the new segments
// This ensures that if Safari fires a click event before React re-renders,
// the click handler will use the updated segments instead of stale ones
clipSegmentsRef.current = finalSegments;
// Safari-specific fix: Set flag to ignore clicks immediately after drag
// Safari fires click events after drag ends, which can interfere with state updates
if (isSafari()) {
dragJustEndedRef.current = true;
// Clear the flag after a delay to allow React to re-render with updated segments
// Increased timeout to ensure state has propagated
if (dragEndTimeoutRef.current) {
clearTimeout(dragEndTimeoutRef.current);
}
dragEndTimeoutRef.current = setTimeout(() => {
dragJustEndedRef.current = false;
dragEndTimeoutRef.current = null;
}, 200); // 200ms to ensure React has processed the state update and re-rendered
}
// Now we can create a history record for the complete drag operation // Now we can create a history record for the complete drag operation
const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end'; const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
document.dispatchEvent( document.dispatchEvent(
@@ -1614,13 +1599,6 @@ const TimelineControls = ({
}) })
); );
// Dispatch segment-drag-end event for other listeners
document.dispatchEvent(
new CustomEvent('segment-drag-end', {
detail: { segmentId },
})
);
// After drag is complete, do a final check to see if playhead is inside the segment // After drag is complete, do a final check to see if playhead is inside the segment
if (selectedSegmentId === segmentId && videoRef.current) { if (selectedSegmentId === segmentId && videoRef.current) {
const currentTime = videoRef.current.currentTime; const currentTime = videoRef.current.currentTime;
@@ -3970,7 +3948,9 @@ const TimelineControls = ({
<button <button
onClick={() => setShowSaveChaptersModal(true)} onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button" className="save-chapters-button"
{...(clipSegments.length === 0 && { 'data-tooltip': 'Clear all chapters' })} data-tooltip={clipSegments.length === 0
? "Clear all chapters"
: "Save chapters"}
> >
{clipSegments.length === 0 {clipSegments.length === 0
? 'Clear Chapters' ? 'Clear Chapters'

View File

@@ -353,18 +353,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
return ( return (
<div className="video-player-container"> <div className="video-player-container">
{/* Persistent background image for audio files (Safari fix) */}
{isAudioFile && posterImage && (
<div
className="audio-poster-background"
style={{ backgroundImage: `url(${posterImage})` }}
aria-hidden="true"
/>
)}
<video <video
ref={videoRef} ref={videoRef}
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
preload="metadata" preload="metadata"
crossOrigin="anonymous" crossOrigin="anonymous"
onClick={handleVideoClick} onClick={handleVideoClick}

View File

@@ -20,7 +20,7 @@ const useVideoChapters = () => {
// Sort by start time to find chronological position // Sort by start time to find chronological position
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime); const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
// Find the index of our new segment // Find the index of our new segment
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime); const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
return `Chapter ${chapterIndex + 1}`; return `Chapter ${chapterIndex + 1}`;
}; };
@@ -30,16 +30,10 @@ const useVideoChapters = () => {
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Renumber each segment based on its chronological position // Renumber each segment based on its chronological position
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles return sortedSegments.map((segment, index) => ({
return sortedSegments.map((segment, index) => {
const currentTitle = segment.chapterTitle || '';
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
return {
...segment, ...segment,
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle, chapterTitle: `Chapter ${index + 1}`
}; }));
});
}; };
// Helper function to parse time string (HH:MM:SS.mmm) to seconds // Helper function to parse time string (HH:MM:SS.mmm) to seconds
@@ -61,9 +55,6 @@ const useVideoChapters = () => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
// Track if editor has been initialized to prevent re-initialization on Safari metadata events
const isInitializedRef = useRef<boolean>(false);
// Timeline state // Timeline state
const [trimStart, setTrimStart] = useState(0); const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0); const [trimEnd, setTrimEnd] = useState(0);
@@ -111,7 +102,11 @@ const useVideoChapters = () => {
// Detect Safari browser // Detect Safari browser
const isSafari = () => { const isSafari = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent); const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
if (isSafariBrowser) {
logger.debug('Safari browser detected, enabling audio support fallbacks');
}
return isSafariBrowser;
}; };
// Initialize video event listeners // Initialize video event listeners
@@ -120,15 +115,7 @@ const useVideoChapters = () => {
if (!video) return; if (!video) return;
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
// CRITICAL: Prevent re-initialization if editor has already been initialized logger.debug('Video loadedmetadata event fired, duration:', video.duration);
// Safari fires loadedmetadata multiple times, which was resetting segments
if (isInitializedRef.current) {
// Still update duration and trimEnd in case they changed
setDuration(video.duration);
setTrimEnd(video.duration);
return;
}
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
@@ -137,7 +124,9 @@ const useVideoChapters = () => {
let initialSegments: Segment[] = []; let initialSegments: Segment[] = [];
// Check if we have existing chapters from the backend // Check if we have existing chapters from the backend
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || []; const existingChapters =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
[];
if (existingChapters.length > 0) { if (existingChapters.length > 0) {
// Create segments from existing chapters // Create segments from existing chapters
@@ -161,7 +150,7 @@ const useVideoChapters = () => {
// Create a default segment that spans the entire video on first load // Create a default segment that spans the entire video on first load
const initialSegment: Segment = { const initialSegment: Segment = {
id: 1, id: 1,
chapterTitle: 'Chapter 1', chapterTitle: '',
startTime: 0, startTime: 0,
endTime: video.duration, endTime: video.duration,
}; };
@@ -180,7 +169,7 @@ const useVideoChapters = () => {
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
setClipSegments(initialSegments); setClipSegments(initialSegments);
isInitializedRef.current = true; // Mark as initialized logger.debug('Editor initialized with segments:', initialSegments.length);
}; };
initializeEditor(); initializeEditor();
@@ -188,18 +177,20 @@ const useVideoChapters = () => {
// Safari-specific fallback for audio files // Safari-specific fallback for audio files
const handleCanPlay = () => { const handleCanPlay = () => {
logger.debug('Video canplay event fired');
// If loadedmetadata hasn't fired yet but we have duration, trigger initialization // If loadedmetadata hasn't fired yet but we have duration, trigger initialization
// Also check if already initialized to prevent re-initialization if (video.duration && duration === 0) {
if (video.duration && duration === 0 && !isInitializedRef.current) { logger.debug('Safari fallback: Using canplay event to initialize');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
// Additional Safari fallback for audio files // Additional Safari fallback for audio files
const handleLoadedData = () => { const handleLoadedData = () => {
logger.debug('Video loadeddata event fired');
// If we still don't have duration, try again // If we still don't have duration, try again
// Also check if already initialized to prevent re-initialization if (video.duration && duration === 0) {
if (video.duration && duration === 0 && !isInitializedRef.current) { logger.debug('Safari fallback: Using loadeddata event to initialize');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
@@ -231,12 +222,14 @@ const useVideoChapters = () => {
// Safari-specific fallback event listeners for audio files // Safari-specific fallback event listeners for audio files
if (isSafari()) { if (isSafari()) {
logger.debug('Adding Safari-specific event listeners for audio support');
video.addEventListener('canplay', handleCanPlay); video.addEventListener('canplay', handleCanPlay);
video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('loadeddata', handleLoadedData);
// Additional timeout fallback for Safari audio files // Additional timeout fallback for Safari audio files
const safariTimeout = setTimeout(() => { const safariTimeout = setTimeout(() => {
if (video.duration && duration === 0 && !isInitializedRef.current) { if (video.duration && duration === 0) {
logger.debug('Safari timeout fallback: Force initializing editor');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}, 1000); }, 1000);
@@ -571,11 +564,8 @@ const useVideoChapters = () => {
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}` `Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
); );
// Renumber all segments to ensure proper chronological naming
const renumberedSegments = renumberAllSegments(e.detail.segments);
// Update segment state immediately for UI feedback // Update segment state immediately for UI feedback
setClipSegments(renumberedSegments); setClipSegments(e.detail.segments);
// Always save state to history for non-intermediate actions // Always save state to history for non-intermediate actions
if (isSignificantChange) { if (isSignificantChange) {
@@ -583,7 +573,7 @@ const useVideoChapters = () => {
// ensure we capture the state properly // ensure we capture the state properly
setTimeout(() => { setTimeout(() => {
// Deep clone to ensure state is captured correctly // Deep clone to ensure state is captured correctly
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments)); const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
// Create a complete state snapshot // Create a complete state snapshot
const stateWithAction: EditorState = { const stateWithAction: EditorState = {

View File

@@ -82,24 +82,27 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--foreground, #333); color: var(--foreground, #333);
margin-bottom: 0.75rem; margin: 0;
} }
.save-chapters-button { .save-chapters-button {
color: #ffffff; display: flex;
background: #059669; align-items: center;
border-radius: 0.25rem; gap: 0.5rem;
font-size: 0.75rem; padding: 0.5rem 1rem;
padding: 0.25rem 0.5rem; background-color: #3b82f6;
cursor: pointer; color: white;
border: none; border: none;
white-space: nowrap; border-radius: 0.375rem;
transition: background-color 0.2s; font-size: 0.875rem;
min-width: fit-content; font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover { &:hover {
background-color: #059669; background-color: #2563eb;
box-shadow: 0 4px 6px -1px rgba(5, 150, 105, 0.3); transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }
&.has-changes { &.has-changes {
@@ -202,9 +205,9 @@
} }
&.selected { &.selected {
border-color: #059669; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background-color: rgba(5, 150, 105, 0.05); background-color: rgba(59, 130, 246, 0.05);
} }
} }
@@ -284,68 +287,29 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
/* Generate 20 shades of #059669 (rgb(5, 150, 105)) */
/* Base color: #059669 = rgb(5, 150, 105) */
/* Creating variations from lighter to darker */
.segment-color-1 { .segment-color-1 {
background-color: rgba(167, 243, 208, 0.2); background-color: rgba(59, 130, 246, 0.15);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(134, 239, 172, 0.2); background-color: rgba(16, 185, 129, 0.15);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(101, 235, 136, 0.2); background-color: rgba(245, 158, 11, 0.15);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(68, 231, 100, 0.2); background-color: rgba(239, 68, 68, 0.15);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(35, 227, 64, 0.2); background-color: rgba(139, 92, 246, 0.15);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(20, 207, 54, 0.2); background-color: rgba(236, 72, 153, 0.15);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(15, 187, 48, 0.2); background-color: rgba(6, 182, 212, 0.15);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(10, 167, 42, 0.2); background-color: rgba(250, 204, 21, 0.15);
}
.segment-color-9 {
background-color: rgba(5, 150, 105, 0.2);
}
.segment-color-10 {
background-color: rgba(4, 135, 95, 0.2);
}
.segment-color-11 {
background-color: rgba(3, 120, 85, 0.2);
}
.segment-color-12 {
background-color: rgba(2, 105, 75, 0.2);
}
.segment-color-13 {
background-color: rgba(2, 90, 65, 0.2);
}
.segment-color-14 {
background-color: rgba(1, 75, 55, 0.2);
}
.segment-color-15 {
background-color: rgba(1, 66, 48, 0.2);
}
.segment-color-16 {
background-color: rgba(1, 57, 41, 0.2);
}
.segment-color-17 {
background-color: rgba(1, 48, 34, 0.2);
}
.segment-color-18 {
background-color: rgba(0, 39, 27, 0.2);
}
.segment-color-19 {
background-color: rgba(0, 30, 20, 0.2);
}
.segment-color-20 {
background-color: rgba(0, 21, 13, 0.2);
} }
/* Responsive styles */ /* Responsive styles */

View File

@@ -31,7 +31,7 @@
.ios-notification-icon { .ios-notification-icon {
flex-shrink: 0; flex-shrink: 0;
color: #059669; color: #0066cc;
margin-right: 15px; margin-right: 15px;
margin-top: 3px; margin-top: 3px;
} }
@@ -96,7 +96,7 @@
} }
.ios-desktop-mode-btn { .ios-desktop-mode-btn {
background-color: #059669; background-color: #0066cc;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;

View File

@@ -8,40 +8,12 @@
overflow: hidden; overflow: hidden;
} }
/* Video wrapper for positioning background */
.ios-video-wrapper {
position: relative;
width: 100%;
background-color: black;
border-radius: 0.5rem;
overflow: hidden;
}
/* Persistent background poster for audio files (Safari fix) */
.ios-audio-poster-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
z-index: -1;
pointer-events: none;
}
.ios-video-player-container video { .ios-video-player-container video {
position: relative;
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 360px; max-height: 360px;
aspect-ratio: 16/9; aspect-ratio: 16/9;
} background-color: black;
/* Make video transparent only for audio files with poster so background shows through */
.ios-video-player-container video.audio-with-poster {
background-color: transparent;
} }
.ios-time-display { .ios-time-display {

View File

@@ -92,12 +92,12 @@
} }
.modal-button-primary { .modal-button-primary {
background-color: #059669; background-color: #0066cc;
color: white; color: white;
} }
.modal-button-primary:hover { .modal-button-primary:hover {
background-color: #059669; background-color: #0055aa;
} }
.modal-button-secondary { .modal-button-secondary {
@@ -138,7 +138,7 @@
.spinner { .spinner {
border: 4px solid rgba(0, 0, 0, 0.1); border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%; border-radius: 50%;
border-top: 4px solid #059669; border-top: 4px solid #0066cc;
width: 30px; width: 30px;
height: 30px; height: 30px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@@ -224,7 +224,7 @@
padding: 12px 16px; padding: 12px 16px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
background-color: #059669; background-color: #0066cc;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@@ -258,12 +258,12 @@
margin: 0 auto; margin: 0 auto;
width: auto; width: auto;
min-width: 220px; min-width: 220px;
background-color: #059669; background-color: #0066cc;
color: white; color: white;
} }
.centered-choice:hover { .centered-choice:hover {
background-color: #059669; background-color: #0055aa;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@@ -300,7 +300,7 @@
.countdown { .countdown {
font-weight: bold; font-weight: bold;
color: #059669; color: #0066cc;
font-size: 1.1rem; font-size: 1.1rem;
} }
} }

View File

@@ -1,16 +1,4 @@
#chapters-editor-root { #chapters-editor-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #059669;
margin: 0;
}
.timeline-container-card { .timeline-container-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -23,8 +11,6 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(16, 185, 129, 0.2);
} }
.timeline-title { .timeline-title {
@@ -34,7 +20,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-size: 0.875rem; font-weight: 700;
} }
.current-time { .current-time {
@@ -62,11 +48,10 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #e2ede4; background-color: #fafbfc;
height: 70px; height: 70px;
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: visible !important; overflow: visible !important;
border: 1px solid rgba(16, 185, 129, 0.2);
} }
.timeline-marker { .timeline-marker {
@@ -209,7 +194,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(16, 185, 129, 0.6); background-color: rgba(0, 0, 0, 0.4);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -217,15 +202,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(16, 185, 129, 0.7); background-color: rgba(0, 0, 0, 0.5);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(5, 150, 105, 0.8); background-color: rgba(59, 130, 246, 0.5);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(5, 150, 105, 0.75); background-color: rgba(59, 130, 246, 0.4);
} }
.clip-segment-name { .clip-segment-name {
@@ -555,7 +540,7 @@
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
color: #ffffff; color: #ffffff;
background: #059669; background: #0066cc;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@@ -728,7 +713,7 @@
height: 50px; height: 50px;
border: 5px solid rgba(0, 0, 0, 0.1); border: 5px solid rgba(0, 0, 0, 0.1);
border-radius: 50%; border-radius: 50%;
border-top-color: #059669; border-top-color: #0066cc;
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
} }
@@ -768,7 +753,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
background-color: #059669; background-color: #0066cc;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
@@ -781,7 +766,7 @@
} }
.modal-choice-button:hover { .modal-choice-button:hover {
background-color:rgb(7, 119, 84); background-color: #0056b3;
} }
.modal-choice-button svg { .modal-choice-button svg {
@@ -956,6 +941,7 @@
.save-chapters-button:hover { .save-chapters-button:hover {
background-color: #2563eb; background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }

View File

@@ -76,26 +76,10 @@
user-select: none; user-select: none;
} }
/* Persistent background poster for audio files (Safari fix) */
.audio-poster-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
z-index: 1;
pointer-events: none;
}
.video-player-container video { .video-player-container video {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
z-index: 2;
/* Force hardware acceleration */ /* Force hardware acceleration */
transform: translateZ(0); transform: translateZ(0);
-webkit-transform: translateZ(0); -webkit-transform: translateZ(0);
@@ -104,11 +88,6 @@
user-select: none; user-select: none;
} }
/* Make video transparent only for audio files with poster so background shows through */
.video-player-container video.audio-with-poster {
background: transparent;
}
/* iOS-specific styles */ /* iOS-specific styles */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.video-player-container video { .video-player-container video {
@@ -130,7 +109,6 @@
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none; pointer-events: none;
z-index: 3;
} }
.video-player-container:hover .play-pause-indicator { .video-player-container:hover .play-pause-indicator {
@@ -209,7 +187,6 @@
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
z-index: 3;
} }
.video-player-container:hover .video-controls { .video-player-container:hover .video-controls {

View File

@@ -309,11 +309,6 @@ const App = () => {
canRedo={historyPosition < history.length - 1} canRedo={historyPosition < history.length - 1}
/> />
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Trim or Split</h2>
</div>
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}

View File

@@ -28,9 +28,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 20 // Return CSS class based on index modulo 8
// This matches the CSS classes for up to 20 segments // This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 20) + 1}`; return `segment-default-color segment-color-${(index % 8) + 1}`;
}; };
return ( return (

View File

@@ -13,7 +13,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined); const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
const [isAudioFile, setIsAudioFile] = useState(false);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -42,13 +41,12 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
setVideoUrl(url); setVideoUrl(url);
// Check if the media is an audio file and set poster image // Check if the media is an audio file and set poster image
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null; const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
setIsAudioFile(audioFile);
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None" // Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || ''; const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== ''; const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined)); setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@@ -130,21 +128,10 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
</span> </span>
</div> </div>
{/* Video container with persistent background for audio files */}
<div className="ios-video-wrapper">
{/* Persistent background image for audio files (Safari fix) */}
{isAudioFile && posterImage && (
<div
className="ios-audio-poster-background"
style={{ backgroundImage: `url(${posterImage})` }}
aria-hidden="true"
/>
)}
{/* iOS-optimized Video Element with Native Controls */} {/* iOS-optimized Video Element with Native Controls */}
<video <video
ref={(ref) => setIosVideoRef(ref)} ref={(ref) => setIosVideoRef(ref)}
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`} className="w-full rounded-md"
src={videoUrl} src={videoUrl}
controls controls
playsInline playsInline
@@ -157,7 +144,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
<source src={videoUrl} type="video/mp4" /> <source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>
</video> </video>
</div>
{/* iOS Video Skip Controls */} {/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4"> <div className="ios-skip-controls mt-3 flex justify-center gap-4">

View File

@@ -353,18 +353,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
return ( return (
<div className="video-player-container"> <div className="video-player-container">
{/* Persistent background image for audio files (Safari fix) */}
{isAudioFile && posterImage && (
<div
className="audio-poster-background"
style={{ backgroundImage: `url(${posterImage})` }}
aria-hidden="true"
/>
)}
<video <video
ref={videoRef} ref={videoRef}
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
preload="metadata" preload="metadata"
crossOrigin="anonymous" crossOrigin="anonymous"
onClick={handleVideoClick} onClick={handleVideoClick}

View File

@@ -99,7 +99,6 @@
} }
.segment-thumbnail { .segment-thumbnail {
display: none;
width: 4rem; width: 4rem;
height: 2.25rem; height: 2.25rem;
background-size: cover; background-size: cover;
@@ -130,7 +129,7 @@
margin-top: 0.25rem; margin-top: 0.25rem;
display: inline-block; display: inline-block;
background-color: #f3f4f6; background-color: #f3f4f6;
padding: 0; padding: 0 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
color: black; color: black;
} }
@@ -170,67 +169,28 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
/* Generate 20 shades of #2563eb (rgb(37, 99, 235)) */
/* Base color: #2563eb = rgb(37, 99, 235) */
/* Creating variations from lighter to darker */
.segment-color-1 { .segment-color-1 {
background-color: rgba(147, 179, 247, 0.2); background-color: rgba(59, 130, 246, 0.15);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(129, 161, 243, 0.2); background-color: rgba(16, 185, 129, 0.15);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(111, 143, 239, 0.2); background-color: rgba(245, 158, 11, 0.15);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(93, 125, 237, 0.2); background-color: rgba(239, 68, 68, 0.15);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(75, 107, 235, 0.2); background-color: rgba(139, 92, 246, 0.15);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(65, 99, 235, 0.2); background-color: rgba(236, 72, 153, 0.15);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(55, 91, 235, 0.2); background-color: rgba(6, 182, 212, 0.15);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(45, 83, 235, 0.2); background-color: rgba(250, 204, 21, 0.15);
}
.segment-color-9 {
background-color: rgba(37, 99, 235, 0.2);
}
.segment-color-10 {
background-color: rgba(33, 89, 215, 0.2);
}
.segment-color-11 {
background-color: rgba(29, 79, 195, 0.2);
}
.segment-color-12 {
background-color: rgba(25, 69, 175, 0.2);
}
.segment-color-13 {
background-color: rgba(21, 59, 155, 0.2);
}
.segment-color-14 {
background-color: rgba(17, 49, 135, 0.2);
}
.segment-color-15 {
background-color: rgba(15, 43, 119, 0.2);
}
.segment-color-16 {
background-color: rgba(13, 37, 103, 0.2);
}
.segment-color-17 {
background-color: rgba(11, 31, 87, 0.2);
}
.segment-color-18 {
background-color: rgba(9, 25, 71, 0.2);
}
.segment-color-19 {
background-color: rgba(7, 19, 55, 0.2);
}
.segment-color-20 {
background-color: rgba(5, 13, 39, 0.2);
} }
} }

View File

@@ -8,40 +8,12 @@
overflow: hidden; overflow: hidden;
} }
/* Video wrapper for positioning background */
.ios-video-wrapper {
position: relative;
width: 100%;
background-color: black;
border-radius: 0.5rem;
overflow: hidden;
}
/* Persistent background poster for audio files (Safari fix) */
.ios-audio-poster-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
z-index: -1;
pointer-events: none;
}
.ios-video-player-container video { .ios-video-player-container video {
position: relative;
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 360px; max-height: 360px;
aspect-ratio: 16/9; aspect-ratio: 16/9;
} background-color: black;
/* Make video transparent only for audio files with poster so background shows through */
.ios-video-player-container video.audio-with-poster {
background-color: transparent;
} }
.ios-time-display { .ios-time-display {

View File

@@ -1,16 +1,4 @@
#video-editor-trim-root { #video-editor-trim-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #2563eb;
margin: 0;
}
.timeline-container-card { .timeline-container-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -23,8 +11,6 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(59, 130, 246, 0.2);
} }
.timeline-title { .timeline-title {
@@ -34,7 +20,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-size: 0.875rem; font-weight: 700;
} }
.current-time { .current-time {
@@ -62,11 +48,10 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #eff6ff; background-color: #fafbfc;
height: 70px; height: 70px;
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: visible !important; overflow: visible !important;
border: 1px solid rgba(59, 130, 246, 0.2);
} }
.timeline-marker { .timeline-marker {
@@ -209,7 +194,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(59, 130, 246, 0.6); background-color: rgba(0, 0, 0, 0.4);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -217,15 +202,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.7); background-color: rgba(0, 0, 0, 0.5);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(37, 99, 235, 0.8); background-color: rgba(59, 130, 246, 0.5);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(37, 99, 235, 0.75); background-color: rgba(59, 130, 246, 0.4);
} }
.clip-segment-name { .clip-segment-name {

View File

@@ -76,26 +76,10 @@
user-select: none; user-select: none;
} }
/* Persistent background poster for audio files (Safari fix) */
.audio-poster-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
z-index: 1;
pointer-events: none;
}
.video-player-container video { .video-player-container video {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
z-index: 2;
/* Force hardware acceleration */ /* Force hardware acceleration */
transform: translateZ(0); transform: translateZ(0);
-webkit-transform: translateZ(0); -webkit-transform: translateZ(0);
@@ -104,11 +88,6 @@
user-select: none; user-select: none;
} }
/* Make video transparent only for audio files with poster so background shows through */
.video-player-container video.audio-with-poster {
background: transparent;
}
/* iOS-specific styles */ /* iOS-specific styles */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.video-player-container video { .video-player-container video {
@@ -130,7 +109,6 @@
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none; pointer-events: none;
z-index: 3;
} }
.video-player-container:hover .play-pause-indicator { .video-player-container:hover .play-pause-indicator {
@@ -209,7 +187,6 @@
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
z-index: 3;
} }
.video-player-container:hover .video-controls { .video-player-container:hover .video-controls {

View File

@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="en" style="height: 100%">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded Video - Full Screen</title>
<style>
body {
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
overflow: hidden;
}
</style>
</head>
<body>
<iframe
src="https://demo.mediacms.io/embed?m=zK2nirNLC"
style="
width: 100%;
max-width: calc(100vh * 16 / 9);
aspect-ratio: 16 / 9;
display: block;
margin: auto;
border: 0;
"
allowfullscreen
></iframe>
</body>
</html>

View File

@@ -20,7 +20,6 @@ class CustomChaptersOverlay extends Component {
this.touchStartTime = 0; this.touchStartTime = 0;
this.touchThreshold = 150; // ms for tap vs scroll detection this.touchThreshold = 150; // ms for tap vs scroll detection
this.isSmallScreen = window.innerWidth <= 480; this.isSmallScreen = window.innerWidth <= 480;
this.scrollY = 0; // Track scroll position before locking
// Bind methods // Bind methods
this.createOverlay = this.createOverlay.bind(this); this.createOverlay = this.createOverlay.bind(this);
@@ -32,8 +31,6 @@ class CustomChaptersOverlay extends Component {
this.handleMobileInteraction = this.handleMobileInteraction.bind(this); this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
this.setupResizeListener = this.setupResizeListener.bind(this); this.setupResizeListener = this.setupResizeListener.bind(this);
this.handleResize = this.handleResize.bind(this); this.handleResize = this.handleResize.bind(this);
this.lockBodyScroll = this.lockBodyScroll.bind(this);
this.unlockBodyScroll = this.unlockBodyScroll.bind(this);
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
@@ -68,9 +65,6 @@ class CustomChaptersOverlay extends Component {
const el = this.player().el(); const el = this.player().el();
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
} }
setupResizeListener() { setupResizeListener() {
@@ -170,8 +164,6 @@ class CustomChaptersOverlay extends Component {
this.overlay.style.display = 'none'; this.overlay.style.display = 'none';
const el = this.player().el(); const el = this.player().el();
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
}; };
chapterClose.appendChild(closeBtn); chapterClose.appendChild(closeBtn);
playlistTitle.appendChild(chapterClose); playlistTitle.appendChild(chapterClose);
@@ -363,37 +355,6 @@ class CustomChaptersOverlay extends Component {
} }
} }
lockBodyScroll() {
if (!this.isMobile) return;
// Save current scroll position
this.scrollY = window.scrollY || window.pageYOffset;
// Lock body scroll with proper iOS handling
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${this.scrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
document.body.style.width = '100%';
}
unlockBodyScroll() {
if (!this.isMobile) return;
// Restore body scroll
const scrollY = this.scrollY;
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
document.body.style.width = '';
// Restore scroll position
window.scrollTo(0, scrollY);
}
toggleOverlay() { toggleOverlay() {
if (!this.overlay) return; if (!this.overlay) return;
@@ -408,11 +369,17 @@ class CustomChaptersOverlay extends Component {
navigator.vibrate(30); navigator.vibrate(30);
} }
// Lock/unlock body scroll on mobile when overlay opens/closes // Prevent body scroll on mobile when overlay is open
if (this.isMobile) {
if (isHidden) { if (isHidden) {
this.lockBodyScroll(); document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
} else { } else {
this.unlockBodyScroll(); document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} }
try { try {
@@ -423,9 +390,7 @@ class CustomChaptersOverlay extends Component {
m.classList.remove('vjs-lock-showing'); m.classList.remove('vjs-lock-showing');
m.style.display = 'none'; m.style.display = 'none';
}); });
} catch { } catch (e) {}
// Ignore errors when closing menus
}
} }
updateCurrentChapter() { updateCurrentChapter() {
@@ -441,6 +406,7 @@ class CustomChaptersOverlay extends Component {
currentTime >= chapter.startTime && currentTime >= chapter.startTime &&
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime); (index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime);
const handle = item.querySelector('.playlist-drag-handle');
const dynamic = item.querySelector('.meta-dynamic'); const dynamic = item.querySelector('.meta-dynamic');
if (isPlaying) { if (isPlaying) {
currentChapterIndex = index; currentChapterIndex = index;
@@ -497,7 +463,11 @@ class CustomChaptersOverlay extends Component {
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile // Restore body scroll on mobile
this.unlockBodyScroll(); if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} }
} }
@@ -509,7 +479,11 @@ class CustomChaptersOverlay extends Component {
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when disposing // Restore body scroll on mobile when disposing
this.unlockBodyScroll(); if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
// Clean up event listeners // Clean up event listeners
if (this.handleResize) { if (this.handleResize) {

View File

@@ -25,7 +25,6 @@ class CustomSettingsMenu extends Component {
this.isMobile = this.detectMobile(); this.isMobile = this.detectMobile();
this.isSmallScreen = window.innerWidth <= 480; this.isSmallScreen = window.innerWidth <= 480;
this.touchThreshold = 150; // ms for tap vs scroll detection this.touchThreshold = 150; // ms for tap vs scroll detection
this.scrollY = 0; // Track scroll position before locking
// Bind methods // Bind methods
this.createSettingsButton = this.createSettingsButton.bind(this); this.createSettingsButton = this.createSettingsButton.bind(this);
@@ -42,8 +41,6 @@ class CustomSettingsMenu extends Component {
this.detectMobile = this.detectMobile.bind(this); this.detectMobile = this.detectMobile.bind(this);
this.handleMobileInteraction = this.handleMobileInteraction.bind(this); this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
this.setupResizeListener = this.setupResizeListener.bind(this); this.setupResizeListener = this.setupResizeListener.bind(this);
this.lockBodyScroll = this.lockBodyScroll.bind(this);
this.unlockBodyScroll = this.unlockBodyScroll.bind(this);
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
@@ -659,8 +656,6 @@ class CustomSettingsMenu extends Component {
if (btnEl) { if (btnEl) {
btnEl.classList.remove('settings-clicked'); btnEl.classList.remove('settings-clicked');
} }
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
}; };
closeButton.addEventListener('click', closeFunction); closeButton.addEventListener('click', closeFunction);
@@ -947,37 +942,6 @@ class CustomSettingsMenu extends Component {
this.startSubtitleSync(); this.startSubtitleSync();
} }
lockBodyScroll() {
if (!this.isMobile) return;
// Save current scroll position
this.scrollY = window.scrollY || window.pageYOffset;
// Lock body scroll with proper iOS handling
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${this.scrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
document.body.style.width = '100%';
}
unlockBodyScroll() {
if (!this.isMobile) return;
// Restore body scroll
const scrollY = this.scrollY;
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
document.body.style.width = '';
// Restore scroll position
window.scrollTo(0, scrollY);
}
toggleSettings(e) { toggleSettings(e) {
// e.stopPropagation(); // e.stopPropagation();
const isVisible = this.settingsOverlay.classList.contains('show'); const isVisible = this.settingsOverlay.classList.contains('show');
@@ -990,7 +954,11 @@ class CustomSettingsMenu extends Component {
this.stopKeepingControlsVisible(); this.stopKeepingControlsVisible();
// Restore body scroll on mobile when closing // Restore body scroll on mobile when closing
this.unlockBodyScroll(); if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} else { } else {
this.settingsOverlay.classList.add('show'); this.settingsOverlay.classList.add('show');
this.settingsOverlay.style.display = 'block'; this.settingsOverlay.style.display = 'block';
@@ -1004,7 +972,11 @@ class CustomSettingsMenu extends Component {
} }
// Prevent body scroll on mobile when overlay is open // Prevent body scroll on mobile when overlay is open
this.lockBodyScroll(); if (this.isMobile) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
} }
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles
@@ -1030,9 +1002,6 @@ class CustomSettingsMenu extends Component {
this.settingsOverlay.classList.add('show'); this.settingsOverlay.classList.add('show');
this.settingsOverlay.style.display = 'block'; this.settingsOverlay.style.display = 'block';
// Lock body scroll when opening
this.lockBodyScroll();
// Hide other submenus and show subtitles submenu // Hide other submenus and show subtitles submenu
this.speedSubmenu.style.display = 'none'; this.speedSubmenu.style.display = 'none';
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
@@ -1103,7 +1072,11 @@ class CustomSettingsMenu extends Component {
} }
// Restore body scroll on mobile when closing // Restore body scroll on mobile when closing
this.unlockBodyScroll(); if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} }
} }
@@ -1444,8 +1417,6 @@ class CustomSettingsMenu extends Component {
if (btnEl) { if (btnEl) {
btnEl.classList.remove('settings-clicked'); btnEl.classList.remove('settings-clicked');
} }
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
} }
} }
@@ -1522,7 +1493,11 @@ class CustomSettingsMenu extends Component {
} }
// Restore body scroll on mobile when disposing // Restore body scroll on mobile when disposing
this.unlockBodyScroll(); if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
// Remove DOM elements // Remove DOM elements
if (this.settingsOverlay) { if (this.settingsOverlay) {

View File

@@ -204,54 +204,6 @@ class SeekIndicator extends Component {
</div> </div>
`; `;
textEl.textContent = 'Pause'; textEl.textContent = 'Pause';
} else if (direction === 'copy-url') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} else if (direction === 'copy-embed') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M16 18l6-6-6-6"/>
<path d="M8 6l-6 6 6 6"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} }
// Clear any text content in the text element // Clear any text content in the text element
@@ -287,11 +239,6 @@ class SeekIndicator extends Component {
this.showTimeout = setTimeout(() => { this.showTimeout = setTimeout(() => {
this.hide(); this.hide();
}, 500); }, 500);
} else if (direction === 'copy-url' || direction === 'copy-embed') {
// Copy operations: 500ms (same as play/pause)
this.showTimeout = setTimeout(() => {
this.hide();
}, 500);
} }
} }

View File

@@ -14,22 +14,10 @@ class EmbedInfoOverlay extends Component {
this.authorThumbnail = options.authorThumbnail || ''; this.authorThumbnail = options.authorThumbnail || '';
this.videoTitle = options.videoTitle || 'Video'; this.videoTitle = options.videoTitle || 'Video';
this.videoUrl = options.videoUrl || ''; this.videoUrl = options.videoUrl || '';
this.showTitle = options.showTitle !== undefined ? options.showTitle : true;
this.showRelated = options.showRelated !== undefined ? options.showRelated : true;
this.showUserAvatar = options.showUserAvatar !== undefined ? options.showUserAvatar : true;
this.linkTitle = options.linkTitle !== undefined ? options.linkTitle : true;
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
if (this.showTitle) {
this.createOverlay(); this.createOverlay();
} else {
// Hide overlay element if showTitle is false
const overlay = this.el();
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
}
}); });
} }
@@ -61,7 +49,7 @@ class EmbedInfoOverlay extends Component {
`; `;
// Create avatar container // Create avatar container
if (this.authorThumbnail && this.showUserAvatar) { if (this.authorThumbnail) {
const avatarContainer = document.createElement('div'); const avatarContainer = document.createElement('div');
avatarContainer.className = 'embed-avatar-container'; avatarContainer.className = 'embed-avatar-container';
avatarContainer.style.cssText = ` avatarContainer.style.cssText = `
@@ -137,7 +125,7 @@ class EmbedInfoOverlay extends Component {
overflow: hidden; overflow: hidden;
`; `;
if (this.videoUrl && this.linkTitle) { if (this.videoUrl) {
const titleLink = document.createElement('a'); const titleLink = document.createElement('a');
titleLink.href = this.videoUrl; titleLink.href = this.videoUrl;
titleLink.target = '_blank'; titleLink.target = '_blank';
@@ -198,16 +186,10 @@ class EmbedInfoOverlay extends Component {
const player = this.player(); const player = this.player();
const overlay = this.el(); const overlay = this.el();
// If showTitle is false, ensure overlay is hidden
if (!this.showTitle) {
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
return;
}
// Sync overlay visibility with control bar visibility // Sync overlay visibility with control bar visibility
const updateOverlayVisibility = () => { const updateOverlayVisibility = () => {
const controlBar = player.getChild('controlBar');
if (!player.hasStarted()) { if (!player.hasStarted()) {
// Show overlay when video hasn't started (poster is showing) - like before // Show overlay when video hasn't started (poster is showing) - like before
overlay.style.opacity = '1'; overlay.style.opacity = '1';

View File

@@ -1,47 +0,0 @@
.video-context-menu {
position: fixed;
background-color: #282828;
border-radius: 4px;
padding: 4px 0;
min-width: 240px;
z-index: 10000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.video-context-menu-item {
display: flex;
align-items: center;
padding: 10px 16px;
color: #ffffff;
cursor: pointer;
transition: background-color 0.15s ease;
font-size: 14px;
user-select: none;
}
.video-context-menu-item:hover {
background-color: #3d3d3d;
}
.video-context-menu-item:active {
background-color: #4a4a4a;
}
.video-context-menu-icon {
width: 18px;
height: 18px;
margin-right: 12px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.video-context-menu-item span {
flex: 1;
white-space: nowrap;
}

View File

@@ -1,85 +0,0 @@
import React, { useEffect, useRef } from 'react';
import './VideoContextMenu.css';
function VideoContextMenu({ visible, position, onClose, onCopyVideoUrl, onCopyVideoUrlAtTime, onCopyEmbedCode }) {
const menuRef = useRef(null);
useEffect(() => {
if (visible && menuRef.current) {
// Position the menu
menuRef.current.style.left = `${position.x}px`;
menuRef.current.style.top = `${position.y}px`;
// Adjust if menu goes off screen
const rect = menuRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (rect.right > windowWidth) {
menuRef.current.style.left = `${position.x - rect.width}px`;
}
if (rect.bottom > windowHeight) {
menuRef.current.style.top = `${position.y - rect.height}px`;
}
}
}, [visible, position]);
useEffect(() => {
const handleClickOutside = (e) => {
if (visible && menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape' && visible) {
onClose();
}
};
if (visible) {
// Use capture phase to catch events earlier, before they can be stopped
// Listen to both mousedown and click to ensure we catch all clicks
document.addEventListener('mousedown', handleClickOutside, true);
document.addEventListener('click', handleClickOutside, true);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
document.removeEventListener('click', handleClickOutside, true);
document.removeEventListener('keydown', handleEscape);
};
}, [visible, onClose]);
if (!visible) return null;
return (
<div ref={menuRef} className="video-context-menu" onClick={(e) => e.stopPropagation()}>
<div className="video-context-menu-item" onClick={onCopyVideoUrl}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL</span>
</div>
<div className="video-context-menu-item" onClick={onCopyVideoUrlAtTime}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL at current time</span>
</div>
<div className="video-context-menu-item" onClick={onCopyEmbedCode}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy embed code</span>
</div>
</div>
);
}
export default VideoContextMenu;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react'; import React, { useEffect, useRef, useMemo } from 'react';
import videojs from 'video.js'; import videojs from 'video.js';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import '../../styles/embed.css'; import '../../styles/embed.css';
@@ -17,7 +17,6 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
import CustomSettingsMenu from '../controls/CustomSettingsMenu'; import CustomSettingsMenu from '../controls/CustomSettingsMenu';
import SeekIndicator from '../controls/SeekIndicator'; import SeekIndicator from '../controls/SeekIndicator';
import VideoContextMenu from '../overlays/VideoContextMenu';
import UserPreferences from '../../utils/UserPreferences'; import UserPreferences from '../../utils/UserPreferences';
import PlayerConfig from '../../config/playerConfig'; import PlayerConfig from '../../config/playerConfig';
import { AutoplayHandler } from '../../utils/AutoplayHandler'; import { AutoplayHandler } from '../../utils/AutoplayHandler';
@@ -170,7 +169,7 @@ const enableStandardButtonTooltips = (player) => {
}, 500); // Delay to ensure all components are ready }, 500); // Delay to ensure all components are ready
}; };
function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelated = true, showUserAvatar = true, linkTitle = true, urlTimestamp = null }) { function VideoJSPlayer({ videoId = 'default-video' }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const playerRef = useRef(null); // Track the player instance const playerRef = useRef(null); // Track the player instance
const userPreferences = useRef(new UserPreferences()); // User preferences instance const userPreferences = useRef(new UserPreferences()); // User preferences instance
@@ -178,17 +177,25 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
const keyboardHandler = useRef(null); // Keyboard handler instance const keyboardHandler = useRef(null); // Keyboard handler instance
const playbackEventHandler = useRef(null); // Playback event handler instance const playbackEventHandler = useRef(null); // Playback event handler instance
// Context menu state
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Check if this is an embed player (disable next video and autoplay features) // Check if this is an embed player (disable next video and autoplay features)
const isEmbedPlayer = videoId === 'video-embed'; const isEmbedPlayer = videoId === 'video-embed';
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Environment-based development mode configuration // Environment-based development mode configuration
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app'); const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
// Safely access window.MEDIA_DATA with fallback using useMemo
// Read options from window.MEDIA_DATA if available (for consistency with embed logic)
const mediaData = useMemo( const mediaData = useMemo(
() => () =>
typeof window !== 'undefined' && window.MEDIA_DATA typeof window !== 'undefined' && window.MEDIA_DATA
@@ -207,37 +214,12 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
}, },
siteUrl: 'https://deic.mediacms.io', siteUrl: 'https://deic.mediacms.io',
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania', nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
urlAutoplay: true,
urlMuted: false,
}, },
[] []
); );
// Helper to get effective value (prop or MEDIA_DATA or default)
const getOption = (propKey, mediaDataKey, defaultValue) => {
if (isEmbedPlayer) {
if (mediaData[mediaDataKey] !== undefined) return mediaData[mediaDataKey];
}
return propKey !== undefined ? propKey : defaultValue;
};
const finalShowTitle = getOption(showTitle, 'showTitle', true);
const finalShowRelated = getOption(showRelated, 'showRelated', true);
const finalShowUserAvatar = getOption(showUserAvatar, 'showUserAvatar', true);
const finalLinkTitle = getOption(linkTitle, 'linkTitle', true);
const finalTimestamp = getOption(urlTimestamp, 'urlTimestamp', null);
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Define chapters as JSON object // Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON // Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
// CONDITIONAL LOGIC: // CONDITIONAL LOGIC:
@@ -549,6 +531,8 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
isPlayList: mediaData?.isPlayList, isPlayList: mediaData?.isPlayList,
related_media: mediaData.data?.related_media || [], related_media: mediaData.data?.related_media || [],
nextLink: mediaData?.nextLink || null, nextLink: mediaData?.nextLink || null,
urlAutoplay: mediaData?.urlAutoplay || true,
urlMuted: mediaData?.urlMuted || false,
sources: getVideoSources(), sources: getVideoSources(),
}; };
@@ -754,212 +738,6 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
} }
}; };
// Context menu handlers
const handleContextMenu = useCallback((e) => {
// Only handle if clicking on video player area
const target = e.target;
const isVideoPlayerArea =
target.closest('.video-js') ||
target.classList.contains('vjs-tech') ||
target.tagName === 'VIDEO' ||
target.closest('video');
if (isVideoPlayerArea) {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition({ x: e.clientX, y: e.clientY });
setContextMenuVisible(true);
}
}, []);
const closeContextMenu = () => {
setContextMenuVisible(false);
};
// Helper function to get media ID
const getMediaId = () => {
if (typeof window !== 'undefined' && window.MEDIA_DATA?.data?.friendly_token) {
return window.MEDIA_DATA.data.friendly_token;
}
if (mediaData?.data?.friendly_token) {
return mediaData.data.friendly_token;
}
// Try to get from URL (works for both main page and embed page)
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const mediaIdFromUrl = urlParams.get('m');
if (mediaIdFromUrl) {
return mediaIdFromUrl;
}
// Also check if we're on an embed page with media ID in path
const pathMatch = window.location.pathname.match(/\/embed\/([^/?]+)/);
if (pathMatch) {
return pathMatch[1];
}
}
return currentVideo.id || 'default-video';
};
// Helper function to get base origin URL (handles embed mode)
const getBaseOrigin = () => {
if (typeof window !== 'undefined') {
// In embed mode, try to get origin from parent window if possible
// Otherwise use current window origin
try {
// Check if we're in an iframe and can access parent
if (window.parent !== window && window.parent.location.origin) {
return window.parent.location.origin;
}
} catch {
// Cross-origin iframe, use current origin
}
return window.location.origin;
}
return mediaData.siteUrl || 'https://deic.mediacms.io';
};
// Helper function to get embed URL
const getEmbedUrl = () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
// Try to get embed URL from config or construct it
if (typeof window !== 'undefined' && window.MediaCMS?.config?.url?.embed) {
return window.MediaCMS.config.url.embed + mediaId;
}
// Fallback: construct embed URL (check if current URL is embed format)
if (typeof window !== 'undefined' && window.location.pathname.includes('/embed')) {
// If we're already on an embed page, use current URL format
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('m', mediaId);
return currentUrl.toString();
}
// Default embed URL format
return `${origin}/embed?m=${mediaId}`;
};
// Copy video URL to clipboard
const handleCopyVideoUrl = async () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
// You can add a notification here if needed
} catch (err) {
console.error('Failed to copy video URL:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy video URL at current time to clipboard
const handleCopyVideoUrlAtTime = async () => {
if (!playerRef.current) {
closeContextMenu();
return;
}
const currentTime = Math.floor(playerRef.current.currentTime() || 0);
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}&t=${currentTime}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
} catch (err) {
console.error('Failed to copy video URL at time:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy embed code to clipboard
const handleCopyEmbedCode = async () => {
const embedUrl = getEmbedUrl();
const embedCode = `<iframe width="560" height="315" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`;
// Show copy embed icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-embed');
}
try {
await navigator.clipboard.writeText(embedCode);
closeContextMenu();
} catch (err) {
console.error('Failed to copy embed code:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = embedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Add context menu handler directly to video element and document (works before and after Video.js initialization)
useEffect(() => {
const videoElement = videoRef.current;
// Attach to document with capture to catch all contextmenu events, then filter
const documentHandler = (e) => {
// Check if the event originated from within the video player
const target = e.target;
const playerWrapper =
videoElement?.closest('.video-js') || document.querySelector(`#${videoId}`)?.closest('.video-js');
if (playerWrapper && (playerWrapper.contains(target) || target === playerWrapper)) {
handleContextMenu(e);
}
};
// Use capture phase on document to catch before anything else
document.addEventListener('contextmenu', documentHandler, true);
// Also attach directly to video element
if (videoElement) {
videoElement.addEventListener('contextmenu', handleContextMenu, true);
}
return () => {
document.removeEventListener('contextmenu', documentHandler, true);
if (videoElement) {
videoElement.removeEventListener('contextmenu', handleContextMenu, true);
}
};
}, [handleContextMenu, videoId]);
useEffect(() => { useEffect(() => {
// Only initialize if we don't already have a player and element exists // Only initialize if we don't already have a player and element exists
if (videoRef.current && !playerRef.current) { if (videoRef.current && !playerRef.current) {
@@ -1300,9 +1078,6 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
currentVideo, currentVideo,
relatedVideos, relatedVideos,
goToNextVideo, goToNextVideo,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
@@ -1323,8 +1098,8 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
} }
// Handle URL timestamp parameter // Handle URL timestamp parameter
if (finalTimestamp !== null && finalTimestamp >= 0) { if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) {
const timestamp = finalTimestamp; const timestamp = mediaData.urlTimestamp;
// Wait for video metadata to be loaded before seeking // Wait for video metadata to be loaded before seeking
if (playerRef.current.readyState() >= 1) { if (playerRef.current.readyState() >= 1) {
@@ -2222,10 +1997,6 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
authorThumbnail: currentVideo.author_thumbnail, authorThumbnail: currentVideo.author_thumbnail,
videoTitle: currentVideo.title, videoTitle: currentVideo.title,
videoUrl: currentVideo.url, videoUrl: currentVideo.url,
showTitle: finalShowTitle,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
} }
// END: Add Embed Info Overlay Component // END: Add Embed Info Overlay Component
@@ -2312,72 +2083,20 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
// Make the video element focusable // Make the video element focusable
const videoElement = playerRef.current.el(); const videoElement = playerRef.current.el();
videoElement.setAttribute('tabindex', '0'); videoElement.setAttribute('tabindex', '0');
if (!isEmbedPlayer) {
videoElement.focus(); videoElement.focus();
} }
// Add context menu (right-click) handler to the player wrapper and video element
// Attach to player wrapper (this catches all clicks on the player)
videoElement.addEventListener('contextmenu', handleContextMenu, true);
// Also try to attach to the actual video tech element
const attachContextMenu = () => {
const techElement =
playerRef.current.el().querySelector('.vjs-tech') ||
playerRef.current.el().querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement && techElement !== videoRef.current && techElement !== videoElement) {
// Use capture phase to catch before Video.js might prevent it
techElement.addEventListener('contextmenu', handleContextMenu, true);
return true;
}
return false;
};
// Try to attach immediately
attachContextMenu();
// Also try after a short delay in case elements aren't ready yet
setTimeout(() => {
attachContextMenu();
}, 100);
// Also try when video is loaded
playerRef.current.one('loadedmetadata', () => {
attachContextMenu();
});
}
}); });
} }
//}, 0); //}, 0);
} }
// Cleanup: Remove context menu event listener
return () => {
if (playerRef.current && playerRef.current.el()) {
const playerEl = playerRef.current.el();
playerEl.removeEventListener('contextmenu', handleContextMenu, true);
const techElement =
playerEl.querySelector('.vjs-tech') ||
playerEl.querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement) {
techElement.removeEventListener('contextmenu', handleContextMenu, true);
}
}
};
}, []); }, []);
return ( return (
<>
<video <video
ref={videoRef} ref={videoRef}
id={videoId} id={videoId}
controls={true} controls={true}
className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`} className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
preload="auto" preload="auto"
poster={currentVideo.poster} poster={currentVideo.poster}
tabIndex="0" tabIndex="0"
@@ -2410,15 +2129,6 @@ function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelate
chaptersData.length > 0 && chaptersData.length > 0 &&
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */} (console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
</video> </video>
<VideoContextMenu
visible={contextMenuVisible}
position={contextMenuPosition}
onClose={closeContextMenu}
onCopyVideoUrl={handleCopyVideoUrl}
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
onCopyEmbedCode={handleCopyEmbedCode}
/>
</>
); );
} }

View File

@@ -63,17 +63,7 @@ export class EndScreenHandler {
} }
handleVideoEnded() { handleVideoEnded() {
const { const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options;
isEmbedPlayer,
userPreferences,
mediaData,
currentVideo,
relatedVideos,
goToNextVideo,
showRelated,
showUserAvatar,
linkTitle,
} = this.options;
// For embed players, show big play button when video ends // For embed players, show big play button when video ends
if (isEmbedPlayer) { if (isEmbedPlayer) {
@@ -83,34 +73,6 @@ export class EndScreenHandler {
} }
} }
// If showRelated is false, we don't show the end screen or autoplay countdown
if (showRelated === false) {
// But we still want to keep the control bar visible and hide the poster
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
const playerEl = this.player.el();
if (playerEl) {
// Hide poster elements
const posterElements = playerEl.querySelectorAll('.vjs-poster');
posterElements.forEach((posterEl) => {
posterEl.style.display = 'none';
posterEl.style.visibility = 'hidden';
posterEl.style.opacity = '0';
});
// Keep control bar visible
const controlBar = this.player.getChild('controlBar');
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents = 'auto';
}
}
}
}, 50);
return;
}
// Keep controls active after video ends // Keep controls active after video ends
setTimeout(() => { setTimeout(() => {
if (this.player && !this.player.isDisposed()) { if (this.player && !this.player.isDisposed()) {

View File

@@ -1,28 +1,12 @@
{ {
"presets": [ "presets": [
"@babel/react", "@babel/react", ["@babel/env", {
[
"@babel/env",
{
"modules": false, "modules": false,
"useBuiltIns": "usage", "useBuiltIns": "usage",
"corejs": 3, "corejs": 3,
"targets": { "targets": {
"browsers": ["defaults"] "browsers": ["defaults"]
} }
} }]
]
],
"env": {
"test": {
"presets": [
[
"@babel/env",
{
"targets": { "node": "current" }
}
]
] ]
} }
}
}

View File

@@ -27,39 +27,3 @@ Open in browser: [http://localhost:8088](http://localhost:8088)
Generates the folder "**_frontend/dist_**". Generates the folder "**_frontend/dist_**".
Copy folders and files from "**_frontend/dist/static_**" into "**_static_**". Copy folders and files from "**_frontend/dist/static_**" into "**_static_**".
---
### Test Scripts
#### test
Run all unit tests once.
```sh
npm run test
```
#### test-watch
Run tests in watch mode for development.
```sh
npm run test-watch
```
#### test-coverage
Run tests with coverage reporting in `./coverage` folder.
```sh
npm run test-coverage
```
#### test-coverage-watch
Run tests with coverage in watch mode.
```sh
npm run test-coverage-watch
```

View File

@@ -1,9 +0,0 @@
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.jsx?$': 'babel-jest',
},
collectCoverageFrom: ['src/**'],
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "mediacms-frontend", "name": "mediacms-frontend",
"version": "0.9.2", "version": "0.9.1",
"description": "", "description": "",
"author": "", "author": "",
"license": "", "license": "",
@@ -8,11 +8,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088", "start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist", "dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist"
"test": "jest",
"test-coverage": "npx rimraf ./coverage && jest --coverage",
"test-coverage-watch": "npm run test-coverage -- --watchAll",
"test-watch": "jest --watch"
}, },
"browserslist": [ "browserslist": [
"cover 99.5%" "cover 99.5%"
@@ -21,14 +17,10 @@
"@babel/core": "^7.26.9", "@babel/core": "^7.26.9",
"@babel/preset-env": "^7.26.9", "@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.26.3",
"@types/flux": "^3.1.15",
"@types/jest": "^29.5.12",
"@types/minimatch": "^5.1.2", "@types/minimatch": "^5.1.2",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/url-parse": "^1.4.11",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-jest": "^30.2.0",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"compass-mixins": "^0.12.12", "compass-mixins": "^0.12.12",
"copy-webpack-plugin": "^13.0.0", "copy-webpack-plugin": "^13.0.0",
@@ -37,18 +29,14 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"ejs-compiled-loader": "^3.1.0", "ejs-compiled-loader": "^3.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^30.2.0",
"jsdom": "^27.3.0",
"mediacms-scripts": "file:packages/scripts", "mediacms-scripts": "file:packages/scripts",
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"typescript": "^5.9.3", "typescript": "^5.8.2",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.98.0" "webpack": "^5.98.0"
}, },

View File

@@ -26,12 +26,17 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
csrfToken, csrfToken,
}) => { }) => {
const [selectedState, setSelectedState] = useState('public'); const [selectedState, setSelectedState] = useState('public');
const [initialState, setInitialState] = useState('public');
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
// Reset state when modal closes // Reset state when modal closes
setSelectedState('public'); setSelectedState('public');
setInitialState('public');
} else {
// When modal opens, set initial state
setInitialState('public');
} }
}, [isOpen]); }, [isOpen]);
@@ -74,9 +79,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
if (!isOpen) return null; if (!isOpen) return null;
// Note: We don't check hasStateChanged because the modal doesn't know the actual const hasStateChanged = selectedState !== initialState;
// current state of the selected media. Users should be able to set any state.
// If the state is already the same, the backend will handle it gracefully.
return ( return (
<div className="publish-state-modal-overlay"> <div className="publish-state-modal-overlay">
@@ -113,7 +116,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
<button <button
className="publish-state-btn publish-state-btn-submit" className="publish-state-btn publish-state-btn-submit"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isProcessing} disabled={isProcessing || !hasStateChanged}
> >
{isProcessing ? translateString('Processing...') : translateString('Submit')} {isProcessing ? translateString('Processing...') : translateString('Submit')}
</button> </button>

View File

@@ -9,29 +9,10 @@
.bulk-actions-container { .bulk-actions-container {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 20px; gap: 20px;
margin-bottom: 16px; margin-bottom: 16px;
.add-media-button {
margin-left: auto;
a {
display: flex;
align-items: center;
}
.circle-icon-button {
width: 48px;
height: 48px;
.material-icons {
font-size: 32px;
}
}
}
} }
} }

View File

@@ -2,9 +2,6 @@ import React from 'react';
import { MediaListRow } from './MediaListRow'; import { MediaListRow } from './MediaListRow';
import { BulkActionsDropdown } from './BulkActionsDropdown'; import { BulkActionsDropdown } from './BulkActionsDropdown';
import { SelectAllCheckbox } from './SelectAllCheckbox'; import { SelectAllCheckbox } from './SelectAllCheckbox';
import { CircleIconButton, MaterialIcon } from './_shared';
import { LinksConsumer } from '../utils/contexts';
import { translateString } from '../utils/helpers/';
import './MediaListWrapper.scss'; import './MediaListWrapper.scss';
interface MediaListWrapperProps { interface MediaListWrapperProps {
@@ -20,7 +17,6 @@ interface MediaListWrapperProps {
onBulkAction?: (action: string) => void; onBulkAction?: (action: string) => void;
onSelectAll?: () => void; onSelectAll?: () => void;
onDeselectAll?: () => void; onDeselectAll?: () => void;
showAddMediaButton?: boolean;
} }
export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
@@ -36,15 +32,11 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
onBulkAction = () => {}, onBulkAction = () => {},
onSelectAll = () => {}, onSelectAll = () => {},
onDeselectAll = () => {}, onDeselectAll = () => {},
showAddMediaButton = false,
}) => ( }) => (
<div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}> <div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}>
<MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}> <MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}>
{showBulkActions && ( {showBulkActions && (
<LinksConsumer>
{(links) => (
<div className="bulk-actions-container"> <div className="bulk-actions-container">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} /> <BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} />
<SelectAllCheckbox <SelectAllCheckbox
totalCount={totalCount} totalCount={totalCount}
@@ -53,18 +45,6 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
onDeselectAll={onDeselectAll} onDeselectAll={onDeselectAll}
/> />
</div> </div>
{showAddMediaButton && (
<div className="add-media-button">
<a href={links.user.addMedia} title={translateString('Add media')}>
<CircleIconButton>
<MaterialIcon type="video_call" />
</CircleIconButton>
</a>
</div>
)}
</div>
)}
</LinksConsumer>
)} )}
{children || null} {children || null}
</MediaListRow> </MediaListRow>

View File

@@ -31,11 +31,8 @@ const VideoJSEmbed = ({
poster, poster,
previewSprite, previewSprite,
subtitlesInfo, subtitlesInfo,
enableAutoplay,
inEmbed, inEmbed,
showTitle,
showRelated,
showUserAvatar,
linkTitle,
hasTheaterMode, hasTheaterMode,
hasNextLink, hasNextLink,
nextLink, nextLink,
@@ -65,10 +62,8 @@ const VideoJSEmbed = ({
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Get URL parameters for autoplay, muted, and timestamp // Get URL parameters for autoplay, muted, and timestamp
const urlTimestamp = getUrlParameter('t'); const urlTimestamp = getUrlParameter('t');
const urlAutoplay = getUrlParameter('autoplay');
const urlMuted = getUrlParameter('muted'); const urlMuted = getUrlParameter('muted');
const urlShowRelated = getUrlParameter('showRelated');
const urlShowUserAvatar = getUrlParameter('showUserAvatar');
const urlLinkTitle = getUrlParameter('linkTitle');
window.MEDIA_DATA = { window.MEDIA_DATA = {
data: data || {}, data: data || {},
@@ -76,7 +71,7 @@ const VideoJSEmbed = ({
version: version, version: version,
isPlayList: isPlayList, isPlayList: isPlayList,
playerVolume: playerVolume || 0.5, playerVolume: playerVolume || 0.5,
playerSoundMuted: urlMuted === '1', playerSoundMuted: playerSoundMuted || (urlMuted === '1'),
videoQuality: videoQuality || 'auto', videoQuality: videoQuality || 'auto',
videoPlaybackSpeed: videoPlaybackSpeed || 1, videoPlaybackSpeed: videoPlaybackSpeed || 1,
inTheaterMode: inTheaterMode || false, inTheaterMode: inTheaterMode || false,
@@ -88,11 +83,8 @@ const VideoJSEmbed = ({
poster: poster || '', poster: poster || '',
previewSprite: previewSprite || null, previewSprite: previewSprite || null,
subtitlesInfo: subtitlesInfo || [], subtitlesInfo: subtitlesInfo || [],
enableAutoplay: enableAutoplay || (urlAutoplay === '1'),
inEmbed: inEmbed || false, inEmbed: inEmbed || false,
showTitle: showTitle || false,
showRelated: showRelated !== undefined ? showRelated : (urlShowRelated === '1' || urlShowRelated === 'true' || urlShowRelated === null),
showUserAvatar: showUserAvatar !== undefined ? showUserAvatar : (urlShowUserAvatar === '1' || urlShowUserAvatar === 'true' || urlShowUserAvatar === null),
linkTitle: linkTitle !== undefined ? linkTitle : (urlLinkTitle === '1' || urlLinkTitle === 'true' || urlLinkTitle === null),
hasTheaterMode: hasTheaterMode || false, hasTheaterMode: hasTheaterMode || false,
hasNextLink: hasNextLink || false, hasNextLink: hasNextLink || false,
nextLink: nextLink || null, nextLink: nextLink || null,
@@ -100,10 +92,8 @@ const VideoJSEmbed = ({
errorMessage: errorMessage || '', errorMessage: errorMessage || '',
// URL parameters // URL parameters
urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null, urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null,
urlAutoplay: urlAutoplay === '1',
urlMuted: urlMuted === '1', urlMuted: urlMuted === '1',
urlShowRelated: urlShowRelated === '1' || urlShowRelated === 'true',
urlShowUserAvatar: urlShowUserAvatar === '1' || urlShowUserAvatar === 'true',
urlLinkTitle: urlLinkTitle === '1' || urlLinkTitle === 'true',
onClickNextCallback: onClickNextCallback || null, onClickNextCallback: onClickNextCallback || null,
onClickPreviousCallback: onClickPreviousCallback || null, onClickPreviousCallback: onClickPreviousCallback || null,
onStateUpdateCallback: onStateUpdateCallback || null, onStateUpdateCallback: onStateUpdateCallback || null,
@@ -186,18 +176,12 @@ const VideoJSEmbed = ({
// Scroll to the video player with smooth behavior // Scroll to the video player with smooth behavior
const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main'); const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main');
if (videoElement) { if (videoElement) {
const urlScroll = getUrlParameter('scroll');
const isIframe = window.parent !== window;
// Only scroll if not in an iframe, OR if explicitly requested via scroll=1 parameter
if (!isIframe || urlScroll === '1' || urlScroll === 'true') {
videoElement.scrollIntoView({ videoElement.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'center', block: 'center',
inline: 'nearest' inline: 'nearest'
}); });
} }
}
} else { } else {
console.warn('VideoJS player not found for timestamp navigation'); console.warn('VideoJS player not found for timestamp navigation');
} }
@@ -236,14 +220,7 @@ const VideoJSEmbed = ({
return ( return (
<div className="video-js-wrapper" ref={containerRef}> <div className="video-js-wrapper" ref={containerRef}>
{inEmbed ? ( {inEmbed ? <div id="video-js-root-embed" className="video-js-root-embed" /> : <div id="video-js-root-main" className="video-js-root-main" />}
<div
id="video-js-root-embed"
className="video-js-root-embed"
/>
) : (
<div id="video-js-root-main" className="video-js-root-main" />
)}
</div> </div>
); );
}; };

View File

@@ -53,9 +53,9 @@ export function PlaylistItem(props) {
<UnderThumbWrapper title={props.title} link={props.link}> <UnderThumbWrapper title={props.title} link={props.link}>
{titleComponent()} {titleComponent()}
{metaComponents()} {metaComponents()}
<span className="view-full-playlist"> <a href={props.link} title="" className="view-full-playlist">
VIEW FULL PLAYLIST VIEW FULL PLAYLIST
</span> </a>
</UnderThumbWrapper> </UnderThumbWrapper>
</div> </div>
</div> </div>

View File

@@ -4,32 +4,10 @@ import { LinksContext, SiteConsumer } from '../../utils/contexts/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { PageActions, MediaPageActions } from '../../utils/actions/'; import { PageActions, MediaPageActions } from '../../utils/actions/';
import { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/'; import { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/';
import VideoViewer from '../media-viewer/VideoViewer';
const EMBED_OPTIONS_STORAGE_KEY = 'mediacms_embed_options';
function loadEmbedOptions() {
try {
const saved = localStorage.getItem(EMBED_OPTIONS_STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (e) {
// Ignore localStorage errors
}
return null;
}
function saveEmbedOptions(options) {
try {
localStorage.setItem(EMBED_OPTIONS_STORAGE_KEY, JSON.stringify(options));
} catch (e) {
// Ignore localStorage errors
}
}
export function MediaShareEmbed(props) { export function MediaShareEmbed(props) {
const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions; const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions;
const savedOptions = loadEmbedOptions();
const links = useContext(LinksContext); const links = useContext(LinksContext);
@@ -40,19 +18,12 @@ export function MediaShareEmbed(props) {
const onRightBottomRef = useRef(null); const onRightBottomRef = useRef(null);
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56); const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56);
const [keepAspectRatio, setKeepAspectRatio] = useState(savedOptions?.keepAspectRatio ?? true); const [keepAspectRatio, setKeepAspectRatio] = useState(false);
const [showTitle, setShowTitle] = useState(savedOptions?.showTitle ?? true); const [aspectRatio, setAspectRatio] = useState('16:9');
const [showRelated, setShowRelated] = useState(savedOptions?.showRelated ?? true); const [embedWidthValue, setEmbedWidthValue] = useState(embedVideoDimensions.width);
const [showUserAvatar, setShowUserAvatar] = useState(savedOptions?.showUserAvatar ?? true); const [embedWidthUnit, setEmbedWidthUnit] = useState(embedVideoDimensions.widthUnit);
const [linkTitle, setLinkTitle] = useState(savedOptions?.linkTitle ?? true); const [embedHeightValue, setEmbedHeightValue] = useState(embedVideoDimensions.height);
const [responsive, setResponsive] = useState(savedOptions?.responsive ?? false); const [embedHeightUnit, setEmbedHeightUnit] = useState(embedVideoDimensions.heightUnit);
const [startAt, setStartAt] = useState(false);
const [startTime, setStartTime] = useState('0:00');
const [aspectRatio, setAspectRatio] = useState(savedOptions?.aspectRatio ?? '16:9');
const [embedWidthValue, setEmbedWidthValue] = useState(savedOptions?.embedWidthValue ?? embedVideoDimensions.width);
const [embedWidthUnit, setEmbedWidthUnit] = useState(savedOptions?.embedWidthUnit ?? embedVideoDimensions.widthUnit);
const [embedHeightValue, setEmbedHeightValue] = useState(savedOptions?.embedHeightValue ?? embedVideoDimensions.height);
const [embedHeightUnit, setEmbedHeightUnit] = useState(savedOptions?.embedHeightUnit ?? embedVideoDimensions.heightUnit);
const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60); const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60);
const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60); const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60);
const [unitOptions, setUnitOptions] = useState([ const [unitOptions, setUnitOptions] = useState([
@@ -100,65 +71,36 @@ export function MediaShareEmbed(props) {
setEmbedHeightUnit(newVal); setEmbedHeightUnit(newVal);
} }
function onShowTitleChange() { function onKeepAspectRatioChange() {
setShowTitle(!showTitle); const newVal = !keepAspectRatio;
}
function onShowRelatedChange() {
setShowRelated(!showRelated);
}
function onShowUserAvatarChange() {
setShowUserAvatar(!showUserAvatar);
}
function onLinkTitleChange() {
setLinkTitle(!linkTitle);
}
function onResponsiveChange() {
const nextResponsive = !responsive;
setResponsive(nextResponsive);
if (!nextResponsive) {
if (aspectRatio !== 'custom') {
const arr = aspectRatio.split(':'); const arr = aspectRatio.split(':');
const x = arr[0]; const x = arr[0];
const y = arr[1]; const y = arr[1];
setKeepAspectRatio(true); setKeepAspectRatio(newVal);
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10)); setEmbedWidthUnit(newVal ? 'px' : embedWidthUnit);
} else { setEmbedHeightUnit(newVal ? 'px' : embedHeightUnit);
setKeepAspectRatio(false); setEmbedHeightValue(newVal ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
} setUnitOptions(
} else { newVal
setKeepAspectRatio(false); ? [{ key: 'px', label: 'px' }]
} : [
} { key: 'px', label: 'px' },
{ key: 'percent', label: '%' },
function onStartAtChange() { ]
setStartAt(!startAt); );
}
function onStartTimeChange(e) {
setStartTime(e.target.value);
} }
function onAspectRatioChange() { function onAspectRatioChange() {
const newVal = aspectRatioValueRef.current.value; const newVal = aspectRatioValueRef.current.value;
if (newVal === 'custom') {
setAspectRatio(newVal);
setKeepAspectRatio(false);
} else {
const arr = newVal.split(':'); const arr = newVal.split(':');
const x = arr[0]; const x = arr[0];
const y = arr[1]; const y = arr[1];
setAspectRatio(newVal); setAspectRatio(newVal);
setKeepAspectRatio(true); setEmbedHeightValue(keepAspectRatio ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
}
} }
function onWindowResize() { function onWindowResize() {
@@ -188,88 +130,13 @@ export function MediaShareEmbed(props) {
}; };
}, []); }, []);
// Save embed options to localStorage when they change (except startAt/startTime)
useEffect(() => {
saveEmbedOptions({
showTitle,
showRelated,
showUserAvatar,
linkTitle,
responsive,
aspectRatio,
embedWidthValue,
embedWidthUnit,
embedHeightValue,
embedHeightUnit,
keepAspectRatio,
});
}, [showTitle, showRelated, showUserAvatar, linkTitle, responsive, aspectRatio, embedWidthValue, embedWidthUnit, embedHeightValue, embedHeightUnit, keepAspectRatio]);
function getEmbedCode() {
const mediaId = MediaPageStore.get('media-id');
const params = new URLSearchParams();
if (showTitle) params.set('showTitle', '1');
else params.set('showTitle', '0');
if (showRelated) params.set('showRelated', '1');
else params.set('showRelated', '0');
if (showUserAvatar) params.set('showUserAvatar', '1');
else params.set('showUserAvatar', '0');
if (linkTitle) params.set('linkTitle', '1');
else params.set('linkTitle', '0');
if (startAt && startTime) {
const parts = startTime.split(':').reverse();
let seconds = 0;
if (parts[0]) seconds += parseInt(parts[0], 10) || 0;
if (parts[1]) seconds += (parseInt(parts[1], 10) || 0) * 60;
if (parts[2]) seconds += (parseInt(parts[2], 10) || 0) * 3600;
if (seconds > 0) params.set('t', seconds);
}
const separator = links.embed.includes('?') ? '&' : '?';
const finalUrl = `${links.embed}${mediaId}${separator}${params.toString()}`;
if (responsive) {
if (aspectRatio === 'custom') {
// Use current width/height values to calculate aspect ratio for custom
const ratio = `${embedWidthValue} / ${embedHeightValue}`;
const maxWidth = `calc(100vh * ${embedWidthValue} / ${embedHeightValue})`;
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
}
const arr = aspectRatio.split(':');
const ratio = `${arr[0]} / ${arr[1]}`;
const maxWidth = `calc(100vh * ${arr[0]} / ${arr[1]})`;
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
}
const width = 'percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue;
const height = 'percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue;
return `<iframe width="${width}" height="${height}" src="${finalUrl}" frameBorder="0" allowFullScreen></iframe>`;
}
return ( return (
<div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}> <div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}>
<div className="share-embed-inner"> <div className="share-embed-inner">
<div className="on-left"> <div className="on-left">
<div className="media-embed-wrap"> <div className="media-embed-wrap">
<SiteConsumer> <SiteConsumer>
{(site) => { {(site) => <VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} inEmbed={true} />}
const previewUrl = `${links.embed + MediaPageStore.get('media-id')}&showTitle=${showTitle ? '1' : '0'}&showRelated=${showRelated ? '1' : '0'}&showUserAvatar=${showUserAvatar ? '1' : '0'}&linkTitle=${linkTitle ? '1' : '0'}${startAt ? '&t=' + (startTime.split(':').reverse().reduce((acc, cur, i) => acc + (parseInt(cur, 10) || 0) * Math.pow(60, i), 0)) : ''}`;
const style = {};
style.width = '100%';
style.height = '480px';
style.overflow = 'hidden';
return (
<div style={style}>
<iframe width="100%" height="100%" src={previewUrl} frameBorder="0" allowFullScreen></iframe>
</div>
);
}}
</SiteConsumer> </SiteConsumer>
</div> </div>
</div> </div>
@@ -291,7 +158,16 @@ export function MediaShareEmbed(props) {
> >
<textarea <textarea
readOnly readOnly
value={getEmbedCode()} value={
'<iframe width="' +
('percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue) +
'" height="' +
('percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue) +
'" src="' +
links.embed +
MediaPageStore.get('media-id') +
'" frameborder="0" allowfullscreen></iframe>'
}
></textarea> ></textarea>
<div className="iframe-config"> <div className="iframe-config">
@@ -303,79 +179,34 @@ export function MediaShareEmbed(props) {
</div>*/} </div>*/}
<div className="option-content"> <div className="option-content">
<div className="ratio-options" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 10px' }}> <div className="ratio-options">
<div className="options-group"> <div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}> <label style={{ minHeight: '36px' }}>
<input type="checkbox" checked={showTitle} onChange={onShowTitleChange} /> <input type="checkbox" checked={keepAspectRatio} onChange={onKeepAspectRatioChange} />
Show title Keep aspect ratio
</label> </label>
</div> </div>
{!keepAspectRatio ? null : (
<div className="options-group"> <div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}> <select ref={aspectRatioValueRef} onChange={onAspectRatioChange} value={aspectRatio}>
<input type="checkbox" checked={linkTitle} onChange={onLinkTitleChange} disabled={!showTitle} /> <optgroup label="Horizontal orientation">
Link title
</label>
</div>
<div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={showRelated} onChange={onShowRelatedChange} />
Show related
</label>
</div>
<div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
<input type="checkbox" checked={showUserAvatar} onChange={onShowUserAvatarChange} disabled={!showTitle} />
Show user avatar
</label>
</div>
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
<input type="checkbox" checked={responsive} onChange={onResponsiveChange} />
Responsive
</label>
</div>
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
<input type="checkbox" checked={startAt} onChange={onStartAtChange} />
Start at
</label>
{startAt && (
<input
type="text"
value={startTime}
onChange={onStartTimeChange}
style={{ width: '60px', height: '28px', fontSize: '12px', padding: '2px 5px' }}
/>
)}
</div>
<div className="options-group" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ fontSize: '12px', marginBottom: '4px', color: 'rgba(0,0,0,0.6)' }}>Aspect Ratio</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<select
ref={aspectRatioValueRef}
onChange={onAspectRatioChange}
value={aspectRatio}
style={{ height: '28px', fontSize: '12px' }}
>
<option value="16:9">16:9</option> <option value="16:9">16:9</option>
<option value="4:3">4:3</option> <option value="4:3">4:3</option>
<option value="3:2">3:2</option> <option value="3:2">3:2</option>
<option value="custom">Custom</option> </optgroup>
<optgroup label="Vertical orientation">
<option value="9:16">9:16</option>
<option value="3:4">3:4</option>
<option value="2:3">2:3</option>
</optgroup>
</select> </select>
</div> </div>
</div> )}
</div> </div>
<br /> <br />
{!responsive && (
<>
<div className="options-group"> <div className="options-group">
<NumericInputWithUnit <NumericInputWithUnit
valueCallback={onEmbedWidthValueChange} valueCallback={onEmbedWidthValueChange}
@@ -401,8 +232,6 @@ export function MediaShareEmbed(props) {
units={unitOptions} units={unitOptions}
/> />
</div> </div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -21,16 +21,12 @@ function downloadOptionsList() {
for (g in encodings_info[k]) { for (g in encodings_info[k]) {
if (encodings_info[k].hasOwnProperty(g)) { if (encodings_info[k].hasOwnProperty(g)) {
if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) { if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) {
// Use original media URL for download instead of encoded version
const originalUrl = media_data.original_media_url;
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
optionsList[encodings_info[k][g].title] = { optionsList[encodings_info[k][g].title] = {
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')', text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')',
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url), link: formatInnerLink(encodings_info[k][g].url, SiteContext._currentValue.url),
linkAttr: { linkAttr: {
target: '_blank', target: '_blank',
download: originalFilename, download: media_data.title + '_' + k + '_' + g.toUpperCase(),
}, },
}; };
} }
@@ -40,16 +36,12 @@ function downloadOptionsList() {
} }
} }
// Extract actual filename from the original media URL
const originalUrl = media_data.original_media_url;
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
optionsList.original_media_url = { optionsList.original_media_url = {
text: 'Original file (' + media_data.size + ')', text: 'Original file (' + media_data.size + ')',
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url), link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
linkAttr: { linkAttr: {
target: '_blank', target: '_blank',
download: originalFilename, download: media_data.title,
}, },
}; };

View File

@@ -1,6 +1,6 @@
@use "sass:math"; @use "sass:math";
@import "../../../css/includes/_variables.scss"; @import '../../../css/includes/_variables.scss';
@import "../../../css/includes/_variables_dimensions.scss"; @import '../../../css/includes/_variables_dimensions.scss';
.visible-sidebar .page-main-wrap { .visible-sidebar .page-main-wrap {
padding-left: 0; padding-left: 0;
@@ -119,7 +119,7 @@
background-color: var(--media-actions-share-copy-field-bg-color); background-color: var(--media-actions-share-copy-field-bg-color);
} }
input[type="text"] { input[type='text'] {
color: var(--media-actions-share-copy-field-input-text-color); color: var(--media-actions-share-copy-field-input-text-color);
} }
} }
@@ -180,7 +180,7 @@
color: var(--report-form-field-label-text-color); color: var(--report-form-field-label-text-color);
} }
input[type="text"], input[type='text'],
textarea { textarea {
color: var(--report-form-field-input-text-color); color: var(--report-form-field-input-text-color);
border-color: var(--report-form-field-input-border-color); border-color: var(--report-form-field-input-border-color);
@@ -479,7 +479,7 @@
&.audio-player-container { &.audio-player-container {
&:before { &:before {
content: "\E3A1"; content: '\E3A1';
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@@ -490,11 +490,12 @@
line-height: 1; line-height: 1;
padding: 0; padding: 0;
font-family: "Material Icons"; font-family: 'Material Icons';
text-decoration: none; text-decoration: none;
color: #888; color: #888;
} }
.vjs-big-play-button { .vjs-big-play-button {
} }
@@ -513,13 +514,6 @@
} }
} }
.embedded-app {
.viewer-container,
.viewer-info {
width: 100%;
}
}
.viewer-image-container { .viewer-image-container {
position: relative; position: relative;
display: block; display: block;
@@ -556,6 +550,8 @@
max-width: 90%; max-width: 90%;
} }
.slideshow-image img { .slideshow-image img {
display: block; display: block;
width: auto; width: auto;
@@ -564,9 +560,7 @@
max-height: 90vh; max-height: 90vh;
border-radius: 0; border-radius: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: transition: transform 60s ease-in-out, opacity 60 ease-in-out;
transform 60s ease-in-out,
opacity 60 ease-in-out;
} }
.slideshow-title { .slideshow-title {
@@ -578,6 +572,7 @@
z-index: 1200; z-index: 1200;
} }
.arrow { .arrow {
position: absolute; position: absolute;
display: flex; display: flex;
@@ -595,9 +590,7 @@
padding: 10px; padding: 10px;
border-radius: 50%; border-radius: 50%;
z-index: 1000; z-index: 1000;
transition: transition: background-color 0.2s ease, transform 0.2s ease;
background-color 0.2s ease,
transform 0.2s ease;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
} }
@@ -692,19 +685,18 @@
width: 100%; // Default width for mobile width: 100%; // Default width for mobile
height: 400px; // Default height for mobile height: 400px; // Default height for mobile
@media (min-width: 768px) and (max-width: 1023px) { @media (min-width: 768px) and (max-width: 1023px) { // Tablets
// Tablets
width: 90%; width: 90%;
height: 600px; height: 600px;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) { // Desktop
// Desktop
width: 85%; width: 85%;
height: 900px; height: 900px;
} }
} }
.viewer-container .player-container.viewer-pdf-container, .viewer-container .player-container.viewer-pdf-container,
.viewer-container .player-container.viewer-attachment-container { .viewer-container .player-container.viewer-attachment-container {
background-color: var(--item-thumb-bg-color); background-color: var(--item-thumb-bg-color);
@@ -1014,7 +1006,7 @@
&.like, &.like,
&.dislike { &.dislike {
&:before { &:before {
content: ""; content: '';
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: -4px; left: -4px;
@@ -1154,7 +1146,7 @@
border-radius: 2px; border-radius: 2px;
} }
input[type="text"] { input[type='text'] {
width: 100%; width: 100%;
height: 42px; height: 42px;
padding: 1px 0 1px 16px; padding: 1px 0 1px 16px;
@@ -1214,18 +1206,13 @@
width: 220px; width: 220px;
} }
box-shadow: box-shadow: 0 16px 24px 2px rgba(#000, 0.14), 0 6px 30px 5px rgba(#000, 0.12),
0 16px 24px 2px rgba(#000, 0.14),
0 6px 30px 5px rgba(#000, 0.12),
0 8px 10px -5px rgba(#000, 0.4); 0 8px 10px -5px rgba(#000, 0.4);
&.main-options, &.main-options,
&.video-download-options { &.video-download-options {
width: 240px; width: 240px;
box-shadow: box-shadow: 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12), 0 3px 1px -2px rgba(#000, 0.2);
0 2px 2px 0 rgba(#000, 0.14),
0 1px 5px 0 rgba(#000, 0.12),
0 3px 1px -2px rgba(#000, 0.2);
} }
} }
} }
@@ -1283,9 +1270,7 @@
padding: 24px; padding: 24px;
text-align: initial; text-align: initial;
box-shadow: box-shadow: rgba(#000, 0.14) 0px 16px 24px 2px, rgba(#000, 0.12) 0px 6px 30px 5px,
rgba(#000, 0.14) 0px 16px 24px 2px,
rgba(#000, 0.12) 0px 6px 30px 5px,
rgba(#000, 0.4) 0px 8px 10px; rgba(#000, 0.4) 0px 8px 10px;
} }
} }
@@ -1318,18 +1303,13 @@
width: 220px; width: 220px;
} }
box-shadow: box-shadow: 0 16px 24px 2px rgba(#000, 0.14), 0 6px 30px 5px rgba(#000, 0.12),
0 16px 24px 2px rgba(#000, 0.14),
0 6px 30px 5px rgba(#000, 0.12),
0 8px 10px -5px rgba(#000, 0.4); 0 8px 10px -5px rgba(#000, 0.4);
&.main-options, &.main-options,
&.video-download-options { &.video-download-options {
width: 240px; width: 240px;
box-shadow: box-shadow: 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12), 0 3px 1px -2px rgba(#000, 0.2);
0 2px 2px 0 rgba(#000, 0.14),
0 1px 5px 0 rgba(#000, 0.12),
0 3px 1px -2px rgba(#000, 0.2);
.popup-main { .popup-main {
min-height: 0; min-height: 0;
@@ -1432,7 +1412,7 @@
font-weight: 500; font-weight: 500;
} }
input[type="text"], input[type='text'],
textarea { textarea {
min-width: 100%; min-width: 100%;
width: 100%; width: 100%;
@@ -1455,7 +1435,7 @@
cursor: not-allowed; cursor: not-allowed;
} }
input[type="text"] { input[type='text'] {
font-size: 14px; font-size: 14px;
} }
@@ -1752,7 +1732,7 @@
border-width: 0 0 1px; border-width: 0 0 1px;
&:after { &:after {
content: ""; content: '';
position: absolute; position: absolute;
bottom: -5px; bottom: -5px;
right: 0; right: 0;
@@ -1808,7 +1788,7 @@
max-height: 100%; max-height: 100%;
padding: 16px; padding: 16px;
cursor: text; cursor: text;
font-family: "Roboto Mono", monospace; font-family: 'Roboto Mono', monospace;
font-size: 14px; font-size: 14px;
line-height: 1.714285714; line-height: 1.714285714;
outline: 0; outline: 0;
@@ -1842,7 +1822,7 @@
vertical-align: top; vertical-align: top;
input { input {
&[type="checkbox"] { &[type='checkbox'] {
margin-left: 0; margin-left: 0;
} }
} }
@@ -1854,7 +1834,7 @@
width: 100%; width: 100%;
input { input {
&[type="checkbox"] { &[type='checkbox'] {
margin-left: 0; margin-left: 0;
} }
} }
@@ -1930,21 +1910,9 @@
} }
} }
.media-embed-wrap {
display: block;
width: 100%;
height: 100%;
background: #000;
.media-embed-wrap { .media-embed-wrap {
display: block; display: block;
.player-container,
.player-container-inner {
width: 100%;
height: 100%;
padding-top: 0;
background: #000;
}
.player-container, .player-container,
.player-container-inner { .player-container-inner {
width: 100%; width: 100%;
@@ -1958,10 +1926,6 @@
.circle-icon-button { .circle-icon-button {
} }
.video-js.vjs-mediacms {
padding-top: 0;
}
}
.video-js.vjs-mediacms { .video-js.vjs-mediacms {
padding-top: math.div(9, 16) * 100%; padding-top: math.div(9, 16) * 100%;
} }
@@ -2015,8 +1979,8 @@
} }
.item-date:before { .item-date:before {
content: ""; content: '';
content: "\2022"; content: '\2022';
margin: 0 4px; margin: 0 4px;
} }
@@ -2053,14 +2017,14 @@
margin-right: 4px; margin-right: 4px;
&:after { &:after {
content: ","; content: ',';
} }
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;
&:after { &:after {
content: ""; content: '';
} }
} }
} }

View File

@@ -3,7 +3,7 @@ import { SiteContext } from '../../utils/contexts/';
import { useUser, usePopup } from '../../utils/hooks/'; import { useUser, usePopup } from '../../utils/hooks/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { PageActions, MediaPageActions } from '../../utils/actions/'; import { PageActions, MediaPageActions } from '../../utils/actions/';
import { formatInnerLink, inEmbeddedApp, publishedOnDate } from '../../utils/helpers/'; import { formatInnerLink, publishedOnDate } from '../../utils/helpers/';
import { PopupMain } from '../_shared/'; import { PopupMain } from '../_shared/';
import CommentsList from '../comments/Comments'; import CommentsList from '../comments/Comments';
import { replaceString } from '../../utils/helpers/'; import { replaceString } from '../../utils/helpers/';
@@ -125,9 +125,7 @@ export default function ViewerInfoContent(props) {
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete'); PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
setTimeout(function () { setTimeout(function () {
window.location.href = window.location.href =
SiteContext._currentValue.url + SiteContext._currentValue.url + '/' + MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
'/' +
MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
}, 2000); }, 2000);
}, 100); }, 100);
@@ -187,12 +185,7 @@ export default function ViewerInfoContent(props) {
{void 0 === PageStore.get('config-media-item').displayAuthor || {void 0 === PageStore.get('config-media-item').displayAuthor ||
null === PageStore.get('config-media-item').displayAuthor || null === PageStore.get('config-media-item').displayAuthor ||
!!PageStore.get('config-media-item').displayAuthor ? ( !!PageStore.get('config-media-item').displayAuthor ? (
<MediaAuthorBanner <MediaAuthorBanner link={authorLink} thumb={authorThumb} name={props.author.name} published={props.published} />
link={authorLink}
thumb={authorThumb}
name={props.author.name}
published={props.published}
/>
) : null} ) : null}
<div className="media-content-banner"> <div className="media-content-banner">
@@ -219,20 +212,14 @@ export default function ViewerInfoContent(props) {
{categoriesContent.length ? ( {categoriesContent.length ? (
<MediaMetaField <MediaMetaField
value={categoriesContent} value={categoriesContent}
title={ title={1 < categoriesContent.length ? translateString('Categories') : translateString('Category')}
1 < categoriesContent.length
? translateString('Categories')
: translateString('Category')
}
id="categories" id="categories"
/> />
) : null} ) : null}
{userCan.editMedia ? ( {userCan.editMedia ? (
<div className="media-author-actions"> <div className="media-author-actions">
{userCan.editMedia ? ( {userCan.editMedia ? <EditMediaButton link={MediaPageStore.get('media-data').edit_url} /> : null}
<EditMediaButton link={MediaPageStore.get('media-data').edit_url} />
) : null}
{userCan.deleteMedia ? ( {userCan.deleteMedia ? (
<PopupTrigger contentRef={popupContentRef}> <PopupTrigger contentRef={popupContentRef}>
@@ -247,22 +234,14 @@ export default function ViewerInfoContent(props) {
<PopupMain> <PopupMain>
<div className="popup-message"> <div className="popup-message">
<span className="popup-message-title">Media removal</span> <span className="popup-message-title">Media removal</span>
<span className="popup-message-main"> <span className="popup-message-main">You're willing to remove media permanently?</span>
You're willing to remove media permanently?
</span>
</div> </div>
<hr /> <hr />
<span className="popup-message-bottom"> <span className="popup-message-bottom">
<button <button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}>
className="button-link cancel-comment-removal"
onClick={cancelMediaRemoval}
>
CANCEL CANCEL
</button> </button>
<button <button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}>
className="button-link proceed-comment-removal"
onClick={proceedMediaRemoval}
>
PROCEED PROCEED
</button> </button>
</span> </span>
@@ -274,7 +253,7 @@ export default function ViewerInfoContent(props) {
</div> </div>
</div> </div>
{!inEmbeddedApp() && <CommentsList />} <CommentsList />
</div> </div>
); );
} }

View File

@@ -54,10 +54,6 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url) ? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
: null; : null;
// Extract actual filename from URL for non-video downloads
const originalUrl = MediaPageStore.get('media-original-url');
this.downloadFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : this.props.title;
this.updateStateValues = this.updateStateValues.bind(this); this.updateStateValues = this.updateStateValues.bind(this);
} }
@@ -108,9 +104,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
render() { render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views; const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaData = MediaPageStore.get('media-data'); const mediaState = MediaPageStore.get('media-data').state;
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = ''; let stateTooltip = '';
@@ -123,8 +117,6 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
break; break;
} }
const sharedTooltip = 'This media is shared with specific users or categories';
return ( return (
<div className="media-title-banner"> <div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle {displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
@@ -133,28 +125,15 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null} {void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{isShared || 'public' !== mediaState ? ( {'public' !== mediaState ? (
<div className="media-labels-area"> <div className="media-labels-area">
<div className="media-labels-area-inner"> <div className="media-labels-area-inner">
{isShared ? (
<>
<span className="media-label-state">
<span>shared</span>
</span>
<span className="helper-icon" data-tooltip={sharedTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : 'public' !== mediaState ? (
<>
<span className="media-label-state"> <span className="media-label-state">
<span>{mediaState}</span> <span>{mediaState}</span>
</span> </span>
<span className="helper-icon" data-tooltip={stateTooltip}> <span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i> <i className="material-icons">help_outline</i>
</span> </span>
</>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -192,7 +171,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
.downloadLink ? ( .downloadLink ? (
<VideoMediaDownloadLink /> <VideoMediaDownloadLink />
) : ( ) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} /> <OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
)} )}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} /> <MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

View File

@@ -1,16 +1,8 @@
import React from 'react'; import React from 'react';
import { formatViewsNumber, inEmbeddedApp } from '../../utils/helpers/'; import { formatViewsNumber } from '../../utils/helpers/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { MemberContext, PlaylistsContext } from '../../utils/contexts/'; import { MemberContext, PlaylistsContext } from '../../utils/contexts/';
import { import { MediaLikeIcon, MediaDislikeIcon, OtherMediaDownloadLink, VideoMediaDownloadLink, MediaSaveButton, MediaShareButton, MediaMoreOptionsIcon } from '../media-actions/';
MediaLikeIcon,
MediaDislikeIcon,
OtherMediaDownloadLink,
VideoMediaDownloadLink,
MediaSaveButton,
MediaShareButton,
MediaMoreOptionsIcon,
} from '../media-actions/';
import ViewerInfoTitleBanner from './ViewerInfoTitleBanner'; import ViewerInfoTitleBanner from './ViewerInfoTitleBanner';
import { translateString } from '../../utils/helpers/'; import { translateString } from '../../utils/helpers/';
@@ -18,9 +10,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
render() { render() {
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views; const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
const mediaData = MediaPageStore.get('media-data'); const mediaState = MediaPageStore.get('media-data').state;
const mediaState = mediaData.state;
const isShared = mediaData.is_shared;
let stateTooltip = ''; let stateTooltip = '';
@@ -33,8 +23,6 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
break; break;
} }
const sharedTooltip = 'This media is shared with specific users or categories';
return ( return (
<div className="media-title-banner"> <div className="media-title-banner">
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle {displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
@@ -43,28 +31,15 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null} {void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
{isShared || 'public' !== mediaState ? ( {'public' !== mediaState ? (
<div className="media-labels-area"> <div className="media-labels-area">
<div className="media-labels-area-inner"> <div className="media-labels-area-inner">
{isShared ? (
<>
<span className="media-label-state">
<span>shared</span>
</span>
<span className="helper-icon" data-tooltip={sharedTooltip}>
<i className="material-icons">help_outline</i>
</span>
</>
) : 'public' !== mediaState ? (
<>
<span className="media-label-state"> <span className="media-label-state">
<span>{mediaState}</span> <span>{mediaState}</span>
</span> </span>
<span className="helper-icon" data-tooltip={stateTooltip}> <span className="helper-icon" data-tooltip={stateTooltip}>
<i className="material-icons">help_outline</i> <i className="material-icons">help_outline</i>
</span> </span>
</>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -82,8 +57,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
{displayViews ? ( {displayViews ? (
<div className="media-views"> <div className="media-views">
{formatViewsNumber(this.props.views, true)}{' '} {formatViewsNumber(this.props.views, true)} {1 >= this.props.views ? translateString('view') : translateString('views')}
{1 >= this.props.views ? translateString('view') : translateString('views')}
</div> </div>
) : null} ) : null}
@@ -91,12 +65,9 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
<div> <div>
{MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null} {MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null}
{MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null} {MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null}
{!inEmbeddedApp() && MemberContext._currentValue.can.shareMedia ? ( {MemberContext._currentValue.can.shareMedia ? <MediaShareButton isVideo={true} /> : null}
<MediaShareButton isVideo={true} />
) : null}
{!inEmbeddedApp() && {!MemberContext._currentValue.is.anonymous &&
!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia && MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? ( -1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
<MediaSaveButton /> <MediaSaveButton />
@@ -106,7 +77,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
.downloadLink ? ( .downloadLink ? (
<VideoMediaDownloadLink /> <VideoMediaDownloadLink />
) : ( ) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} /> <OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
)} )}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} /> <MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

View File

@@ -410,12 +410,8 @@ export default class VideoViewer extends React.PureComponent {
poster: this.videoPoster, poster: this.videoPoster,
previewSprite: previewSprite, previewSprite: previewSprite,
subtitlesInfo: this.props.data.subtitles_info, subtitlesInfo: this.props.data.subtitles_info,
enableAutoplay: !this.props.inEmbed,
inEmbed: this.props.inEmbed, inEmbed: this.props.inEmbed,
showTitle: this.props.showTitle,
showRelated: this.props.showRelated,
showUserAvatar: this.props.showUserAvatar,
linkTitle: this.props.linkTitle,
urlTimestamp: this.props.timestamp,
hasTheaterMode: !this.props.inEmbed, hasTheaterMode: !this.props.inEmbed,
hasNextLink: !!nextLink, hasNextLink: !!nextLink,
nextLink: nextLink, nextLink: nextLink,
@@ -439,19 +435,9 @@ export default class VideoViewer extends React.PureComponent {
VideoViewer.defaultProps = { VideoViewer.defaultProps = {
inEmbed: !0, inEmbed: !0,
showTitle: !0,
showRelated: !0,
showUserAvatar: !0,
linkTitle: !0,
timestamp: null,
siteUrl: PropTypes.string.isRequired, siteUrl: PropTypes.string.isRequired,
}; };
VideoViewer.propTypes = { VideoViewer.propTypes = {
inEmbed: PropTypes.bool, inEmbed: PropTypes.bool,
showTitle: PropTypes.bool,
showRelated: PropTypes.bool,
showUserAvatar: PropTypes.bool,
linkTitle: PropTypes.bool,
timestamp: PropTypes.number,
}; };

View File

@@ -23,11 +23,6 @@
transition-property: padding-left; transition-property: padding-left;
transition-duration: 0.2s; transition-duration: 0.2s;
} }
.embedded-app & {
padding-top: 0;
padding-left: 0;
}
} }
#page-profile-media, #page-profile-media,

View File

@@ -162,16 +162,12 @@ class ProfileSearchBar extends React.PureComponent {
if (!this.state.visibleForm) { if (!this.state.visibleForm) {
return ( return (
<span <span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.showForm}>
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }}
onClick={this.showForm}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">search</i> <i className="material-icons">search</i>
</CircleIconButton> </CircleIconButton>
{hasSearchText ? ( {hasSearchText ? (
<span <span style={{
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -180,8 +176,7 @@ class ProfileSearchBar extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}} }}></span>
></span>
) : null} ) : null}
</span> </span>
); );
@@ -194,8 +189,7 @@ class ProfileSearchBar extends React.PureComponent {
<i className="material-icons">search</i> <i className="material-icons">search</i>
</CircleIconButton> </CircleIconButton>
{hasSearchText ? ( {hasSearchText ? (
<span <span style={{
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -204,8 +198,7 @@ class ProfileSearchBar extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}} }}></span>
></span>
) : null} ) : null}
</span> </span>
<span> <span>
@@ -434,32 +427,17 @@ class NavMenuInlineTabs extends React.PureComponent {
{!['about', 'playlists'].includes(this.props.type) ? ( {!['about', 'playlists'].includes(this.props.type) ? (
<li className="media-search"> <li className="media-search">
<ProfileSearchBar <ProfileSearchBar onQueryChange={this.props.onQueryChange} toggleSearchField={this.onToggleSearchField} type={this.props.type} />
onQueryChange={this.props.onQueryChange}
toggleSearchField={this.onToggleSearchField}
type={this.props.type}
/>
</li> </li>
) : null} ) : null}
{this.props.onToggleFiltersClick && {this.props.onToggleFiltersClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-filters-toggle"> <li className="media-filters-toggle">
<span <span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleFiltersClick} title={translateString('Filters')}>
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleFiltersClick}
title={translateString('Filters')}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">filter_list</i> <i className="material-icons">filter_list</i>
</CircleIconButton> </CircleIconButton>
{this.props.hasActiveFilters ? ( {this.props.hasActiveFilters ? (
<span <span style={{
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -468,31 +446,19 @@ class NavMenuInlineTabs extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}} }}></span>
></span>
) : null} ) : null}
</span> </span>
</li> </li>
) : null} ) : null}
{this.props.onToggleTagsClick && {this.props.onToggleTagsClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-tags-toggle"> <li className="media-tags-toggle">
<span <span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleTagsClick} title={translateString('Tags')}>
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleTagsClick}
title={translateString('Tags')}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">local_offer</i> <i className="material-icons">local_offer</i>
</CircleIconButton> </CircleIconButton>
{this.props.hasActiveTags ? ( {this.props.hasActiveTags ? (
<span <span style={{
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -501,31 +467,19 @@ class NavMenuInlineTabs extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}} }}></span>
></span>
) : null} ) : null}
</span> </span>
</li> </li>
) : null} ) : null}
{this.props.onToggleSortingClick && {this.props.onToggleSortingClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-sorting-toggle"> <li className="media-sorting-toggle">
<span <span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleSortingClick} title={translateString('Sort By')}>
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleSortingClick}
title={translateString('Sort By')}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">swap_vert</i> <i className="material-icons">swap_vert</i>
</CircleIconButton> </CircleIconButton>
{this.props.hasActiveSort ? ( {this.props.hasActiveSort ? (
<span <span style={{
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -534,8 +488,7 @@ class NavMenuInlineTabs extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}} }}></span>
></span>
) : null} ) : null}
</span> </span>
</li> </li>
@@ -705,7 +658,6 @@ export default function ProfilePagesHeader(props) {
return ( return (
<div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}> <div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}>
{!props.hideChannelBanner && (
<span className="profile-banner-wrap"> <span className="profile-banner-wrap">
{props.author.banner_thumbnail_url ? ( {props.author.banner_thumbnail_url ? (
<span <span
@@ -733,22 +685,14 @@ export default function ProfilePagesHeader(props) {
<PopupMain> <PopupMain>
<div className="popup-message"> <div className="popup-message">
<span className="popup-message-title">Profile removal</span> <span className="popup-message-title">Profile removal</span>
<span className="popup-message-main"> <span className="popup-message-main">You're willing to remove profile permanently?</span>
You're willing to remove profile permanently?
</span>
</div> </div>
<hr /> <hr />
<span className="popup-message-bottom"> <span className="popup-message-bottom">
<button <button className="button-link cancel-profile-removal" onClick={cancelProfileRemoval}>
className="button-link cancel-profile-removal"
onClick={cancelProfileRemoval}
>
CANCEL CANCEL
</button> </button>
<button <button className="button-link proceed-profile-removal" onClick={proceedMediaRemoval}>
className="button-link proceed-profile-removal"
onClick={proceedMediaRemoval}
>
PROCEED PROCEED
</button> </button>
</span> </span>
@@ -765,22 +709,17 @@ export default function ProfilePagesHeader(props) {
) )
) : null} ) : null}
</span> </span>
)}
<div className="profile-info-nav-wrap"> <div className="profile-info-nav-wrap">
{props.author.thumbnail_url || props.author.name ? ( {props.author.thumbnail_url || props.author.name ? (
<div className="profile-info"> <div className="profile-info">
<div className="profile-info-inner"> <div className="profile-info-inner">
<div> <div>{props.author.thumbnail_url ? <img src={props.author.thumbnail_url} alt="" /> : null}</div>
{props.author.thumbnail_url ? <img src={props.author.thumbnail_url} alt="" /> : null}
</div>
<div> <div>
{props.author.name ? ( {props.author.name ? (
<div className="profile-name-edit-wrapper"> <div className="profile-name-edit-wrapper">
<h1>{props.author.name}</h1> <h1>{props.author.name}</h1>
{userCanEditProfile && !userIsAuthor ? ( {userCanEditProfile && !userIsAuthor ? <EditProfileButton link={ProfilePageStore.get('author-data').edit_url} /> : null}
<EditProfileButton link={ProfilePageStore.get('author-data').edit_url} />
) : null}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -32,7 +32,6 @@ const filters = {
{ id: 'private', title: translateString('Private') }, { id: 'private', title: translateString('Private') },
{ id: 'unlisted', title: translateString('Unlisted') }, { id: 'unlisted', title: translateString('Unlisted') },
{ id: 'public', title: translateString('Published') }, { id: 'public', title: translateString('Published') },
{ id: 'shared', title: translateString('Shared') },
], ],
sort_by: [ sort_by: [
{ id: 'date_added_desc', title: translateString('Upload date (newest)') }, { id: 'date_added_desc', title: translateString('Upload date (newest)') },

View File

@@ -41,7 +41,7 @@ export const EmbedPage: React.FC = () => {
}, []); }, []);
return ( return (
<div className="embed-wrap media-embed-wrap" style={wrapperStyles}> <div className="embed-wrap" style={wrapperStyles}>
{failedMediaLoad && ( {failedMediaLoad && (
<div className="player-container player-container-error" style={containerStyles}> <div className="player-container player-container-error" style={containerStyles}>
<div className="player-container-inner" style={containerStyles}> <div className="player-container-inner" style={containerStyles}>
@@ -59,32 +59,9 @@ export const EmbedPage: React.FC = () => {
{loadedVideo && ( {loadedVideo && (
<SiteConsumer> <SiteConsumer>
{(site) => { {(site) => (
const urlParams = new URLSearchParams(window.location.search); <VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} containerStyles={containerStyles} />
const urlShowTitle = urlParams.get('showTitle'); )}
const showTitle = urlShowTitle !== '0';
const urlShowRelated = urlParams.get('showRelated');
const showRelated = urlShowRelated !== '0';
const urlShowUserAvatar = urlParams.get('showUserAvatar');
const showUserAvatar = urlShowUserAvatar !== '0';
const urlLinkTitle = urlParams.get('linkTitle');
const linkTitle = urlLinkTitle !== '0';
const urlTimestamp = urlParams.get('t');
const timestamp = urlTimestamp ? parseInt(urlTimestamp, 10) : null;
return (
<VideoViewer
data={MediaPageStore.get('media-data')}
siteUrl={site.url}
containerStyles={containerStyles}
showTitle={showTitle}
showRelated={showRelated}
showUserAvatar={showUserAvatar}
linkTitle={linkTitle}
timestamp={timestamp}
/>
);
}}
</SiteConsumer> </SiteConsumer>
)} )}
</div> </div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import UrlParse from 'url-parse'; import UrlParse from 'url-parse';
import { ApiUrlContext, MemberContext, SiteContext } from '../utils/contexts/'; import { ApiUrlContext, MemberContext, SiteContext } from '../utils/contexts/';
import { formatInnerLink, csrfToken, postRequest, inEmbeddedApp } from '../utils/helpers/'; import { formatInnerLink, csrfToken, postRequest } from '../utils/helpers/';
import { PageActions } from '../utils/actions/'; import { PageActions } from '../utils/actions/';
import { PageStore, ProfilePageStore } from '../utils/stores/'; import { PageStore, ProfilePageStore } from '../utils/stores/';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
@@ -268,7 +268,7 @@ export class ProfileAboutPage extends ProfileMediaPage {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" hideChannelBanner={inEmbeddedApp()} /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}> <ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}>

View File

@@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@@ -29,7 +28,7 @@ export class ProfileHistoryPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" hideChannelBanner={inEmbeddedApp()} /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

Some files were not shown because too many files have changed in this diff Show More