Audio songs: one-folder storage, version-aware download/share, GPU-checked renders

Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-23 14:03:43 +03:00
parent 66fd78c10f
commit a4384113c2
24 changed files with 1738 additions and 258 deletions

View File

@ -90,8 +90,9 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS
## `<x-image-cropper>` ## `<x-image-cropper>`
**File:** `resources/views/components/image-cropper.blade.php` **File:** `resources/views/components/image-cropper.blade.php`
**Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading). **Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading), `target-input` (form mode: ID of file input the cropped File is set on), `preview-img` (ID of `<img>` updated with the cropped preview), `output-width` (final output px width), `result-callback` (callback mode: name of a global JS fn given the cropped `File`).
**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). After crop: POSTs base64 to `/image-upload`, optionally POSTs the path to `update-url`, then calls `callback(url)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required. **Three operating modes (mutually exclusive, checked in this order):** (1) **callback mode** — when `result-callback` is set, both "Crop & Save" and "Upload as-is" hand the resulting `File` to `window[resultCallback](file)` and do **not** auto-close; the host fn decides when to close (`closeCropperModal(id)`) or load the next image. Used for multi-image queues (cover slides). (2) **form mode** — when `target-input` is set, the cropped File is placed on that file input (DataTransfer) and a `change` event is dispatched. (3) **server mode** — otherwise POSTs base64 to `/image-upload`, optionally POSTs path to `update-url`, then calls `callback(url)`.
**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). Exposes per-id globals: `openCropperModal_{id}()`, `tcPreload_{id}(file)`, `closeCropperModal(id)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required.
**Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`. **Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`.
**Routes needed:** `image.upload` (POST `/image-upload`). **Routes needed:** `image.upload` (POST `/image-upload`).
@ -105,6 +106,10 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS
| `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px | | `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px |
| `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px | | `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px |
| `resources/views/playlists/show.blade.php` | `thumb_pl_edit` — square 448×252 | Form mode; `target-input=playlistThumbnailInput`; output 1280px | | `resources/views/playlists/show.blade.php` | `thumb_pl_edit` — square 448×252 | Form mode; `target-input=playlistThumbnailInput`; output 1280px |
| `resources/views/layouts/partials/edit-video-modal.blade.php` | `slides_edit` — square 448×252 | Callback mode; `result-callback=editSlidesCropDone`; crops each cover slide before it enters the strip (queues multiple) |
| `resources/views/layouts/partials/upload-modal.blade.php` | `slides_upload` — square 448×252 | Callback mode; `result-callback=uploadSlidesCropDone`; cover-slide crop queue |
| `resources/views/videos/create.blade.php` | `slides_create_mobile` — square 448×252 | Mobile; callback mode; `result-callback=cSlidesCropDone`; cover-slide crop queue |
| `resources/views/videos/edit.blade.php` | `slides_edit_mobile` — square 448×252 | Mobile; callback mode; `result-callback=epSlidesCropDone`; cover-slide crop queue |
--- ---
@ -156,7 +161,7 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
**File:** `resources/views/components/track-editor-form.blade.php` **File:** `resources/views/components/track-editor-form.blade.php`
**Props:** `prefix` (default `'t1'`), `isPrimary` (bool, default `false`), `languageName`, `languageId`, `titleName`, `titleId`, `descName`, `descId`, `videoFileInputId`. **Props:** `prefix` (default `'t1'`), `isPrimary` (bool, default `false`), `languageName`, `languageId`, `titleName`, `titleId`, `descName`, `descId`, `videoFileInputId`.
**Behaviour:** Renders the full track editor form panel shown inside the Track Editor popup. Contains: optional Primary Track banner (when `:is-primary="true"`), language dropdown (`<x-language-select>`), title input, description textarea, video+thumbnail zone (hidden, shown for video/match type via `_editApplyMode`), and audio+slides zone (hidden, shown for music type). All element IDs are prefixed with `edit-{prefix}-*`. JS functions `editHandleThumbnail(input, prefix)`, `editRemoveThumbnail(event, prefix)`, `editSlidesZoneClick(event, tid)`, `editHandleSlides(files, tid)`, `editClearSlides(event, tid)` all accept the prefix/tid param. **Behaviour:** Renders the full track editor form panel shown inside the Track Editor popup. Contains: optional Primary Track banner (when `:is-primary="true"`), language dropdown (`<x-language-select>`), title input, description rich-text editor (`<x-rich-text-editor>`), video+thumbnail zone (hidden, shown for video/match type via `_editApplyMode`), and audio+slides zone (hidden, shown for music type). All element IDs are prefixed with `edit-{prefix}-*`. JS functions `editHandleThumbnail(input, prefix)`, `editRemoveThumbnail(event, prefix)`, `editSlidesZoneClick(event, tid)`, `editHandleSlides(files, tid)`, `editClearSlides(event, tid)` all accept the prefix/tid param. Adding cover slides routes through the `slides_edit` image-cropper (callback mode `editSlidesCropDone`) — each picked/dropped image is cropped to 16:9 before entering `_editSlidesData`; the live `<x-image-cropper>` instances are defined in edit-video-modal.blade.php.
| View file | Prefix used | Notes | | View file | Prefix used | Notes |
|---|---|---| |---|---|---|
@ -184,6 +189,26 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
--- ---
## `<x-rich-text-editor>`
**File:** `resources/views/components/rich-text-editor.blade.php`
**Props:** `name`, `id`, `value` (initial HTML), `placeholder`, `class`, `style`, `minHeight` (default `110px`).
**Server sanitizer:** `app/Support/HtmlSanitizer.php``HtmlSanitizer::clean()` (allowlist, on save) and `HtmlSanitizer::render()` (display; upgrades legacy plain text).
**Stored value:** sanitized HTML. Allowed tags: `p, br, div, span, b/strong, i/em, u, s, h2, h3, ul, ol, li, blockquote, a`. `<a>` may carry `href` (http/https/mailto only), `target="_blank"` (auto `rel=noopener`), and `class` limited to `.action-btn` variants (button links). `style` limited to `text-align`.
**Behaviour:** Renders a hidden `<textarea class="rte-source" name id>` as the form field (source of truth) wrapped in `.rte-wrap`. `window.RTE` builds the toolbar + `contenteditable` editor in JS (so Blade-rendered and JS-generated rows share one implementation) and a `MutationObserver` auto-inits any `.rte-wrap` added later (modals, cloned track rows). Toolbar: bold, italic, underline, strikethrough, heading (H2), bullet/numbered list, quote, align left/center/right, link, button-link (`.action-btn`), emoji, clear formatting. Editor↔textarea stay synced via `input`; external code that sets `textarea.value` must dispatch `new Event('rte:refresh')` to update the editor.
**Rendering:** display HTML via `{!! \App\Support\HtmlSanitizer::render($value) !!}`; truncation is CSS-clamp (`.vdb-clamp`) + JS overflow check, never character-truncation (would break tags).
| View file | Field name / id | Notes |
|---|---|---|
| `resources/views/components/track-editor-form.blade.php` | `$descName` / `$descId` | Description in the Track Editor popup; primary + JS-cloned template tracks (edit-video-modal) |
| `resources/views/layouts/partials/upload-modal.blade.php` | (no name) `lt-track1-desc-modal` + `extra_track_descriptions[]` | Primary desc collected manually into FormData; extra-track rows generated via JS template string (`.rte-wrap` markup) |
| `resources/views/videos/create.blade.php` | `description` `video-description`, (no name) `lt-track1-desc-create`, `extra_track_descriptions[]` | Mobile upload; extra rows are JS template literal markup |
| `resources/views/videos/edit.blade.php` | `description` `edit-description`, `track_description_updates[{id}]` | Mobile edit; per-track rows rendered via Blade `@foreach` |
**Render sites (display):** `resources/views/videos/partials/description-box.blade.php` (generic/match, also music), `resources/views/videos/partials/audio-player.blade.php` (`_updateDescriptionBox` per-track switch). SPA swaps re-run `_vdbCheckOverflow()` in `generic.blade.php` / `match.blade.php`.
---
## Usage example ## Usage example
```blade ```blade

View File

@ -176,18 +176,65 @@ Rules that must never be violated:
**NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.** **NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.**
File types and their NAS locations: #### Canonical storage layout — IDENTICAL on local disk and on the NAS
| File type | NAS path | Served via | **This is the single source-of-truth file structure. Both the local `storage/app/` cache and the NAS root MUST follow this exact same tree. Never invent a different layout for one or the other — any code that writes a user file (upload, edit, sync, fallback) must produce these paths verbatim, and `videos.path` / `video_audio_tracks.path` / `video_slides.filename` / etc. store the full `users/...` path identically whether the file currently lives on NAS or local.**
```
users/{user-slug}/
├── profile/
│ ├── avatar.{ext}
│ └── cover.{ext} ← banner (DB column is users.banner)
├── playlists/
│ └── {playlist-id}/
│ └── thumb.{ext}
├── posts/
│ └── {post-id}/
│ └── {filename} ← post images
└── videos/
└── {song-slug}/ ← ONE folder per song/video. EVERYTHING for it lives here.
│ ┌──────────────── SOURCE OF TRUTH (synced to NAS) ───────┐
├── {title-slug}.{ext} ← primary track / video file (canonical name)
├── {song-slug}-{lang}-{id}.{ext} ← each extra-language audio track (one per language)
├── slides/
│ └── {position}.{ext} ← cover image(s) / slideshow frames
├── thumb.{ext} ← cover for video-type uploads that have no slides
├── meta.json ← {id, user_id, title, created_at}
│ └──────────────────────────────────────────────────────┘
└── cache/ ← REGENERABLE renders. LOCAL-ONLY. Never on NAS. Safe to wipe.
├── video.mp4 ← generated "Download Video" (plain)
├── video-viz.mp4 ← generated "Download Video" (visualizer)
└── hls/
└── {variant}/… ← adaptive-streaming rendition (.m3u8 + .ts)
```
**Sources vs. the `cache/` subfolder — a hard rule:**
- The song-folder **root** holds only the **source of truth** (primary track, extra-language tracks, slides, thumb, meta.json). These are what gets pushed to / pulled from the NAS.
- **`cache/` holds only regenerable, derived renders** — the "Download Video" mp4s and the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (`syncVideo()` pushes only the source files). Deleting `cache/` (or the whole subtree) is always safe — it rebuilds on the next download/stream. There must be **no shared `public/slideshow` or `public/hls` caches** anymore; every render lives under its song's `cache/`.
- DB pointers: `videos.slideshow_video_path``users/.../{song}/cache/video.mp4`; `videos.hls_path``users/.../{song}/cache/hls`.
- Reclaim space anytime with `php artisan nas:free-local-storage` (deletes song `cache/` folders); `tracks:reorganize` never treats anything under `cache/` as an orphan.
**Naming rules (apply on both local and NAS, always lowercase, Unicode-preserving slug via `NasSyncService::titleSlug()`):**
- **One folder per song/video** at `users/{slug}/videos/{song-slug}/`. There is **NO `tracks/` subfolder** — every audio track (primary AND secondary) sits directly in this folder.
- **Primary** keeps the canonical `{title-slug}.{ext}` (the name the NAS layer reconstructs in `uploadDirectToNas()` / `syncVideo()` — do not change this scheme).
- **Each extra-language track** is `{song-slug}-{lang}-{db-track-id}.{ext}`. The DB id makes every track filename globally unique, so no two tracks can ever overwrite each other even within the same folder/language.
- Slides are `slides/{position}.{ext}`.
File types and their canonical locations (same string on NAS and local):
| File type | Path (relative to NAS root and to `storage/app/`) | Served via |
|---|---|---| |---|---|---|
| Video / audio | `users/{slug}/videos/{title-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` | | Primary video / audio | `users/{slug}/videos/{song-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` | | Extra audio track | `users/{slug}/videos/{song-slug}/{song-slug}-{lang}-{track-id}.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` | | Video thumbnail | `users/{slug}/videos/{song-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Slides | `users/{slug}/videos/{song-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` | | Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` | | Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
| Banner | `users/{slug}/profile/banner.{ext}` | `MediaController::banner` + `ensureLocalAsset()` | | Banner | `users/{slug}/profile/cover.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` | | Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs (dry-run by default; `--force` to apply).
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS. The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations: **The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:

View File

@ -224,24 +224,27 @@ class NasFreeLocalStorage extends Command
$this->line(' Done scanning banners.'); $this->line(' Done scanning banners.');
// ── Slideshow cache directories ─────────────────────────────────────── // ── Generated/derived renders inside song folders ─────────────────────
// The slideshow/ directory is a render cache that is always regenerated on // Everything under a song's cache/ subfolder (download videos + HLS) is a
// demand, so its contents are safe to delete unconditionally. // render that regenerates on demand, so it is always safe to delete to free
// space. Sources (audio, tracks, slides) live outside cache/ and are untouched.
$this->newLine(); $this->newLine();
$this->info('Scanning slideshow cache…'); $this->info('Scanning generated renders (song cache/ folders)…');
$slideshowDir = storage_path('app/public/slideshow'); $usersRoot = storage_path('app/users');
if (is_dir($slideshowDir)) { if (is_dir($usersRoot)) {
$iterator = new \RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS) new \RecursiveDirectoryIterator($usersRoot, \FilesystemIterator::SKIP_DOTS)
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (! $file->isFile()) continue; if (! $file->isFile()) continue;
$path = $file->getPathname();
if (! str_contains($path, '/cache/')) continue;
$bytes = $file->getSize(); $bytes = $file->getSize();
$totalBytes += $bytes; $totalBytes += $bytes;
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes]; $toDelete[] = ['label' => str_contains($path, '/cache/hls/') ? 'hls' : 'download-video', 'path' => $path, 'bytes' => $bytes];
} }
$this->line(' Done scanning slideshow cache.'); $this->line(' Done scanning generated renders.');
} }
// ── NAS stream cache (nas_cache/videos/) ────────────────────────────── // ── NAS stream cache (nas_cache/videos/) ──────────────────────────────

View File

@ -0,0 +1,325 @@
<?php
namespace App\Console\Commands;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Major file-structure cleanup for audio "songs":
*
* 1. CONSOLIDATE every song's files into the song's own folder. Each file is
* gathered from wherever its local copy currently is (the canonical path OR
* the nas_cache/) and moved to:
* - primary : kept at its canonical name; promoted/legacy primaries are
* relocated to {song-folder}/{title-slug}.{ext}
* - secondary: {song-folder}/{folder-slug}-{lang}-{track-id}.{ext}
* (lowercase, unique the db id means nothing can ever overwrite anything,
* and there is no more tracks/ subfolder).
* 2. UPDATE the DB records (path + filename) to match.
* 3. DELETE orphan files (referenced by NO database column) and empty folders
* across the media roots.
*
* Moves are two-phase (src -> temp -> final) so the historical primary<->secondary
* "swaps" cannot clobber each other. Serving works straight afterwards because
* NasSyncService::ensureLocalCopy() prefers the local canonical path.
*
* Defaults to a DRY RUN. Pass --force to apply.
*/
class ReorganizeAudioTracks extends Command
{
protected $signature = 'tracks:reorganize {--force : Apply changes (default is a dry run)}';
protected $description = 'Consolidate audio tracks into one folder per song with unique names, update records, delete orphans + empty folders';
private const AUDIO_EXT = ['mp3', 'm4a', 'aac', 'wav', 'flac', 'ogg', 'opus', 'wma'];
/** Media roots (relative to storage/app) that hold ONLY user media — safe to clean. */
private const SCAN_ROOTS = ['users', 'nas_cache/videos', 'public/videos', 'public/thumbnails', 'public/avatars'];
private bool $dry = true;
private string $appRoot;
public function handle(): int
{
$this->dry = ! $this->option('force');
$this->appRoot = storage_path('app');
$this->info($this->dry ? '=== DRY RUN (no changes) — pass --force to apply ===' : '=== APPLYING CHANGES ===');
$this->newLine();
$plan = $this->buildPlan();
$this->printPlan($plan);
if (! $this->dry) {
$this->applyPlan($plan);
}
$finalPaths = $this->finalReferencedPaths($plan);
$this->handleOrphans($plan, $finalPaths);
$this->handleEmptyDirs();
$this->newLine();
$this->info($this->dry ? 'Dry run complete. Re-run with --force to apply.' : 'Done.');
return self::SUCCESS;
}
// ── helpers ────────────────────────────────────────────────────────────────
private function isAudio(Video $v): bool
{
if ($v->mime_type && str_starts_with($v->mime_type, 'audio/')) return true;
return in_array(strtolower(pathinfo($v->filename ?? '', PATHINFO_EXTENSION)), self::AUDIO_EXT, true);
}
private function titleSlug(string $title): string
{
$title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;
$slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
$slug = trim(mb_strtolower($slug), '-');
if (mb_strlen($slug) > 100) $slug = rtrim(mb_substr($slug, 0, 100), '-');
return $slug ?: 'video';
}
private function songDir(Video $v): string
{
$path = (string) $v->path;
if (str_starts_with($path, 'users/')) {
$dir = dirname($path);
if (basename($dir) === 'tracks') $dir = dirname($dir); // promoted-primary case
return $dir;
}
$userSlug = $v->user?->username ?: (string) $v->user_id;
return 'users/' . $userSlug . '/videos/' . $this->titleSlug($v->title);
}
private function trackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
/** Locate the actual local copy of a file given its DB path + filename. */
private function locate(string $dbPath, string $filename): ?string
{
foreach ([$dbPath, 'nas_cache/videos/' . $filename] as $cand) {
if ($cand && is_file($this->appRoot . '/' . $cand)) return $cand;
}
return null;
}
// ── plan ─────────────────────────────────────────────────────────────────
private function buildPlan(): array
{
$plan = [];
foreach (Video::with(['user', 'audioTracks'])->get() as $video) {
if (! $this->isAudio($video)) continue;
$dir = $this->songDir($video);
$base = basename($dir);
// ── primary ──
$pExt = strtolower(pathinfo($video->filename ?: $video->path, PATHINFO_EXTENSION)) ?: 'mp3';
$inDir = str_starts_with((string) $video->path, 'users/') && basename(dirname((string) $video->path)) !== 'tracks';
$pDst = $inDir
? (string) $video->path // already in song folder — keep its name
: $dir . '/' . $this->titleSlug($video->title) . '.' . $pExt; // promoted/legacy — canonical name
$plan[] = $this->item('primary', $video, (string) $video->path, $video->filename, $pDst);
// ── secondaries ──
foreach ($video->audioTracks as $t) {
$tExt = strtolower(pathinfo($t->filename ?: $t->path, PATHINFO_EXTENSION)) ?: 'mp3';
$tDst = $dir . '/' . $this->trackName($base, $t->language, $t->id, $tExt);
$plan[] = $this->item('track', $t, (string) $t->path, $t->filename, $tDst);
}
}
return $plan;
}
private function item(string $type, $model, string $dbPath, ?string $filename, string $dst): array
{
$src = $this->locate($dbPath, (string) $filename);
return [
'type' => $type,
'id' => $model->id,
'model' => $model,
'db_path' => $dbPath,
'src' => $src, // actual local file (or null if only on NAS)
'dst' => $dst,
'needsMove' => $src !== null && $src !== $dst,
'atDest' => $src !== null && $src === $dst,
'dbStale' => $dbPath !== $dst, // DB needs updating even if file already at dest
];
}
private function printPlan(array $plan): void
{
$moves = array_filter($plan, fn ($p) => $p['needsMove']);
$miss = array_filter($plan, fn ($p) => $p['src'] === null);
$this->line('<comment>Consolidation / rename plan:</comment>');
foreach ($plan as $p) {
if ($p['src'] === null) {
$this->line(sprintf(' <fg=red>[%s #%d] NO LOCAL COPY</> (db: %s) — skipped', $p['type'], $p['id'], $p['db_path']));
continue;
}
if ($p['needsMove']) {
$this->line(sprintf(' [%s #%d] %s', $p['type'], $p['id'], $p['src']));
$this->line(sprintf(' -> %s', $p['dst']));
}
}
$this->newLine();
$this->line(' moves: ' . count($moves) . ' | already correct: ' . (count($plan) - count($moves) - count($miss)) . ' | no local copy: ' . count($miss));
$this->newLine();
}
private function applyPlan(array $plan): void
{
// Phase 1: move every file that needs moving to a unique temp name (collision-safe).
$temps = [];
foreach ($plan as $i => $p) {
if (! $p['needsMove']) continue;
$srcAbs = $this->appRoot . '/' . $p['src'];
$tmpRel = dirname($p['dst']) . '/.reorg_' . $p['type'] . '_' . $p['id'] . '.tmp';
$tmpAbs = $this->appRoot . '/' . $tmpRel;
@mkdir(dirname($tmpAbs), 0755, true);
if (@rename($srcAbs, $tmpAbs)) {
$temps[$i] = $tmpRel;
} else {
$this->error(" FAILED phase1 move: {$p['src']}");
}
}
// Phase 2: temp -> final, then update DB.
foreach ($plan as $i => $p) {
if (isset($temps[$i])) {
$tmpAbs = $this->appRoot . '/' . $temps[$i];
$dstAbs = $this->appRoot . '/' . $p['dst'];
if (! @rename($tmpAbs, $dstAbs)) {
$this->error(" FAILED phase2 move -> {$p['dst']}");
continue;
}
$this->line(" moved #{$p['id']} -> {$p['dst']}");
}
// Update DB record whenever the stored path/name is out of date.
if ($p['src'] !== null && $p['dbStale']) {
$p['model']->update(['path' => $p['dst'], 'filename' => basename($p['dst'])]);
}
}
}
// ── orphans + empties ──────────────────────────────────────────────────────
/** All file paths (relative to storage/app) referenced by the DB after migration. */
private function finalReferencedPaths(array $plan): array
{
$paths = [];
$planned = []; // db_path keyed -> handled by plan
foreach ($plan as $p) {
if ($p['src'] !== null) { $paths[$p['dst']] = true; $planned[$p['db_path']] = true; }
else { $paths[$p['db_path']] = true; } // keep NAS-only refs
}
foreach (Video::all() as $v) {
if ($v->path && ! isset($planned[$v->path]) && ! $this->plannedPrimary($plan, $v->id)) $paths[$v->path] = true;
if ($v->thumbnail) $paths[$v->thumbnail] = true;
if ($v->slideshow_video_path) $paths[$v->slideshow_video_path] = true; // generated download video
}
foreach (VideoAudioTrack::all() as $t) {
if ($t->path && ! isset($planned[$t->path]) && ! $this->plannedTrack($plan, $t->id)) $paths[$t->path] = true;
}
foreach (DB::table('video_slides')->pluck('filename') as $f) if ($f) $paths[$f] = true;
foreach (User::all() as $u) { if ($u->avatar) $paths[$u->avatar] = true; if ($u->banner) $paths[$u->banner] = true; }
foreach (Playlist::all() as $pl) if ($pl->thumbnail) $paths[$pl->thumbnail] = true;
foreach (DB::table('post_images')->pluck('filename') as $f) if ($f) $paths[$f] = true;
foreach (DB::table('posts')->pluck('image') as $f) if ($f) $paths[$f] = true;
return $paths;
}
private function plannedPrimary(array $plan, int $id): bool
{
foreach ($plan as $p) if ($p['type'] === 'primary' && $p['id'] === $id && $p['src'] !== null) return true;
return false;
}
private function plannedTrack(array $plan, int $id): bool
{
foreach ($plan as $p) if ($p['type'] === 'track' && $p['id'] === $id && $p['src'] !== null) return true;
return false;
}
private function handleOrphans(array $plan, array $finalPaths): void
{
// Conservative: also protect any file whose basename matches a referenced basename
// or a planned move-source (sources are being moved, not orphaned).
$keepBasenames = ['meta.json' => true];
foreach (array_keys($finalPaths) as $rel) $keepBasenames[basename($rel)] = true;
foreach ($plan as $p) if ($p['src']) $keepBasenames[basename($p['src'])] = true;
// Files actively moved this run (their old location is vacated, not an orphan to re-check).
$movedFrom = [];
foreach ($plan as $p) if ($p['needsMove']) $movedFrom[$p['src']] = true;
$orphans = [];
foreach (self::SCAN_ROOTS as $root) {
$abs = $this->appRoot . '/' . $root;
if (! is_dir($abs)) continue;
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if (! $file->isFile()) continue;
$rel = substr($file->getPathname(), strlen($this->appRoot) + 1);
if (isset($finalPaths[$rel])) continue;
if (isset($keepBasenames[$file->getFilename()])) continue;
if (isset($movedFrom[$rel])) continue;
// Everything under a song's cache/ is a regenerable render — never an orphan.
if (str_contains($rel, '/cache/')) continue;
$orphans[] = $rel;
}
}
$this->newLine();
if (! $orphans) { $this->line('No orphan files found.'); return; }
$this->line('<comment>Orphan files (no DB reference) — ' . count($orphans) . ':</comment>');
$bytes = 0;
foreach ($orphans as $rel) {
$abs = $this->appRoot . '/' . $rel;
$sz = is_file($abs) ? filesize($abs) : 0; $bytes += $sz;
$this->line(sprintf(' %s (%s KB)', $rel, number_format($sz / 1024, 1)));
if (! $this->dry) @unlink($abs);
}
$this->line(' total: ' . number_format($bytes / 1048576, 1) . ' MB' . ($this->dry ? '' : ' (deleted)'));
}
private function handleEmptyDirs(): void
{
$this->newLine();
$removed = 0; $listed = [];
// Loop because removing leaf dirs can empty their parents.
do {
$found = 0;
foreach (self::SCAN_ROOTS as $root) {
$abs = $this->appRoot . '/' . $root;
if (! is_dir($abs)) continue;
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
if (! $f->isDir()) continue;
if ((new \FilesystemIterator($f->getPathname()))->valid()) continue; // not empty
$rel = substr($f->getPathname(), strlen($this->appRoot) + 1);
if (isset($listed[$rel])) continue;
$listed[$rel] = true; $found++;
if (! $this->dry) { @rmdir($f->getPathname()); $removed++; }
}
}
} while (! $this->dry && $found > 0);
if (! $listed) { $this->line('No empty folders found.'); return; }
$this->line('<comment>Empty folders — ' . count($listed) . ':</comment>');
foreach (array_keys($listed) as $rel) $this->line(' ' . $rel);
if (! $this->dry) $this->line(" ({$removed} removed)");
}
}

View File

@ -884,6 +884,9 @@ class SuperAdminController extends Controller
Setting::set('gpu_preset', $request->gpu_preset); Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary); Setting::set('ffmpeg_binary', $binary);
// GPU config changed — drop the cached health-check so the next encode re-probes.
Setting::flushGpuProbe();
return back()->with('success', 'Settings saved.'); return back()->with('success', 'Settings saved.');
} }
@ -924,21 +927,9 @@ class SuperAdminController extends Controller
*/ */
private function probeNvenc(): bool private function probeNvenc(): bool
{ {
$ffmpeg = Setting::ffmpegBinary(); // Single source of truth lives on the Setting model; force the NVENC encoder so the
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4'; // admin indicator always reflects GPU capability regardless of the configured encoder.
$device = Setting::gpuDevice(); return Setting::probeGpu('h264_nvenc');
exec(
escapeshellcmd($ffmpeg)
. ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1'
. " -c:v h264_nvenc -gpu {$device}"
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$out, $exit
);
$ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0);
@unlink($tmp);
return $ok;
} }
public function nasStorage() public function nasStorage()

View File

@ -241,7 +241,7 @@ class VideoController extends Controller
$video = Video::create([ $video = Video::create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'title' => $request->title, 'title' => $request->title,
'description' => $request->description, 'description' => \App\Support\HtmlSanitizer::clean($request->description),
'filename' => $filename, 'filename' => $filename,
'path' => $path, 'path' => $path,
'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null, 'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
@ -339,7 +339,7 @@ class VideoController extends Controller
$lang = $trackLangs[$i] ?? 'en'; $lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3'); $ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null; $title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? $trackDescs[$i] : null; $desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
// Create a placeholder record to get the DB ID for the filename // Create a placeholder record to get the DB ID for the filename
$track = VideoAudioTrack::create([ $track = VideoAudioTrack::create([
@ -354,20 +354,19 @@ class VideoController extends Controller
if ($nas->isEnabled()) { if ($nas->isEnabled()) {
try { try {
$userSlug = $nas->userSlug($video->user);
$videoDir = $nas->resolveVideoDir($video); $videoDir = $nas->resolveVideoDir($video);
$nasDir = "{$videoDir}/tracks"; $nas->mkdirp($videoDir);
$nas->mkdirp($nasDir); $trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath); $tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}"; $nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath); $nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs); @unlink($tempAbs);
$track->update([ $track->update([
'path' => $nasPath, 'path' => $nasPath,
'filename' => "{$track->id}.{$ext}", 'filename' => $trackName,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage()); \Log::error("Extra track NAS upload failed: " . $e->getMessage());
@ -501,7 +500,20 @@ class VideoController extends Controller
} }
} }
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides']); $video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', 'audioTracks']);
// Version-aware share metadata: when the link carries ?track={id}, the OG/Twitter
// tags and <title> reflect that language track (so a shared English version shows
// the English title, not the primary). Falls back to the primary when unset.
$shareTitle = $video->title;
$shareDescription = $video->description;
if ($shareTrackId = (int) $request->input('track', 0)) {
$shareTrack = $video->audioTracks->firstWhere('id', $shareTrackId);
if ($shareTrack) {
if (! empty($shareTrack->title)) $shareTitle = $shareTrack->title;
if (! empty($shareTrack->description)) $shareDescription = $shareTrack->description;
}
}
$playlist = null; $playlist = null;
$nextVideo = null; $nextVideo = null;
@ -534,7 +546,7 @@ class VideoController extends Controller
$did = $request->cookie('_did') ?: (string) Str::uuid(); $did = $request->cookie('_did') ?: (string) Str::uuid();
return response() return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos')) ->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate') ->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
} }
@ -679,6 +691,9 @@ class VideoController extends Controller
$oldTitle = $video->title; $oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']); $data = $request->only(['title', 'description', 'visibility', 'type']);
if (array_key_exists('description', $data)) {
$data['description'] = \App\Support\HtmlSanitizer::clean($data['description']);
}
$data['download_access'] = $request->input('download_access', 'disabled'); $data['download_access'] = $request->input('download_access', 'disabled');
if ($request->has('primary_language')) { if ($request->has('primary_language')) {
$data['language'] = $request->input('primary_language') ?: null; $data['language'] = $request->input('primary_language') ?: null;
@ -802,9 +817,11 @@ class VideoController extends Controller
$data['thumbnail'] = $firstSlide->filename; $data['thumbnail'] = $firstSlide->filename;
} }
// Invalidate cached slideshow video whenever slides change // Invalidate cached slideshow videos (plain + visualizer) whenever slides change
if ($slidesChanged) { if ($slidesChanged) {
@unlink(storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4')); // Slides feed every rendered variant (all tracks + visualizer) — wipe them all.
$cacheDir = dirname(storage_path('app/' . $this->slideshowRel($video, false, 0)));
foreach (glob($cacheDir . '/video*.mp4') ?: [] as $f) @unlink($f);
$data['slideshow_video_path'] = null; $data['slideshow_video_path'] = null;
} }
} }
@ -838,7 +855,7 @@ class VideoController extends Controller
foreach ($allTrackIds as $trackId) { foreach ($allTrackIds as $trackId) {
$fields = []; $fields = [];
if (array_key_exists($trackId, $titleUpdates)) $fields['title'] = $titleUpdates[$trackId] ?: null; if (array_key_exists($trackId, $titleUpdates)) $fields['title'] = $titleUpdates[$trackId] ?: null;
if (array_key_exists($trackId, $descUpdates)) $fields['description'] = $descUpdates[$trackId] ?: null; if (array_key_exists($trackId, $descUpdates)) $fields['description'] = \App\Support\HtmlSanitizer::clean($descUpdates[$trackId]) ?: null;
if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) { if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) {
$fields['language'] = $languageUpdates[$trackId]; $fields['language'] = $languageUpdates[$trackId];
$fields['label'] = strtoupper($languageUpdates[$trackId]); $fields['label'] = strtoupper($languageUpdates[$trackId]);
@ -856,17 +873,17 @@ class VideoController extends Controller
if ($nas->isEnabled()) { if ($nas->isEnabled()) {
try { try {
$videoDir = $this->nasVideoDir($video, $nas); $videoDir = $this->nasVideoDir($video, $nas);
$nasDir = "{$videoDir}/tracks"; $nas->mkdirp($videoDir);
$nas->mkdirp($nasDir); $trackName = $this->audioTrackName(basename($videoDir), $track->language, $track->id, $ext);
if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) { if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {} try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
} }
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath); $tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}"; $nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath); $nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs); @unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]); $track->update(['path' => $nasPath, 'filename' => $trackName]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage()); \Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage());
} }
@ -881,18 +898,21 @@ class VideoController extends Controller
$trackFiles = $request->file('extra_track_files'); $trackFiles = $request->file('extra_track_files');
$trackLangs = $request->input('extra_track_languages', []); $trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []); $trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
foreach ($trackFiles as $i => $trackFile) { foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue; if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en'; $lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3'); $ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null; $title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
$track = VideoAudioTrack::create([ $track = VideoAudioTrack::create([
'video_id' => $video->id, 'video_id' => $video->id,
'language' => $lang, 'language' => $lang,
'label' => strtoupper($lang), 'label' => strtoupper($lang),
'title' => $title, 'title' => $title,
'description' => $desc,
'path' => '__pending__', 'path' => '__pending__',
'filename' => '__pending__', 'filename' => '__pending__',
]); ]);
@ -900,14 +920,14 @@ class VideoController extends Controller
if ($nas->isEnabled()) { if ($nas->isEnabled()) {
try { try {
$videoDir = $this->nasVideoDir($video, $nas); $videoDir = $this->nasVideoDir($video, $nas);
$nasDir = "{$videoDir}/tracks"; $nas->mkdirp($videoDir);
$nas->mkdirp($nasDir); $trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath); $tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}"; $nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath); $nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs); @unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]); $track->update(['path' => $nasPath, 'filename' => $trackName]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed (update): " . $e->getMessage()); \Log::error("Extra track NAS upload failed (update): " . $e->getMessage());
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
@ -1447,18 +1467,32 @@ class VideoController extends Controller
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{ {
try { try {
$localDir = $nas->localVideoDir($video) . '/tracks'; // All tracks (primary + secondary) live directly in the song's own folder —
// never a separate tracks/ subfolder — with a unique, lowercase name.
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true); @mkdir($localDir, 0755, true);
$trackFile->move($localDir, "{$track->id}.{$ext}"); $base = basename($localDir);
$trackName = $this->audioTrackName($base, $track->language, $track->id, $ext);
$trackFile->move($localDir, $trackName);
$userSlug = $nas->userSlug($video->user); $userSlug = $nas->userSlug($video->user);
$videoBase = basename($nas->localVideoDir($video)); $relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
$relPath = "users/{$userSlug}/videos/{$videoBase}/tracks/{$track->id}.{$ext}"; $track->update(['path' => $relPath, 'filename' => $trackName]);
$track->update(['path' => $relPath, 'filename' => "{$track->id}.{$ext}"]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage()); \Log::error("storeTrackLocally failed: " . $e->getMessage());
} }
} }
/**
* Unique, lowercase filename for an audio track kept in the song's own folder:
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
* overwrite each other even when they share a language.
*/
private function audioTrackName(string $base, ?string $lang, int $id, string $ext): string
{
$lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
}
public function hls(Video $video, $file = 'playlist.m3u8') public function hls(Video $video, $file = 'playlist.m3u8')
{ {
if (! $video->canView(Auth::user())) { if (! $video->canView(Auth::user())) {
@ -1547,11 +1581,26 @@ class VideoController extends Controller
]); ]);
} }
// Audio-only file → generate video // Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer');
// Serve pre-generated slideshow — DB column must confirm it's the current version // Any non-primary or visualizer variant is served straight off disk (no DB column).
if ($viz || $trackId) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4',
'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
]);
}
// Not generated yet — let the player trigger background generation.
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
}
// Primary plain — DB column must confirm it's the current version.
if ($video->slideshow_video_path) { if ($video->slideshow_video_path) {
$slideshowCache = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4'); $slideshowCache = storage_path('app/' . $video->slideshow_video_path);
if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) { if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) {
return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [ return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4', 'Content-Type' => 'video/mp4',
@ -1568,9 +1617,65 @@ class VideoController extends Controller
return redirect()->route('videos.show', $video)->with('_auto_dl_video', true); return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
} }
/**
* Extract up to three dominant, saturated colours from a cover image the same
* algorithm the in-player visualizer uses (extractColors() in audio-player.blade.php)
* so the baked-in download bars match what viewers see on the page. Returns three
* lowercase hex strings ordered left middle right of the bar gradient; falls
* back to light greys when nothing usable is found.
*/
private function slideVisualizerColors(?string $imagePath): array
{
$default = ['ffffff', 'c8c8c8', 'aaaaaa'];
if (! $imagePath || ! is_file($imagePath) || ! function_exists('imagecreatefromstring')) {
return $default;
}
$raw = @file_get_contents($imagePath);
$img = $raw ? @imagecreatefromstring($raw) : false;
if (! $img) return $default;
$t = imagecreatetruecolor(24, 24);
imagecopyresampled($t, $img, 0, 0, 0, 0, 24, 24, imagesx($img), imagesy($img));
imagedestroy($img);
$buckets = [];
for ($y = 0; $y < 24; $y++) {
for ($x = 0; $x < 24; $x++) {
$p = imagecolorat($t, $x, $y);
$r = ($p >> 16) & 255; $g = ($p >> 8) & 255; $b = $p & 255;
$bright = ($r + $g + $b) / 3;
if ($bright < 25 || $bright > 230) continue; // skip near-black / near-white
$mx = max($r, $g, $b); $mn = min($r, $g, $b);
if ($mx == 0 || ($mx - $mn) / $mx < 0.25) continue; // skip low-saturation
$k = ($r >> 2) . ',' . ($g >> 2) . ',' . ($b >> 2);
if (! isset($buckets[$k])) $buckets[$k] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
$buckets[$k]['r'] += $r; $buckets[$k]['g'] += $g; $buckets[$k]['b'] += $b; $buckets[$k]['n']++;
}
}
imagedestroy($t);
if (! $buckets) return $default;
usort($buckets, fn ($a, $b) => $b['n'] - $a['n']);
$avg = fn ($c) => [$c['r'] / $c['n'], $c['g'] / $c['n'], $c['b'] / $c['n']];
// Most-common colour first, then the next colours that are visually distinct from it.
$chosen = [$avg($buckets[0])];
for ($i = 1; $i < count($buckets) && count($chosen) < 3; $i++) {
$c = $avg($buckets[$i]); $far = true;
foreach ($chosen as $e) {
if (sqrt(($e[0]-$c[0])**2 + ($e[1]-$c[1])**2 + ($e[2]-$c[2])**2) <= 60) { $far = false; break; }
}
if ($far) $chosen[] = $c;
}
while (count($chosen) < 3) $chosen[] = $chosen[0];
return array_map(fn ($c) => sprintf('%02x%02x%02x', round($c[0]), round($c[1]), round($c[2])), $chosen);
}
// ── Background slideshow generation + progress polling ──────── // ── Background slideshow generation + progress polling ────────
public function slideshowGenerate(Video $video) public function slideshowGenerate(Request $request, Video $video)
{ {
$this->checkDownloadAccess($video); $this->checkDownloadAccess($video);
@ -1578,28 +1683,55 @@ class VideoController extends Controller
return response()->json(['error' => 'Not an audio file'], 422); return response()->json(['error' => 'Not an audio file'], 422);
} }
// Version-aware: render the video from the audio the viewer is playing — the
// primary track, or a specific language track (?track={id}). The optional
// visualizer (?visualizer=1) bakes in the frequency bars. Each (track, viz)
// combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary(); $ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe'); $ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
// Audio source = the version being played.
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$audioPath = $track->localPath();
if (! file_exists($audioPath) && $nas->isEnabled()) {
$audioPath = $nas->ensureLocalTrackCopy($track) ?: $audioPath;
}
} else {
$audioPath = $video->localVideoPath(); $audioPath = $video->localVideoPath();
}
if (! file_exists($audioPath)) { if (! file_exists($audioPath)) {
return response()->json(['error' => 'Audio file not found'], 404); return response()->json(['error' => 'Audio file not found'], 404);
} }
$cacheDir = storage_path('app/public/slideshow'); $outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true); $outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . $suffix . '.txt';
$outPath = $cacheDir . '/' . $video->id . '_slideshow.mp4'; // Already cached. Only the primary plain video is tracked by the DB column; every
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt'; // other variant (a language track and/or visualizer) is guarded by file existence.
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt'; // All are invalidated together when the slides are edited (see update()).
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . '.txt'; $usesDbColumn = (! $viz && ! $trackId);
$cached = $usesDbColumn
// Already cached — only trust it when the DB column confirms it's the right version ? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
if ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0) { : (file_exists($outPath) && filesize($outPath) > 0);
if ($cached) {
return response()->json(['status' => 'ready']); return response()->json(['status' => 'ready']);
} }
// File on disk but DB says it's stale (e.g. slides were edited) — delete it // Stale file on disk (e.g. slides were edited) — delete it before regenerating
if (file_exists($outPath)) { if (file_exists($outPath)) {
@unlink($outPath); @unlink($outPath);
} }
@ -1631,16 +1763,87 @@ class VideoController extends Controller
@unlink($progressFile); @unlink($progressFile);
@unlink($pidFile); @unlink($pidFile);
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails // Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails.
$isStillImage = ($validSlides->count() === 1); // A single slide is normally a still image, but with the visualizer overlay the
// bars animate — so it must not be encoded with -tune stillimage.
$isStillImage = ($validSlides->count() === 1) && ! $viz;
$vFlags = $this->ffmpegVideoFlags($isStillImage); $vFlags = $this->ffmpegVideoFlags($isStillImage);
$cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage); $cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);
if ($viz) {
// ── Visualizer build ─────────────────────────────────────────────────
// Decode each slide ONCE and repeat it with the loop filter. Re-decoding a
// multi-megapixel PNG on every output frame (the naive `-loop 1` input) made
// this run below real-time; the loop filter makes it ~25x real-time instead.
// overlay shortest=1 bounds the otherwise-endless looped background to the audio.
$rate = 20;
$inputs = '';
$fcParts = [];
if ($validSlides->count() >= 2) { if ($validSlides->count() >= 2) {
$n = $validSlides->count(); $n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15)); $fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n; $T = ($dur + ($n - 1) * $fade) / $n;
foreach ($validSlides as $i => $slide) {
$inputs .= ' -i ' . escapeshellarg($slide->localPath());
// fps MUST come last so each branch is constant-frame-rate for xfade.
$fcParts[] = "[{$i}:v]{$scale},loop=loop=-1:size=1,trim=duration="
. number_format($T + 1, 3) . ",setpts=PTS-STARTPTS,fps={$rate}[s{$i}]";
}
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = $n;
$prev = '[s0]';
for ($i = 1; $i < $n; $i++) {
$offset = number_format($i * ($T - $fade), 3);
$outLabel = $i === $n - 1 ? '[vbase]' : "[v{$i}]";
$fcParts[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel;
}
} elseif ($validSlides->count() === 1) {
$inputs .= ' -i ' . escapeshellarg($validSlides->first()->localPath());
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]{$scale},loop=loop=-1:size=1,fps={$rate}[vbase]";
} else {
$inputs .= ' -f lavfi -i color=c=black:s=1280x720:r=' . $rate;
$inputs .= ' -i ' . escapeshellarg($audioPath);
$audioIdx = 1;
$fcParts[] = "[0:v]format=yuv420p[vbase]";
}
// Frequency bars matched to the in-player visualizer:
// • equal-width bars → fscale=lin (log made the left bars wide, right thin)
// • cover colours → render white bars (a shape+alpha mask), then tint
// them with a horizontal gradient of the cover's 3 dominant colours
// (left → middle → right), exactly like the page's canvas bars
// • translucent overlay → showfreqs already outputs a transparent backdrop,
// so we just dial the bar alpha down and composite over the artwork.
[$c0, $c1, $c2] = $this->slideVisualizerColors(
$validSlides->isNotEmpty() ? $validSlides->first()->localPath() : null
);
$fcParts[] = "[{$audioIdx}:a]showfreqs=mode=bar:ascale=log:fscale=lin:win_size=128"
. ":rate={$rate}:s=1280x180:colors=white,format=rgba[bars]";
$fcParts[] = "gradients=s=1280x180:nb_colors=3:c0=0x{$c0}:c1=0x{$c1}:c2=0x{$c2}"
. ":x0=0:y0=90:x1=1280:y1=90,format=rgba[grad]";
$fcParts[] = "[bars]alphaextract[vmask]";
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts);
$cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $audioIdx . ':a'
. ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
} elseif ($validSlides->count() >= 2) {
$n = $validSlides->count();
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
$T = ($dur + ($n - 1) * $fade) / $n;
$inputs = ''; $inputs = '';
$scaleFc = []; $scaleFc = [];
foreach ($validSlides as $i => $slide) { foreach ($validSlides as $i => $slide) {
@ -1659,7 +1862,6 @@ class VideoController extends Controller
. number_format($fade, 3) . ":offset={$offset}{$outLabel}"; . number_format($fade, 3) . ":offset={$offset}{$outLabel}";
$prev = $outLabel; $prev = $outLabel;
} }
if ($n === 1) $xfadeFc[] = '[s0]copy[vout]';
$fc = implode(';', array_merge($scaleFc, $xfadeFc)); $fc = implode(';', array_merge($scaleFc, $xfadeFc));
$cmd = "{$ffmpeg} -y{$inputs}" $cmd = "{$ffmpeg} -y{$inputs}"
@ -1689,9 +1891,10 @@ class VideoController extends Controller
$cmd .= ' -progress ' . escapeshellarg($progressFile) $cmd .= ' -progress ' . escapeshellarg($progressFile)
. ' ' . escapeshellarg($outPath); . ' ' . escapeshellarg($outPath);
// When GPU is active, wrap in a bash fallback: if GPU command fails, clear the // $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
// progress file and retry immediately with CPU (libx264) so the download still works. // GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
if (Setting::gpuEnabled() && $vFlags !== $cpuFlags) { // on CPU (libx264) and the download keeps working.
if (Setting::gpuUsable() && $vFlags !== $cpuFlags) {
$cpuCmd = str_replace($vFlags, $cpuFlags, $cmd); $cpuCmd = str_replace($vFlags, $cpuFlags, $cmd);
$inner = $cmd $inner = $cmd
. ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }'; . ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
@ -1711,9 +1914,13 @@ class VideoController extends Controller
public function slideshowProgress(Video $video) public function slideshowProgress(Video $video)
{ {
$outPath = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4'); $viz = request()->boolean('visualizer');
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt'; $trackId = (int) request()->input('track', 0);
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt'; $suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
// Read progress content once // Read progress content once
$content = file_exists($progressFile) ? file_get_contents($progressFile) : ''; $content = file_exists($progressFile) ? file_get_contents($progressFile) : '';
@ -1728,8 +1935,10 @@ class VideoController extends Controller
} }
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) { if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
if (! $video->slideshow_video_path) { // Only the plain variant is tracked by the DB column; the visualizer variant
$video->update(['slideshow_video_path' => 'public/slideshow/' . $video->id . '_slideshow.mp4']); // is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
} }
return response()->json(['percent' => 100, 'status' => 'ready']); return response()->json(['percent' => 100, 'status' => 'ready']);
} }
@ -1856,6 +2065,20 @@ class VideoController extends Controller
return Setting::ffmpegHwaccelFlags($inputIsVideo); return Setting::ffmpegHwaccelFlags($inputIsVideo);
} }
/**
* Song-folder-relative path (under storage/app) for the generated "Download Video".
* Regenerable renders live in the song's own `cache/` subfolder separated from the
* source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/
private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string
{
$nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '') . '.mp4';
}
/** /**
* Return the NAS video root directory (where thumb.jpg and slides/ live). * Return the NAS video root directory (where thumb.jpg and slides/ live).
* When the primary file was promoted from a secondary track, video->path points * When the primary file was promoted from a secondary track, video->path points
@ -1990,7 +2213,14 @@ class VideoController extends Controller
]); ]);
} }
return redirect(route('videos.showByToken', $video->share_token)) // Carry the version selector (?track=) through the redirect so the recipient opens
// the exact language the sharer was listening to.
$dest = route('videos.showByToken', $video->share_token);
if ($request->filled('track')) {
$dest .= '?track=' . (int) $request->input('track');
}
return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
} }

View File

@ -51,7 +51,9 @@ class CompressVideoJob implements ShouldQueue
]); ]);
$ffmpegVideo = $ffmpeg->open($originalPath); $ffmpegVideo = $ffmpeg->open($originalPath);
$gpuEnabled = Setting::gpuEnabled(); // Verify the GPU is actually reachable and able to encode before sending
// the file to it; otherwise fall back to CPU so the job never hangs.
$gpuEnabled = Setting::gpuUsable();
$encoder = Setting::gpuEncoder(); $encoder = Setting::gpuEncoder();
$preset = Setting::gpuPreset(); $preset = Setting::gpuPreset();
$device = Setting::gpuDevice(); $device = Setting::gpuDevice();

View File

@ -52,7 +52,12 @@ class GenerateHlsJob implements ShouldQueue
} }
} }
$hlsDir = 'public/hls/' . $video->id; // HLS rendition lives in the song's own cache/ subfolder (regenerable, local-only —
// never pushed to NAS). Fall back to the shared public/hls only for legacy rows
// whose path is not in the users/ layout.
$hlsDir = str_starts_with((string) $video->path, 'users/')
? dirname($video->path) . '/cache/hls'
: 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir); $hlsPath = storage_path('app/' . $hlsDir);
if (is_dir($hlsPath)) { if (is_dir($hlsPath)) {
@ -72,7 +77,9 @@ class GenerateHlsJob implements ShouldQueue
try { try {
$ffmpegBin = \App\Models\Setting::ffmpegBinary(); $ffmpegBin = \App\Models\Setting::ffmpegBinary();
$gpuEnabled = Setting::gpuEnabled(); // Verify the GPU is actually reachable and able to encode before sending
// the file to it; otherwise fall back to CPU so the job never hangs.
$gpuEnabled = Setting::gpuUsable();
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264 $encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
$preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264 $preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); // GPU index (0, 1, …) $device = Setting::gpuDevice(); // GPU index (0, 1, …)

View File

@ -3,6 +3,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class Setting extends Model class Setting extends Model
{ {
@ -31,6 +33,7 @@ class Setting extends Model
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')); return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
} }
/** Raw setting: is GPU encoding switched on in the admin panel? */
public static function gpuEnabled(): bool public static function gpuEnabled(): bool
{ {
return static::get('gpu_enabled', 'true') === 'true'; return static::get('gpu_enabled', 'true') === 'true';
@ -41,16 +44,105 @@ class Setting extends Model
return (int) static::get('gpu_device', '0'); return (int) static::get('gpu_device', '0');
} }
/**
* Runtime gate used by every encode path: the GPU is switched on AND it is
* actually reachable and able to encode right now. Unlike gpuEnabled() this
* runs a live smoke-test (cached briefly) so a misconfigured / unplugged /
* driver-mismatched GPU automatically routes work to the CPU instead of
* producing a hung or failed encode.
*/
public static function gpuUsable(): bool
{
if (! static::gpuEnabled()) {
return false;
}
// Cache the smoke-test result briefly, but never let a cache backend problem
// (e.g. an unwritable cache dir) break an encode — fall back to a direct probe.
try {
return (bool) Cache::remember('gpu_usable_probe', 60, fn () => static::probeGpu());
} catch (\Throwable $e) {
return static::probeGpu();
}
}
/** Forget the cached probe result — call this whenever GPU settings change. */
public static function flushGpuProbe(): void
{
try {
Cache::forget('gpu_usable_probe');
} catch (\Throwable $e) {
// ignore — a missing/unwritable cache just means the next call re-probes
}
}
/**
* Smoke-test the GPU encode path. Two checks:
* 1. the device is visible to the NVIDIA driver (nvidia-smi), and
* 2. the encoder actually produces a frame.
* Step 2 is the decisive one it catches CUDA / driver-version mismatches
* that nvidia-smi cannot see. Returns true only if the GPU can really encode.
*
* @param string|null $encoder Encoder to test; defaults to the configured one.
*/
public static function probeGpu(?string $encoder = null): bool
{
$encoder = $encoder ?: static::get('gpu_encoder', 'h264_nvenc');
$device = static::gpuDevice();
$isNvenc = str_contains($encoder, 'nvenc');
// 1) Device visible to the driver? (catches unplugged card / unloaded module)
if ($isNvenc) {
@exec('nvidia-smi -i ' . (int) $device
. ' --query-gpu=name --format=csv,noheader,nounits 2>/dev/null', $smi, $smiExit);
if ($smiExit !== 0 || trim(implode('', $smi)) === '') {
Log::warning('GPU probe: device not visible via nvidia-smi — using CPU', [
'device' => $device,
]);
return false;
}
}
// 2) Encoder actually works? (encode a single throwaway frame)
$ffmpeg = static::ffmpegBinary();
$tmp = sys_get_temp_dir() . '/gpu_probe_' . getmypid() . '_' . uniqid() . '.mp4';
$gpuArg = $isNvenc ? ' -gpu ' . (int) $device : '';
// 256x144 is the smallest 16:9 size NVENC will accept (it rejects tiny frames
// such as 128x72 with "Frame Dimension less than the minimum supported value").
@exec(
escapeshellcmd($ffmpeg)
. ' -hide_banner -loglevel error'
. ' -f lavfi -i color=c=black:s=256x144:r=1 -frames:v 1'
. ' -c:v ' . escapeshellarg($encoder) . $gpuArg
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$o, $exit
);
$ok = ($exit === 0 && is_file($tmp) && filesize($tmp) > 0);
@unlink($tmp);
if (! $ok) {
Log::warning('GPU probe: encode smoke-test failed — using CPU', [
'encoder' => $encoder,
'device' => $device,
'binary' => $ffmpeg,
]);
}
return $ok;
}
public static function gpuEncoder(): string public static function gpuEncoder(): string
{ {
return static::gpuEnabled() return static::gpuUsable()
? static::get('gpu_encoder', 'h264_nvenc') ? static::get('gpu_encoder', 'h264_nvenc')
: 'libx264'; : 'libx264';
} }
public static function gpuPreset(): string public static function gpuPreset(): string
{ {
return static::gpuEnabled() return static::gpuUsable()
? static::get('gpu_preset', 'p4') ? static::get('gpu_preset', 'p4')
: 'fast'; : 'fast';
} }
@ -63,7 +155,7 @@ class Setting extends Model
/** Returns the full video codec flags for FFmpeg shell commands. */ /** Returns the full video codec flags for FFmpeg shell commands. */
public static function ffmpegVideoFlags(bool $stillImage = false): string public static function ffmpegVideoFlags(bool $stillImage = false): string
{ {
if (static::gpuEnabled()) { if (static::gpuUsable()) {
$enc = static::get('gpu_encoder', 'h264_nvenc'); $enc = static::get('gpu_encoder', 'h264_nvenc');
$preset = static::get('gpu_preset', 'p4'); $preset = static::get('gpu_preset', 'p4');
$device = static::gpuDevice(); $device = static::gpuDevice();
@ -84,7 +176,7 @@ class Setting extends Model
/** Returns hwaccel decode flags when the input source is a video file. */ /** Returns hwaccel decode flags when the input source is a video file. */
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
{ {
if (! $inputIsVideo || ! static::gpuEnabled()) return ''; if (! $inputIsVideo || ! static::gpuUsable()) return '';
$hwaccel = static::get('gpu_hwaccel', 'cuda'); $hwaccel = static::get('gpu_hwaccel', 'cuda');
$device = static::gpuDevice(); $device = static::gpuDevice();
return "-hwaccel {$hwaccel} -hwaccel_device {$device} "; return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";

View File

@ -0,0 +1,223 @@
<?php
namespace App\Support;
use DOMDocument;
use DOMElement;
use DOMNode;
/**
* Allowlist-based HTML sanitizer for rich-text descriptions.
*
* Descriptions are authored in a self-hosted contenteditable editor and stored
* as HTML. clean() strips everything not on the allowlist before storage;
* render() produces safe display HTML (and upgrades legacy plain-text values).
*/
class HtmlSanitizer
{
/** Tags allowed in stored description HTML. */
private const ALLOWED_TAGS = [
'p', 'br', 'div', 'span',
'b', 'strong', 'i', 'em', 'u', 's', 'strike',
'h2', 'h3',
'ul', 'ol', 'li',
'blockquote', 'a',
];
/** CSS class values permitted on <a> (button-link styling). */
private const ALLOWED_CLASSES = [
'action-btn', 'action-btn-primary', 'action-btn-danger',
'action-btn-link', 'primary', 'danger', 'icon-only',
];
/** URL schemes permitted in href. */
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto'];
/** Tags removed wholesale, including their text content. */
private const DROP_TAGS = [
'script', 'style', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option', 'link', 'meta',
'svg', 'math', 'noscript', 'template',
];
/**
* Sanitize untrusted HTML down to the allowlist, for storage.
*/
public static function clean(?string $html): string
{
$html = trim((string) $html);
if ($html === '') {
return '';
}
// Wrap so DOMDocument has a single root and a known encoding.
$wrapped = '<?xml encoding="UTF-8"><div id="__rte_root__">' . $html . '</div>';
$doc = new DOMDocument();
$libxmlPrev = libxml_use_internal_errors(true);
$doc->loadHTML($wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($libxmlPrev);
$root = $doc->getElementById('__rte_root__');
if (!$root) {
return '';
}
self::sanitizeChildren($root);
$out = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$out .= $doc->saveHTML($child);
}
return self::tidy($out);
}
/**
* Produce safe display HTML. Legacy plain-text values (no tags) are
* escaped and converted to <br>; rich values are run through clean().
*/
public static function render(?string $value): string
{
$value = (string) $value;
if (trim($value) === '') {
return '';
}
if (strip_tags($value) === $value) {
return nl2br(e($value), false);
}
return self::clean($value);
}
private static function sanitizeChildren(DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof DOMElement) {
self::sanitizeElement($child);
} elseif ($child->nodeType === XML_COMMENT_NODE) {
$child->parentNode->removeChild($child);
}
// Text nodes are kept as-is (saveHTML re-encodes them safely).
}
}
private static function sanitizeElement(DOMElement $el): void
{
$tag = strtolower($el->nodeName);
if (in_array($tag, self::DROP_TAGS, true)) {
$el->parentNode->removeChild($el);
return;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
// Unwrap: keep sanitized children, drop the disallowed wrapper.
self::sanitizeChildren($el);
$parent = $el->parentNode;
while ($el->firstChild) {
$parent->insertBefore($el->firstChild, $el);
}
$parent->removeChild($el);
return;
}
// Strip every attribute, then re-add only the allowed ones.
$keep = [];
$alignAttr = null;
foreach (iterator_to_array($el->attributes) as $attr) {
$name = strtolower($attr->nodeName);
$val = $attr->nodeValue;
if ($name === 'align') {
$a = strtolower(trim($val));
if (in_array($a, ['left', 'right', 'center', 'justify'], true)) {
$alignAttr = $a;
}
} elseif ($name === 'href' && $tag === 'a') {
$href = self::safeUrl($val);
if ($href !== null) {
$keep['href'] = $href;
}
} elseif ($name === 'class') {
$classes = array_values(array_intersect(
preg_split('/\s+/', trim($val)) ?: [],
self::ALLOWED_CLASSES
));
if ($classes) {
$keep['class'] = implode(' ', $classes);
}
} elseif ($name === 'style') {
$style = self::safeStyle($val);
if ($style !== '') {
$keep['style'] = $style;
}
} elseif ($name === 'target' && $tag === 'a') {
if ($val === '_blank') {
$keep['target'] = '_blank';
}
}
}
// Fold a legacy align="" attribute into the text-align style.
if ($alignAttr !== null && !str_contains($keep['style'] ?? '', 'text-align')) {
$keep['style'] = trim(($keep['style'] ?? '') . ';text-align:' . $alignAttr, ';');
}
while ($el->attributes->length) {
$el->removeAttribute($el->attributes->item(0)->nodeName);
}
foreach ($keep as $name => $val) {
$el->setAttribute($name, $val);
}
// Any link opening a new tab gets rel protection.
if ($tag === 'a' && ($keep['target'] ?? '') === '_blank') {
$el->setAttribute('rel', 'noopener noreferrer');
}
self::sanitizeChildren($el);
}
private static function safeUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
// Reject control chars that could smuggle a scheme.
$stripped = preg_replace('/[\x00-\x20]+/', '', $url);
if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', $stripped, $m)) {
if (!in_array(strtolower($m[1]), self::ALLOWED_SCHEMES, true)) {
return null;
}
}
// Allow relative URLs and fragments/anchors as-is.
return $url;
}
private static function safeStyle(?string $style): string
{
$out = [];
foreach (explode(';', (string) $style) as $decl) {
if (!str_contains($decl, ':')) {
continue;
}
[$prop, $val] = array_map('trim', explode(':', $decl, 2));
$prop = strtolower($prop);
$val = strtolower($val);
if ($prop === 'text-align' && in_array($val, ['left', 'right', 'center', 'justify'], true)) {
$out[] = "text-align:{$val}";
}
}
return implode(';', $out);
}
private static function tidy(string $html): string
{
// DOMDocument can emit empty paragraphs/divs from editor churn.
$html = preg_replace('#<(p|div)>(\s|&nbsp;|<br\s*/?>)*</\1>#i', '', $html);
return trim((string) $html);
}
}

View File

@ -11,6 +11,7 @@
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size) $outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on $targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview $previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
@endphp @endphp
@once @once
@ -202,8 +203,22 @@
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }}; var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
var previewImgId = '{{ $previewImg }}'; var previewImgId = '{{ $previewImg }}';
var resultCbName = '{{ $resultCallback }}'; // callback mode: name of a global fn given the cropped File
var isFormMode = targetInputId !== ''; var isFormMode = targetInputId !== '';
var isCallbackMode = resultCbName !== '';
// In callback mode the host function decides when to close / advance, so we
// never auto-close here — we just hand the File back and reset the button.
function deliverResult(file) {
if (typeof window[resultCbName] === 'function') window[resultCbName](file);
var sb = document.getElementById('tcSaveBtn_' + id);
var st = document.getElementById('tcSaveBtnText_' + id);
var ab = document.getElementById('tcAsIsBtn_' + id);
if (sb) sb.disabled = false;
if (ab) ab.disabled = false;
if (st) st.textContent = 'Crop & Save';
}
function getCsrf() { function getCsrf() {
var m = document.querySelector('meta[name="csrf-token"]'); var m = document.querySelector('meta[name="csrf-token"]');
@ -343,6 +358,11 @@
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' }; var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
cropperInst.crop(cropOpts).then(function (base64) { cropperInst.crop(cropOpts).then(function (base64) {
if (isCallbackMode) {
var cbName = originalFile ? originalFile.name : 'cropped.png';
deliverResult(base64ToFile(base64, cbName));
return;
}
if (isFormMode) { if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png'; var fname = originalFile ? originalFile.name : 'cropped.png';
setOnTargetInput(base64ToFile(base64, fname)); setOnTargetInput(base64ToFile(base64, fname));
@ -370,7 +390,9 @@
window['tcUploadAsIs_' + id] = function () { window['tcUploadAsIs_' + id] = function () {
if (!originalFile) { window.closeCropperModal(id); return; } if (!originalFile) { window.closeCropperModal(id); return; }
if (isFormMode) { if (isCallbackMode) {
deliverResult(originalFile);
} else if (isFormMode) {
// Put the original (un-cropped) file on the target input so the form sees it. // Put the original (un-cropped) file on the target input so the form sees it.
setOnTargetInput(originalFile); setOnTargetInput(originalFile);
window.closeCropperModal(id); window.closeCropperModal(id);

View File

@ -0,0 +1,304 @@
@props([
'name' => '',
'id' => null,
'value' => '',
'placeholder' => 'Tell viewers about this content…',
'class' => '',
'style' => '',
'minHeight' => '110px',
])
@php
$rid = $id ?: 'rte-' . \Illuminate\Support\Str::random(6);
@endphp
{{-- ── Shared CSS (once per page) ─────────────────────────────────────────── --}}
@once('rte-styles')
<style>
.rte-wrap { position: relative; }
.rte-source { display: none !important; }
.rte-toolbar {
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030); border-bottom: none;
border-radius: 8px 8px 0 0; padding: 5px 6px;
}
.rte-tbtn {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border: none; border-radius: 6px;
background: none; color: var(--text-secondary, #aaa);
font-size: 15px; cursor: pointer; transition: background .12s, color .12s; outline: none;
}
.rte-tbtn:hover { background: rgba(255,255,255,.08); color: var(--text-primary, #f1f1f1); }
.rte-tbtn.active { background: rgba(230,30,30,.18); color: var(--brand-red, #e61e1e); }
.rte-sep { width: 1px; height: 18px; background: var(--border-color, #303030); margin: 0 3px; flex-shrink: 0; }
.rte-editable {
background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030); border-radius: 0 0 8px 8px;
padding: 10px 12px; color: var(--text-primary, #f1f1f1);
font-size: 13px; line-height: 1.6; font-family: inherit;
outline: none; overflow-y: auto; word-break: break-word;
}
.rte-editable:focus { border-color: var(--brand-red, #e61e1e); }
.rte-editable:empty::before { content: attr(data-placeholder); color: var(--text-secondary, #777); pointer-events: none; }
.rte-editable p { margin: 0 0 8px; }
.rte-editable p:last-child { margin-bottom: 0; }
.rte-editable h2 { font-size: 18px; font-weight: 700; margin: 4px 0 8px; }
.rte-editable h3 { font-size: 15px; font-weight: 700; margin: 4px 0 6px; }
.rte-editable ul, .rte-editable ol { margin: 0 0 8px; padding-left: 22px; }
.rte-editable blockquote { margin: 0 0 8px; padding-left: 12px; border-left: 3px solid var(--border-color, #303030); color: var(--text-secondary, #aaa); }
.rte-editable a { color: #3ea6ff; }
.rte-editable a.action-btn, .rte-editable a.action-btn-primary, .rte-editable a.action-btn-danger {
color: inherit; text-decoration: none;
}
/* inline popover (link / button / emoji) */
.rte-pop {
position: absolute; z-index: 1090; min-width: 240px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); padding: 10px;
}
.rte-pop[hidden] { display: none; }
.rte-pop label { display: block; font-size: 11px; color: var(--text-secondary, #aaa); margin: 0 0 4px; }
.rte-pop input[type=text] {
width: 100%; box-sizing: border-box; background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030); border-radius: 6px;
padding: 7px 9px; color: var(--text-primary, #f1f1f1); font-size: 13px; outline: none; margin-bottom: 8px;
}
.rte-pop input[type=text]:focus { border-color: var(--brand-red, #e61e1e); }
.rte-pop-actions { display: flex; gap: 6px; justify-content: flex-end; }
.rte-pop-btn { border: none; border-radius: 6px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
.rte-pop-btn.cancel { background: rgba(255,255,255,.08); color: var(--text-secondary, #aaa); }
.rte-pop-btn.ok { background: var(--brand-red, #e61e1e); color: #fff; }
.rte-emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; max-height: 180px; overflow-y: auto; }
.rte-emoji-grid button { border: none; background: none; font-size: 18px; line-height: 1; padding: 5px; border-radius: 6px; cursor: pointer; }
.rte-emoji-grid button:hover { background: rgba(255,255,255,.1); }
</style>
@endonce
{{-- ── Engine (once per page) ─────────────────────────────────────────────── --}}
@once('rte-script')
<script>
(function () {
if (window.RTE) return;
var EMOJI = ('😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 😉 😍 🥰 😘 😎 🤩 🥳 🤔 🤨 😐 😴 😭 😡 🥺 😱 '
+ '👍 👎 👏 🙌 🙏 💪 🔥 ✨ ⭐ 🌟 💯 ✅ ❌ ❓ ❗ ❤️ 🧡 💛 💚 💙 💜 🖤 💔 💕 💖 '
+ '🎉 🎊 🎁 🏆 🥇 ⚽ 🏀 🏈 🎵 🎶 🎬 📺 📱 💻 📷 🎤 🎮 🚀 🌍 ☀️ 🌙 ⚡ 💧 🍀').split(' ');
function saveSel(ed) {
var s = window.getSelection();
if (s && s.rangeCount && ed.contains(s.anchorNode)) ed._range = s.getRangeAt(0).cloneRange();
}
function restoreSel(ed) {
ed.focus();
if (ed._range) {
var s = window.getSelection();
s.removeAllRanges();
s.addRange(ed._range);
}
}
function exec(ed, cmd, val) { restoreSel(ed); document.execCommand(cmd, false, val || null); sync(ed); }
// Alignment must be emitted as CSS text-align (not the legacy align attribute,
// which the server sanitizer strips), so toggle styleWithCSS just for these.
function execAlign(ed, cmd) {
restoreSel(ed);
try { document.execCommand('styleWithCSS', false, true); } catch (e) {}
document.execCommand(cmd, false, null);
try { document.execCommand('styleWithCSS', false, false); } catch (e) {}
sync(ed);
}
function sync(ed) {
var src = ed._source;
var html = ed.innerHTML.trim();
if (html === '<br>' || html === '<div><br></div>' || html === '<p><br></p>') html = '';
src.value = html;
src.dispatchEvent(new Event('input', { bubbles: true }));
src.dispatchEvent(new Event('change', { bubbles: true }));
}
function tbtn(icon, title, fn) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'rte-tbtn'; b.title = title;
b.innerHTML = '<i class="bi bi-' + icon + '"></i>';
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
b.addEventListener('click', function (e) { e.preventDefault(); fn(e, b); });
return b;
}
function sep() { var s = document.createElement('span'); s.className = 'rte-sep'; return s; }
function buildPopover(wrap, ed) {
var pop = document.createElement('div');
pop.className = 'rte-pop'; pop.hidden = true;
wrap.appendChild(pop);
document.addEventListener('click', function (e) {
if (!pop.hidden && !pop.contains(e.target) && !wrap.querySelector('.rte-toolbar').contains(e.target)) pop.hidden = true;
});
return pop;
}
function openPop(pop, anchorBtn, html) {
pop.innerHTML = html;
pop.hidden = false;
var tb = anchorBtn.closest('.rte-toolbar');
pop.style.top = (tb.offsetTop + tb.offsetHeight + 4) + 'px';
pop.style.left = Math.min(anchorBtn.offsetLeft, tb.offsetWidth - 250) + 'px';
var f = pop.querySelector('input[type=text]');
if (f) setTimeout(function () { f.focus(); }, 10);
}
function linkForm(pop, ed, anchorBtn, asButton) {
saveSel(ed);
var sel = window.getSelection();
var selText = (sel && ed.contains(sel.anchorNode)) ? sel.toString() : '';
openPop(pop, anchorBtn,
'<label>' + (asButton ? 'Button label' : 'Link text') + '</label>'
+ '<input type="text" class="rte-f-text" value="' + selText.replace(/"/g, '&quot;') + '" placeholder="' + (asButton ? 'Click here' : 'Text to show') + '">'
+ '<label>URL</label>'
+ '<input type="text" class="rte-f-url" placeholder="https://…">'
+ '<div class="rte-pop-actions"><button type="button" class="rte-pop-btn cancel">Cancel</button>'
+ '<button type="button" class="rte-pop-btn ok">Insert</button></div>');
pop.querySelector('.cancel').onclick = function () { pop.hidden = true; };
pop.querySelector('.ok').onclick = function () {
var txt = pop.querySelector('.rte-f-text').value.trim();
var url = pop.querySelector('.rte-f-url').value.trim();
if (!url) { pop.querySelector('.rte-f-url').focus(); return; }
if (!/^([a-z][a-z0-9+.\-]*:|\/|#)/i.test(url)) url = 'https://' + url;
txt = txt || url;
var esc = function (s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var cls = asButton ? ' class="action-btn action-btn-primary"' : '';
var a = '<a href="' + esc(url) + '" target="_blank"' + cls + '>' + esc(txt) + '</a>';
restoreSel(ed);
document.execCommand('insertHTML', false, a + (asButton ? '<span>&nbsp;</span>' : ''));
sync(ed);
pop.hidden = true;
};
}
function emojiForm(pop, ed, anchorBtn) {
saveSel(ed);
var grid = '<div class="rte-emoji-grid">';
for (var i = 0; i < EMOJI.length; i++) grid += '<button type="button" data-e="' + EMOJI[i] + '">' + EMOJI[i] + '</button>';
grid += '</div>';
openPop(pop, anchorBtn, grid);
pop.querySelectorAll('.rte-emoji-grid button').forEach(function (b) {
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
b.addEventListener('click', function () {
restoreSel(ed);
document.execCommand('insertText', false, b.dataset.e);
sync(ed);
pop.hidden = true;
});
});
}
window.RTE = {
init: function (wrap) {
if (!wrap || wrap.dataset.rteReady) return;
var src = wrap.querySelector('.rte-source');
if (!src) return;
// Skip un-instantiated templates whose placeholders aren't filled in yet
// (e.g. edit-__TPL__-desc / __DESCNAME__). Closed modals are aria-hidden
// but must still init, so we only key off the placeholder marker here.
var marker = (src.id || '') + '|' + (src.getAttribute('name') || '');
if (marker.indexOf('__') !== -1) return;
wrap.dataset.rteReady = '1';
var toolbar = document.createElement('div');
toolbar.className = 'rte-toolbar';
var ed = document.createElement('div');
ed.className = 'rte-editable';
ed.contentEditable = 'true';
ed.setAttribute('data-placeholder', src.getAttribute('placeholder') || '');
ed.style.minHeight = wrap.dataset.minHeight || '110px';
ed.innerHTML = src.value || '';
ed._source = src;
src._editable = ed;
var pop = null;
function P() { return pop || (pop = buildPopover(wrap, ed)); }
toolbar.appendChild(tbtn('type-bold', 'Bold', function () { exec(ed, 'bold'); }));
toolbar.appendChild(tbtn('type-italic', 'Italic', function () { exec(ed, 'italic'); }));
toolbar.appendChild(tbtn('type-underline', 'Underline', function () { exec(ed, 'underline'); }));
toolbar.appendChild(tbtn('type-strikethrough', 'Strikethrough', function () { exec(ed, 'strikeThrough'); }));
toolbar.appendChild(sep());
toolbar.appendChild(tbtn('type-h2', 'Heading', function () {
var inH = document.queryCommandValue('formatBlock').toLowerCase();
exec(ed, 'formatBlock', (inH === 'h2' || inH === '<h2>') ? 'p' : 'h2');
}));
toolbar.appendChild(tbtn('list-ul', 'Bulleted list', function () { exec(ed, 'insertUnorderedList'); }));
toolbar.appendChild(tbtn('list-ol', 'Numbered list', function () { exec(ed, 'insertOrderedList'); }));
toolbar.appendChild(tbtn('quote', 'Quote', function () { exec(ed, 'formatBlock', 'blockquote'); }));
toolbar.appendChild(sep());
toolbar.appendChild(tbtn('text-left', 'Align left', function () { execAlign(ed, 'justifyLeft'); }));
toolbar.appendChild(tbtn('text-center', 'Align center', function () { execAlign(ed, 'justifyCenter'); }));
toolbar.appendChild(tbtn('text-right', 'Align right', function () { execAlign(ed, 'justifyRight'); }));
toolbar.appendChild(sep());
toolbar.appendChild(tbtn('link-45deg', 'Insert link', function (e, b) { linkForm(P(), ed, b, false); }));
toolbar.appendChild(tbtn('hand-index-thumb', 'Insert button link', function (e, b) { linkForm(P(), ed, b, true); }));
toolbar.appendChild(tbtn('emoji-smile', 'Insert emoji', function (e, b) { emojiForm(P(), ed, b); }));
toolbar.appendChild(sep());
toolbar.appendChild(tbtn('eraser', 'Clear formatting', function () { exec(ed, 'removeFormat'); }));
src.parentNode.insertBefore(toolbar, src);
src.parentNode.insertBefore(ed, src);
ed.addEventListener('input', function () { sync(ed); });
ed.addEventListener('keyup', function () { saveSel(ed); });
ed.addEventListener('mouseup', function () { saveSel(ed); });
ed.addEventListener('blur', function () { saveSel(ed); sync(ed); });
// Paste as plain text to avoid junk markup.
ed.addEventListener('paste', function (e) {
e.preventDefault();
var t = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, t);
sync(ed);
});
// Keep editor in sync if external code rewrites the textarea value.
src.addEventListener('rte:refresh', function () { ed.innerHTML = src.value || ''; });
var form = src.closest('form');
if (form && !form.dataset.rteSubmitBound) {
form.dataset.rteSubmitBound = '1';
form.addEventListener('submit', function () {
form.querySelectorAll('.rte-editable').forEach(function (e2) { if (e2._source) sync(e2); });
}, true);
}
},
initAll: function (root) {
(root || document).querySelectorAll('.rte-wrap:not([data-rte-ready])').forEach(function (w) { RTE.init(w); });
}
};
// Auto-init any editor markup added later (modals, JS-cloned track rows).
var mo = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
for (var j = 0; j < muts[i].addedNodes.length; j++) {
var n = muts[i].addedNodes[j];
if (n.nodeType !== 1) continue;
if (n.classList && n.classList.contains('rte-wrap')) RTE.init(n);
if (n.querySelectorAll) n.querySelectorAll('.rte-wrap:not([data-rte-ready])').forEach(function (w) { RTE.init(w); });
}
}
});
function rteBoot() {
RTE.initAll();
if (document.body) mo.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', rteBoot);
} else {
rteBoot();
}
}());
</script>
@endonce
{{-- ── Instance ───────────────────────────────────────────────────────────── --}}
<div class="rte-wrap {{ $class }}" data-min-height="{{ $minHeight }}" @if($style) style="{{ $style }}" @endif>
<textarea class="rte-source"
name="{{ $name }}"
id="{{ $rid }}"
placeholder="{{ $placeholder }}">{{ $value }}</textarea>
</div>

View File

@ -207,15 +207,10 @@
</div> </div>
</div> </div>
{{-- ── Description ────────────────────────────────────────────── --}} {{-- ── Description (rich text) ────────────────────────────────── --}}
<div class="um-field"> <div class="um-field">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description @if(!$isPrimary)<span class="um-lbl-hint">optional</span>@endif</label> <label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description @if(!$isPrimary)<span class="um-lbl-hint">optional</span>@endif</label>
<textarea name="{{ $descName }}" <x-rich-text-editor :name="$descName" :id="$descId" placeholder="Tell viewers about this content…" />
id="{{ $descId }}"
class="um-input um-textarea"
rows="3"
style="font-size:13px;padding:9px 12px;"
placeholder="Tell viewers about this content…"></textarea>
</div> </div>
{{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}} {{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}}

View File

@ -168,7 +168,7 @@
@if ($video->isShareable()) @if ($video->isShareable())
<button class="action-btn desktop-action" <button class="action-btn desktop-action"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')"> onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i><span>Share</span> <i class="bi bi-share"></i><span>Share</span>
</button> </button>
@endif @endif
@ -271,7 +271,7 @@
@if ($video->isShareable()) @if ($video->isShareable())
<button class="dropdown-item" <button class="dropdown-item"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')"> onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share <i class="bi bi-share"></i> Share
</button> </button>
@endif @endif
@ -514,6 +514,14 @@ if (!window._slideshowDlInit) {
document.getElementById('sl-dl-cancel').textContent = 'Close'; document.getElementById('sl-dl-cancel').textContent = 'Close';
} }
// Share the version the viewer is on: append ?track={id} so the recipient's player
// opens directly on that language (window._ytpTrackId; 0 = primary → plain link).
window.shareCurrent = function (baseUrl, title, recordUrl) {
var t = window._ytpTrackId || 0;
var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl;
if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl);
};
window.startSlideshowDownload = function (routeKey) { window.startSlideshowDownload = function (routeKey) {
if (_currentKey && _currentKey !== routeKey) { if (_currentKey && _currentKey !== routeKey) {
// Different video — reset // Different video — reset
@ -521,6 +529,17 @@ if (!window._slideshowDlInit) {
_pollTimer = null; _pollTimer = null;
} }
_currentKey = routeKey; _currentKey = routeKey;
// Act on the version the viewer is currently playing: the visualizer toggle AND
// the selected language track (window._ytpTrackId; 0 = primary).
var vizOn = localStorage.getItem('audioBarsOn') === '1';
var trackId = window._ytpTrackId || 0;
var _p = [];
if (vizOn) _p.push('visualizer=1');
if (trackId) _p.push('track=' + trackId);
var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download
var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration=
_maxPct = 0; _maxPct = 0;
document.getElementById('sl-dl-bar').style.background = ''; // restore gradient document.getElementById('sl-dl-bar').style.background = ''; // restore gradient
document.getElementById('sl-dl-cancel').textContent = 'Cancel'; document.getElementById('sl-dl-cancel').textContent = 'Cancel';
@ -532,7 +551,7 @@ if (!window._slideshowDlInit) {
var csrfMeta = document.querySelector('meta[name="csrf-token"]'); var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : ''); var token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
fetch('/videos/' + routeKey + '/slideshow/generate', { fetch('/videos/' + routeKey + '/slideshow/generate' + qs, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' } headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
}) })
@ -546,7 +565,7 @@ if (!window._slideshowDlInit) {
document.getElementById('sl-dl-cancel').style.display = 'none'; document.getElementById('sl-dl-cancel').style.display = 'none';
setTimeout(function () { setTimeout(function () {
window._slideshowDlCancel(); window._slideshowDlCancel();
window.location.href = '/videos/' + routeKey + '/download'; window.location.href = '/videos/' + routeKey + '/download' + qs;
}, 600); }, 600);
return; return;
} }
@ -559,7 +578,7 @@ if (!window._slideshowDlInit) {
_setProgress(2, 'Generating video...'); _setProgress(2, 'Generating video...');
_pollTimer = setInterval(function () { _pollTimer = setInterval(function () {
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration, { fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration + qsAmp, {
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@ -581,7 +600,7 @@ if (!window._slideshowDlInit) {
document.getElementById('sl-dl-cancel').style.display = 'none'; document.getElementById('sl-dl-cancel').style.display = 'none';
setTimeout(function () { setTimeout(function () {
window._slideshowDlCancel(); window._slideshowDlCancel();
window.location.href = '/videos/' + routeKey + '/download'; window.location.href = '/videos/' + routeKey + '/download' + qs;
}, 600); }, 600);
return; return;
} }

View File

@ -269,7 +269,7 @@ function openEditVideoModal(videoId) {
const titleEl = document.getElementById('edit-track1-title'); const titleEl = document.getElementById('edit-track1-title');
const descEl = document.getElementById('edit-track1-desc'); const descEl = document.getElementById('edit-track1-desc');
if (titleEl) titleEl.value = v.title || ''; if (titleEl) titleEl.value = v.title || '';
if (descEl) descEl.value = v.description || ''; if (descEl) { descEl.value = v.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
_editSetLangSelect(v.language || ''); _editSetLangSelect(v.language || '');
// Thumbnail // Thumbnail
@ -620,16 +620,50 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
function editHandleSlides(fileList, tid) { function editHandleSlides(fileList, tid) {
tid = tid || 't1'; tid = tid || 't1';
if (!fileList || !fileList.length) return; _editSlidesCropStart(fileList, tid, _editRenderSlides);
if (!_editSlidesData[tid]) _editSlidesData[tid] = []; }
for (const f of Array.from(fileList)) {
if (_editSlidesData[tid].length >= 10) break; // ── Slides crop queue: every added image is cropped before it enters the strip ──
const reader = new FileReader(); let _editSlidesCropQueue = [];
reader.onload = ev => { _editSlidesData[tid].push({ file: f, url: ev.target.result }); _editRenderSlides(tid); }; let _editSlidesCropTid = null;
reader.readAsDataURL(f); let _editSlidesCropRender = null;
function _editSlidesCropStart(fileList, tid, renderFn) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_editSlidesCropTid = tid;
_editSlidesCropRender = renderFn;
_editSlidesCropQueue = imgs;
_editSlidesCropLoadNext();
}
function _editSlidesCropLoadNext() {
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
const f = _editSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit === 'function') {
window.tcPreload_slides_edit(f);
window.openCropperModal_slides_edit();
} }
} }
function editSlidesCropDone(file) {
const tid = _editSlidesCropTid;
if (tid && file) {
if (!_editSlidesData[tid]) _editSlidesData[tid] = [];
if (_editSlidesData[tid].length < 10) {
const reader = new FileReader();
reader.onload = ev => {
_editSlidesData[tid].push({ file, url: ev.target.result });
if (_editSlidesCropRender) _editSlidesCropRender(tid);
_editSlidesCropLoadNext();
};
reader.readAsDataURL(file);
return;
}
}
_editSlidesCropLoadNext();
}
function editClearSlides(e, tid) { function editClearSlides(e, tid) {
tid = tid || 't1'; tid = tid || 't1';
if (e) { e.preventDefault(); e.stopPropagation(); } if (e) { e.preventDefault(); e.stopPropagation(); }
@ -693,14 +727,7 @@ function editSlidesZoneDragleaveE(tid) { document.getElementById('edit-slides-
function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getElementById('edit-slides-zone-'+tid).classList.remove('dragover'); if (e.dataTransfer.files.length) editHandleSlidesForTrack(tid, e.dataTransfer.files); } function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getElementById('edit-slides-zone-'+tid).classList.remove('dragover'); if (e.dataTransfer.files.length) editHandleSlidesForTrack(tid, e.dataTransfer.files); }
function editHandleSlidesForTrack(tid, fileList) { function editHandleSlidesForTrack(tid, fileList) {
if (!fileList || !fileList.length) return; _editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
if (!_editSlidesData[tid]) _editSlidesData[tid] = [];
for (const f of Array.from(fileList)) {
if (_editSlidesData[tid].length >= 10) break;
const reader = new FileReader();
reader.onload = ev => { _editSlidesData[tid].push({ file: f, url: ev.target.result }); _editRenderSlidesForTrack(tid); };
reader.readAsDataURL(f);
}
} }
function editClearSlidesForTrack(e, tid) { function editClearSlidesForTrack(e, tid) {
@ -868,7 +895,7 @@ function _editAddExistingTrack(track) {
const titleEl = document.getElementById('edit-' + pfx + '-title'); const titleEl = document.getElementById('edit-' + pfx + '-title');
if (titleEl) titleEl.value = track.title || ''; if (titleEl) titleEl.value = track.title || '';
const descEl = document.getElementById('edit-' + pfx + '-desc'); const descEl = document.getElementById('edit-' + pfx + '-desc');
if (descEl) descEl.value = track.description || ''; if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
if (track.language && window.CSD) { if (track.language && window.CSD) {
const hi = document.getElementById('csd_v_' + pfx); const hi = document.getElementById('csd_v_' + pfx);
const wrap = document.getElementById('csd_' + pfx); const wrap = document.getElementById('csd_' + pfx);
@ -1030,3 +1057,13 @@ _editApplyMode('generic');
output-width="1280" output-width="1280"
title="Crop Thumbnail" title="Crop Thumbnail"
/> />
<x-image-cropper
id="slides_edit"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="editSlidesCropDone"
/>

View File

@ -74,6 +74,11 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
var csrfToken = _getLatestCsrf(); var csrfToken = _getLatestCsrf();
var shareUrl = videoUrl; var shareUrl = videoUrl;
// Preserve the version selector (?track=) from the requested URL — the server's tracked
// share link replaces shareUrl below, so we re-attach it afterwards.
var trackParam = '';
try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {}
// Obtain a unique tracked share link from the server // Obtain a unique tracked share link from the server
if (recordUrl) { if (recordUrl) {
try { try {
@ -92,6 +97,11 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
} catch (e) { /* fallback to plain URL */ } } catch (e) { /* fallback to plain URL */ }
} }
// Re-attach the version selector so the recipient opens the right language.
if (trackParam) {
shareUrl += (shareUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + encodeURIComponent(trackParam);
}
// Mobile: use native share sheet with the unique link // Mobile: use native share sheet with the unique link
if (window.innerWidth <= 768 && navigator.share) { if (window.innerWidth <= 768 && navigator.share) {
navigator.share({ title: videoTitle, url: shareUrl }).catch(function() {}); navigator.share({ title: videoTitle, url: shareUrl }).catch(function() {});

View File

@ -188,9 +188,7 @@
<div class="um-field"> <div class="um-field">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description</label> <label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description</label>
<textarea id="lt-track1-desc-modal" class="um-input um-textarea" <x-rich-text-editor name="" id="lt-track1-desc-modal" placeholder="Tell viewers about this content…" />
rows="3" style="font-size:13px;padding:9px 12px;"
placeholder="Tell viewers about this content…"></textarea>
</div> </div>
{{-- Video file + Thumbnail side by side (video/match mode) --}} {{-- Video file + Thumbnail side by side (video/match mode) --}}
@ -716,7 +714,7 @@ function resetUploadForm() {
const t1Desc = document.getElementById('lt-track1-desc-modal'); const t1Desc = document.getElementById('lt-track1-desc-modal');
const t1Fname = document.getElementById('lt-track1-fname'); const t1Fname = document.getElementById('lt-track1-fname');
if (t1Title) t1Title.value = ''; if (t1Title) t1Title.value = '';
if (t1Desc) t1Desc.value = ''; if (t1Desc) { t1Desc.value = ''; t1Desc.dispatchEvent(new Event('rte:refresh')); }
if (t1Fname) t1Fname.textContent = 'Click to choose file…'; if (t1Fname) t1Fname.textContent = 'Click to choose file…';
const audioBoxR = document.getElementById('um-tf-t1-audio-box'); const audioBoxR = document.getElementById('um-tf-t1-audio-box');
if (audioBoxR) audioBoxR.style.borderColor = ''; if (audioBoxR) audioBoxR.style.borderColor = '';
@ -933,7 +931,7 @@ function removeVideoModal(e) {
const t1Title = document.getElementById('lt-track1-title-modal'); const t1Title = document.getElementById('lt-track1-title-modal');
const t1Desc = document.getElementById('lt-track1-desc-modal'); const t1Desc = document.getElementById('lt-track1-desc-modal');
if (t1Title) t1Title.value = ''; if (t1Title) t1Title.value = '';
if (t1Desc) t1Desc.value = ''; if (t1Desc) { t1Desc.value = ''; t1Desc.dispatchEvent(new Event('rte:refresh')); }
if (_currentMode === 'music') { if (_currentMode === 'music') {
_applyMode('generic'); _applyMode('generic');
_gsSetDefault('type', 'generic', 'bi-film', 'Generic'); _gsSetDefault('type', 'generic', 'bi-film', 'Generic');
@ -1018,13 +1016,40 @@ function slidesZoneDrop(e, tid) {
} }
function handleSlidesForTrack(tid, fileList) { function handleSlidesForTrack(tid, fileList) {
if (!fileList || !fileList.length) return; _slidesCropStart(fileList, tid);
if (!_slidesData[tid]) _slidesData[tid] = []; }
for (const f of Array.from(fileList)) {
if (_slidesData[tid].length >= 10) break; // ── Slides crop queue: every added image is cropped before it enters the strip ──
_slidesData[tid].push(f); let _slidesCropQueue = [];
let _slidesCropTid = null;
function _slidesCropStart(fileList, tid) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_slidesCropTid = tid;
_slidesCropQueue = imgs;
_slidesCropLoadNext();
}
function _slidesCropLoadNext() {
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
const f = _slidesCropQueue.shift();
if (typeof window.tcPreload_slides_upload === 'function') {
window.tcPreload_slides_upload(f);
window.openCropperModal_slides_upload();
} }
}
function uploadSlidesCropDone(file) {
const tid = _slidesCropTid;
if (tid && file) {
if (!_slidesData[tid]) _slidesData[tid] = [];
if (_slidesData[tid].length < 10) {
_slidesData[tid].push(file);
renderSlidesStrip(tid); renderSlidesStrip(tid);
}
}
_slidesCropLoadNext();
} }
function renderSlidesStrip(tid) { function renderSlidesStrip(tid) {
@ -1214,10 +1239,11 @@ function addExtraTrackModal() {
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;"> <label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Description <span class="um-lbl-hint">optional</span> Description <span class="um-lbl-hint">optional</span>
</label> </label>
<textarea name="extra_track_descriptions[]" class="um-input um-textarea" <div class="rte-wrap" data-min-height="100px">
rows="3" style="font-size:13px;padding:9px 12px;" <textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
placeholder="Description in this language…"></textarea> placeholder="Description in this language…"></textarea>
</div> </div>
</div>
<div style="margin-bottom:14px;"> <div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;"> <label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
<i class="bi bi-music-note-beamed"></i> Audio File <i class="bi bi-music-note-beamed"></i> Audio File
@ -1425,3 +1451,13 @@ _applyMode('generic');
output-width="1280" output-width="1280"
title="Crop Thumbnail" title="Crop Thumbnail"
/> />
<x-image-cropper
id="slides_upload"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="uploadSlidesCropDone"
/>

View File

@ -196,7 +196,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea> <x-rich-text-editor name="description" id="video-description" placeholder="Tell viewers about your video" />
</div> </div>
</div> </div>
@ -266,9 +266,7 @@
<!-- Description (audio mode) --> <!-- Description (audio mode) -->
<div style="margin-bottom:14px;"> <div style="margin-bottom:14px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Description</label> <label class="form-label" style="font-size:13px;margin-bottom:6px;">Description</label>
<textarea id="lt-track1-desc-create" class="form-textarea" <x-rich-text-editor name="" id="lt-track1-desc-create" min-height="80px" placeholder="Tell viewers about this track…" />
rows="2" style="font-size:13px;min-height:64px;"
placeholder="Tell viewers about this track…"></textarea>
</div> </div>
<!-- Slides --> <!-- Slides -->
@ -562,7 +560,10 @@
if (cFname) cFname.textContent = 'Choose audio file…'; if (cFname) cFname.textContent = 'Choose audio file…';
document.getElementById('video-title').value = ''; document.getElementById('video-title').value = '';
document.getElementById('lt-track1-title-create').value = ''; document.getElementById('lt-track1-title-create').value = '';
document.getElementById('lt-track1-desc-create').value = ''; ['video-description','lt-track1-desc-create'].forEach(function(id){
const el = document.getElementById(id);
if (el) { el.value = ''; el.dispatchEvent(new Event('rte:refresh')); }
});
setAudioMode(false); setAudioMode(false);
} }
@ -646,14 +647,42 @@
} }
function handleCSlidesForTrack(trackId, fileList) { function handleCSlidesForTrack(trackId, fileList) {
if (!fileList || !fileList.length) return; _cSlidesCropStart(fileList, trackId);
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
for (const f of Array.from(fileList)) {
if (_cSlidesData[trackId].length >= 10) break;
_cSlidesData[trackId].push(f);
} }
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _cSlidesCropQueue = [];
let _cSlidesCropTid = null;
function _cSlidesCropStart(fileList, trackId) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_cSlidesCropTid = trackId;
_cSlidesCropQueue = imgs;
_cSlidesCropLoadNext();
}
function _cSlidesCropLoadNext() {
if (!_cSlidesCropQueue.length) { window.closeCropperModal('slides_create_mobile'); return; }
const f = _cSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_create_mobile === 'function') {
window.tcPreload_slides_create_mobile(f);
window.openCropperModal_slides_create_mobile();
}
}
function cSlidesCropDone(file) {
const trackId = _cSlidesCropTid;
if (trackId && file) {
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
if (_cSlidesData[trackId].length < 10) {
_cSlidesData[trackId].push(file);
renderCSlidesStrip(trackId); renderCSlidesStrip(trackId);
} }
}
_cSlidesCropLoadNext();
}
window.cSlidesCropDone = cSlidesCropDone;
function renderCSlidesStrip(trackId) { function renderCSlidesStrip(trackId) {
const files = _cSlidesData[trackId] || []; const files = _cSlidesData[trackId] || [];
@ -945,10 +974,11 @@
<label class="form-label" style="font-size:13px;margin-bottom:6px;"> <label class="form-label" style="font-size:13px;margin-bottom:6px;">
Localized Description <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span> Localized Description <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span>
</label> </label>
<textarea name="extra_track_descriptions[]" class="form-textarea" <div class="rte-wrap" data-min-height="80px">
rows="2" style="font-size:13px;min-height:64px;" <textarea class="rte-source" name="extra_track_descriptions[]" id="lt-desc-ce${n}"
placeholder="Description in this language…"></textarea> placeholder="Description in this language…"></textarea>
</div> </div>
</div>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;"> <label class="form-label" style="font-size:13px;margin-bottom:6px;">
Cover Slides <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional · drag to reorder</span> Cover Slides <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional · drag to reorder</span>
@ -1038,4 +1068,14 @@
output-width="1280" output-width="1280"
title="Crop Thumbnail" title="Crop Thumbnail"
/> />
<x-image-cropper
id="slides_create_mobile"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="cSlidesCropDone"
/>
@endsection @endsection

View File

@ -198,8 +198,7 @@
{{-- Description --}} {{-- Description --}}
<div class="form-group"> <div class="form-group">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="description" id="edit-description" rows="4" class="form-textarea" <x-rich-text-editor name="description" id="edit-description" :value="$video->description" placeholder="Tell viewers about your video…" />
placeholder="Tell viewers about your video (Markdown supported)">{{ $video->description }}</textarea>
</div> </div>
@if(!$video->isAudioOnly()) @if(!$video->isAudioOnly())
@ -279,9 +278,11 @@
value="{{ $track->title ?? '' }}" value="{{ $track->title ?? '' }}"
placeholder="Title in this language…" placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;"> style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;">
<textarea name="track_description_updates[{{ $track->id }}]" <x-rich-text-editor :name="'track_description_updates[' . $track->id . ']'"
placeholder="Description in this language…" rows="2" :id="'ep-track-desc-' . $track->id"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;resize:none;margin-bottom:6px;">{{ $track->description ?? '' }}</textarea> :value="$track->description ?? ''"
min-height="72px"
placeholder="Description in this language…" />
<label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;" <label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;"
onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'"> onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i> <i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i>
@ -542,16 +543,42 @@
} }
document.getElementById('ep-slides-add-input').addEventListener('change', function() { document.getElementById('ep-slides-add-input').addEventListener('change', function() {
Array.from(this.files).forEach(file => { epSlidesCropStart(this.files);
this.value = '';
});
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _epSlidesCropQueue = [];
function epSlidesCropStart(fileList) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_epSlidesCropQueue = imgs;
_epSlidesCropLoadNext();
}
function _epSlidesCropLoadNext() {
if (!_epSlidesCropQueue.length) { window.closeCropperModal('slides_edit_mobile'); return; }
const f = _epSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit_mobile === 'function') {
window.tcPreload_slides_edit_mobile(f);
window.openCropperModal_slides_edit_mobile();
}
}
window.epSlidesCropDone = function(file) {
if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => { reader.onload = e => {
epSlidesData.push({ file, url: e.target.result }); epSlidesData.push({ file, url: e.target.result });
epSlidesRefresh(); epSlidesRefresh();
_epSlidesCropLoadNext();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); return;
this.value = ''; }
}); _epSlidesCropLoadNext();
};
// Initialise strip with existing slides // Initialise strip with existing slides
epSlidesRefresh(); epSlidesRefresh();
@ -785,4 +812,14 @@
output-width="1280" output-width="1280"
title="Crop Thumbnail" title="Crop Thumbnail"
/> />
<x-image-cropper
id="slides_edit_mobile"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="epSlidesCropDone"
/>
@endsection @endsection

View File

@ -14,6 +14,7 @@
'id' => 0, 'id' => 0,
'language' => $primaryLang, 'language' => $primaryLang,
'label' => $video->language ? strtoupper($video->language) : 'Default', 'label' => $video->language ? strtoupper($video->language) : 'Default',
'name' => $video->language ? ($allLangData[$primaryLang]['name'] ?? strtoupper($video->language)) : 'Default',
'flag' => $primaryFlag, 'flag' => $primaryFlag,
'stream_url' => $audioUrl, 'stream_url' => $audioUrl,
'title' => $video->title, 'title' => $video->title,
@ -23,6 +24,7 @@
'id' => $t->id, 'id' => $t->id,
'language' => $t->language, 'language' => $t->language,
'label' => $t->label, 'label' => $t->label,
'name' => $allLangData[$t->language]['name'] ?? strtoupper($t->language),
'flag' => $allLangData[$t->language]['flag'] ?? null, 'flag' => $allLangData[$t->language]['flag'] ?? null,
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp, 'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
'title' => $t->title ?? '', 'title' => $t->title ?? '',
@ -123,12 +125,13 @@
data-lang-id="{{ $track['id'] }}" data-lang-id="{{ $track['id'] }}"
data-lang-url="{{ $track['stream_url'] }}" data-lang-url="{{ $track['stream_url'] }}"
data-lang-label="{{ $track['label'] }}" data-lang-label="{{ $track['label'] }}"
data-lang-name="{{ $track['name'] }}"
data-lang-flag="{{ $track['flag'] ?? '' }}" data-lang-flag="{{ $track['flag'] ?? '' }}"
data-lang-title="{{ $track['title'] ?? '' }}" data-lang-title="{{ $track['title'] ?? '' }}"
data-lang-description="{{ $track['description'] ?? '' }}" data-lang-description="{{ $track['description'] ?? '' }}"
data-lang-dl-url="{{ $track['dl_url'] }}"> data-lang-dl-url="{{ $track['dl_url'] }}">
<span class="fi fi-{{ $track['flag'] ?: 'xx' }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;"></span> <span class="fi fi-{{ $track['flag'] ?: 'xx' }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;"></span>
<span class="ytp-lang-opt-label">{{ $track['label'] }}</span> <span class="ytp-lang-opt-label">{{ $track['name'] }}</span>
<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> <svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</div> </div>
@endforeach @endforeach
@ -450,6 +453,7 @@ const barsBtn = document.getElementById('ytpBarsBtn');
const animCanvas = document.getElementById('audioAnimCanvas'); const animCanvas = document.getElementById('audioAnimCanvas');
const NEXT_URL = @json($nextUrl ?? null); const NEXT_URL = @json($nextUrl ?? null);
window._LANG_NAMES = window._LANG_NAMES || @json(collect(\App\Data\Languages::all())->map(fn($l) => $l['name']));
let hideTimer = null; let hideTimer = null;
let isDragging = false; let isDragging = false;
let userSeeking = false; let userSeeking = false;
@ -586,6 +590,10 @@ const langBtn = document.getElementById('ytpLangBtn');
const langPopup = document.getElementById('ytpLangPopup'); const langPopup = document.getElementById('ytpLangPopup');
const langOpts = document.querySelectorAll('.ytp-lang-option'); const langOpts = document.querySelectorAll('.ytp-lang-option');
// Which version is currently playing (0 = primary). Download + Share read this so they
// act on exactly the version the viewer chose.
window._ytpTrackId = 0;
if (langBtn && langPopup) { if (langBtn && langPopup) {
langBtn.addEventListener('click', e => { langBtn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@ -603,6 +611,7 @@ if (langBtn && langPopup) {
const url = opt.dataset.langUrl; const url = opt.dataset.langUrl;
const flag = opt.dataset.langFlag; const flag = opt.dataset.langFlag;
if (!url) return; if (!url) return;
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
const relPos = audio.duration ? audio.currentTime / audio.duration : 0; const relPos = audio.duration ? audio.currentTime / audio.duration : 0;
const wasPlaying = !audio.paused; const wasPlaying = !audio.paused;
const _vol = audio.volume, _muted = audio.muted; const _vol = audio.volume, _muted = audio.muted;
@ -646,83 +655,79 @@ if (langBtn && langPopup) {
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; }); if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
}); });
}); });
// Shared / deep links: ?track={id} opens directly on that version so the recipient gets
// the same language end-to-end (audio + title + flag + About description). Deferred to
// DOMContentLoaded because this player partial is included BEFORE the title/description
// elements appear in the page — they must exist before the switch handler can update them.
const _ytpAutoSelectTrack = function () {
const want = new URLSearchParams(window.location.search).get('track');
if (!want || want === '0') return;
const opt = [...langOpts].find(o => o.dataset.langId === String(want));
if (opt && !opt.classList.contains('active')) opt.click();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _ytpAutoSelectTrack);
} else {
_ytpAutoSelectTrack();
}
} }
// ── Description box updater ────────────────────────────────── // ── Description box updater ──────────────────────────────────
function _rteToHtml(s) {
s = (s || '').trim();
if (!s) return '';
if (/<[a-z][\s\S]*>/i.test(s)) return s; // already sanitized HTML (server-side)
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML.replace(/\n/g, '<br>'); // legacy plain text
}
function _updateDescriptionBox(text) { function _updateDescriptionBox(text) {
text = (text || '').trim(); const html = _rteToHtml(text);
const SHORT_LEN = 200;
const needsExpand = text.length > SHORT_LEN;
const short = needsExpand ? text.slice(0, SHORT_LEN) + '…' : text;
let shortEl = document.getElementById('vdbDescShort');
const fullEl = document.getElementById('vdbDescFull');
// If the description elements don't exist yet, inject them into vdb-about
if (!shortEl) {
const aboutPanel = document.getElementById('vdb-about'); const aboutPanel = document.getElementById('vdb-about');
if (aboutPanel) { let shortEl = document.getElementById('vdbDescShort');
// Remove existing "No description" placeholder if present
if (!shortEl && aboutPanel) {
const ph = aboutPanel.querySelector('p[style*="text-secondary"]'); const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
if (ph) ph.style.display = 'none'; if (ph) ph.style.display = 'none';
shortEl = document.createElement('div'); shortEl = document.createElement('div');
shortEl.id = 'vdbDescShort'; shortEl.id = 'vdbDescShort';
shortEl.className = 'vdb-desc-text'; shortEl.className = 'vdb-desc-text vdb-clamp';
aboutPanel.appendChild(shortEl); aboutPanel.appendChild(shortEl);
} }
}
if (!shortEl) return; if (!shortEl) return;
if (!text) { // Drop the legacy two-element model if a previous build left it behind.
const legacyFull = document.getElementById('vdbDescFull');
if (legacyFull) legacyFull.remove();
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
if (!html) {
shortEl.style.display = 'none'; shortEl.style.display = 'none';
if (fullEl) fullEl.style.display = 'none'; if (moreBtn) moreBtn.style.display = 'none';
const moreBtn2 = shortEl.nextElementSibling;
if (moreBtn2 && moreBtn2.classList.contains('vdb-show-more')) moreBtn2.style.display = 'none';
return; return;
} }
// Always reset to the collapsed view on a language switch
shortEl.textContent = needsExpand ? short : text;
shortEl.style.display = ''; shortEl.style.display = '';
shortEl.classList.add('vdb-clamp');
shortEl.classList.remove('vdb-expanded');
shortEl.innerHTML = html;
// Ensure the full-text element exists when expansion is needed
let full = fullEl;
if (needsExpand && !full) {
full = document.createElement('div');
full.id = 'vdbDescFull';
full.className = 'vdb-desc-text';
full.style.display = 'none';
shortEl.insertAdjacentElement('afterend', full);
}
if (full) {
full.textContent = text;
full.style.display = 'none';
}
// Locate (or create) the show-more button — it is NOT necessarily shortEl's
// direct sibling because #vdbDescFull sits between them, so search the panel.
const aboutPanel = document.getElementById('vdb-about');
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
if (needsExpand) {
if (!moreBtn) { if (!moreBtn) {
moreBtn = document.createElement('button'); moreBtn = document.createElement('button');
moreBtn.className = 'vdb-show-more'; moreBtn.className = 'vdb-show-more';
moreBtn.onclick = function() { moreBtn.onclick = function () { if (window.toggleVdbDesc) toggleVdbDesc(moreBtn); };
const f = document.getElementById('vdbDescFull'); shortEl.insertAdjacentElement('afterend', moreBtn);
const collapsed = !f || f.style.display === 'none';
if (f) f.style.display = collapsed ? 'block' : 'none';
shortEl.style.display = collapsed ? 'none' : '';
moreBtn.textContent = collapsed ? 'Show less' : 'Show more';
};
(full || shortEl).insertAdjacentElement('afterend', moreBtn);
} }
moreBtn.textContent = 'Show more'; moreBtn.textContent = 'Show more';
moreBtn.style.display = ''; // Reveal "Show more" only when the content overflows the clamp. Compare the
} else if (moreBtn) { // natural content height to the clamp's pixel limit (130px, see .vdb-clamp) —
moreBtn.style.display = 'none'; // clientHeight is unreliable right after innerHTML swap / when re-laying out.
} const _btn = moreBtn, _el = shortEl;
requestAnimationFrame(function () {
_btn.style.display = (_el.scrollHeight > 138) ? 'block' : 'none';
});
} }
// ── Fullscreen ─────────────────────────────────────────────── // ── Fullscreen ───────────────────────────────────────────────
@ -1063,6 +1068,10 @@ window._audioPlayerUpdate = function(d) {
: 'width:22px;height:16px;border-radius:2px;display:inline-block;'; : 'width:22px;height:16px;border-radius:2px;display:inline-block;';
return '<span class="fi fi-' + (flag || 'xx') + '" style="' + s + '"></span>'; return '<span class="fi fi-' + (flag || 'xx') + '" style="' + s + '"></span>';
} }
function langName(code) {
if (!code) return 'Default';
return (window._LANG_NAMES && window._LANG_NAMES[String(code).toLowerCase()]) || String(code).toUpperCase();
}
// Build combined track list: primary + deduped extras // Build combined track list: primary + deduped extras
var primaryLangNew = d.language || null; var primaryLangNew = d.language || null;
@ -1116,12 +1125,13 @@ window._audioPlayerUpdate = function(d) {
+ ' data-lang-id="' + t.id + '"' + ' data-lang-id="' + t.id + '"'
+ ' data-lang-url="' + t.stream_url + '"' + ' data-lang-url="' + t.stream_url + '"'
+ ' data-lang-label="' + t.label + '"' + ' data-lang-label="' + t.label + '"'
+ ' data-lang-name="' + langName(t.language) + '"'
+ ' data-lang-flag="' + (t.flag || '') + '"' + ' data-lang-flag="' + (t.flag || '') + '"'
+ ' data-lang-title="' + safeTitle + '"' + ' data-lang-title="' + safeTitle + '"'
+ ' data-lang-description="' + safeDesc + '"' + ' data-lang-description="' + safeDesc + '"'
+ ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">' + ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">'
+ flagHtml(t.flag, 'sm') + flagHtml(t.flag, 'sm')
+ '<span class="ytp-lang-opt-label">' + t.label + '</span>' + '<span class="ytp-lang-opt-label">' + langName(t.language) + '</span>'
+ '<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>' + '<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>'
+ '</div>'; + '</div>';
}); });

View File

@ -5,11 +5,9 @@
--}} --}}
@php @php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id; $isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$hasDesc = !empty($video->description) || isset($descriptionSlot); $renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner; $showBox = $hasDesc || $isVideoOwner;
$fullDescription = $video->description ?? '';
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp @endphp
@if ($showBox) @if ($showBox)
<style> <style>
@ -23,9 +21,17 @@
.vdb-panel { display:none; padding:14px 16px 16px; } .vdb-panel { display:none; padding:14px 16px 16px; }
.vdb-panel.active { display:block; } .vdb-panel.active { display:block; }
.vdb-meta { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:10px; display:flex; gap:10px; flex-wrap:wrap; } .vdb-meta { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:10px; display:flex; gap:10px; flex-wrap:wrap; }
.vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); white-space:pre-wrap; } .vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
.vdb-desc-text p { margin-bottom:8px; } .vdb-desc-text p { margin:0 0 8px; }
.vdb-desc-text p:last-child { margin-bottom:0; }
.vdb-desc-text h2 { font-size:19px; font-weight:700; margin:6px 0 8px; }
.vdb-desc-text h3 { font-size:16px; font-weight:700; margin:6px 0 6px; }
.vdb-desc-text ul, .vdb-desc-text ol { margin:0 0 8px; padding-left:22px; }
.vdb-desc-text blockquote { margin:0 0 8px; padding-left:12px; border-left:3px solid var(--border-color); color:var(--text-secondary); }
.vdb-desc-text a { color:#3ea6ff; } .vdb-desc-text a { color:#3ea6ff; }
.vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; }
.vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); }
.vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; }
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; } .vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; }
</style> </style>
@ -50,12 +56,9 @@
</div> </div>
@if(isset($descriptionSlot)) @if(isset($descriptionSlot))
{!! $descriptionSlot !!} {!! $descriptionSlot !!}
@elseif($hasDesc) @elseif($renderedDescription !== '')
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div> <div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
@if($needsExpand) <button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)">Show more</button>
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
@endif
@else @else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p> <p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif @endif
@ -80,10 +83,21 @@ function switchVdbTab(panelId, btn) {
} }
} }
function toggleVdbDesc(btn) { function toggleVdbDesc(btn) {
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull'); const d = document.getElementById('vdbDescShort');
if (!f) return; if (!d) return;
if (f.style.display === 'none') { s.style.display='none'; f.style.display='block'; btn.textContent='Show less'; } const expanded = d.classList.toggle('vdb-expanded');
else { s.style.display='block'; f.style.display='none'; btn.textContent='Show more'; } btn.textContent = expanded ? 'Show less' : 'Show more';
} }
// Reveal "Show more" only when the description overflows the clamp. Compare the
// natural content height to the clamp limit (130px) rather than clientHeight,
// which is unreliable right after a content swap.
function _vdbCheckOverflow() {
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
if (!d || !b) return;
if (d.classList.contains('vdb-expanded')) { b.style.display = 'block'; return; }
b.style.display = (d.scrollHeight > 138) ? 'block' : 'none';
}
document.addEventListener('DOMContentLoaded', _vdbCheckOverflow);
window.addEventListener('load', _vdbCheckOverflow);
</script> </script>
@endif @endif

View File

@ -565,7 +565,7 @@
var doc=new DOMParser().parseFromString(html,'text/html'); var doc=new DOMParser().parseFromString(html,'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap'); var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML; if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row'); var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML; if(nc&&oc) oc.innerHTML=nc.innerHTML;
@ -725,8 +725,10 @@
</div> </div>
<div class="sidebar-info"> <div class="sidebar-info">
<div class="sidebar-title"> <div class="sidebar-title">
@php $recFlag = \App\Data\Languages::flag($recVideo->language); @endphp
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}" <i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i> style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
@if($recFlag)<span class="fi fi-{{ $recFlag }}" style="display:inline-block;width:16px;height:12px;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>@endif
{{ Str::limit($recVideo->title, 60) }} {{ Str::limit($recVideo->title, 60) }}
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
@ -822,7 +824,7 @@
// description // description
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap'); var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML; if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
// channel row // channel row
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row'); var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');

View File

@ -2637,7 +2637,7 @@
var doc=new DOMParser().parseFromString(html,'text/html'); var doc=new DOMParser().parseFromString(html,'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap'); var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML; if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row'); var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML; if(nc&&oc) oc.innerHTML=nc.innerHTML;
@ -2798,8 +2798,10 @@
</div> </div>
<div class="sidebar-info"> <div class="sidebar-info">
<div class="sidebar-title"> <div class="sidebar-title">
@php $recFlag = \App\Data\Languages::flag($recVideo->language); @endphp
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}" <i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i> style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
@if($recFlag)<span class="fi fi-{{ $recFlag }}" style="display:inline-block;width:16px;height:12px;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>@endif
{{ Str::limit($recVideo->title, 60) }} {{ Str::limit($recVideo->title, 60) }}
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
@ -2890,7 +2892,7 @@
var doc = new DOMParser().parseFromString(html, 'text/html'); var doc = new DOMParser().parseFromString(html, 'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap'); var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML; if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row'); var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML; if(nc&&oc) oc.innerHTML=nc.innerHTML;

View File

@ -1,20 +1,25 @@
@extends('layouts.app') @extends('layouts.app')
@section('main_class', 'video-view-page') @section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name')) @php
$metaTitle = $shareTitle ?? $video->title;
$metaDesc = $shareDescription ?? $video->description;
$metaUrl = $video->share_url . (!empty($shareTrackId) ? '?track=' . $shareTrackId : '');
@endphp
@section('title', $metaTitle . ' | ' . config('app.name'))
@push('head') @push('head')
<meta property="og:title" content="{{ $video->title }}"> <meta property="og:title" content="{{ $metaTitle }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}"> <meta property="og:description" content="{{ Str::limit(strip_tags($metaDesc ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}"> <meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}"> <meta property="og:url" content="{{ $metaUrl }}">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}"> <meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}"> <meta name="twitter:title" content="{{ $metaTitle }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}"> <meta name="twitter:description" content="{{ Str::limit(strip_tags($metaDesc ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}"> <meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush @endpush
@ -599,7 +604,7 @@
// swap description box // swap description box
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap'); var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML; if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
// swap channel row // swap channel row
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row'); var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
@ -801,7 +806,7 @@
var newVdb = doc.getElementById('vdbWrap'); var newVdb = doc.getElementById('vdbWrap');
var oldVdb = document.getElementById('vdbWrap'); var oldVdb = document.getElementById('vdbWrap');
if (newVdb && oldVdb) oldVdb.innerHTML = newVdb.innerHTML; if (newVdb && oldVdb) { oldVdb.innerHTML = newVdb.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
var newCh = doc.querySelector('.channel-row'); var newCh = doc.querySelector('.channel-row');
var oldCh = document.querySelector('.channel-row'); var oldCh = document.querySelector('.channel-row');
@ -887,8 +892,10 @@
</div> </div>
<div class="sidebar-info"> <div class="sidebar-info">
<div class="sidebar-title"> <div class="sidebar-title">
@php $recFlag = \App\Data\Languages::flag($recVideo->language); @endphp
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}" <i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i> style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
@if($recFlag)<span class="fi fi-{{ $recFlag }}" style="display:inline-block;width:16px;height:12px;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>@endif
{{ Str::limit($recVideo->title, 60) }} {{ Str::limit($recVideo->title, 60) }}
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">