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:
parent
66fd78c10f
commit
a4384113c2
@ -90,8 +90,9 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS
|
||||
## `<x-image-cropper>`
|
||||
|
||||
**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).
|
||||
**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.
|
||||
**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`).
|
||||
**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`.
|
||||
**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/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/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`
|
||||
**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 |
|
||||
|---|---|---|
|
||||
@ -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
|
||||
|
||||
```blade
|
||||
|
||||
59
CLAUDE.md
59
CLAUDE.md
@ -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.**
|
||||
|
||||
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()` |
|
||||
| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Primary video / audio | `users/{slug}/videos/{song-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
||||
| Extra audio track | `users/{slug}/videos/{song-slug}/{song-slug}-{lang}-{track-id}.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
|
||||
| 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()` |
|
||||
| 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()` |
|
||||
|
||||
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 following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
|
||||
|
||||
@ -224,24 +224,27 @@ class NasFreeLocalStorage extends Command
|
||||
|
||||
$this->line(' Done scanning banners.');
|
||||
|
||||
// ── Slideshow cache directories ───────────────────────────────────────
|
||||
// The slideshow/ directory is a render cache that is always regenerated on
|
||||
// demand, so its contents are safe to delete unconditionally.
|
||||
// ── Generated/derived renders inside song folders ─────────────────────
|
||||
// Everything under a song's cache/ subfolder (download videos + HLS) is a
|
||||
// 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->info('Scanning slideshow cache…');
|
||||
$this->info('Scanning generated renders (song cache/ folders)…');
|
||||
|
||||
$slideshowDir = storage_path('app/public/slideshow');
|
||||
if (is_dir($slideshowDir)) {
|
||||
$usersRoot = storage_path('app/users');
|
||||
if (is_dir($usersRoot)) {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
|
||||
new \RecursiveDirectoryIterator($usersRoot, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) continue;
|
||||
$path = $file->getPathname();
|
||||
if (! str_contains($path, '/cache/')) continue;
|
||||
$bytes = $file->getSize();
|
||||
$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/) ──────────────────────────────
|
||||
|
||||
325
app/Console/Commands/ReorganizeAudioTracks.php
Normal file
325
app/Console/Commands/ReorganizeAudioTracks.php
Normal 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)");
|
||||
}
|
||||
}
|
||||
@ -884,6 +884,9 @@ class SuperAdminController extends Controller
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
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.');
|
||||
}
|
||||
|
||||
@ -924,21 +927,9 @@ class SuperAdminController extends Controller
|
||||
*/
|
||||
private function probeNvenc(): bool
|
||||
{
|
||||
$ffmpeg = Setting::ffmpegBinary();
|
||||
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4';
|
||||
$device = Setting::gpuDevice();
|
||||
|
||||
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;
|
||||
// Single source of truth lives on the Setting model; force the NVENC encoder so the
|
||||
// admin indicator always reflects GPU capability regardless of the configured encoder.
|
||||
return Setting::probeGpu('h264_nvenc');
|
||||
}
|
||||
|
||||
public function nasStorage()
|
||||
|
||||
@ -241,7 +241,7 @@ class VideoController extends Controller
|
||||
$video = Video::create([
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'description' => \App\Support\HtmlSanitizer::clean($request->description),
|
||||
'filename' => $filename,
|
||||
'path' => $path,
|
||||
'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
|
||||
@ -339,7 +339,7 @@ class VideoController extends Controller
|
||||
$lang = $trackLangs[$i] ?? 'en';
|
||||
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
|
||||
$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
|
||||
$track = VideoAudioTrack::create([
|
||||
@ -354,20 +354,19 @@ class VideoController extends Controller
|
||||
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$userSlug = $nas->userSlug($video->user);
|
||||
$videoDir = $nas->resolveVideoDir($video);
|
||||
$nasDir = "{$videoDir}/tracks";
|
||||
$nas->mkdirp($nasDir);
|
||||
$nas->mkdirp($videoDir);
|
||||
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
|
||||
|
||||
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
|
||||
$nasPath = "{$videoDir}/{$trackName}";
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
|
||||
$track->update([
|
||||
'path' => $nasPath,
|
||||
'filename' => "{$track->id}.{$ext}",
|
||||
'filename' => $trackName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\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;
|
||||
$nextVideo = null;
|
||||
@ -534,7 +546,7 @@ class VideoController extends Controller
|
||||
$did = $request->cookie('_did') ?: (string) Str::uuid();
|
||||
|
||||
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')
|
||||
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
|
||||
}
|
||||
@ -679,6 +691,9 @@ class VideoController extends Controller
|
||||
$oldTitle = $video->title;
|
||||
|
||||
$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');
|
||||
if ($request->has('primary_language')) {
|
||||
$data['language'] = $request->input('primary_language') ?: null;
|
||||
@ -802,9 +817,11 @@ class VideoController extends Controller
|
||||
$data['thumbnail'] = $firstSlide->filename;
|
||||
}
|
||||
|
||||
// Invalidate cached slideshow video whenever slides change
|
||||
// Invalidate cached slideshow videos (plain + visualizer) whenever slides change
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -838,7 +855,7 @@ class VideoController extends Controller
|
||||
foreach ($allTrackIds as $trackId) {
|
||||
$fields = [];
|
||||
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]) {
|
||||
$fields['language'] = $languageUpdates[$trackId];
|
||||
$fields['label'] = strtoupper($languageUpdates[$trackId]);
|
||||
@ -856,17 +873,17 @@ class VideoController extends Controller
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$videoDir = $this->nasVideoDir($video, $nas);
|
||||
$nasDir = "{$videoDir}/tracks";
|
||||
$nas->mkdirp($nasDir);
|
||||
$nas->mkdirp($videoDir);
|
||||
$trackName = $this->audioTrackName(basename($videoDir), $track->language, $track->id, $ext);
|
||||
if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) {
|
||||
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
|
||||
}
|
||||
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
|
||||
$nasPath = "{$videoDir}/{$trackName}";
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]);
|
||||
$track->update(['path' => $nasPath, 'filename' => $trackName]);
|
||||
} catch (\Throwable $e) {
|
||||
\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');
|
||||
$trackLangs = $request->input('extra_track_languages', []);
|
||||
$trackTitles = $request->input('extra_track_titles', []);
|
||||
$trackDescs = $request->input('extra_track_descriptions', []);
|
||||
|
||||
foreach ($trackFiles as $i => $trackFile) {
|
||||
if (! $trackFile || ! $trackFile->isValid()) continue;
|
||||
$lang = $trackLangs[$i] ?? 'en';
|
||||
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
|
||||
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
|
||||
$desc = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;
|
||||
|
||||
$track = VideoAudioTrack::create([
|
||||
'video_id' => $video->id,
|
||||
'language' => $lang,
|
||||
'label' => strtoupper($lang),
|
||||
'title' => $title,
|
||||
'description' => $desc,
|
||||
'path' => '__pending__',
|
||||
'filename' => '__pending__',
|
||||
]);
|
||||
@ -900,14 +920,14 @@ class VideoController extends Controller
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$videoDir = $this->nasVideoDir($video, $nas);
|
||||
$nasDir = "{$videoDir}/tracks";
|
||||
$nas->mkdirp($nasDir);
|
||||
$nas->mkdirp($videoDir);
|
||||
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
|
||||
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
|
||||
$nasPath = "{$videoDir}/{$trackName}";
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]);
|
||||
$track->update(['path' => $nasPath, 'filename' => $trackName]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error("Extra track NAS upload failed (update): " . $e->getMessage());
|
||||
$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
|
||||
{
|
||||
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);
|
||||
$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);
|
||||
$videoBase = basename($nas->localVideoDir($video));
|
||||
$relPath = "users/{$userSlug}/videos/{$videoBase}/tracks/{$track->id}.{$ext}";
|
||||
$track->update(['path' => $relPath, 'filename' => "{$track->id}.{$ext}"]);
|
||||
$relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
|
||||
$track->update(['path' => $relPath, 'filename' => $trackName]);
|
||||
} catch (\Throwable $e) {
|
||||
\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')
|
||||
{
|
||||
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) {
|
||||
$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) {
|
||||
return response()->download($slideshowCache, $this->safeFilename($video->title, '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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ────────
|
||||
|
||||
public function slideshowGenerate(Video $video)
|
||||
public function slideshowGenerate(Request $request, Video $video)
|
||||
{
|
||||
$this->checkDownloadAccess($video);
|
||||
|
||||
@ -1578,28 +1683,55 @@ class VideoController extends Controller
|
||||
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();
|
||||
$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();
|
||||
}
|
||||
|
||||
if (! file_exists($audioPath)) {
|
||||
return response()->json(['error' => 'Audio file not found'], 404);
|
||||
}
|
||||
|
||||
$cacheDir = storage_path('app/public/slideshow');
|
||||
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||||
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
|
||||
$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';
|
||||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt';
|
||||
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt';
|
||||
$logFile = sys_get_temp_dir() . '/sl_log_' . $video->id . '.txt';
|
||||
|
||||
// Already cached — only trust it when the DB column confirms it's the right version
|
||||
if ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0) {
|
||||
// Already cached. Only the primary plain video is tracked by the DB column; every
|
||||
// other variant (a language track and/or visualizer) is guarded by file existence.
|
||||
// All are invalidated together when the slides are edited (see update()).
|
||||
$usesDbColumn = (! $viz && ! $trackId);
|
||||
$cached = $usesDbColumn
|
||||
? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
|
||||
: (file_exists($outPath) && filesize($outPath) > 0);
|
||||
if ($cached) {
|
||||
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)) {
|
||||
@unlink($outPath);
|
||||
}
|
||||
@ -1631,16 +1763,87 @@ class VideoController extends Controller
|
||||
@unlink($progressFile);
|
||||
@unlink($pidFile);
|
||||
|
||||
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails
|
||||
$isStillImage = ($validSlides->count() === 1);
|
||||
// Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails.
|
||||
// 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);
|
||||
$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) {
|
||||
$n = $validSlides->count();
|
||||
$fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
|
||||
$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 = '';
|
||||
$scaleFc = [];
|
||||
foreach ($validSlides as $i => $slide) {
|
||||
@ -1659,7 +1862,6 @@ class VideoController extends Controller
|
||||
. number_format($fade, 3) . ":offset={$offset}{$outLabel}";
|
||||
$prev = $outLabel;
|
||||
}
|
||||
if ($n === 1) $xfadeFc[] = '[s0]copy[vout]';
|
||||
|
||||
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
|
||||
$cmd = "{$ffmpeg} -y{$inputs}"
|
||||
@ -1689,9 +1891,10 @@ class VideoController extends Controller
|
||||
$cmd .= ' -progress ' . escapeshellarg($progressFile)
|
||||
. ' ' . escapeshellarg($outPath);
|
||||
|
||||
// When GPU is active, wrap in a bash fallback: if GPU command fails, clear the
|
||||
// progress file and retry immediately with CPU (libx264) so the download still works.
|
||||
if (Setting::gpuEnabled() && $vFlags !== $cpuFlags) {
|
||||
// $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
|
||||
// GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
|
||||
// on CPU (libx264) and the download keeps working.
|
||||
if (Setting::gpuUsable() && $vFlags !== $cpuFlags) {
|
||||
$cpuCmd = str_replace($vFlags, $cpuFlags, $cmd);
|
||||
$inner = $cmd
|
||||
. ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
|
||||
@ -1711,9 +1914,13 @@ class VideoController extends Controller
|
||||
|
||||
public function slideshowProgress(Video $video)
|
||||
{
|
||||
$outPath = storage_path('app/public/slideshow/' . $video->id . '_slideshow.mp4');
|
||||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . '.txt';
|
||||
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . '.txt';
|
||||
$viz = request()->boolean('visualizer');
|
||||
$trackId = (int) request()->input('track', 0);
|
||||
$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
|
||||
$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 (! $video->slideshow_video_path) {
|
||||
$video->update(['slideshow_video_path' => 'public/slideshow/' . $video->id . '_slideshow.mp4']);
|
||||
// Only the plain variant is tracked by the DB column; the visualizer variant
|
||||
// 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']);
|
||||
}
|
||||
@ -1856,6 +2065,20 @@ class VideoController extends Controller
|
||||
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).
|
||||
* 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));
|
||||
}
|
||||
|
||||
|
||||
@ -51,7 +51,9 @@ class CompressVideoJob implements ShouldQueue
|
||||
]);
|
||||
$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();
|
||||
$preset = Setting::gpuPreset();
|
||||
$device = Setting::gpuDevice();
|
||||
|
||||
@ -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);
|
||||
|
||||
if (is_dir($hlsPath)) {
|
||||
@ -72,7 +77,9 @@ class GenerateHlsJob implements ShouldQueue
|
||||
|
||||
try {
|
||||
$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
|
||||
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
@ -31,6 +33,7 @@ class Setting extends Model
|
||||
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
|
||||
{
|
||||
return static::get('gpu_enabled', 'true') === 'true';
|
||||
@ -41,16 +44,105 @@ class Setting extends Model
|
||||
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
|
||||
{
|
||||
return static::gpuEnabled()
|
||||
return static::gpuUsable()
|
||||
? static::get('gpu_encoder', 'h264_nvenc')
|
||||
: 'libx264';
|
||||
}
|
||||
|
||||
public static function gpuPreset(): string
|
||||
{
|
||||
return static::gpuEnabled()
|
||||
return static::gpuUsable()
|
||||
? static::get('gpu_preset', 'p4')
|
||||
: 'fast';
|
||||
}
|
||||
@ -63,7 +155,7 @@ class Setting extends Model
|
||||
/** Returns the full video codec flags for FFmpeg shell commands. */
|
||||
public static function ffmpegVideoFlags(bool $stillImage = false): string
|
||||
{
|
||||
if (static::gpuEnabled()) {
|
||||
if (static::gpuUsable()) {
|
||||
$enc = static::get('gpu_encoder', 'h264_nvenc');
|
||||
$preset = static::get('gpu_preset', 'p4');
|
||||
$device = static::gpuDevice();
|
||||
@ -84,7 +176,7 @@ class Setting extends Model
|
||||
/** Returns hwaccel decode flags when the input source is a video file. */
|
||||
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
|
||||
{
|
||||
if (! $inputIsVideo || ! static::gpuEnabled()) return '';
|
||||
if (! $inputIsVideo || ! static::gpuUsable()) return '';
|
||||
$hwaccel = static::get('gpu_hwaccel', 'cuda');
|
||||
$device = static::gpuDevice();
|
||||
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
|
||||
|
||||
223
app/Support/HtmlSanitizer.php
Normal file
223
app/Support/HtmlSanitizer.php
Normal 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| |<br\s*/?>)*</\1>#i', '', $html);
|
||||
return trim((string) $html);
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
$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
|
||||
$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
|
||||
|
||||
@once
|
||||
@ -202,8 +203,22 @@
|
||||
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
|
||||
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
|
||||
var previewImgId = '{{ $previewImg }}';
|
||||
var resultCbName = '{{ $resultCallback }}'; // callback mode: name of a global fn given the cropped File
|
||||
|
||||
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() {
|
||||
var m = document.querySelector('meta[name="csrf-token"]');
|
||||
@ -343,6 +358,11 @@
|
||||
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
||||
|
||||
cropperInst.crop(cropOpts).then(function (base64) {
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.png';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
@ -370,7 +390,9 @@
|
||||
window['tcUploadAsIs_' + id] = function () {
|
||||
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.
|
||||
setOnTargetInput(originalFile);
|
||||
window.closeCropperModal(id);
|
||||
|
||||
304
resources/views/components/rich-text-editor.blade.php
Normal file
304
resources/views/components/rich-text-editor.blade.php
Normal 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, '"') + '" 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
||||
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> </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>
|
||||
@ -207,15 +207,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Description ────────────────────────────────────────────── --}}
|
||||
{{-- ── Description (rich text) ────────────────────────────────── --}}
|
||||
<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>
|
||||
<textarea name="{{ $descName }}"
|
||||
id="{{ $descId }}"
|
||||
class="um-input um-textarea"
|
||||
rows="3"
|
||||
style="font-size:13px;padding:9px 12px;"
|
||||
placeholder="Tell viewers about this content…"></textarea>
|
||||
<x-rich-text-editor :name="$descName" :id="$descId" placeholder="Tell viewers about this content…" />
|
||||
</div>
|
||||
|
||||
{{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}}
|
||||
|
||||
@ -168,7 +168,7 @@
|
||||
|
||||
@if ($video->isShareable())
|
||||
<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>
|
||||
</button>
|
||||
@endif
|
||||
@ -271,7 +271,7 @@
|
||||
|
||||
@if ($video->isShareable())
|
||||
<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
|
||||
</button>
|
||||
@endif
|
||||
@ -514,6 +514,14 @@ if (!window._slideshowDlInit) {
|
||||
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) {
|
||||
if (_currentKey && _currentKey !== routeKey) {
|
||||
// Different video — reset
|
||||
@ -521,6 +529,17 @@ if (!window._slideshowDlInit) {
|
||||
_pollTimer = null;
|
||||
}
|
||||
_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;
|
||||
document.getElementById('sl-dl-bar').style.background = ''; // restore gradient
|
||||
document.getElementById('sl-dl-cancel').textContent = 'Cancel';
|
||||
@ -532,7 +551,7 @@ if (!window._slideshowDlInit) {
|
||||
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
var token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
|
||||
|
||||
fetch('/videos/' + routeKey + '/slideshow/generate', {
|
||||
fetch('/videos/' + routeKey + '/slideshow/generate' + qs, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
|
||||
})
|
||||
@ -546,7 +565,7 @@ if (!window._slideshowDlInit) {
|
||||
document.getElementById('sl-dl-cancel').style.display = 'none';
|
||||
setTimeout(function () {
|
||||
window._slideshowDlCancel();
|
||||
window.location.href = '/videos/' + routeKey + '/download';
|
||||
window.location.href = '/videos/' + routeKey + '/download' + qs;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
@ -559,7 +578,7 @@ if (!window._slideshowDlInit) {
|
||||
_setProgress(2, 'Generating video...');
|
||||
|
||||
_pollTimer = setInterval(function () {
|
||||
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration, {
|
||||
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration + qsAmp, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
@ -581,7 +600,7 @@ if (!window._slideshowDlInit) {
|
||||
document.getElementById('sl-dl-cancel').style.display = 'none';
|
||||
setTimeout(function () {
|
||||
window._slideshowDlCancel();
|
||||
window.location.href = '/videos/' + routeKey + '/download';
|
||||
window.location.href = '/videos/' + routeKey + '/download' + qs;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -269,7 +269,7 @@ function openEditVideoModal(videoId) {
|
||||
const titleEl = document.getElementById('edit-track1-title');
|
||||
const descEl = document.getElementById('edit-track1-desc');
|
||||
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 || '');
|
||||
|
||||
// Thumbnail
|
||||
@ -620,14 +620,48 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
|
||||
|
||||
function editHandleSlides(fileList, tid) {
|
||||
tid = tid || 't1';
|
||||
if (!fileList || !fileList.length) return;
|
||||
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 }); _editRenderSlides(tid); };
|
||||
reader.readAsDataURL(f);
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlides);
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
let _editSlidesCropQueue = [];
|
||||
let _editSlidesCropTid = null;
|
||||
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) {
|
||||
@ -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 editHandleSlidesForTrack(tid, fileList) {
|
||||
if (!fileList || !fileList.length) return;
|
||||
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);
|
||||
}
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
|
||||
}
|
||||
|
||||
function editClearSlidesForTrack(e, tid) {
|
||||
@ -868,7 +895,7 @@ function _editAddExistingTrack(track) {
|
||||
const titleEl = document.getElementById('edit-' + pfx + '-title');
|
||||
if (titleEl) titleEl.value = track.title || '';
|
||||
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) {
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
const wrap = document.getElementById('csd_' + pfx);
|
||||
@ -1030,3 +1057,13 @@ _editApplyMode('generic');
|
||||
output-width="1280"
|
||||
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"
|
||||
/>
|
||||
|
||||
@ -74,6 +74,11 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
|
||||
var csrfToken = _getLatestCsrf();
|
||||
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
|
||||
if (recordUrl) {
|
||||
try {
|
||||
@ -92,6 +97,11 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
|
||||
} 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
|
||||
if (window.innerWidth <= 768 && navigator.share) {
|
||||
navigator.share({ title: videoTitle, url: shareUrl }).catch(function() {});
|
||||
|
||||
@ -188,9 +188,7 @@
|
||||
|
||||
<div class="um-field">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description</label>
|
||||
<textarea id="lt-track1-desc-modal" class="um-input um-textarea"
|
||||
rows="3" style="font-size:13px;padding:9px 12px;"
|
||||
placeholder="Tell viewers about this content…"></textarea>
|
||||
<x-rich-text-editor name="" id="lt-track1-desc-modal" placeholder="Tell viewers about this content…" />
|
||||
</div>
|
||||
|
||||
{{-- Video file + Thumbnail side by side (video/match mode) --}}
|
||||
@ -716,7 +714,7 @@ function resetUploadForm() {
|
||||
const t1Desc = document.getElementById('lt-track1-desc-modal');
|
||||
const t1Fname = document.getElementById('lt-track1-fname');
|
||||
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…';
|
||||
const audioBoxR = document.getElementById('um-tf-t1-audio-box');
|
||||
if (audioBoxR) audioBoxR.style.borderColor = '';
|
||||
@ -933,7 +931,7 @@ function removeVideoModal(e) {
|
||||
const t1Title = document.getElementById('lt-track1-title-modal');
|
||||
const t1Desc = document.getElementById('lt-track1-desc-modal');
|
||||
if (t1Title) t1Title.value = '';
|
||||
if (t1Desc) t1Desc.value = '';
|
||||
if (t1Desc) { t1Desc.value = ''; t1Desc.dispatchEvent(new Event('rte:refresh')); }
|
||||
if (_currentMode === 'music') {
|
||||
_applyMode('generic');
|
||||
_gsSetDefault('type', 'generic', 'bi-film', 'Generic');
|
||||
@ -1018,14 +1016,41 @@ function slidesZoneDrop(e, tid) {
|
||||
}
|
||||
|
||||
function handleSlidesForTrack(tid, fileList) {
|
||||
if (!fileList || !fileList.length) return;
|
||||
if (!_slidesData[tid]) _slidesData[tid] = [];
|
||||
for (const f of Array.from(fileList)) {
|
||||
if (_slidesData[tid].length >= 10) break;
|
||||
_slidesData[tid].push(f);
|
||||
_slidesCropStart(fileList, tid);
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
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);
|
||||
}
|
||||
}
|
||||
_slidesCropLoadNext();
|
||||
}
|
||||
|
||||
function renderSlidesStrip(tid) {
|
||||
const files = _slidesData[tid] || [];
|
||||
@ -1214,10 +1239,11 @@ function addExtraTrackModal() {
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Description <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<textarea name="extra_track_descriptions[]" class="um-input um-textarea"
|
||||
rows="3" style="font-size:13px;padding:9px 12px;"
|
||||
<div class="rte-wrap" data-min-height="100px">
|
||||
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
|
||||
placeholder="Description in this language…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-music-note-beamed"></i> Audio File
|
||||
@ -1425,3 +1451,13 @@ _applyMode('generic');
|
||||
output-width="1280"
|
||||
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"
|
||||
/>
|
||||
|
||||
@ -196,7 +196,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
|
||||
@ -266,9 +266,7 @@
|
||||
<!-- Description (audio mode) -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Description</label>
|
||||
<textarea id="lt-track1-desc-create" class="form-textarea"
|
||||
rows="2" style="font-size:13px;min-height:64px;"
|
||||
placeholder="Tell viewers about this track…"></textarea>
|
||||
<x-rich-text-editor name="" id="lt-track1-desc-create" min-height="80px" placeholder="Tell viewers about this track…" />
|
||||
</div>
|
||||
|
||||
<!-- Slides -->
|
||||
@ -562,7 +560,10 @@
|
||||
if (cFname) cFname.textContent = 'Choose audio file…';
|
||||
document.getElementById('video-title').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);
|
||||
}
|
||||
|
||||
@ -646,14 +647,42 @@
|
||||
}
|
||||
|
||||
function handleCSlidesForTrack(trackId, fileList) {
|
||||
if (!fileList || !fileList.length) return;
|
||||
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
|
||||
for (const f of Array.from(fileList)) {
|
||||
if (_cSlidesData[trackId].length >= 10) break;
|
||||
_cSlidesData[trackId].push(f);
|
||||
_cSlidesCropStart(fileList, trackId);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
_cSlidesCropLoadNext();
|
||||
}
|
||||
window.cSlidesCropDone = cSlidesCropDone;
|
||||
|
||||
function renderCSlidesStrip(trackId) {
|
||||
const files = _cSlidesData[trackId] || [];
|
||||
@ -945,10 +974,11 @@
|
||||
<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>
|
||||
</label>
|
||||
<textarea name="extra_track_descriptions[]" class="form-textarea"
|
||||
rows="2" style="font-size:13px;min-height:64px;"
|
||||
<div class="rte-wrap" data-min-height="80px">
|
||||
<textarea class="rte-source" name="extra_track_descriptions[]" id="lt-desc-ce${n}"
|
||||
placeholder="Description in this language…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<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>
|
||||
@ -1038,4 +1068,14 @@
|
||||
output-width="1280"
|
||||
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
|
||||
|
||||
@ -198,8 +198,7 @@
|
||||
{{-- Description --}}
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" id="edit-description" rows="4" class="form-textarea"
|
||||
placeholder="Tell viewers about your video (Markdown supported)">{{ $video->description }}</textarea>
|
||||
<x-rich-text-editor name="description" id="edit-description" :value="$video->description" placeholder="Tell viewers about your video…" />
|
||||
</div>
|
||||
|
||||
@if(!$video->isAudioOnly())
|
||||
@ -279,9 +278,11 @@
|
||||
value="{{ $track->title ?? '' }}"
|
||||
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;">
|
||||
<textarea name="track_description_updates[{{ $track->id }}]"
|
||||
placeholder="Description in this language…" rows="2"
|
||||
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>
|
||||
<x-rich-text-editor :name="'track_description_updates[' . $track->id . ']'"
|
||||
:id="'ep-track-desc-' . $track->id"
|
||||
: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;"
|
||||
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>
|
||||
@ -542,16 +543,42 @@
|
||||
}
|
||||
|
||||
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();
|
||||
reader.onload = e => {
|
||||
epSlidesData.push({ file, url: e.target.result });
|
||||
epSlidesRefresh();
|
||||
_epSlidesCropLoadNext();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
this.value = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
_epSlidesCropLoadNext();
|
||||
};
|
||||
|
||||
// Initialise strip with existing slides
|
||||
epSlidesRefresh();
|
||||
@ -785,4 +812,14 @@
|
||||
output-width="1280"
|
||||
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
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
'id' => 0,
|
||||
'language' => $primaryLang,
|
||||
'label' => $video->language ? strtoupper($video->language) : 'Default',
|
||||
'name' => $video->language ? ($allLangData[$primaryLang]['name'] ?? strtoupper($video->language)) : 'Default',
|
||||
'flag' => $primaryFlag,
|
||||
'stream_url' => $audioUrl,
|
||||
'title' => $video->title,
|
||||
@ -23,6 +24,7 @@
|
||||
'id' => $t->id,
|
||||
'language' => $t->language,
|
||||
'label' => $t->label,
|
||||
'name' => $allLangData[$t->language]['name'] ?? strtoupper($t->language),
|
||||
'flag' => $allLangData[$t->language]['flag'] ?? null,
|
||||
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
|
||||
'title' => $t->title ?? '',
|
||||
@ -123,12 +125,13 @@
|
||||
data-lang-id="{{ $track['id'] }}"
|
||||
data-lang-url="{{ $track['stream_url'] }}"
|
||||
data-lang-label="{{ $track['label'] }}"
|
||||
data-lang-name="{{ $track['name'] }}"
|
||||
data-lang-flag="{{ $track['flag'] ?? '' }}"
|
||||
data-lang-title="{{ $track['title'] ?? '' }}"
|
||||
data-lang-description="{{ $track['description'] ?? '' }}"
|
||||
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="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>
|
||||
</div>
|
||||
@endforeach
|
||||
@ -450,6 +453,7 @@ const barsBtn = document.getElementById('ytpBarsBtn');
|
||||
const animCanvas = document.getElementById('audioAnimCanvas');
|
||||
|
||||
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 isDragging = false;
|
||||
let userSeeking = false;
|
||||
@ -586,6 +590,10 @@ const langBtn = document.getElementById('ytpLangBtn');
|
||||
const langPopup = document.getElementById('ytpLangPopup');
|
||||
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) {
|
||||
langBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
@ -603,6 +611,7 @@ if (langBtn && langPopup) {
|
||||
const url = opt.dataset.langUrl;
|
||||
const flag = opt.dataset.langFlag;
|
||||
if (!url) return;
|
||||
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
|
||||
const relPos = audio.duration ? audio.currentTime / audio.duration : 0;
|
||||
const wasPlaying = !audio.paused;
|
||||
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; });
|
||||
});
|
||||
});
|
||||
|
||||
// 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 ──────────────────────────────────
|
||||
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) {
|
||||
text = (text || '').trim();
|
||||
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 html = _rteToHtml(text);
|
||||
const aboutPanel = document.getElementById('vdb-about');
|
||||
if (aboutPanel) {
|
||||
// Remove existing "No description" placeholder if present
|
||||
let shortEl = document.getElementById('vdbDescShort');
|
||||
|
||||
if (!shortEl && aboutPanel) {
|
||||
const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
|
||||
if (ph) ph.style.display = 'none';
|
||||
|
||||
shortEl = document.createElement('div');
|
||||
shortEl.id = 'vdbDescShort';
|
||||
shortEl.className = 'vdb-desc-text';
|
||||
shortEl.className = 'vdb-desc-text vdb-clamp';
|
||||
aboutPanel.appendChild(shortEl);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
if (fullEl) fullEl.style.display = 'none';
|
||||
const moreBtn2 = shortEl.nextElementSibling;
|
||||
if (moreBtn2 && moreBtn2.classList.contains('vdb-show-more')) moreBtn2.style.display = 'none';
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Always reset to the collapsed view on a language switch
|
||||
shortEl.textContent = needsExpand ? short : text;
|
||||
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) {
|
||||
moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'vdb-show-more';
|
||||
moreBtn.onclick = function() {
|
||||
const f = document.getElementById('vdbDescFull');
|
||||
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.onclick = function () { if (window.toggleVdbDesc) toggleVdbDesc(moreBtn); };
|
||||
shortEl.insertAdjacentElement('afterend', moreBtn);
|
||||
}
|
||||
moreBtn.textContent = 'Show more';
|
||||
moreBtn.style.display = '';
|
||||
} else if (moreBtn) {
|
||||
moreBtn.style.display = 'none';
|
||||
}
|
||||
// Reveal "Show more" only when the content overflows the clamp. Compare the
|
||||
// natural content height to the clamp's pixel limit (130px, see .vdb-clamp) —
|
||||
// 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 ───────────────────────────────────────────────
|
||||
@ -1063,6 +1068,10 @@ window._audioPlayerUpdate = function(d) {
|
||||
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
|
||||
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
|
||||
var primaryLangNew = d.language || null;
|
||||
@ -1116,12 +1125,13 @@ window._audioPlayerUpdate = function(d) {
|
||||
+ ' data-lang-id="' + t.id + '"'
|
||||
+ ' data-lang-url="' + t.stream_url + '"'
|
||||
+ ' data-lang-label="' + t.label + '"'
|
||||
+ ' data-lang-name="' + langName(t.language) + '"'
|
||||
+ ' data-lang-flag="' + (t.flag || '') + '"'
|
||||
+ ' data-lang-title="' + safeTitle + '"'
|
||||
+ ' data-lang-description="' + safeDesc + '"'
|
||||
+ ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">'
|
||||
+ 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>'
|
||||
+ '</div>';
|
||||
});
|
||||
|
||||
@ -5,11 +5,9 @@
|
||||
--}}
|
||||
@php
|
||||
$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;
|
||||
$fullDescription = $video->description ?? '';
|
||||
$shortDescription = Str::limit($fullDescription, 200);
|
||||
$needsExpand = strlen($fullDescription) > 200;
|
||||
@endphp
|
||||
@if ($showBox)
|
||||
<style>
|
||||
@ -23,9 +21,17 @@
|
||||
.vdb-panel { display:none; padding:14px 16px 16px; }
|
||||
.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-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); white-space:pre-wrap; }
|
||||
.vdb-desc-text p { margin-bottom:8px; }
|
||||
.vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
|
||||
.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.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; }
|
||||
</style>
|
||||
|
||||
@ -50,12 +56,9 @@
|
||||
</div>
|
||||
@if(isset($descriptionSlot))
|
||||
{!! $descriptionSlot !!}
|
||||
@elseif($hasDesc)
|
||||
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
|
||||
@if($needsExpand)
|
||||
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
|
||||
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
|
||||
@endif
|
||||
@elseif($renderedDescription !== '')
|
||||
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
||||
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)">Show more</button>
|
||||
@else
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
||||
@endif
|
||||
@ -80,10 +83,21 @@ function switchVdbTab(panelId, btn) {
|
||||
}
|
||||
}
|
||||
function toggleVdbDesc(btn) {
|
||||
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull');
|
||||
if (!f) return;
|
||||
if (f.style.display === 'none') { s.style.display='none'; f.style.display='block'; btn.textContent='Show less'; }
|
||||
else { s.style.display='block'; f.style.display='none'; btn.textContent='Show more'; }
|
||||
const d = document.getElementById('vdbDescShort');
|
||||
if (!d) return;
|
||||
const expanded = d.classList.toggle('vdb-expanded');
|
||||
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>
|
||||
@endif
|
||||
|
||||
@ -565,7 +565,7 @@
|
||||
var doc=new DOMParser().parseFromString(html,'text/html');
|
||||
|
||||
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');
|
||||
if(nc&&oc) oc.innerHTML=nc.innerHTML;
|
||||
@ -725,8 +725,10 @@
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<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'} }}"
|
||||
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) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
@ -822,7 +824,7 @@
|
||||
|
||||
// description
|
||||
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
|
||||
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
|
||||
|
||||
@ -2637,7 +2637,7 @@
|
||||
var doc=new DOMParser().parseFromString(html,'text/html');
|
||||
|
||||
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');
|
||||
if(nc&&oc) oc.innerHTML=nc.innerHTML;
|
||||
@ -2798,8 +2798,10 @@
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<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'} }}"
|
||||
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) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
@ -2890,7 +2892,7 @@
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
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');
|
||||
if(nc&&oc) oc.innerHTML=nc.innerHTML;
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@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')
|
||||
<meta property="og:title" content="{{ $video->title }}">
|
||||
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
|
||||
<meta property="og:title" content="{{ $metaTitle }}">
|
||||
<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:width" content="1200">
|
||||
<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:site_name" content="{{ config('app.name') }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $video->title }}">
|
||||
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
|
||||
<meta name="twitter:title" content="{{ $metaTitle }}">
|
||||
<meta name="twitter:description" content="{{ Str::limit(strip_tags($metaDesc ?? ''), 200) }}">
|
||||
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
|
||||
@endpush
|
||||
|
||||
@ -599,7 +604,7 @@
|
||||
|
||||
// swap description box
|
||||
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
|
||||
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
|
||||
@ -801,7 +806,7 @@
|
||||
|
||||
var newVdb = doc.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 oldCh = document.querySelector('.channel-row');
|
||||
@ -887,8 +892,10 @@
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<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'} }}"
|
||||
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) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user