Compare commits

...

2 Commits

Author SHA1 Message Date
ghassan
80948efff7 Merge branch 'lyrics' into master 2026-05-31 22:02:07 +03:00
ghassan
f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00
60 changed files with 7920 additions and 2675 deletions

11
.gitignore vendored
View File

@ -3,7 +3,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/storage/*.key /data/*.key
/vendor /vendor
.env .env
.env.backup .env.backup
@ -18,3 +18,12 @@ yarn-error.log
/.idea /.idea
/.vscode /.vscode
/.claude/mcp /.claude/mcp
# Lyrics ML stack: keep transcribe.py, ignore the heavy venv + model cache
/ml/venv
/ml/cache
/ml/__pycache__
# Runtime storage (moved from /storage to /data) — user uploads, sessions,
# cache, logs, tmp. Never goes into git.
/data

188
CLAUDE.md
View File

@ -122,6 +122,49 @@ Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</sp
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead. - **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
- **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements. - **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements.
**Every new Blade page must ship desktop + mobile style partials from day one.** When you create a view at `resources/views/<scope>/<page>.blade.php`, also create:
```
resources/views/<scope>/partials/<page>/styles/
├── desktop.blade.php ← base + desktop CSS (the foundation)
└── mobile.blade.php ← @media (max-width: 768px) and below
```
…and wire them up in the page's `@section('extra_styles')` block:
```blade
@section('extra_styles')
@php
// Any shared Blade variables consumed by BOTH partials (palette,
// computed sizes, theme values) must be defined here, not inside
// a partial — each @include runs in its own variable scope.
@endphp
@include('<scope>.partials.<page>.styles.desktop')
@include('<scope>.partials.<page>.styles.mobile')
@endsection
```
Rules that must never be violated:
1. **Never put a `@media (max-width: ...)` block inside `desktop.blade.php`.** All mobile-scoped rules go in `mobile.blade.php`. The whole point is that editing one cannot affect the other.
2. **Never put a non-media-query rule inside `mobile.blade.php`.** Every selector in the mobile partial must be inside an `@media (max-width: 768px)` (or smaller) block. A naked rule would leak to desktop.
3. **Folder name is `styles/`, not `styles.`.** Laravel resolves dots in `@include('foo.bar.baz')` as directory separators, so a file called `styles.mobile.blade.php` can't be referenced by `@include('....styles.mobile')`. Always put the two files under a `styles/` subdirectory.
4. **Shared `@php` variables go in the parent page**, never duplicated across partials. Define `$hue`, palette values, computed sizes, etc. in the page's `@section('extra_styles')` block above the `@include`s, so both partials inherit them.
5. **Reference example:** the channel page (`resources/views/user/channel.blade.php` + `resources/views/user/partials/channel/styles/{desktop,mobile}.blade.php`) is the canonical implementation. Mirror its structure on every new page.
6. **No “I'll add mobile styles later.”** A page without a mobile partial is not finished. Create `mobile.blade.php` with at minimum the empty media-query scaffold:
```blade
<style>
@media (max-width: 768px) {
/* mobile overrides for <page> */
}
@media (max-width: 480px) {
/* small-phone refinements */
}
</style>
```
…even when there are no mobile-specific rules yet. This guarantees the file exists for the next person who needs to tweak mobile.
7. **When editing an existing page that still has inline styles**, refactor it to this structure as part of the same task — don't add new CSS to a page that hasn't been split yet without splitting it first.
**Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.** **Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.**
**Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side. **Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side.
@ -190,71 +233,144 @@ Rules that must never be violated:
**NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.** **NAS is the primary storage backend. When NAS is reachable, every user file must end up on the NAS and be served from the NAS. When NAS is unreachable, files are stored locally and automatically migrated to NAS when it comes back online.**
#### Framework storage lives at `data/`, not `storage/`
**The Laravel framework storage directory has been relocated from `storage/` to `data/` so the only entry named `storage` in the project tree is the `public/storage` symlink.**
Layout (verbatim):
- `data/app/` → file storage (with `app/public/` exposed to the web).
- `data/framework/` → sessions, cache, compiled views, route cache.
- `data/logs/` → application logs.
- `public/storage` → a **symlink** to `../data/app/public`. It exposes public files to the web without making the rest of `data/` reachable.
The redirect is wired in `bootstrap/app.php`:
```php
$app->useStoragePath(base_path('data'));
```
This means every `storage_path(...)` call, `Storage::disk('local'|'public')` operation, session/cache/view/log write, and the local NAS file cache resolves through `data/`. The `storage/` directory at the project root **does not exist** and must never be re-created.
Rules:
1. Never re-create `storage/` at the project root. Laravel's storage path is `data/`. The framework can't run without `data/`.
2. Never delete the `data/` directory or any subdirectory of it.
3. Never replace the `public/storage` symlink with a real directory or copy files into it. It must remain a symlink targeting `../data/app/public`.
4. Never move user files into `public/storage` directly. All file writes go through the `data/app/` tree (NAS-mirrored paths), and the symlink + `MediaController` handle public serving.
5. If `public/storage` is missing or broken, fix it with `ln -sfn ../data/app/public public/storage`. Do not run `php artisan storage:link` blindly — that targets `data/app/public` which doesn't exist; you'd have to pass `--relative` and a custom target.
6. Nginx must alias `/storage` to `/var/www/videoplatform/data/app/public` (set in `/etc/nginx/sites-enabled/videoplatform`). If you ever edit that file, keep the alias pointing at `data/`, not `storage/`.
#### Canonical storage layout — IDENTICAL on local disk and on the NAS #### Canonical storage layout — IDENTICAL on local disk and on the NAS
**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.** **This is the single source-of-truth file structure. Both the local `data/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.**
**Type-segregated top-level folders.** Every video has a `type` (`music`, `match`, `generic`). The folder it lives under is determined by that type and is frozen at upload time — editing a video's type does NOT move its files. The mapping is:
| Video `type` | Folder | Has `tracks/` subfolder? |
|---|---|---|
| `music` | `music/` | **Yes** — every track (primary + extras) lives in its own subfolder |
| `match` (sports) | `sports/` | No — single video file in the slug folder |
| `generic` | `videos/` | No — single video file in the slug folder |
``` ```
users/{user-slug}/ users/{user-slug}/
├── profile/ ├── profile/
│ ├── avatar.{ext} │ ├── avatar.{ext}
│ └── cover.{ext} ← banner (DB column is users.banner) │ └── cover.{ext} ← banner (DB column is users.banner)
├── playlists/ ├── playlists/
│ └── {playlist-id}/ │ └── {playlist-id}/
│ └── thumb.{ext} │ └── thumb.{ext}
├── posts/ ├── posts/
│ └── {post-id}/ │ └── {post-id}/
│ └── {filename} ← post images │ └── {filename} ← post images
└── videos/
└── {song-slug}/ ← ONE folder per song/video. EVERYTHING for it lives here. ├── music/ ← type = music
│ ┌──────────────── SOURCE OF TRUTH (synced to NAS) ───────┐ │ └── {song-slug}/ ← ONE folder per song
├── {title-slug}.{ext} ← primary track / video file (canonical name) │ ├── meta.json ← {id, user_id, title, type:"music", created_at}
├── {song-slug}-{lang}-{id}.{ext} ← each extra-language audio track (one per language) │ └── tracks/
├── slides/ │ ├── {primary-lang}-{primary-track-id}/ ← primary track has its own folder
│ └── {position}.{ext} ← cover image(s) / slideshow frames │ │ │ ┌─── SOURCE OF TRUTH (synced to NAS) ───┐
├── thumb.{ext} ← cover for video-type uploads that have no slides │ │ ├── audio.{ext} ← the audio file (canonical name)
├── meta.json ← {id, user_id, title, created_at} │ │ ├── lyrics.ass ← synced lyrics for THIS track
│ └──────────────────────────────────────────────────────┘ │ │ ├── thumb.{ext} ← cover when this track has no slides
└── cache/ ← REGENERABLE renders. LOCAL-ONLY. Never on NAS. Safe to wipe. │ │ ├── slides/
├── video.mp4 ← generated "Download Video" (plain) │ │ │ └── {position}.{ext} ← THIS track's slideshow frames
├── video-viz.mp4 ← generated "Download Video" (visualizer) │ │ │ └────────────────────────────────────────┘
└── hls/ │ │ └── cache/ ← LOCAL-only, regenerable, never on NAS
└── {variant}/… ← adaptive-streaming rendition (.m3u8 + .ts) │ │ ├── video.mp4
│ │ ├── video-viz.mp4
│ │ └── hls/{variant}/…
│ └── {extra-lang}-{extra-track-id}/ ← every extra-language track, same shape
│ ├── audio.{ext}
│ ├── lyrics.ass
│ ├── thumb.{ext}
│ ├── slides/{position}.{ext}
│ └── cache/…
├── sports/ ← type = match
│ └── {match-slug}/
│ │ ┌─── SOURCE OF TRUTH ───┐
│ ├── meta.json
│ ├── video.{ext} ← the match video
│ ├── thumb.{ext}
│ │ └────────────────────────┘
│ └── cache/ ← LOCAL-only
│ └── hls/{variant}/…
└── videos/ ← type = generic
└── {video-slug}/
│ ┌─── SOURCE OF TRUTH ───┐
├── meta.json
├── video.{ext}
├── thumb.{ext}
│ └────────────────────────┘
└── cache/ ← LOCAL-only
└── hls/{variant}/…
``` ```
**Sources vs. the `cache/` subfolder — a hard rule:** **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. - The track-folder root (or video-folder root for sports/generic) holds only the **source of truth** (audio/video file, slides, thumb, lyrics, 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/`. - **`cache/` holds only regenerable, derived renders** — "Download Video" mp4s, the HLS rendition. It is **LOCAL-only and is NEVER pushed to the NAS** (the sync layer pushes only source files). Deleting `cache/` is always safe; it rebuilds on next download/stream.
- DB pointers: `videos.slideshow_video_path``users/.../{song}/cache/video.mp4`; `videos.hls_path``users/.../{song}/cache/hls`. - DB pointers: `videos.slideshow_video_path``users/.../<track-folder>/cache/video.mp4` (music) or `users/.../<video-folder>/cache/video.mp4` (sports/generic). `videos.hls_path` → the parent `cache/hls` for that file.
- Reclaim space anytime with `php artisan nas:free-local-storage` (deletes song `cache/` folders); `tracks:reorganize` never treats anything under `cache/` as an orphan. - Reclaim space anytime with `php artisan nas:free-local-storage`; `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()`):** **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). - **Music — one song folder, one folder per track inside it:**
- **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. - Song folder: `users/{slug}/music/{song-slug}/`.
- Slides are `slides/{position}.{ext}`. - Track folder name: `{lang}-{db-track-id}` (e.g. `en-12`, `ar-47`). The DB id makes the folder name globally unique even when two tracks share a language.
- Inside each track folder, filenames are **canonical**`audio.{ext}`, `lyrics.ass`, `thumb.{ext}`, `slides/{position}.{ext}`. **Do not** put track-id or language in these filenames; the *folder* already disambiguates.
- **Sports (match):** `users/{slug}/sports/{match-slug}/video.{ext}`, `thumb.{ext}`.
- **Generic:** `users/{slug}/videos/{video-slug}/video.{ext}`, `thumb.{ext}`.
**Type is frozen at upload time.** When a user edits a video's type (e.g. `generic``music`), the on-disk folder does NOT move. The path remains under the original type folder for the life of that record. New uploads use the type-aware path. The migration command (`tracks:reorganize`) is the only thing that may move files between type folders.
File types and their canonical locations (same string on NAS and local): 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 | | File type | Path (relative to NAS root and to `data/app/`) | Served via |
|---|---|---| |---|---|---|
| Primary video / audio | `users/{slug}/videos/{song-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` | | Music primary audio | `users/{slug}/music/{song-slug}/tracks/{lang}-{primary-id}/audio.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Extra audio track | `users/{slug}/videos/{song-slug}/{song-slug}-{lang}-{track-id}.{ext}` | `NasSyncService::ensureLocalTrackCopy()` | | Music extra audio track | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/audio.{ext}` | `NasSyncService::ensureLocalTrackCopy()` |
| Video thumbnail | `users/{slug}/videos/{song-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` | | Music track lyrics | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/lyrics.ass` | `NasSyncService::getLocalLyrics()` |
| Slides | `users/{slug}/videos/{song-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` | | Music track slides | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Music track thumb | `users/{slug}/music/{song-slug}/tracks/{lang}-{track-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Sports video | `users/{slug}/sports/{match-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Sports thumb | `users/{slug}/sports/{match-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Generic video | `users/{slug}/videos/{video-slug}/video.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Generic thumb | `users/{slug}/videos/{video-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` | | Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` | | Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
| Banner | `users/{slug}/profile/cover.{ext}` | `MediaController::banner` + `ensureLocalAsset()` | | Banner | `users/{slug}/profile/cover.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` | | Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs (dry-run by default; `--force` to apply). The one-time migration `php artisan tracks:reorganize` enforces this layout for existing songs/videos (dry-run by default; `--force` to apply). It moves any pre-existing flat-layout content into the type-segregated, per-track-folder layout above and updates the DB pointers in lockstep.
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. **Slide sharing across tracks (music only):** A music track owns its own slides via `video_slides.audio_track_id`. If a track has no slides of its own, the player and the render pipeline fall back via `Video::slidesForTrack($trackId)` to: (1) the primary track's slides, then (2) any other track's slides, then (3) the cover image. Files are never duplicated to support this — the fallback is purely a query/runtime concern.
The only files that live permanently on local disk are HLS segments (`data/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations: **The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only - `data/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only - `data/app/public/avatars/` — formerly held user avatars; now NAS only
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only - `data/app/public/videos/` — formerly held uploaded video files; now NAS only
These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there. These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.

View File

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Jobs\GenerateLyricsJob;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
/**
* Backfill synced lyrics for existing songs. New uploads generate automatically;
* this covers the catalogue that predates the feature.
*
* php artisan lyrics:generate 163 # one video (primary + every track)
* php artisan lyrics:generate --all # every music video missing lyrics
* php artisan lyrics:generate --all --force # regenerate even if a file exists
*/
class GenerateLyrics extends Command
{
protected $signature = 'lyrics:generate {video? : Video id} {--all : All music videos} {--force : Regenerate even when lyrics already exist}';
protected $description = 'Generate word-level synced lyrics for songs (dispatched to the video-processing queue)';
public function handle(NasSyncService $nas): int
{
$force = (bool) $this->option('force');
if ($videoId = $this->argument('video')) {
$video = Video::find($videoId);
if (! $video) {
$this->error("Video #{$videoId} not found.");
return self::FAILURE;
}
$videos = collect([$video]);
} elseif ($this->option('all')) {
$videos = Video::where('type', 'music')->get();
} else {
$this->error('Pass a video id or --all.');
return self::FAILURE;
}
$dispatched = 0;
foreach ($videos as $video) {
$video->loadMissing('audioTracks');
if ($force || ! is_array($nas->getLyrics($video, null))) {
GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
$dispatched++;
}
foreach ($video->audioTracks as $track) {
if ($force || ! is_array($nas->getLyrics($video, $track))) {
GenerateLyricsJob::dispatch($video->id, $track->id)->onConnection('database');
$dispatched++;
}
}
$this->line("Queued lyrics for #{$video->id}{$video->title}");
}
$this->info("Dispatched {$dispatched} lyrics job(s) to the video-processing queue.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,241 @@
<?php
namespace App\Console\Commands;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Models\VideoSlide;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
/**
* One-time migration that moves existing songs/videos from the legacy flat
* layout into the canonical type-segregated, per-track-folder layout described
* in CLAUDE.md.
*
* Dry-run by default pass --force to actually move files and update the DB.
* Run this only after backing up the DB and confirming the dry-run plan looks
* correct. The command is idempotent: rows already in the new layout are
* skipped.
*/
class MigrateStorageLayout extends Command
{
protected $signature = 'storage:migrate-layout {--force : Apply changes (default is dry-run)} {--video= : Migrate only this video id}';
protected $description = 'Move existing songs/videos to the type-segregated + per-track-folder layout';
public function handle(NasSyncService $nas): int
{
$apply = (bool) $this->option('force');
$only = $this->option('video') ? (int) $this->option('video') : null;
$this->info($apply ? '→ APPLY mode (files and DB will be modified)' : '→ DRY-RUN (no changes will be made)');
$q = Video::query()->with(['user', 'audioTracks', 'slides']);
if ($only) $q->where('id', $only);
$videos = $q->orderBy('id')->get();
$this->info("Found {$videos->count()} video(s) to inspect.");
$stats = ['skipped' => 0, 'planned' => 0, 'applied' => 0, 'errors' => 0];
foreach ($videos as $video) {
try {
$plan = $this->planVideo($video, $nas);
if ($plan === null) { $stats['skipped']++; continue; }
$this->line("");
$this->info("Video #{$video->id} ({$video->type}): {$video->title}");
foreach ($plan as $move) {
$this->line(" {$move['from']}{$move['to']}");
}
$stats['planned']++;
if ($apply) {
$this->applyPlan($video, $plan, $nas);
$stats['applied']++;
}
} catch (\Throwable $e) {
$stats['errors']++;
$this->error("Video #{$video->id}: " . $e->getMessage());
}
}
$this->line("");
$this->info("Done. " . json_encode($stats));
return self::SUCCESS;
}
/**
* Build the move list for one video. Returns null if it's already in the
* target layout. Each plan entry is ['from' => oldPath, 'to' => newPath,
* 'kind' => 'video'|'audio'|'slide'|'thumb', 'model' => modelOrNull,
* 'slide_field' => 'filename' | 'path' | 'thumbnail'].
*/
private function planVideo(Video $video, NasSyncService $nas): ?array
{
$path = (string) $video->path;
if (! str_starts_with($path, 'users/')) {
return null; // unorganised — outside this migration's scope
}
$segs = explode('/', $path);
if (count($segs) < 4) return null;
$expectedTypeFolder = $nas->typeFolder($video);
$currentTypeFolder = $segs[2] ?? null;
$isMusic = ($video->type === 'music');
// Already in target layout? Music: tracks/{lang-id}/audio.{ext}. Others: video.{ext}.
$inTracks = ($segs[4] ?? null) === 'tracks';
$canonicalPrimary = $isMusic ? 'audio' : 'video';
$endsCanonical = preg_match("#/{$canonicalPrimary}\\.[a-z0-9]+$#i", $path) === 1;
if ($currentTypeFolder === $expectedTypeFolder
&& (! $isMusic || $inTracks)
&& $endsCanonical) {
return null; // already migrated
}
// Compute new song/video root: users/{slug}/{type-folder}/{slug}
$userSlug = $segs[1];
$videoSlug = $isMusic ? ($segs[3] ?? null) : ($segs[3] ?? null);
if (! $videoSlug) return null;
$newRoot = "users/{$userSlug}/{$expectedTypeFolder}/{$videoSlug}";
$ext = pathinfo($video->filename ?: $path, PATHINFO_EXTENSION) ?: 'mp4';
$plan = [];
// Primary file
if ($isMusic) {
$primaryFolder = $nas->trackFolderName($video, null);
$plan[] = [
'from' => $path,
'to' => "{$newRoot}/tracks/{$primaryFolder}/audio.{$ext}",
'kind' => 'video',
'model' => $video,
'set' => ['path', 'filename'],
'new_filename'=> "audio.{$ext}",
];
} else {
$plan[] = [
'from' => $path,
'to' => "{$newRoot}/video.{$ext}",
'kind' => 'video',
'model' => $video,
'set' => ['path', 'filename'],
'new_filename'=> "video.{$ext}",
];
}
// Thumbnail
if ($video->thumbnail && str_starts_with($video->thumbnail, 'users/')) {
$thumbExt = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'webp';
$thumbDir = $isMusic
? "{$newRoot}/tracks/" . $nas->trackFolderName($video, null)
: $newRoot;
$plan[] = [
'from' => $video->thumbnail,
'to' => "{$thumbDir}/thumb.{$thumbExt}",
'kind' => 'thumb',
'model' => $video,
'set' => ['thumbnail'],
];
}
// Extra audio tracks (music only)
if ($isMusic) {
foreach ($video->audioTracks as $track) {
if (! str_starts_with((string) $track->path, 'users/')) continue;
$tExt = pathinfo($track->filename ?: $track->path, PATHINFO_EXTENSION) ?: 'mp3';
$trackFolder = $nas->trackFolderName($video, $track);
$plan[] = [
'from' => $track->path,
'to' => "{$newRoot}/tracks/{$trackFolder}/audio.{$tExt}",
'kind' => 'audio',
'model' => $track,
'set' => ['path', 'filename'],
'new_filename'=> "audio.{$tExt}",
];
}
// Slides (music only) — owners may be NULL (primary), or any track id
foreach ($video->slides as $slide) {
if (! str_starts_with((string) $slide->filename, 'users/')) continue;
$sExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
$ownerTrack = $slide->audio_track_id
? $video->audioTracks->firstWhere('id', $slide->audio_track_id)
: null;
$folder = $nas->trackFolderName($video, $ownerTrack);
$plan[] = [
'from' => $slide->filename,
'to' => "{$newRoot}/tracks/{$folder}/slides/{$slide->position}.{$sExt}",
'kind' => 'slide',
'model' => $slide,
'set' => ['filename'],
];
}
}
return $plan;
}
/**
* Execute one video's plan. On NAS: copy then delete (smbclient has no rename).
* On local: rename(). Either way, DB columns are updated after the move succeeds.
*/
private function applyPlan(Video $video, array $plan, NasSyncService $nas): void
{
$nasOn = $nas->isEnabled();
foreach ($plan as $move) {
// Ensure target dir exists
$targetDir = dirname($move['to']);
if ($nasOn) {
$nas->mkdirp($targetDir);
}
@mkdir(storage_path('app/' . $targetDir), 0755, true);
// Move on local
$localFrom = storage_path('app/' . $move['from']);
$localTo = storage_path('app/' . $move['to']);
if (is_file($localFrom)) {
@rename($localFrom, $localTo);
}
// Move on NAS via copy+delete
if ($nasOn) {
$tmp = tempnam(sys_get_temp_dir(), 'mig_');
if ($nas->getContent($move['from']) !== null) {
// small file like .json — already cached as content; round-trip
}
// For binary files, pull → push → delete-source
$localCache = storage_path('app/' . $move['from']);
if (! is_file($localCache)) {
$nas->ensureLocalAsset($localCache, $move['from']);
}
if (is_file($localCache)) {
if ($nas->putFile($localCache, $move['to'])) {
$nas->deleteFile($move['from']);
}
}
@unlink($tmp);
}
// Update DB
$model = $move['model'];
$updates = [];
foreach ($move['set'] as $col) {
if ($col === 'path' || $col === 'thumbnail' || $col === 'filename') {
$updates[$col] = ($col === 'filename' && isset($move['new_filename']))
? $move['new_filename']
: $move['to'];
}
}
// VideoSlide stores the full path under `filename`
if ($model instanceof VideoSlide) {
$model->update(['filename' => $move['to']]);
} else {
$model->update($updates);
}
}
}
}

View File

@ -28,9 +28,8 @@ class PlaylistController extends Controller
} }
// View a single playlist // View a single playlist
public function show(Playlist $playlist) public function show(Request $request, Playlist $playlist)
{ {
// Check if user can view this playlist
if (! $playlist->canView(Auth::user())) { if (! $playlist->canView(Auth::user())) {
abort(404, 'Playlist not found'); abort(404, 'Playlist not found');
} }
@ -38,11 +37,17 @@ class PlaylistController extends Controller
$playlist->loadMissing('user'); $playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get(); $videos = $playlist->videos()->with('user')->orderBy('position')->get();
// Count this visit (deduped per device) after the response is sent so
// the page render isn't blocked by the EXISTS / INSERT / UPDATE round-trip.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
return view('playlists.show', compact('playlist', 'videos')); return view('playlists.show', compact('playlist', 'videos'));
} }
// View playlist via unguessable share token (unlisted playlists) // View playlist via unguessable share token (unlisted playlists)
public function showByToken(string $token) public function showByToken(Request $request, string $token)
{ {
$playlist = Playlist::where('share_token', $token)->firstOrFail(); $playlist = Playlist::where('share_token', $token)->firstOrFail();
@ -53,6 +58,10 @@ class PlaylistController extends Controller
$playlist->loadMissing('user'); $playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get(); $videos = $playlist->videos()->with('user')->orderBy('position')->get();
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
return view('playlists.show', compact('playlist', 'videos')); return view('playlists.show', compact('playlist', 'videos'));
} }
@ -137,6 +146,11 @@ class PlaylistController extends Controller
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
} }
// Human share-link click counts as a playlist view (deduped per device).
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
$firstVideo = $playlist->videos()->orderBy('position')->first(); $firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo $destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token ? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
@ -336,6 +350,18 @@ class PlaylistController extends Controller
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.'); return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
} }
/**
* Body-based mirror of removeVideo: looks up the Video by raw numeric id
* from the request body. Lets callers (e.g. the add-to-playlist modal) drive
* the toggle without having to know the encoded route key for Video.
*/
public function removeVideoByBody(Request $request, Playlist $playlist)
{
$request->validate(['video_id' => 'required|exists:videos,id']);
$video = Video::findOrFail($request->video_id);
return $this->removeVideo($request, $playlist, $video);
}
// Remove video from playlist // Remove video from playlist
public function removeVideo(Request $request, Playlist $playlist, Video $video) public function removeVideo(Request $request, Playlist $playlist, Video $video)
{ {

View File

@ -846,48 +846,204 @@ class SuperAdminController extends Controller
public function settings() public function settings()
{ {
$settings = [ $settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'), 'llm_enabled' => Setting::get('llm_enabled', 'false'),
'gpu_device' => Setting::get('gpu_device', '0'), 'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'), 'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'), 'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [],
'gpu_preset' => Setting::get('gpu_preset', 'p4'), 'llm_active_id' => (string) Setting::get('llm_active_id', ''),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')), ];
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
return view('admin.settings', compact('settings'));
}
/**
* Settings save handler accepts partial submissions from any of the
* separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys
* that appear in the request.
*/
public function updateSettings(Request $request)
{
// ── GPU section ──────────────────────────────────────────────────────
if ($request->has('gpu_enabled')) {
$request->validate([
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
Setting::flushGpuProbe();
}
// ── Lyrics pipeline section ──────────────────────────────────────────
if ($request->has('lyrics_section')) {
foreach ([
'lyrics_enabled', // master switch
'lyrics_use_description', // align to description text
'lyrics_vad_enabled', // Silero VAD filter
'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions
'lyrics_demucs_enabled', // vocal isolation (Demucs)
'lyrics_llm_decorate', // post-bake emojis via LLM
] as $k) {
Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false');
}
}
// ── AI / LLM section ─────────────────────────────────────────────────
if ($request->has('llm_section')) {
Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false');
Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false');
Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false');
$this->saveLlmProviders($request);
}
return back()->with('success', 'Settings saved.');
}
/**
* Probe an LLM provider endpoint: verify the connection and list
* available models. Used by the AI / LLM settings page.
*
* Accepts kind / endpoint / api_key from the form, plus an optional
* provider id so we can fall back to the saved key when the admin
* left the password field blank (placeholder ••••••••).
*/
public function llmProviderTest(Request $request)
{
$kind = (string) $request->input('kind', 'ollama');
$endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind);
$endpoint = rtrim($endpoint, '/');
$apiKey = (string) $request->input('api_key', '');
$id = (string) $request->input('id', '');
if ($apiKey === '' && $id !== '') {
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
foreach ($providers as $p) {
if (($p['id'] ?? '') === $id) {
$apiKey = (string) ($p['api_key'] ?? '');
break;
}
}
}
if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) {
return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422);
}
if ($kind !== 'ollama' && $apiKey === '') {
return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422);
}
try {
$models = match ($kind) {
'ollama' => $this->fetchOllamaModels($endpoint),
'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey),
'openai' => $this->fetchOpenAIModels($endpoint, $apiKey),
};
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()]);
}
sort($models, SORT_NATURAL | SORT_FLAG_CASE);
return response()->json([
'ok' => true,
'count' => count($models),
'models' => $models,
]);
}
private function fetchOllamaModels(string $endpoint): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags');
if (! $resp->successful()) {
throw new \RuntimeException('Ollama returned HTTP ' . $resp->status());
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['name'] ?? ''),
$j['models'] ?? []
)));
}
private function fetchAnthropicModels(string $endpoint, string $apiKey): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->get($endpoint . '/v1/models');
if (! $resp->successful()) {
$body = $resp->json();
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['id'] ?? ''),
$j['data'] ?? []
)));
}
private function fetchOpenAIModels(string $endpoint, string $apiKey): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(15)
->withToken($apiKey)->acceptJson()
->get($endpoint . '/v1/models');
if (! $resp->successful()) {
$body = $resp->json();
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['id'] ?? ''),
$j['data'] ?? []
)));
}
public function lyrics()
{
$settings = [
'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'),
'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'),
'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'),
'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'),
'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'),
'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')),
];
return view('admin.lyrics', compact('settings'));
}
public function gpu()
{
$settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
'gpu_device' => Setting::get('gpu_device', '0'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
]; ];
$gpus = $this->probeGpus(); $gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc(); $nvencWorks = $this->probeNvenc();
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks')); return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
} }
public function updateSettings(Request $request) public function backup()
{ {
$request->validate([ return view('admin.backup');
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
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.');
} }
public function detectGpu() public function detectGpu()
@ -895,6 +1051,67 @@ class SuperAdminController extends Controller
return response()->json(['gpus' => $this->probeGpus()]); return response()->json(['gpus' => $this->probeGpus()]);
} }
/**
* Persist the LLM provider list from the multi-provider form. Each row
* carries id / name / kind (ollama|anthropic|openai) / endpoint / model /
* api_key. An empty api_key means "keep the previously stored value" so the
* admin doesn't have to retype it on every save.
*/
private function saveLlmProviders(Request $request): void
{
$existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [])
->keyBy(fn ($p) => $p['id'] ?? '');
$kinds = ['ollama', 'anthropic', 'openai'];
$rows = (array) $request->input('providers', []);
$out = [];
foreach ($rows as $row) {
$name = trim((string) ($row['name'] ?? ''));
$kind = (string) ($row['kind'] ?? 'ollama');
if (! in_array($kind, $kinds, true)) $kind = 'ollama';
if ($name === '') continue;
$id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid();
$endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind);
$model = trim((string) ($row['model'] ?? ''));
$apiKeyIn = (string) ($row['api_key'] ?? '');
// Blank input → keep the previously-stored key for this id (admin
// didn't retype it). Non-blank → use the new value verbatim.
$apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? '');
$out[] = [
'id' => $id,
'name' => $name,
'kind' => $kind,
'endpoint' => $endpoint,
'model' => $model,
'api_key' => $apiKey,
];
}
Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE));
$activeId = trim((string) $request->input('llm_active_id', ''));
$validIds = array_column($out, 'id');
if ($activeId !== '' && in_array($activeId, $validIds, true)) {
Setting::set('llm_active_id', $activeId);
} elseif (count($validIds) === 1) {
Setting::set('llm_active_id', $validIds[0]);
} elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) {
Setting::set('llm_active_id', '');
}
}
private static function defaultEndpoint(string $kind): string
{
return match ($kind) {
'anthropic' => 'https://api.anthropic.com',
'openai' => 'https://api.openai.com',
default => 'http://localhost:11434',
};
}
private function probeGpus(): array private function probeGpus(): array
{ {
$gpus = []; $gpus = [];
@ -935,7 +1152,10 @@ class SuperAdminController extends Controller
public function nasStorage() public function nasStorage()
{ {
$nodes = config('nas-file-manager.schema', []); $nodes = config('nas-file-manager.schema', []);
return view('admin.nas-storage', compact('nodes')); $settings = [
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
];
return view('admin.nas-storage', compact('nodes', 'settings'));
} }
public function nasDelete(Request $request) public function nasDelete(Request $request)

View File

@ -26,7 +26,7 @@ class VideoController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']); $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
} }
public function index() public function index()
@ -161,6 +161,12 @@ class VideoController extends Controller
'extra_track_titles.*' => 'nullable|string|max:255', 'extra_track_titles.*' => 'nullable|string|max:255',
'extra_track_descriptions' => 'nullable|array', 'extra_track_descriptions' => 'nullable|array',
'extra_track_descriptions.*'=> 'nullable|string', 'extra_track_descriptions.*'=> 'nullable|string',
// Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
// If absent for a given index, the track inherits the primary's slides
// at render time via Video::slidesForTrack().
'extra_track_slides' => 'nullable|array',
'extra_track_slides.*' => 'nullable|array',
'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]); ]);
$videoFile = $request->file('video'); $videoFile = $request->file('video');
@ -332,6 +338,7 @@ class VideoController extends Controller
$trackLangs = $request->input('extra_track_languages', []); $trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []); $trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []); $trackDescs = $request->input('extra_track_descriptions', []);
$trackSlides = $request->file('extra_track_slides') ?: [];
foreach ($trackFiles as $i => $trackFile) { foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue; if (! $trackFile || ! $trackFile->isValid()) continue;
@ -354,19 +361,20 @@ class VideoController extends Controller
if ($nas->isEnabled()) { if ($nas->isEnabled()) {
try { try {
$videoDir = $nas->resolveVideoDir($video); // Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
$nas->mkdirp($videoDir); $trackDir = $nas->trackDir($video, $track);
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext); $nas->mkdirp($trackDir);
$canonical = "audio.{$ext}";
$nasPath = "{$trackDir}/{$canonical}";
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}"); $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath); $tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$videoDir}/{$trackName}";
$nas->putFile($tempAbs, $nasPath); $nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs); @unlink($tempAbs);
$track->update([ $track->update([
'path' => $nasPath, 'path' => $nasPath,
'filename' => $trackName, 'filename' => $canonical,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage()); \Log::error("Extra track NAS upload failed: " . $e->getMessage());
@ -376,6 +384,23 @@ class VideoController extends Controller
} else { } else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas); $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
} }
// ── Optional per-track slides ──────────────────────────────
// The track only owns slides that were uploaded for it. If none
// were uploaded, the player falls back to the primary's at render
// time via Video::slidesForTrack — no row needed here.
$files = $trackSlides[$i] ?? null;
if (is_array($files) && count($files) > 0) {
$this->storeTrackSlides($video, $track, $files, $nas);
}
}
}
// ── Synced lyrics generation (audio/music uploads only) ───────────────
if ($isAudioUpload) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
foreach ($video->audioTracks()->pluck('id') as $tid) {
\App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
} }
} }
@ -544,9 +569,19 @@ class VideoController extends Controller
if ($playlistParam) { if ($playlistParam) {
$playlist = Playlist::where('share_token', $playlistParam)->first(); $playlist = Playlist::where('share_token', $playlistParam)->first();
if ($playlist && $playlist->canViewViaToken(Auth::user())) { if ($playlist && $playlist->canViewViaToken(Auth::user())) {
$nextVideo = $playlist->getNextVideo($video); // Load the videos ONCE with their owners eager-loaded, then
$previousVideo = $playlist->getPreviousVideo($video); // compute prev/next in PHP. The old code fired 4+ separate
$playlistVideos = $playlist->videos; // queries for prev/next/list — the sidebar lag the user
// reported was almost entirely those extra round-trips.
$playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
[$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
// Count the playlist view (deduped per device, 1-hour window)
// after the response is flushed so we don't pay the round-trip
// on the hot path.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
} }
} }
@ -579,9 +614,16 @@ class VideoController extends Controller
? route('media.thumbnail', $video->thumbnail) ? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png'); : asset('storage/images/logo.png');
$slides = $video->slides->count() > 1 // Per-track slide map (key "0" = primary). Each entry already has the
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all() // sharing fallback applied by Video::slidesForTrack — a track without its
: []; // own slides borrows the primary's (or a sibling's) automatically.
$slideMap = ['0' => $video->slidesForTrack(null)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
foreach ($video->audioTracks as $_t) {
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
}
$slides = $slideMap['0'];
$allLangData = \App\Data\Languages::all(); $allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [ $audioTracks = $video->audioTracks->map(fn ($t) => [
@ -595,6 +637,14 @@ class VideoController extends Controller
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp, 'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])->values()->all(); ])->values()->all();
// Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
// Local mirror only — must not block this hot path on NAS I/O.
$nasLyrics = app(\App\Services\NasSyncService::class);
$lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
foreach ($video->audioTracks as $t) {
$lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
}
return response()->json([ return response()->json([
'id' => $video->id, 'id' => $video->id,
'key' => $video->getRouteKey(), 'key' => $video->getRouteKey(),
@ -604,6 +654,7 @@ class VideoController extends Controller
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp, 'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl, 'cover_url' => $coverUrl,
'slides' => $slides, 'slides' => $slides,
'slide_map' => $slideMap,
'title' => $video->title, 'title' => $video->title,
'author' => $video->user->name ?? '', 'author' => $video->user->name ?? '',
'duration' => $video->duration, 'duration' => $video->duration,
@ -612,9 +663,212 @@ class VideoController extends Controller
'language' => $video->language, 'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null, 'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks, 'audio_tracks' => $audioTracks,
'lyrics' => $lyricsMap,
]); ]);
} }
/**
* Owner-triggered lyrics generation for the current audio track (?track={id},
* 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
* polls player-data and shows the lyrics once the file lands.
*/
public function generateLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
}
if (! $this->isAudioOnlyFile($video)) {
return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
}
$trackId = (int) $request->input('track', 0);
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
\App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
return response()->json(['status' => 'queued']);
}
/**
* Owner-triggered delete of the saved lyrics for a track. Wipes the local
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) {
return response()->json(['error' => 'Track not found.'], 404);
}
}
app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
// Clear any in-flight progress / temp artifacts so a queued job that
// fires later can't repopulate stale output.
@unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
@unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
return response()->json(['status' => 'deleted']);
}
/**
* Live progress for an in-flight lyrics generation, driving the player's
* progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
*/
public function lyricsProgress(Request $request, Video $video)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['status' => 'none']);
}
$data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
if (is_array($data)) {
$st = $data['status'] ?? 'ready';
if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
if ($st === 'failed') return response()->json(['status' => 'failed']);
}
// Live percentage from the pipeline's progress file.
$progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
if (is_file($progFile)) {
$p = json_decode((string) file_get_contents($progFile), true);
if (is_array($p)) {
return response()->json([
'status' => 'processing',
'pct' => (int) ($p['pct'] ?? 1),
'stage' => $p['stage'] ?? 'Working',
]);
}
}
// A 'processing' lyrics marker but no progress file yet → just queued.
if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
return response()->json(['status' => 'none']);
}
/**
* Owner-edited lyrics save. Receives the (possibly corrected) lines for a
* track; preserves precise word timing for lines that weren't changed and
* redistributes timing evenly across the new words for edited lines.
*/
public function saveLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
abort(403);
}
$trackId = (int) $request->input('track', 0);
$track = null;
if ($trackId) {
$track = $video->audioTracks()->find($trackId);
if (! $track) return response()->json(['error' => 'Track not found.'], 404);
}
$nas = app(\App\Services\NasSyncService::class);
$existing = $nas->getLocalLyrics($video, $track) ?: [];
$spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
$inLines = $request->input('lines', []);
if (! is_array($inLines)) $inLines = [];
$out = [];
foreach ($inLines as $ln) {
$text = trim(strip_tags((string) ($ln['text'] ?? '')));
if ($text === '' || mb_strlen($text) > 1000) {
if ($text === '') continue;
$text = mb_substr($text, 0, 1000);
}
$start = round((float) ($ln['start'] ?? 0), 3);
$end = round((float) ($ln['end'] ?? $start), 3);
$lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
$isSpaceless = in_array($lang, $spaceless, true);
// Keep original word timings if the text wasn't changed; otherwise
// redistribute the line's span evenly across the new tokens.
$origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
$sep = $isSpaceless ? '' : ' ';
$joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
$unchanged = $origWords
&& preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
if ($unchanged) {
$words = array_map(fn ($w) => [
'start' => round((float) ($w['start'] ?? $start), 3),
'end' => round((float) ($w['end'] ?? $end), 3),
'text' => (string) ($w['text'] ?? ''),
], $origWords);
} else {
$words = $this->redistributeWords($start, $end, $text, $isSpaceless);
}
$out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
if (count($out) >= 1000) break;
}
usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
$nas->putLyrics($video, $track, [
'version' => 1,
'status' => 'ready',
'source' => 'edited',
'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
'multilingual' => $existing['multilingual'] ?? false,
'lines' => $out,
'generated_at' => now()->toIso8601String(),
]);
return response()->json(['status' => 'ok', 'lines' => count($out)]);
}
/** Evenly distribute a line's [start,end] across its words (used for edited lines). */
private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
{
$text = trim($text);
if ($text === '' || $end <= $start) {
return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
}
$tokens = $spaceless
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
$tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
$n = count($tokens);
if ($n === 0) return [];
$slice = ($end - $start) / $n;
$words = [];
foreach ($tokens as $i => $tok) {
$words[] = [
'start' => round($start + $i * $slice, 3),
'end' => round($start + ($i + 1) * $slice, 3),
'text' => $tok,
];
}
return $words;
}
public function matchData(Video $video) public function matchData(Video $video)
{ {
if (! $video->canView(Auth::user())) { if (! $video->canView(Auth::user())) {
@ -639,8 +893,9 @@ class VideoController extends Controller
} }
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [ $slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
'id' => $s->id, 'id' => $s->id,
'url' => $s->url, 'url' => $s->url,
'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
])->values(); ])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [ $audioTracks = $video->audioTracks->map(fn ($t) => [
@ -1485,21 +1740,93 @@ class VideoController extends Controller
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{ {
try { try {
// All tracks (primary + secondary) live directly in the song's own folder — // Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
// never a separate tracks/ subfolder — with a unique, lowercase name. // (storeTrackLocally is only reached for music since extra tracks
$localDir = $nas->localVideoDir($video); // only exist on music videos.)
@mkdir($localDir, 0755, true); $songLocalDir = $nas->localVideoDir($video);
$base = basename($localDir); $trackFolder = $nas->trackFolderName($video, $track);
$trackName = $this->audioTrackName($base, $track->language, $track->id, $ext); $trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
$trackFile->move($localDir, $trackName); @mkdir($trackDirAbs, 0755, true);
$userSlug = $nas->userSlug($video->user);
$relPath = "users/{$userSlug}/videos/{$base}/{$trackName}"; $canonical = "audio.{$ext}";
$track->update(['path' => $relPath, 'filename' => $trackName]); $trackFile->move($trackDirAbs, $canonical);
// Build the NAS-relative path from the song's relative path so it
// works on both local-only and NAS-enabled setups.
$songRel = $this->relFromStoragePath($songLocalDir);
$relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
$track->update(['path' => $relPath, 'filename' => $canonical]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage()); \Log::error("storeTrackLocally failed: " . $e->getMessage());
} }
} }
/** Convert an absolute storage_path() value back to a storage-relative path. */
private function relFromStoragePath(string $abs): string
{
$prefix = storage_path('app/');
if (str_starts_with($abs, $prefix)) {
return substr($abs, strlen($prefix));
}
return $abs;
}
/**
* Persist per-track slides the new track owns them via audio_track_id.
* Filename scheme keeps tracks from colliding in the shared slides/ folder:
* slides/track-{trackId}-{position}.{ext}
* Primary slides keep the legacy slides/{position}.{ext} scheme.
*/
private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
{
// Per-track slides live in the track's own folder:
// tracks/{lang-id}/slides/{position}.{ext}
// Filenames are canonical (just {position}.{ext}) because the folder
// already disambiguates by track.
$nasEnabled = $nas->isEnabled();
$songLocal = $nas->localVideoDir($video);
$songRel = $this->relFromStoragePath($songLocal);
$trackFold = $nas->trackFolderName($video, $track);
$nasTrackDir = "{$songRel}/tracks/{$trackFold}";
$localTrackDir = "{$songLocal}/tracks/{$trackFold}";
if ($nasEnabled) {
try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
} else {
@mkdir("{$localTrackDir}/slides", 0755, true);
}
foreach ($files as $pos => $file) {
if (! $file || ! $file->isValid()) continue;
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$name = "{$pos}.{$ext}";
$slide = VideoSlide::create([
'video_id' => $video->id,
'audio_track_id' => $track->id,
'filename' => '__pending__',
'position' => $pos,
]);
$tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
if ($nasEnabled) {
$nasPath = "{$nasTrackDir}/slides/{$name}";
if ($nas->putFile($tempAbs, $nasPath)) {
@unlink($tempAbs);
$slide->update(['filename' => $nasPath]);
continue;
}
// Fall through to local on NAS failure
}
@mkdir("{$localTrackDir}/slides", 0755, true);
@rename($tempAbs, "{$localTrackDir}/slides/{$name}");
$slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
}
}
/** /**
* Unique, lowercase filename for an audio track kept in the song's own folder: * 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 * {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
@ -1602,10 +1929,11 @@ class VideoController extends Controller
// Audio-only file → generate/serve a video for the version being played. // Audio-only file → generate/serve a video for the version being played.
$trackId = (int) request()->input('track', 0); $trackId = (int) request()->input('track', 0);
$viz = request()->boolean('visualizer'); $viz = request()->boolean('visualizer');
$lyrics = request()->boolean('lyrics');
// Any non-primary or visualizer variant is served straight off disk (no DB column). // Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
if ($viz || $trackId) { if ($viz || $trackId || $lyrics) {
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId)); $cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
if (file_exists($cacheFile) && filesize($cacheFile) > 0) { if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [ return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
'Content-Type' => 'video/mp4', 'Content-Type' => 'video/mp4',
@ -1707,7 +2035,8 @@ class VideoController extends Controller
// combination is cached under its own filename so they never clobber each other. // combination is cached under its own filename so they never clobber each other.
$viz = $request->boolean('visualizer'); $viz = $request->boolean('visualizer');
$trackId = (int) $request->input('track', 0); $trackId = (int) $request->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : ''); $lyrics = $request->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$ffmpeg = \App\Models\Setting::ffmpegBinary(); $ffmpeg = \App\Models\Setting::ffmpegBinary();
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe'); $ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
@ -1731,7 +2060,7 @@ class VideoController extends Controller
return response()->json(['error' => 'Audio file not found'], 404); return response()->json(['error' => 'Audio file not found'], 404);
} }
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder $outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // song's cache/ folder
$outPath = storage_path('app/' . $outRel); $outPath = storage_path('app/' . $outRel);
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true); if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt'; $progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
@ -1772,12 +2101,37 @@ class VideoController extends Controller
return response()->json(['error' => 'Cannot probe audio duration'], 500); return response()->json(['error' => 'Cannot probe audio duration'], 500);
} }
$slides = $video->slides()->orderBy('position')->get(); // Use the slides for the track being rendered, applying the per-track
// sharing fallback (own → primary → sibling). Ensures the download .mp4
// matches the slideshow the listener saw in the player.
$video->loadMissing('slides');
$slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values(); $validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,' $scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p'; . 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
// ── Optional burned-in lyrics (ASS via libass) ───────────────────────
// When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
// track and weave it into the final video output. $voutLabel/$assArg/
// $assTail let each render branch inject the burn uniformly.
$voutLabel = '[vout]';
$assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
$assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
if ($lyrics) {
$lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
$lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
$assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
$assFilter = 'ass=' . $assPath;
$assArg = ',' . $assFilter;
$assTail = ';[vout]' . $assFilter . '[vsub]';
$voutLabel = '[vsub]';
}
}
}
@unlink($progressFile); @unlink($progressFile);
@unlink($pidFile); @unlink($pidFile);
@ -1850,10 +2204,10 @@ class VideoController extends Controller
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]"; $fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]"; $fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
$fc = implode(';', $fcParts); $fc = implode(';', $fcParts) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}" $cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc) . ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $audioIdx . ':a' . ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
. ' ' . $vFlags . ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest'; . ' -c:a aac -b:a 192k -movflags +faststart -shortest';
@ -1881,10 +2235,10 @@ class VideoController extends Controller
$prev = $outLabel; $prev = $outLabel;
} }
$fc = implode(';', array_merge($scaleFc, $xfadeFc)); $fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
$cmd = "{$ffmpeg} -y{$inputs}" $cmd = "{$ffmpeg} -y{$inputs}"
. ' -filter_complex ' . escapeshellarg($fc) . ' -filter_complex ' . escapeshellarg($fc)
. ' -map [vout] -map ' . $n . ':a' . ' -map ' . $voutLabel . ' -map ' . $n . ':a'
. ' ' . $vFlags . ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart -shortest'; . ' -c:a aac -b:a 192k -movflags +faststart -shortest';
@ -1894,7 +2248,7 @@ class VideoController extends Controller
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath) . ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
. ' -i ' . escapeshellarg($audioPath) . ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest' . ' -map 0:v:0 -map 1:a:0 -shortest'
. ' -vf ' . escapeshellarg($scale) . ' -vf ' . escapeshellarg($scale . $assArg)
. ' ' . $vFlags . ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart'; . ' -c:a aac -b:a 192k -movflags +faststart';
} else { } else {
@ -1902,6 +2256,7 @@ class VideoController extends Controller
. ' -f lavfi -i color=c=black:s=1280x720:r=1' . ' -f lavfi -i color=c=black:s=1280x720:r=1'
. ' -i ' . escapeshellarg($audioPath) . ' -i ' . escapeshellarg($audioPath)
. ' -map 0:v:0 -map 1:a:0 -shortest' . ' -map 0:v:0 -map 1:a:0 -shortest'
. ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
. ' ' . $vFlags . ' ' . $vFlags
. ' -c:a aac -b:a 192k -movflags +faststart'; . ' -c:a aac -b:a 192k -movflags +faststart';
} }
@ -1934,9 +2289,10 @@ class VideoController extends Controller
{ {
$viz = request()->boolean('visualizer'); $viz = request()->boolean('visualizer');
$trackId = (int) request()->input('track', 0); $trackId = (int) request()->input('track', 0);
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : ''); $lyrics = request()->boolean('lyrics');
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId)); $outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt'; $progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt'; $pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
@ -1955,7 +2311,7 @@ class VideoController extends Controller
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) { if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
// Only the plain variant is tracked by the DB column; the visualizer variant // Only the plain variant is tracked by the DB column; the visualizer variant
// is served straight off disk (see download()). // is served straight off disk (see download()).
if (! $viz && ! $trackId && ! $video->slideshow_video_path) { if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]); $video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
} }
return response()->json(['percent' => 100, 'status' => 'ready']); return response()->json(['percent' => 100, 'status' => 'ready']);
@ -2089,12 +2445,13 @@ class VideoController extends Controller
* source files and kept LOCAL-only (never pushed to NAS): * source files and kept LOCAL-only (never pushed to NAS):
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4 * {song-folder}/cache/video.mp4 / cache/video-viz.mp4
*/ */
private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
{ {
$nas = app(\App\Services\NasSyncService::class); $nas = app(\App\Services\NasSyncService::class);
return $this->nasVideoDir($video, $nas) . '/cache/video' return $this->nasVideoDir($video, $nas) . '/cache/video'
. ($trackId ? '-t' . $trackId : '') . ($trackId ? '-t' . $trackId : '')
. ($viz ? '-viz' : '') . '.mp4'; . ($viz ? '-viz' : '')
. ($lyrics ? '-lyr' : '') . '.mp4';
} }
/** /**

View File

@ -0,0 +1,139 @@
<?php
namespace App\Jobs;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Services\LlmLyricsService;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Bake heavy emoji decoration into the saved lyrics JSON using the active LLM.
* Original words are preserved verbatim; emojis are layered on top (in-line +
* trailing, multiple per line) per the admin's decoration prompt.
*
* Runs as its own job so a flaky LLM call can never delay or fail a successful
* transcription. Safe to re-run already-decorated lines are skipped, so a
* second pass only fills in gaps.
*/
class DecorateLyricsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 600;
public int $tries = 1;
/** Languages written without spaces between words (mirrors transcribe.py). */
private const SPACELESS_LANGS = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
public function __construct(public int $videoId, public ?int $trackId = null)
{
$this->onQueue('video-processing');
}
public function handle(LlmLyricsService $llm, NasSyncService $nas): void
{
// Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline
// page) gates this job, and the LLM-service-level switch (AI/LLM page)
// gates the LLM call inside it. Either being OFF skips decoration.
if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return;
if (! $llm->decorateEnabled()) return;
$video = Video::find($this->videoId);
if (! $video) return;
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
if ($this->trackId && ! $track) return;
$data = $nas->getLyrics($video, $track);
if (! is_array($data) || empty($data['lines'])) return;
if (($data['status'] ?? null) !== 'ready') return;
// Decorate only lines that haven't been decorated yet — a re-run fills
// gaps cheaply instead of re-stamping the whole song.
$texts = [];
$indices = [];
foreach ($data['lines'] as $i => $ln) {
if (! empty($ln['decorated'])) continue;
$t = (string) ($ln['text'] ?? '');
if (trim($t) === '') continue;
$texts[] = $t;
$indices[] = $i;
}
if (! $texts) return;
try {
$decorated = $llm->decorateLines($texts);
} catch (\Throwable $e) {
Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
]);
return;
}
if (! $decorated) return;
$applied = 0;
foreach ($decorated as $localIdx => $newText) {
if (! isset($indices[$localIdx])) continue;
$globalIdx = $indices[$localIdx];
$line = &$data['lines'][$globalIdx];
$line['text'] = $newText;
$line['decorated'] = true;
// The words array no longer matches the new (emoji-laced) text. We
// redistribute the existing [start,end] window evenly across the
// new tokens so the karaoke word-highlight still tracks the audio.
// Tokens that are pure emoji get the same per-slot timing as words.
$lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en'));
$line['words'] = $this->redistributeWords(
(float) ($line['start'] ?? 0),
(float) ($line['end'] ?? 0),
$newText, $lang
);
unset($line);
$applied++;
}
if (! $applied) return;
$data['decorated_at'] = now()->toIso8601String();
$nas->putLyrics($video, $track, $data);
Log::info('DecorateLyricsJob: done', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
'decorated' => $applied,
]);
}
/**
* Evenly distribute [start,end] across the line's tokens. Words for spaced
* languages, characters for spaceless scripts (Thai/CJK/). Used after
* decoration so the karaoke word-highlight still tracks the audio.
*/
private function redistributeWords(float $start, float $end, string $text, string $lang): array
{
if ($text === '' || $end <= $start) return [];
$spaceless = in_array($lang, self::SPACELESS_LANGS, true);
$tokens = $spaceless
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
$n = count($tokens ?: []);
if ($n === 0) return [];
$slot = ($end - $start) / $n;
$out = [];
foreach ($tokens as $i => $t) {
$out[] = [
'start' => round($start + $i * $slot, 3),
'end' => round($start + ($i + 1) * $slot, 3),
'text' => $t,
];
}
return $out;
}
}

View File

@ -0,0 +1,244 @@
<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
/**
* Generate word-level synced lyrics for one audio track (the video's primary
* audio when $trackId is null, otherwise a specific extra-language track).
*
* Output is a per-track lyrics JSON written through NasSyncService::putLyrics()
* source-of-truth, synced to NAS, never under cache/. Runs the GPU pipeline
* exactly once; playback just loads the file afterwards.
*/
class GenerateLyricsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 3600;
public int $tries = 1;
public function __construct(public int $videoId, public ?int $trackId = null)
{
$this->onQueue('video-processing');
}
/** Shared progress-file path (written by the pipeline, read by the status endpoint). */
public static function progressPath(int $videoId, ?int $trackId): string
{
return storage_path('app/tmp/lyrics_prog_' . $videoId . '_' . ($trackId ?? 'primary') . '.json');
}
/** Index of the GPU with the most free memory, or null if it can't be queried. */
private function freestGpu(): ?int
{
$out = []; $code = 1;
@exec('nvidia-smi --query-gpu=index,memory.free --format=csv,noheader,nounits 2>/dev/null', $out, $code);
if ($code !== 0 || empty($out)) return null;
$best = null; $bestFree = -1;
foreach ($out as $line) {
$parts = array_map('trim', explode(',', $line));
if (count($parts) < 2) continue;
$idx = (int) $parts[0]; $free = (int) $parts[1];
if ($free > $bestFree) { $bestFree = $free; $best = $idx; }
}
return $best;
}
public function handle(NasSyncService $nas): void
{
$video = Video::find($this->videoId);
if (! $video) return;
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
if ($this->trackId && ! $track) return;
$language = $track ? $track->language : $video->language;
// Mark as processing so the UI can show a generating state before the file lands.
$nas->putLyrics($video, $track, [
'version' => 1,
'status' => 'processing',
'language' => $language,
]);
// Resolve a readable local copy of the audio (downloads from NAS if needed).
$audioPath = $track ? $nas->ensureLocalTrackCopy($track) : $nas->ensureLocalCopy($video);
$nasDownloaded = $audioPath && str_starts_with($audioPath, storage_path('app/nas_cache/'))
? $audioPath : null;
if (! $audioPath || ! file_exists($audioPath)) {
Log::error('GenerateLyricsJob: audio file unavailable', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
]);
$nas->putLyrics($video, $track, [
'version' => 1, 'status' => 'failed', 'language' => $language,
'error' => 'audio file unavailable',
]);
return;
}
$python = base_path('ml/venv/bin/python');
$script = base_path('ml/transcribe.py');
$outTmp = storage_path('app/tmp/lyrics_' . $this->videoId . '_' . ($this->trackId ?? 'primary') . '.json');
$progress = self::progressPath($this->videoId, $this->trackId);
if (! is_dir(dirname($outTmp))) @mkdir(dirname($outTmp), 0775, true);
@file_put_contents($progress, json_encode(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']));
// Model/weight downloads land in a www-data-writable cache, not root's $HOME.
$cacheDir = base_path('ml/cache');
if (! is_dir($cacheDir)) @mkdir($cacheDir, 0775, true);
// NOTE: we deliberately do NOT force --language. The stored label is just
// metadata and is often wrong (e.g. a Tagalog song mislabeled "en"), which
// made WhisperX transcribe the wrong language. Auto-detecting from the
// isolated vocals is ground truth; the detected language is saved instead.
$args = [$python, $script, '--audio', $audioPath, '--out', $outTmp, '--progress', $progress];
// Pipeline feature toggles (admin → Lyrics Pipeline). Defaults preserve
// current behavior; admin can disable any sub-step that misbehaves.
$useDescription = Setting::get('lyrics_use_description', 'true') === 'true';
$vadEnabled = Setting::get('lyrics_vad_enabled', 'true') === 'true';
$vocalGapFill = Setting::get('lyrics_vocal_region_gapfill', 'true') === 'true';
$demucsEnabled = Setting::get('lyrics_demucs_enabled', 'false') === 'true';
if (! $vadEnabled) $args[] = '--no-vad';
if (! $vocalGapFill) $args[] = '--no-vocal-gapfill';
// If the song's description contains the lyrics (typed by the uploader),
// pass them to the pipeline so it ALIGNS those exact lines to the audio
// instead of generating noisier text from scratch. Only for the primary
// track — extra-language tracks have their own audio and aren't paired
// with the description text.
$userLyrFile = null;
if ($useDescription && ! $this->trackId && $video->description) {
// Prefer the deterministic regex parser. It strips emojis line-by-line
// without touching the underlying words, so it preserves every
// language a multilingual song contains (e.g. an English+Thai song
// keeps both halves). The LLM cleaner is only a backup for cases
// where the regex returns nothing — we've seen the LLM silently
// drop whole verses that happened to be wrapped in emoji decoration.
$descLines = \App\Support\LyricsDescriptionParser::extract($video->description);
$source = 'regex';
if (empty($descLines)) {
$llm = app(\App\Services\LlmLyricsService::class);
if ($llm->cleanLyricsEnabled()) {
try {
$descLines = $llm->cleanDescription($video->description);
$source = 'llm';
} catch (\Throwable $e) {
Log::warning('LLM clean failed: ' . $e->getMessage());
}
}
}
if ($descLines) {
$userLyrFile = storage_path('app/tmp/userlyr_' . $this->videoId . '.txt');
file_put_contents($userLyrFile, implode("\n", $descLines));
$args[] = '--user-lyrics';
$args[] = $userLyrFile;
// With description lyrics, Whisper is only providing word-timing
// anchors — its actual transcription text is discarded by the
// aligner. Vocal isolation (Demucs) helps transcription QUALITY
// but is unnecessary for timing, AND the Demucs→Whisper CUDA-
// context handoff has caused intermittent 50% futex deadlocks.
// So we skip Demucs in this mode by default; the admin can
// re-enable via the Lyrics Pipeline page.
$args[] = '--no-demucs';
Log::info('GenerateLyricsJob: using description lyrics', [
'video_id' => $this->videoId, 'lines' => count($descLines),
'source' => $source, 'demucs' => false,
'vad' => $vadEnabled, 'vocal_gapfill' => $vocalGapFill,
]);
}
}
// Honor the admin Demucs toggle for tracks WITHOUT description lyrics
// (where Whisper's transcription quality actually matters).
if (! $userLyrFile && ! $demucsEnabled) {
$args[] = '--no-demucs';
}
if (Setting::gpuUsable()) {
// Run on the GPU with the most free VRAM so a busy card never forces an
// out-of-memory fall back to slow CPU. With two cards this keeps every
// generation on the GPU and fast.
$args[] = '--gpu';
$args[] = (string) ($this->freestGpu() ?? Setting::gpuDevice());
}
Log::info('GenerateLyricsJob: starting', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
'language' => $language, 'gpu' => Setting::gpuUsable(),
]);
try {
$result = Process::timeout($this->timeout)
->env([
'HOME' => $cacheDir,
'XDG_CACHE_HOME' => $cacheDir,
'HF_HOME' => $cacheDir . '/huggingface',
'TORCH_HOME' => $cacheDir . '/torch',
// Demucs runs as a subprocess BEFORE faster-whisper is imported.
// If OpenMP gets initialised in the parent before that fork, the
// post-fork CUDA/ctranslate2 stack can deadlock in futex_wait —
// we've seen this hang lyrics jobs at 50% indefinitely. Forcing
// single-threaded OpenMP in the parent eliminates the race
// (faster-whisper sets its own thread count internally anyway).
'OMP_NUM_THREADS' => '1',
'MKL_NUM_THREADS' => '1',
'OPENBLAS_NUM_THREADS' => '1',
])
->run($args);
if (! $result->successful() || ! file_exists($outTmp)) {
throw new \RuntimeException('transcribe.py failed: ' . substr($result->errorOutput(), -2000));
}
$data = json_decode((string) file_get_contents($outTmp), true);
if (! is_array($data) || empty($data['lines'])) {
throw new \RuntimeException('transcribe.py produced no lines');
}
$data['status'] = 'ready';
$data['generated_at'] = now()->toIso8601String();
$data['language'] = $data['language'] ?? $language;
$nas->putLyrics($video, $track, $data);
// Decoration is independent of the audio pipeline — kick it off as
// its own job so a flaky LLM call can't delay or fail a successful
// transcription. Skips itself silently if the decorator is off.
DecorateLyricsJob::dispatch($this->videoId, $this->trackId)
->onConnection('database');
Log::info('GenerateLyricsJob: done', [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
'lines' => count($data['lines']),
]);
} catch (\Throwable $e) {
Log::error('GenerateLyricsJob failed: ' . $e->getMessage(), [
'video_id' => $this->videoId, 'track_id' => $this->trackId,
]);
$nas->putLyrics($video, $track, [
'version' => 1, 'status' => 'failed', 'language' => $language,
'error' => $e->getMessage(),
]);
} finally {
@unlink($outTmp);
@unlink($progress);
if ($userLyrFile) @unlink($userLyrFile);
if ($nasDownloaded) @unlink($nasDownloaded);
}
}
}

View File

@ -17,10 +17,12 @@ class Playlist extends Model
'visibility', 'visibility',
'is_default', 'is_default',
'share_token', 'share_token',
'view_count',
]; ];
protected $casts = [ protected $casts = [
'is_default' => 'boolean', 'is_default' => 'boolean',
'view_count' => 'integer',
]; ];
// Relationships // Relationships
@ -83,12 +85,87 @@ class Playlist extends Model
return "{$minutes}m"; return "{$minutes}m";
} }
// Get total views of all videos in playlist // Total of every viewer-session aggregated across the playlist's videos.
// Kept for the analytics-style "video time watched" metric — for the
// playlist's OWN view counter (cards, share link), use $playlist->view_count
// which is incremented per-device by bumpViewIfNew().
public function getTotalViewsAttribute() public function getTotalViewsAttribute()
{ {
return $this->videos()->get()->sum('view_count'); return $this->videos()->get()->sum('view_count');
} }
/**
* Record a playlist view if this viewer hasn't already counted within the
* dedup window. Mirrors the video_views pattern: signed-in users dedup by
* user_id, guests dedup by device fingerprint (preferred) or device cookie.
*
* Cheap and idempotent runs as one EXISTS + (optionally) one INSERT +
* one atomic increment, all on indexed columns. Called via
* dispatchAfterResponse() so it never adds latency to the page render.
*/
public function bumpViewIfNew(\Illuminate\Http\Request $request): void
{
$userId = \Illuminate\Support\Facades\Auth::id();
$did = $request->cookie('_did');
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
// No identifier at all? Skip silently — a unidentifiable request would
// count on every refresh and inflate the counter.
if (! $userId && ! $did && ! $fp) return;
$q = \DB::table('playlist_views')
->where('playlist_id', $this->id)
->where('viewed_at', '>', now()->subHour());
if ($userId) {
$q->where('user_id', $userId);
} else {
$q->whereNull('user_id')->where(function ($w) use ($fp, $did) {
if ($fp) $w->orWhere('device_hash', $fp);
if ($did) $w->orWhere('device_id', $did);
});
}
if ($q->exists()) return;
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = \App\Services\GeoIpService::lookup($ip);
\DB::table('playlist_views')->insert([
'playlist_id' => $this->id,
'user_id' => $userId,
'device_id' => $did,
'device_hash' => $fp,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'viewed_at' => now(),
]);
\DB::table('playlists')->where('id', $this->id)->increment('view_count');
}
/**
* Compute previous/next videos from an already-loaded ordered collection
* (the playlist's videos in position order). Saves the 4+ extra queries
* that getNextVideo() / getPreviousVideo() would each fire.
*/
public function neighborsFromCollection(\Illuminate\Support\Collection $orderedVideos, Video $current): array
{
$idx = $orderedVideos->search(fn ($v) => $v->id === $current->id);
if ($idx === false) {
return [null, $orderedVideos->first()];
}
return [
$idx > 0 ? $orderedVideos[$idx - 1] : null,
$idx < $orderedVideos->count() - 1 ? $orderedVideos[$idx + 1] : null,
];
}
// Check if user owns this playlist // Check if user owns this playlist
public function isOwnedBy($user) public function isOwnedBy($user)
{ {

View File

@ -75,6 +75,40 @@ class Video extends Model
return $this->slides()->count() > 1; return $this->slides()->count() > 1;
} }
/**
* Resolve the slide list for a given audio track, applying the sharing rule:
* 1. Slides explicitly owned by this track (audio_track_id = $trackId).
* 2. Slides owned by the primary (audio_track_id IS NULL = song-wide / legacy).
* 3. Slides owned by any other track (first track in id order).
* 4. Empty (caller falls back to cover image).
*
* Pass `null` for the primary audio (the one stored on the videos row).
*
* @return \Illuminate\Support\Collection<int,\App\Models\VideoSlide>
*/
public function slidesForTrack(?int $audioTrackId)
{
$all = $this->slides; // eager-loaded collection of every slide
if ($audioTrackId !== null) {
$own = $all->where('audio_track_id', $audioTrackId)->values();
if ($own->isNotEmpty()) return $own;
}
// Primary / song-wide bucket (audio_track_id IS NULL).
$primary = $all->whereNull('audio_track_id')->values();
if ($primary->isNotEmpty()) return $primary;
// Borrow from the first track (by id) that has any.
$byTrack = $all->whereNotNull('audio_track_id')->groupBy('audio_track_id');
if ($byTrack->isNotEmpty()) {
$firstTrackId = $byTrack->keys()->sort()->first();
return $byTrack[$firstTrackId]->values();
}
return collect();
}
// ── Local filesystem path helpers ───────────────────────────────────────── // ── Local filesystem path helpers ─────────────────────────────────────────
/** /**

View File

@ -14,6 +14,12 @@ class VideoAudioTrack extends Model
return $this->belongsTo(Video::class); return $this->belongsTo(Video::class);
} }
/** Slides explicitly owned by this track. Use Video::slidesForTrack() for fallback resolution. */
public function slides()
{
return $this->hasMany(VideoSlide::class, 'audio_track_id')->orderBy('position');
}
public function getStreamUrlAttribute(): string public function getStreamUrlAttribute(): string
{ {
return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]); return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]);

View File

@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
class VideoSlide extends Model class VideoSlide extends Model
{ {
protected $fillable = ['video_id', 'filename', 'position']; protected $fillable = ['video_id', 'audio_track_id', 'filename', 'position'];
public function video() public function video()
{ {
return $this->belongsTo(Video::class); return $this->belongsTo(Video::class);
} }
public function audioTrack()
{
return $this->belongsTo(VideoAudioTrack::class, 'audio_track_id');
}
public function getUrlAttribute(): string public function getUrlAttribute(): string
{ {
return route('media.thumbnail', $this->filename); return route('media.thumbnail', $this->filename);

View File

@ -0,0 +1,266 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Optional LLM helper for the lyrics pipeline.
*
* Supports multiple providers configured in Admin Settings AI / LLM
* (local Ollama, hosted Anthropic Claude, or any OpenAI-compatible endpoint).
* Picks the provider flagged "Active" and dispatches the request through the
* matching adapter. Results are cached so a regenerate doesn't re-bill / re-hit
* the local model.
*/
class LlmLyricsService
{
public function isEnabled(): bool
{
return Setting::get('llm_enabled', 'false') === 'true'
&& $this->activeProvider() !== null;
}
public function cleanLyricsEnabled(): bool
{
return $this->isEnabled() && Setting::get('llm_clean_lyrics', 'true') === 'true';
}
public function decorateEnabled(): bool
{
return $this->isEnabled() && Setting::get('llm_decorate_lyrics', 'false') === 'true';
}
/** The currently selected provider config, or null if none. */
public function activeProvider(): ?array
{
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
if (! $providers) return null;
$activeId = (string) Setting::get('llm_active_id', '');
foreach ($providers as $p) {
if (($p['id'] ?? null) === $activeId) {
$kind = $p['kind'] ?? 'ollama';
// An Ollama provider doesn't need a key; the others do.
if ($kind !== 'ollama' && trim((string) ($p['api_key'] ?? '')) === '') return null;
if (trim((string) ($p['model'] ?? '')) === '') return null;
return $p;
}
}
return null;
}
/** Returns clean lyric lines extracted by the LLM, or [] on any failure. */
public function cleanDescription(?string $description): array
{
if (! $description || ! $this->cleanLyricsEnabled()) return [];
$provider = $this->activeProvider();
// v2 cache key: the prompt was rewritten to stop dropping English/Thai
// lyric lines that happened to carry leading/trailing emoji decoration.
$cacheKey = 'llm_lyrics_clean_v2:' . ($provider['id'] ?? '') . ':' . sha1($description);
return Cache::remember($cacheKey, now()->addDays(30), function () use ($description) {
$prompt = "Extract the SUNG lyric lines from this song description, preserving every\n"
. "language exactly as written. Songs are often MULTILINGUAL (e.g. mixed English\n"
. "and Thai, English and Italian, English and Arabic) — KEEP EVERY LANGUAGE.\n\n"
. "KEEP a line when it contains real lyric words, even if it's wrapped in or\n"
. " punctuated by emojis. Example: '🛡️💻 Met behind the firewalls 🌌' → KEEP.\n"
. " Strip ONLY the emojis themselves; the lyric words stay untouched.\n"
. "DROP a line ONLY when it is one of:\n"
. " • the song title or artist credit\n"
. " • a pure section header (Verse / Chorus / Bridge / Verso / Ritornello /\n"
. " Pre-Chorus / Outro / Intro / 副歌 / 후렴 / كورس / ท่อน / etc.) — typically\n"
. " one or two words, possibly numbered\n"
. " • an instrument or production note inside 【…】 or 〔…〕 brackets\n"
. " • a row that is ONLY emojis / separators / decorative symbols with no words\n"
. " • commentary or social-media call-to-action (subscribe, follow, link in bio)\n\n"
. "Hard rules:\n"
. " - DO NOT translate. DO NOT re-script (no romanising Thai/Arabic, no converting\n"
. " English to Thai). The output of each kept line must be in the SAME language\n"
. " and script as the original line.\n"
. " - DO NOT merge or split lines. One source lyric line → one output entry.\n"
. " - Preserve original punctuation (drop only the emojis).\n"
. " - Maintain the original order.\n\n"
. "Respond with ONLY a JSON array of strings. No prose, no markdown, no code fence.\n\n"
. "DESCRIPTION:\n" . $description;
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$arr = json_decode($raw, true);
if (! is_array($arr)) return [];
$out = [];
foreach ($arr as $line) {
$line = trim((string) $line);
if ($line === '') continue;
$out[] = $line;
}
return $out;
});
}
/**
* Rewrite each lyric line with heavy, expressive emoji styling. Emojis go
* inside the line AND at the end; multiple per line where it fits. The
* original words are NEVER changed emojis are layered on top.
*
* Returns [index => decoratedLineText]. The caller swaps line.text and
* re-distributes the word timings across the new tokens.
*/
public function decorateLines(array $lines): array
{
if (! $lines || ! $this->decorateEnabled()) return [];
$provider = $this->activeProvider();
$cacheKey = 'llm_lyrics_deco_v3:' . ($provider['id'] ?? '') . ':' . sha1(json_encode($lines));
return Cache::remember($cacheKey, now()->addDays(30), function () use ($lines) {
$numbered = [];
foreach ($lines as $i => $l) $numbered[] = ($i + 1) . '. ' . $l;
$prompt = "Decorate the following song lyrics with heavy, expressive emoji styling.\n\n"
. "Strict instructions:\n"
. "- Add emojis to almost every line (rich and visually striking, not minimal).\n"
. "- Place emojis both WITHIN lines and AT THE END where they enhance meaning.\n"
. "- Use 24 emojis per line on average, more on emotional peaks.\n"
. "- Match emojis to the line's specific emotion, action, image, or vibe.\n"
. "- VARIETY IS CRITICAL. Across the WHOLE song you must use a wide palette:\n"
. " • Aim for 30+ distinct emojis across the song.\n"
. " • Never reuse the same emoji on two adjacent lines.\n"
. " • Do NOT lean on the same 56 staples (🔥💔✨🎵❤️). Reach for less obvious\n"
. " ones that fit: ⚡🌊🌙🕯️🪞🥀🦋🌪️🗡️👁️🩸🦅🌀💎🪽🌑🪐🩹🌹🫧🌧️🔮🧨🪞🛡️\n"
. " ⚔️🏹🪄💫🥷🧿🪙🥀🎭🩰🪦⛓️🌌🚪🧊🌠💢🪶🩷🫀🪐🕊️ and many others.\n"
. "- Keep the original lyrics 100% UNCHANGED — no rewriting, no translation, no\n"
. " re-spelling, no script conversion. Preserve every original word verbatim.\n"
. "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive — like a\n"
. " designed lyric post or viral TikTok caption.\n"
. "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n"
. " must map to exactly one output line, in the same order, with the same words.\n\n"
. "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n"
. "decorated line text. No prose, no markdown, no code fence.\n\n"
. "LINES:\n" . implode("\n", $numbered);
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$obj = json_decode($raw, true);
if (! is_array($obj)) return [];
$out = [];
foreach ($obj as $k => $v) {
if (! is_string($v)) continue;
$v = trim($v);
if ($v === '') continue;
$idx = ((int) $k) - 1;
if ($idx < 0 || ! isset($lines[$idx])) continue;
// Cheap safety check: the original words must survive verbatim
// (the LLM should only LAYER emojis on top). Drop the
// decoration if too many original characters are missing.
if (! self::preservesOriginal($lines[$idx], $v)) continue;
$out[$idx] = $v;
}
return $out;
});
}
/**
* Verify the decorated line still contains every alphanumeric character of
* the original (in the same order). Stops the LLM from quietly rewording
* a line we keep only decorations that strictly add emojis on top.
*/
private static function preservesOriginal(string $orig, string $decorated): bool
{
$strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
$a = $strip($orig);
$b = $strip($decorated);
if ($a === '') return true;
// Sequential subsequence check: every char of $a must appear in $b in order.
$aLen = mb_strlen($a); $bLen = mb_strlen($b);
$j = 0;
for ($i = 0; $i < $aLen; $i++) {
$needle = mb_substr($a, $i, 1);
$found = false;
for (; $j < $bLen; $j++) {
if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; }
}
if (! $found) return false;
}
return true;
}
/** Dispatch to the active provider's adapter. */
private function call(string $prompt, int $maxTokens): string
{
$p = $this->activeProvider();
if (! $p) return '';
try {
return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
default => $this->callOllama($p, $prompt, $maxTokens),
};
} catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
return '';
}
}
private function callOllama(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
]);
if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['message']['content'] ?? '');
}
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([
'x-api-key' => (string) $p['api_key'],
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post($endpoint . '/v1/messages', [
'model' => $p['model'],
'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]],
]);
if (! $resp->successful()) {
Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['content'][0]['text'] ?? '');
}
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
->acceptJson()
->post($endpoint . '/v1/chat/completions', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.2,
]);
if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['choices'][0]['message']['content'] ?? '');
}
}

View File

@ -67,18 +67,69 @@ class NasSyncService
return $slug ?: 'video'; return $slug ?: 'video';
} }
/**
* Top-level folder under users/{slug}/ for a video of the given type.
* Frozen at upload time see CLAUDE.md (canonical storage layout).
*/
public function typeFolder(Video $video): string
{
return match ($video->type) {
'music' => 'music',
'match' => 'sports',
default => 'videos', // generic + any unknown
};
}
/**
* Track folder name inside a music song folder. Format: {lang}-{db-id}.
* For the primary audio (no VideoAudioTrack row), pass null the videos
* row id is used as the track id and the video's primary language as the
* language. Always lowercase. Falls back to 'xx' when no language is set.
*/
public function trackFolderName(Video $video, ?VideoAudioTrack $track = null): string
{
$lang = mb_strtolower(trim((string) ($track ? $track->language : $video->language)));
if ($lang === '') $lang = 'xx';
$id = $track ? $track->id : $video->id;
return "{$lang}-{$id}";
}
/**
* Absolute NAS-relative path to a music track's folder. Music only.
* Caller must ensure $video->type === 'music'.
*/
public function trackDir(Video $video, ?VideoAudioTrack $track = null): string
{
return $this->resolveVideoDir($video) . '/tracks/' . $this->trackFolderName($video, $track);
}
/** /**
* Return the NAS directory for a video. * Return the NAS directory for a video.
* *
* On first sync pick a conflict-free slug and create the folder. * On first sync pick a conflict-free slug and create the folder.
* On subsequent find the existing folder by matching video ID in meta.json * On subsequent find the existing folder by matching video ID in meta.json
* so renames of the title never lose the folder. * so renames of the title never lose the folder.
*
* Type-segregated: music music/, sports sports/, generic videos/.
* The folder is frozen at first resolution if the video's path already
* points somewhere, that location wins (so type edits don't move files).
*/ */
public function resolveVideoDir(Video $video): string public function resolveVideoDir(Video $video): string
{ {
// Already organised — trust the stored path verbatim. This keeps legacy
// flat layouts working and prevents type edits from relocating files.
if (str_starts_with((string) $video->path, 'users/')) {
// Walk up past any tracks/{lang-id}/ then file-name segments.
$segs = explode('/', $video->path);
// Expect users/{slug}/{type-folder}/{video-slug}/...
if (count($segs) >= 4) {
return implode('/', array_slice($segs, 0, 4));
}
}
$video->loadMissing('user'); $video->loadMissing('user');
$userSlug = $this->userSlug($video->user); $userSlug = $this->userSlug($video->user);
$base = "users/{$userSlug}/videos"; $base = "users/{$userSlug}/" . $this->typeFolder($video);
$titleSlug = $this->titleSlug($video->title); $titleSlug = $this->titleSlug($video->title);
// 1. Try the current title slug and numbered variants (-2, -3 …) // 1. Try the current title slug and numbered variants (-2, -3 …)
@ -156,20 +207,22 @@ class NasSyncService
*/ */
public function localVideoDir(Video $video): string public function localVideoDir(Video $video): string
{ {
// Already organised — derive from path // Already organised — derive from path (respects whichever type folder it
if (str_starts_with($video->path, 'users/')) { // ended up in, even if the type has since been edited).
$dir = dirname(storage_path('app/' . $video->path)); if (str_starts_with((string) $video->path, 'users/')) {
// If the primary file lives inside a 'tracks/' subfolder (promoted track), $segs = explode('/', $video->path);
// go up one extra level to reach the video root directory. if (count($segs) >= 4) {
if (basename($dir) === 'tracks') { return storage_path('app/' . implode('/', array_slice($segs, 0, 4)));
$dir = dirname($dir);
} }
// Defensive fallback for malformed legacy paths
$dir = dirname(storage_path('app/' . $video->path));
if (basename($dir) === 'tracks') $dir = dirname($dir);
return $dir; return $dir;
} }
$video->loadMissing('user'); $video->loadMissing('user');
$userSlug = $this->userSlug($video->user); $userSlug = $this->userSlug($video->user);
$base = storage_path("app/users/{$userSlug}/videos"); $base = storage_path("app/users/{$userSlug}/" . $this->typeFolder($video));
$titleSlug = $this->titleSlug($video->title); $titleSlug = $this->titleSlug($video->title);
for ($i = 1; $i <= 50; $i++) { for ($i = 1; $i <= 50; $i++) {
@ -206,60 +259,68 @@ class NasSyncService
$video->loadMissing(['user', 'slides']); $video->loadMissing(['user', 'slides']);
$dir = $this->localVideoDir($video); $videoDir = $this->localVideoDir($video); // users/{slug}/{type-folder}/{slug}
$fileSlug = $this->titleSlug($video->title); $isMusic = ($video->type === 'music');
// Music wraps the primary inside tracks/{lang-id}/; others stay flat.
$primaryDir = $isMusic
? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
: $videoDir;
@mkdir($dir, 0755, true); @mkdir($primaryDir, 0755, true);
$userSlug = $this->userSlug($video->user); $userSlug = $this->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($dir); $videoRel = 'users/' . $userSlug . '/' . $this->typeFolder($video) . '/' . basename($videoDir);
$updates = []; $primaryRel = $isMusic
? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
: $videoRel;
$updates = [];
// ── Video file ─────────────────────────────────────────────────── // ── Video / primary audio file ───────────────────────────────────
$oldVideoPath = storage_path('app/' . $video->path); $oldVideoPath = storage_path('app/' . $video->path);
if (file_exists($oldVideoPath)) { if (file_exists($oldVideoPath)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
$newFileName = "{$fileSlug}.{$ext}"; $canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
rename($oldVideoPath, "{$dir}/{$newFileName}"); rename($oldVideoPath, "{$primaryDir}/{$canonical}");
$updates['path'] = "{$relDir}/{$newFileName}"; $updates['path'] = "{$primaryRel}/{$canonical}";
$updates['filename'] = $newFileName; $updates['filename'] = $canonical;
} }
// ── Slides (process first; for audio, thumbnail IS slide 0) ────── // ── Slides — music primary track only ────────────────────────────
$firstSlideRelPath = null; $firstSlideRelPath = null;
if ($video->slides->isNotEmpty()) { if ($isMusic && $video->slides->isNotEmpty()) {
@mkdir("{$dir}/slides", 0755, true); @mkdir("{$primaryDir}/slides", 0755, true);
foreach ($video->slides->sortBy('position') as $slide) { foreach ($video->slides->sortBy('position') as $slide) {
$oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename); $oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
if (! file_exists($oldSlidePath)) continue; if (! file_exists($oldSlidePath)) continue;
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg'; $ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
$newSlideName = "{$slide->id}.{$ext}"; $newSlideName = "{$slide->position}.{$ext}";
rename($oldSlidePath, "{$dir}/slides/{$newSlideName}"); rename($oldSlidePath, "{$primaryDir}/slides/{$newSlideName}");
$newSlideFilename = "{$relDir}/slides/{$newSlideName}"; $newSlideFilename = "{$primaryRel}/slides/{$newSlideName}";
$slide->update(['filename' => $newSlideFilename]); $slide->update(['filename' => $newSlideFilename]);
if ($firstSlideRelPath === null) { if ($firstSlideRelPath === null) {
$firstSlideRelPath = $newSlideFilename; $firstSlideRelPath = $newSlideFilename;
} }
} }
// For audio uploads the thumbnail is the first slide
if ($firstSlideRelPath !== null) { if ($firstSlideRelPath !== null) {
$updates['thumbnail'] = $firstSlideRelPath; $updates['thumbnail'] = $firstSlideRelPath;
} }
} }
// ── Standalone thumbnail (video uploads, no slides) ───────────── // ── Standalone thumbnail (sports/generic + music-without-slides)
if ($video->thumbnail && ! isset($updates['thumbnail'])) { if ($video->thumbnail && ! isset($updates['thumbnail'])) {
$oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail); $oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
if (file_exists($oldThumbPath)) { if (file_exists($oldThumbPath)) {
$ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg'; $ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
$newThumbName = "thumb.{$ext}"; $newThumbName = "thumb.{$ext}";
rename($oldThumbPath, "{$dir}/{$newThumbName}"); $thumbDirAbs = $isMusic ? $primaryDir : $videoDir;
$updates['thumbnail'] = "{$relDir}/{$newThumbName}"; $thumbRelDir = $isMusic ? $primaryRel : $videoRel;
rename($oldThumbPath, "{$thumbDirAbs}/{$newThumbName}");
$updates['thumbnail'] = "{$thumbRelDir}/{$newThumbName}";
} }
} }
// ── meta.json (enables localVideoDir to identify this dir later) // ── meta.json (lives at the video / song root, not per-track) ───
$this->writeLocalMeta($video, $dir); $this->writeLocalMeta($video, $videoDir);
if (! empty($updates)) { if (! empty($updates)) {
$video->update($updates); $video->update($updates);
@ -603,41 +664,46 @@ class NasSyncService
): void { ): void {
$video->loadMissing(['user', 'slides']); $video->loadMissing(['user', 'slides']);
$dir = $this->resolveVideoDir($video); // NAS-relative, e.g. users/john/videos/my-video $videoDir = $this->resolveVideoDir($video); // users/{slug}/{type-folder}/{slug}
$fileSlug = $this->titleSlug($video->title); $isMusic = ($video->type === 'music');
// Music uses per-track folders. Primary audio + its slides + lyrics live in
$this->mkdirp($dir); // tracks/{lang-id}/. Sports + generic stay flat: video.{ext} at the root.
$primaryDir = $isMusic
? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
: $videoDir;
$this->mkdirp($primaryDir);
$updates = []; $updates = [];
// ── Video file ─────────────────────────────────────────────────── // ── Video / primary audio file ───────────────────────────────────
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4'; $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
if (file_exists($tempVideoPath)) { if (file_exists($tempVideoPath)) {
$ok = $this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}"); // New canonical name. Music: audio.{ext}. Sports/generic: video.{ext}.
$canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
$nasFile = "{$primaryDir}/{$canonical}";
$ok = $this->putFile($tempVideoPath, $nasFile);
if (! $ok) { if (! $ok) {
// Leave the local temp file intact so the caller can fall back to local storage throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$canonical}");
throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$fileSlug}.{$ext}");
} }
@unlink($tempVideoPath); @unlink($tempVideoPath);
$updates['path'] = "{$dir}/{$fileSlug}.{$ext}"; $updates['path'] = $nasFile;
$updates['filename'] = "{$fileSlug}.{$ext}"; $updates['filename'] = $canonical;
} }
// ── Slides (audio uploads — thumbnail IS the first slide) ──────── // ── Slides — music primary track only ────────────────────────────
$firstSlideNasPath = null; $firstSlideNasPath = null;
if (! empty($slideAbsPaths)) { if ($isMusic && ! empty($slideAbsPaths)) {
$this->mkdirp("{$dir}/slides"); $this->mkdirp("{$primaryDir}/slides");
foreach ($video->slides->sortBy('position') as $slide) { foreach ($video->slides->sortBy('position') as $slide) {
$absPath = $slideAbsPaths[$slide->position] ?? null; $absPath = $slideAbsPaths[$slide->position] ?? null;
if (! $absPath || ! file_exists($absPath)) continue; if (! $absPath || ! file_exists($absPath)) continue;
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg'; $slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
$nasSlideFile = "{$dir}/slides/{$slide->position}.{$slideExt}"; $nasSlideFile = "{$primaryDir}/slides/{$slide->position}.{$slideExt}";
if ($this->putFile($absPath, $nasSlideFile)) { if ($this->putFile($absPath, $nasSlideFile)) {
@unlink($absPath); @unlink($absPath);
$slideRelPath = $nasSlideFile; $slide->update(['filename' => $nasSlideFile]);
$slide->update(['filename' => $slideRelPath]);
if ($firstSlideNasPath === null) { if ($firstSlideNasPath === null) {
$firstSlideNasPath = $slideRelPath; $firstSlideNasPath = $nasSlideFile;
} }
} }
} }
@ -646,26 +712,31 @@ class NasSyncService
} }
} }
// ── Standalone thumbnail (video uploads without slides) ────────── // ── Standalone thumbnail (sports/generic + music-without-slides) ──
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) { if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
if ($this->putFile($tempThumbPath, "{$dir}/thumb.webp")) { // Music thumb lives next to the primary audio inside the track folder.
$thumbDir = $isMusic ? $primaryDir : $videoDir;
$thumbExt = pathinfo($tempThumbPath, PATHINFO_EXTENSION) ?: 'webp';
$nasThumb = "{$thumbDir}/thumb.{$thumbExt}";
if ($this->putFile($tempThumbPath, $nasThumb)) {
@unlink($tempThumbPath); @unlink($tempThumbPath);
$updates['thumbnail'] = "{$dir}/thumb.webp"; $updates['thumbnail'] = $nasThumb;
} }
} }
// ── meta.json ──────────────────────────────────────────────────── // ── meta.json (song / video root level, not per-track) ───────────
$this->putContent(json_encode([ $this->putContent(json_encode([
'id' => $video->id, 'id' => $video->id,
'user_id' => $video->user_id, 'user_id' => $video->user_id,
'title' => $video->title, 'title' => $video->title,
'type' => $video->type,
'created_at' => $video->created_at?->toIso8601String(), 'created_at' => $video->created_at?->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json"); ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$videoDir}/meta.json");
// File is now on NAS and accessible — mark as ready // File is now on NAS and accessible — mark as ready
$updates['status'] = 'ready'; $updates['status'] = 'ready';
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $dir]); Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $videoDir]);
$video->update($updates); $video->update($updates);
} }
@ -705,6 +776,17 @@ class NasSyncService
} }
} }
// lyrics/*.json (source-of-truth; push any local mirror that exists)
$video->loadMissing('audioTracks');
$lyricsTargets = array_merge([null], $video->audioTracks->all());
foreach ($lyricsTargets as $lt) {
$localLyrics = $this->lyricsLocalPath($video, $lt);
if (is_file($localLyrics)) {
$this->mkdirp("{$dir}/lyrics");
$this->putFile($localLyrics, $this->lyricsNasPath($video, $lt));
}
}
// meta.json (always written last so readMeta can find the folder) // meta.json (always written last so readMeta can find the folder)
$this->putContent(json_encode([ $this->putContent(json_encode([
'id' => $video->id, 'id' => $video->id,
@ -994,6 +1076,130 @@ class NasSyncService
return $content !== false ? $content : null; return $content !== false ? $content : null;
} }
// ── Lyrics (per-track, source-of-truth JSON synced to NAS) ─────────────────
public function lyricsDir(Video $video): string
{
// Legacy shared lyrics/ folder location — kept for read-fallback only.
// Derived from the stored path (no NAS lookup) — see callers for context.
if (str_starts_with((string) $video->path, 'users/')) {
$dir = dirname($video->path);
if (basename($dir) === 'tracks') $dir = dirname($dir);
return $dir . '/lyrics';
}
return $this->resolveVideoDir($video) . '/lyrics';
}
/**
* NAS-relative path for a track's lyrics under the new per-track-folder layout:
* tracks/{lang-id}/lyrics.json
* Falls back to the legacy shared lyrics/ folder if the song still uses the
* old flat layout (we detect this by checking whether the path passes through
* a tracks/ segment).
*/
public function lyricsNasPath(Video $video, ?VideoAudioTrack $track = null): string
{
// New layout (music with per-track folders).
if ($video->type === 'music' && str_starts_with((string) $video->path, 'users/')) {
$segs = explode('/', $video->path);
// users/{slug}/music/{song-slug}/tracks/{lang-id}/audio.{ext}
if (count($segs) >= 6 && $segs[4] === 'tracks') {
$songRoot = implode('/', array_slice($segs, 0, 4));
$folder = $this->trackFolderName($video, $track);
return "{$songRoot}/tracks/{$folder}/lyrics.json";
}
}
// Legacy fallback — shared lyrics/ folder, keyed by track suffix.
$name = $track ? "track-{$track->id}" : 'primary';
return $this->lyricsDir($video) . "/{$name}.json";
}
/** Canonical local mirror path for a track's lyrics. */
public function lyricsLocalPath(Video $video, ?VideoAudioTrack $track = null): string
{
return storage_path('app/' . $this->lyricsNasPath($video, $track));
}
/**
* Write lyrics JSON for a track. Always writes the local mirror; pushes to
* NAS when reachable. Returns false only if the NAS push was attempted and
* failed (the local copy is written regardless, so nas:auto-sync can retry).
*/
public function putLyrics(Video $video, ?VideoAudioTrack $track, array $data): bool
{
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
$local = $this->lyricsLocalPath($video, $track);
if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
file_put_contents($local, $json);
if ($this->isEnabled()) {
// Make sure the directory containing this lyrics file exists on NAS.
// New layout: tracks/{lang-id}/. Legacy: shared lyrics/ folder.
$this->mkdirp(dirname($this->lyricsNasPath($video, $track)));
return $this->putContent($json, $this->lyricsNasPath($video, $track));
}
return true;
}
/**
* Read lyrics JSON from the LOCAL mirror only never touches the NAS.
* Use this on hot paths (page render, player-data) so a missing file can't
* block the request on smbclient or the port-445 reachability probe. The
* lyrics mirror is written by putLyrics() and is never wiped by cache
* cleanup, so a song that has lyrics will have them locally.
*/
public function getLocalLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
{
$local = $this->lyricsLocalPath($video, $track);
if (! is_file($local)) {
// Read fallback to the legacy shared lyrics/ folder so songs that
// haven't been migrated yet still serve their lyrics.
$legacy = storage_path('app/' . $this->lyricsDir($video) . '/' . ($track ? "track-{$track->id}" : 'primary') . '.json');
if (is_file($legacy)) $local = $legacy;
}
if (is_file($local)) {
$d = json_decode((string) file_get_contents($local), true);
if (is_array($d)) return $d;
}
return null;
}
/**
* Remove a track's lyrics from both the local mirror and the NAS. Used by
* the owner when the generated lyrics are wrong and they want to start
* over after calling this, the next Generate produces a fresh file.
*/
public function deleteLyrics(Video $video, ?VideoAudioTrack $track = null): void
{
$local = $this->lyricsLocalPath($video, $track);
if (is_file($local)) @unlink($local);
if ($this->isEnabled()) {
try { $this->deleteFile($this->lyricsNasPath($video, $track)); }
catch (\Throwable $e) { /* best-effort: local removal is what matters for next regenerate */ }
}
}
/** Read lyrics JSON for a track, pulling from NAS into the local mirror if needed. */
public function getLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
{
$local = $this->lyricsLocalPath($video, $track);
if (is_file($local)) {
$d = json_decode((string) file_get_contents($local), true);
if (is_array($d)) return $d;
}
if ($this->isEnabled()) {
$c = $this->getContent($this->lyricsNasPath($video, $track));
if ($c !== null) {
if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
file_put_contents($local, $c);
$d = json_decode($c, true);
if (is_array($d)) return $d;
}
}
return null;
}
public function deleteFile(string $nasRelPath): void public function deleteFile(string $nasRelPath): void
{ {
$cfg = $this->cfg(); $cfg = $this->cfg();

109
app/Support/LyricsAss.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace App\Support;
/**
* Build an ASS (Advanced SubStation Alpha) subtitle file with word-level
* karaoke timing from a lyrics JSON payload (the shape produced by
* ml/transcribe.py). Burned into the downloadable mp4 via libass.
*
* Karaoke fill uses ASS \k tags: each word's \k duration (centiseconds) is the
* time it waits before the highlight reaches it, so the cumulative \k before a
* word equals its onset relative to the line start. PrimaryColour is the sung
* (filled) colour, SecondaryColour the not-yet-sung colour.
*/
class LyricsAss
{
/** Render at the same canvas the slideshow uses. */
private const W = 1280;
private const H = 720;
/**
* Write an .ass file for the given lyrics data. Returns true on success,
* false when there are no usable timed lines (caller should skip burning).
*/
public static function write(array $lyrics, string $outPath): bool
{
$lines = $lyrics['lines'] ?? [];
if (! is_array($lines) || ! $lines) return false;
$events = [];
foreach ($lines as $ln) {
$start = $ln['start'] ?? null;
$end = $ln['end'] ?? null;
if ($start === null || $end === null) continue;
$words = (isset($ln['words']) && is_array($ln['words']) && $ln['words']) ? $ln['words'] : null;
$text = $words
? self::karaokeText($words, (float) $start)
: self::escape((string) ($ln['text'] ?? ''));
if ($text === '') continue;
// Hold the line a touch past its last word for readability.
// Fields per the Format line: Layer,Start,End,Style,Name,MarginL,MarginR,Effect,Text
$events[] = 'Dialogue: 0,' . self::ts((float) $start) . ',' . self::ts((float) $end + 0.4)
. ',Lyrics,,0,0,,' . $text;
}
if (! $events) return false;
$ass = self::header() . implode("\n", $events) . "\n";
return file_put_contents($outPath, $ass) !== false;
}
private static function karaokeText(array $words, float $lineStart): string
{
$out = '';
$lead = (int) round((((float) $words[0]['start']) - $lineStart) * 100);
if ($lead > 0) $out .= '{\k' . $lead . '}';
$n = count($words);
foreach ($words as $i => $w) {
$wStart = (float) $w['start'];
$wEnd = (float) $w['end'];
$dur = max(1, (int) round(($wEnd - $wStart) * 100));
$out .= '{\k' . $dur . '}' . self::escape((string) $w['text']);
if ($i < $n - 1) {
$gap = (int) round((((float) $words[$i + 1]['start']) - $wEnd) * 100);
$out .= ($gap > 0 ? '{\k' . $gap . '}' : '') . ' ';
}
}
return $out;
}
/** ASS timestamp: H:MM:SS.cc (centiseconds). */
private static function ts(float $t): string
{
if ($t < 0) $t = 0;
$cs = (int) round($t * 100);
$h = intdiv($cs, 360000);
$m = intdiv($cs % 360000, 6000);
$s = intdiv($cs % 6000, 100);
$c = $cs % 100;
return sprintf('%d:%02d:%02d.%02d', $h, $m, $s, $c);
}
private static function escape(string $s): string
{
// Strip ASS override delimiters and collapse newlines.
$s = str_replace(['{', '}', '\\'], ['(', ')', '/'], $s);
return str_replace(["\r\n", "\n", "\r"], ' ', $s);
}
private static function header(): string
{
// Colours are &HAABBGGRR. Primary = sung (white), Secondary = unsung
// (translucent grey), heavy outline + shadow for legibility over artwork.
return "[Script Info]\n"
. "ScriptType: v4.00+\n"
. 'PlayResX: ' . self::W . "\n"
. 'PlayResY: ' . self::H . "\n"
. "WrapStyle: 0\n"
. "ScaledBorderAndShadow: yes\n\n"
. "[V4+ Styles]\n"
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
. "Style: Lyrics,Sans,54,&H00FFFFFF,&H64C8C8C8,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,80,80,70,1\n\n"
. "[Events]\n"
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, Effect, Text\n";
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Support;
/**
* Extract clean lyric lines from a video's free-text description.
*
* Many users paste the song's lyrics into the description with section markers,
* emoji decorations, instrument tags, etc. When that's present we want to USE
* those exact lines (and let the pipeline only do the *sync*, not the
* transcription), because they're far more accurate than anything Whisper can
* derive from sung audio.
*
* Returns an empty array when no usable lyric block is found.
*/
class LyricsDescriptionParser
{
/** Heuristic threshold: descriptions with fewer cleaned lines aren't worth aligning. */
private const MIN_LYRIC_LINES = 4;
public static function extract(?string $desc): array
{
if (! $desc) return [];
// HTML descriptions use <br> for line breaks — convert those (and other
// block-ending tags) into real newlines BEFORE stripping tags, otherwise
// the entire body collapses into one long run-on line.
// Heading tags (<h1>…<h6>) carry the song title — drop their content
// entirely so the title never leaks into the lyric list.
$text = preg_replace('/<\s*h[1-6][^>]*>.*?<\s*\/\s*h[1-6]\s*>/isu', "\n", $desc);
$text = preg_replace('/<\s*br\s*\/?>/i', "\n", $text);
$text = preg_replace('/<\s*\/\s*(p|div|li|tr|blockquote)\s*>/i', "\n", $text);
$text = strip_tags($text);
// Decode HTML entities (&nbsp;, &amp;, etc.) so the comparison later isn't fooled.
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\r\n|\r/u', "\n", $text);
// First pass: clean each line and flag section headers (Verse / Ritornello
// / Bridge / etc.) so they can be dropped — those aren't sung.
$cleaned = [];
foreach (explode("\n", $text) as $line) {
$line = self::cleanLine($line);
if ($line === null) continue;
$cleaned[] = ['text' => $line, 'header' => self::isSectionHeader($line)];
}
// Title detection: if the first non-header line is immediately followed by
// a section header (e.g. "Figlio Mio — Viaggio di Vita" then "Verso 1"),
// that first line is the song title — drop it too.
$firstIdx = null;
foreach ($cleaned as $i => $c) {
if (! $c['header']) { $firstIdx = $i; break; }
}
$dropTitle = false;
if ($firstIdx !== null) {
for ($j = $firstIdx + 1; $j < count($cleaned); $j++) {
if ($cleaned[$j]['header']) { $dropTitle = true; break; }
break; // first thing after is a real lyric line → not a title block
}
}
$out = [];
foreach ($cleaned as $i => $c) {
if ($c['header']) continue;
if ($dropTitle && $i === $firstIdx) continue;
$out[] = $c['text'];
}
// Avoid mistakenly aligning non-lyric descriptions (a credit line, a URL,
// etc.). Require at least a handful of plausible lyric lines.
if (count($out) < self::MIN_LYRIC_LINES) return [];
return $out;
}
/**
* True when a line is a section marker (Verse / Chorus / Bridge / Outro /
* their many translations) rather than a sung lyric. Matches the WHOLE line
* so a real lyric containing one of these words isn't mistakenly dropped.
*/
private static function isSectionHeader(string $line): bool
{
$t = mb_strtolower(trim($line));
if ($t === '') return false;
$roots = [
'intro', 'outro', 'interlude', 'instrumental',
'verse', 'verso', 'verset', 'couplet', 'estrofa', 'strofa',
'chorus', 'ritornello', 'refrain', 'refrão', 'refrao', 'coro', 'estribillo',
'pre[\s\-]?chorus', 'pre[\s\-]?ritornello', 'pre[\s\-]?coro',
'pre[\s\-]?refrain', 'pre[\s\-]?refrão', 'pré[\s\-]?refrain',
'bridge', 'ponte', 'puente', 'pont', 'brücke', 'brucke',
'hook', 'drop', 'breakdown', 'tag', 'vamp', 'coda', 'reprise',
// CJK / Thai / Arabic / Korean
'サビ', 'コーラス', 'バース', 'ブリッジ', 'イントロ', 'アウトロ', 'フック', '間奏',
'ท่อน', 'คอรัส', 'ฮุก', 'บริดจ์', 'อินโทร', 'เอาท์โทร',
'前奏', '副歌', '桥段', '主歌', '尾奏',
'كورس', 'بريدج', 'كوبليه',
'후렴', '브릿지', '인트로', '아웃트로', '훅',
];
$rootRe = implode('|', $roots);
// Optional trailing number, "final/finale/reprise", roman numerals.
$pattern = '/^(?:' . $rootRe . ')[\s\d:\-—\.]*(?:final|finale|reprise|ii|iii|iv|v|vi|2|3|4|5)?\s*$/iu';
return (bool) preg_match($pattern, $t);
}
/** Returns the cleaned line, or null if it should be discarded. */
private static function cleanLine(string $line): ?string
{
$line = trim($line);
if ($line === '') return null;
// Strip markdown emphasis (* _ ~) and leading list bullets / quote markers.
$line = preg_replace('/^[\s>\-\*•♪♫·]+/u', '', $line);
$line = preg_replace('/[\*_~`]+/u', '', $line);
// Drop instrument / section annotations inside Japanese-style brackets:
// 【 箏・尺八・篠笛・優しい歌声 】 — these aren't lyrics.
$line = preg_replace('/【[^】]*】/u', '', $line);
$line = preg_replace('/[^]*/u', '', $line);
$line = preg_replace('/\[\[[^\]]*\]\]/u', '', $line);
// Strip emoji / pictographic symbols and the invisible glue that often
// sticks to them (variation selectors, ZWJ) so nothing leaves behind a
// bare diacritic when the visible emoji is removed.
$line = preg_replace(
'/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{0F3A}-\x{0F3D}\x{FE00}-\x{FE0F}\x{200B}-\x{200F}\x{2060}]/u',
'', $line
);
// Collapse internal whitespace.
$line = preg_replace('/\s+/u', ' ', $line);
$line = trim($line);
if ($line === '') return null;
// Must contain at least one letter (Unicode), and at least 3 characters
// after stripping — discards "🌸 平穏 🌸" (header) and "──" separators.
if (! preg_match('/\p{L}/u', $line)) return null;
if (mb_strlen($line) < 3) return null;
return $line;
}
}

View File

@ -15,6 +15,14 @@ $app = new Illuminate\Foundation\Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
); );
// Framework storage directory lives at base_path('data') instead of the default
// base_path('storage'). This keeps the project tree free of two "storage"
// entries — the only `storage` visible is the public-facing symlink at
// public/storage (→ data/app/public). All Laravel storage_path() calls,
// session/cache/log/view writes, and the local NAS file cache resolve through
// here transparently.
$app->useStoragePath(base_path('data'));
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Bind Important Interfaces | Bind Important Interfaces

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_slides', function (Blueprint $table) {
// NULL = song-wide (legacy / shared). Non-null = owned by that audio track.
// On track delete, slides become song-wide rather than disappear.
$table->foreignId('audio_track_id')
->nullable()
->after('video_id')
->constrained('video_audio_tracks')
->nullOnDelete();
$table->index(['video_id', 'audio_track_id']);
});
}
public function down(): void
{
Schema::table('video_slides', function (Blueprint $table) {
$table->dropIndex(['video_id', 'audio_track_id']);
$table->dropConstrainedForeignId('audio_track_id');
});
}
};

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add playlist view tracking mirrors the video_views pattern.
*
* playlists.view_count denormalised total for fast card rendering
* playlist_views table one row per (playlist, viewer-id) so a refresh
* within the dedup window doesn't double-count
*/
public function up(): void
{
Schema::table('playlists', function (Blueprint $table) {
$table->unsignedBigInteger('view_count')->default(0)->after('share_token');
});
Schema::create('playlist_views', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('playlist_id');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('device_id', 64)->nullable();
$table->string('device_hash', 64)->nullable();
$table->string('ip_address', 64)->nullable();
$table->string('country', 8)->nullable();
$table->string('country_name', 64)->nullable();
$table->string('user_agent', 512)->nullable();
$table->timestamp('viewed_at');
$table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade');
$table->index(['playlist_id', 'viewed_at']);
$table->index(['playlist_id', 'user_id', 'viewed_at']);
$table->index(['playlist_id', 'device_id', 'viewed_at']);
$table->index(['playlist_id', 'device_hash', 'viewed_at']);
});
}
public function down(): void
{
Schema::dropIfExists('playlist_views');
Schema::table('playlists', function (Blueprint $table) {
$table->dropColumn('view_count');
});
}
};

895
ml/transcribe.py Normal file
View File

@ -0,0 +1,895 @@
#!/usr/bin/env python3
"""
Lyrics transcription + word-level alignment pipeline.
Pipeline: Demucs (isolate vocals) -> WhisperX transcribe (large-v3) -> forced
word alignment. Emits a JSON file with line- and word-level timestamps that the
web player overlay and the ASS subtitle burner both consume.
Usage:
transcribe.py --audio /abs/song.mp3 --out /abs/lyrics.json \
[--language en] [--gpu 0] [--model large-v3] [--no-demucs]
All heavy logs go to stderr; stdout stays clean. Exit code 0 on success.
The output JSON shape is:
{
"version": 1,
"language": "en",
"source": "whisperx",
"model": "large-v3",
"demucs": true,
"lines": [
{"start": 12.30, "end": 16.80, "text": "...",
"words": [{"start": 12.30, "end": 12.55, "text": "..."}]}
]
}
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
def log(*a):
print(*a, file=sys.stderr, flush=True)
# Progress file path, set from --progress. The web layer polls a status endpoint
# that reads this file to drive a live progress bar.
_PROGRESS_PATH = None
def write_progress(pct: int, stage: str):
if not _PROGRESS_PATH:
return
try:
tmp = _PROGRESS_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"status": "processing", "pct": int(pct), "stage": stage}, f)
os.replace(tmp, _PROGRESS_PATH)
except Exception:
pass # progress is best-effort, never fail the run over it
def isolate_vocals(audio_path: str, gpu: int | None) -> str | None:
"""Run Demucs two-stem separation and return the path to vocals.wav.
Returns None if separation fails so the caller can fall back to the raw mix.
"""
tmp_dir = tempfile.mkdtemp(prefix="demucs_")
cmd = [
sys.executable, "-m", "demucs",
"--two-stems", "vocals",
"-n", "htdemucs",
"-o", tmp_dir,
audio_path,
]
env = dict(os.environ)
if gpu is not None:
env["CUDA_VISIBLE_DEVICES"] = str(gpu)
cmd += ["-d", "cuda"]
else:
cmd += ["-d", "cpu"]
log(f"[demucs] separating vocals -> {tmp_dir}")
try:
# Stream stderr so demucs' tqdm percentage drives live progress (8→38%).
import re
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, bufsize=0)
buf = b""
last = -1
while True:
chunk = proc.stderr.read(64)
if not chunk:
break
buf += chunk
# tqdm overwrites with \r; scan the tail for the newest "NN%".
text = buf[-200:].decode("utf-8", "ignore")
m = re.findall(r"(\d{1,3})%", text)
if m:
p = int(m[-1])
if 0 <= p <= 100 and p != last:
last = p
write_progress(8 + int(p * 0.30), "Separating vocals")
proc.wait()
if proc.returncode != 0:
log(f"[demucs] exited {proc.returncode}; falling back to raw mix")
return None
except Exception as e:
log(f"[demucs] failed ({e}); falling back to raw mix")
return None
stem = Path(audio_path).stem
vocals = Path(tmp_dir) / "htdemucs" / stem / "vocals.wav"
if vocals.exists():
log(f"[demucs] vocals at {vocals}")
return str(vocals)
log("[demucs] vocals.wav not found; falling back to raw mix")
return None
# Karaoke display lines are short — we re-split a segment's words on natural
# pauses, a soft word cap, and (for spaced scripts) clause punctuation / new
# capitalised lines.
LINE_GAP = 0.65 # seconds of silence that ends a display line
LINE_MAX_WORDS = 12 # hard cap so Latin-script lines never overflow
LINE_MAX_CHARS = 30 # char cap for spaceless scripts (Thai/CJK/…)
LINE_MIN_WORDS = 3 # don't break on punctuation before this many words
PUNCT_END = (".", ",", "!", "?", ";", ":", "")
# Scripts written without spaces between words — join tokens directly and split
# by character count instead of word count.
SPACELESS = {"th", "zh", "ja", "lo", "my", "km", "yue", "wuu"}
# Languages that use a non-Latin script — used to detect a mis-forced pass (a
# Thai/Arabic/… pass that produced Latin text is really a misheard English part).
NONLATIN_LANGS = {
"th", "zh", "ja", "ko", "ar", "he", "ru", "uk", "bg", "sr", "mk", "el",
"hi", "bn", "ta", "te", "kn", "ml", "mr", "ne", "si", "my", "km", "lo",
"ka", "am", "fa", "ur", "ps", "yue", "wuu", "yi",
}
def _emit(words: list, lang: str) -> dict | None:
if not words:
return None
sep = "" if lang in SPACELESS else " "
return {
"start": words[0]["start"],
"end": words[-1]["end"],
"text": sep.join(w["text"] for w in words),
"lang": lang,
"words": words,
}
def _norm_for_match(s: str) -> str:
"""Normalize text for similarity comparison (lowercase, keep letters/numbers
including non-ASCII scripts; drop everything else)."""
out = []
for c in s or "":
if c.isalnum():
out.append(c.lower())
return "".join(out)
def _guess_lang_from_script(text: str) -> str:
"""Best-effort language guess from a line's Unicode script (used when we have
no whisper anchor to inherit the language from)."""
for c in text or "":
co = ord(c)
if 0x3040 <= co <= 0x30FF or 0x4E00 <= co <= 0x9FFF:
return "ja"
if 0x0E00 <= co <= 0x0E7F:
return "th"
if 0xAC00 <= co <= 0xD7AF:
return "ko"
if 0x0600 <= co <= 0x06FF:
return "ar"
if 0x0400 <= co <= 0x04FF:
return "ru"
return "en"
def _redistribute_words(start: float, end: float, text: str, lang: str) -> list:
"""Evenly distribute the line's [start,end] across its tokens — words for
spaced languages, characters for spaceless scripts (Thai/CJK/)."""
if not text or end <= start:
return []
tokens = list(text) if lang in SPACELESS else text.split()
tokens = [t for t in tokens if t.strip()]
n = len(tokens)
if n == 0:
return []
slot = (end - start) / n
return [{"start": round(start + i * slot, 3),
"end": round(start + (i + 1) * slot, 3),
"text": t} for i, t in enumerate(tokens)]
def _distribute_in_vocal_regions(lines: list, regions: list,
gap_start: float, gap_end: float) -> list:
"""Place each line at a moment within [gap_start, gap_end] where vocals
are actually active. `regions` is a list of (start, end) seconds covering
the whole song. Falls back to even spread if no vocal activity is detected
in the gap (e.g. instrumental break with no vocals at all)."""
gap_regions = []
for s, e in regions:
s_clip = max(s, gap_start)
e_clip = min(e, gap_end)
if e_clip - s_clip >= 0.3:
gap_regions.append((s_clip, e_clip))
N = len(lines)
if N == 0: return []
if not gap_regions or gap_end <= gap_start:
# No vocals in the gap — last-resort even spread so coverage isn't lost.
if gap_end <= gap_start: return []
slot = (gap_end - gap_start) / N
out = []
for k, ul in enumerate(lines):
s = gap_start + k * slot
e = gap_start + (k + 1) * slot
lang = _guess_lang_from_script(ul)
out.append({"start": round(s, 3), "end": round(e, 3),
"text": ul, "lang": lang,
"words": _redistribute_words(s, e, ul, lang)})
return out
M = len(gap_regions)
out = []
if N <= M:
# Fewer lines than vocal regions — pick N regions roughly evenly spaced
# and start each line at its region's start. Each line ends at the next
# selected region's start (or its own region's end if last).
chosen = [int(round(i * (M - 1) / max(1, N - 1))) if N > 1 else 0 for i in range(N)]
# Ensure strictly increasing
for i in range(1, len(chosen)):
if chosen[i] <= chosen[i - 1]:
chosen[i] = min(M - 1, chosen[i - 1] + 1)
for i, ul in enumerate(lines):
rs, re = gap_regions[chosen[i]]
if i + 1 < N:
nxt_rs = gap_regions[chosen[i + 1]][0]
line_end = min(re, nxt_rs - 0.05)
else:
line_end = re
line_end = max(rs + 0.4, line_end)
lang = _guess_lang_from_script(ul)
out.append({"start": round(rs, 3), "end": round(line_end, 3),
"text": ul, "lang": lang,
"words": _redistribute_words(rs, line_end, ul, lang)})
else:
# More lines than vocal regions — assign multiple lines per region,
# divided proportionally to each region's duration so longer regions
# take more lines.
total = sum(e - s for s, e in gap_regions)
line_idx = 0
consumed = 0.0
for ri, (rs, re) in enumerate(gap_regions):
# Lines that should land in this region: proportional to its share
# of total vocal time, rounded so the last region takes the rest.
if ri == M - 1:
n_here = N - line_idx
else:
consumed += re - rs
target = int(round(consumed / total * N))
n_here = max(0, target - line_idx)
if n_here <= 0: continue
slot = (re - rs) / n_here
for k in range(n_here):
if line_idx >= N: break
s = rs + k * slot
e = rs + (k + 1) * slot
ul = lines[line_idx]
lang = _guess_lang_from_script(ul)
out.append({"start": round(s, 3), "end": round(e, 3),
"text": ul, "lang": lang,
"words": _redistribute_words(s, e, ul, lang)})
line_idx += 1
return out
def correct_whisper_with_description(whisper_lines: list, user_lines: list,
audio_duration: float = 0.0,
vocal_regions: list = None) -> list:
"""Description-first alignment, with whisper used only as structural anchors:
1. Find HIGH-confidence whisper-to-description matches (sim STRONG).
Weak/spurious matches are ignored they cause downstream skips and
misplacements (e.g. line #5 anchored at 30s because of a loose match,
making line #4 disappear).
2. The strong anchors partition the description into segments. Each
segment of description lines is distributed across the vocal regions
in its time window so every line lands on actual singing and every
line appears exactly once, in order.
3. No description line is ever skipped; no weak match consumes the wrong
slot; every output line carries description text (never whisper).
Falls back to pure vocal-region distribution if no strong anchors exist.
"""
from difflib import SequenceMatcher
if not user_lines:
return whisper_lines or []
U = [u for u in user_lines if u.strip()]
if not U:
return whisper_lines or []
vocal_regions = vocal_regions or []
audio_end = max(audio_duration, 10.0)
if vocal_regions:
audio_end = max(audio_end, vocal_regions[-1][1])
# ── Find strong anchors ────────────────────────────────────────────────
# Only matches at STRONG similarity (0.55+) count as anchors. Anything
# less confident than that has historically misled the alignment.
user_script = [_guess_lang_from_script(u) for u in U]
user_norm = [_norm_for_match(u) for u in U]
LATIN = {"en", "es", "pt", "it", "fr", "de", "nl", "ca", "ro", "tr", "vi", "id", "ms"}
def same_script(a: str, b: str) -> bool:
if a in LATIN and b in LATIN: return True
return a == b
STRONG = 0.55
SKIP_AHEAD = 10
anchors = [] # list of (user_idx, whisper_start, whisper_end)
next_u = 0
for w in (whisper_lines or []):
w_text = (w.get("text") or "").strip()
if not w_text: continue
w_lang = w.get("lang") or _guess_lang_from_script(w_text)
w_norm = _norm_for_match(w_text)
if not w_norm: continue
best_u = -1; best_sim = 0.0
end = min(next_u + SKIP_AHEAD + 1, len(U))
for ui in range(next_u, end):
if not same_script(user_script[ui], w_lang): continue
if not user_norm[ui]: continue
sim = SequenceMatcher(None, user_norm[ui], w_norm).ratio()
if sim > best_sim:
best_sim = sim; best_u = ui
if best_u >= 0 and best_sim >= STRONG:
anchors.append((best_u, float(w["start"]), float(w["end"])))
next_u = best_u + 1
# ── Build output ───────────────────────────────────────────────────────
out = []
if not anchors:
# No reliable whisper structure — distribute all description lines
# across the vocal regions in order. Best-effort but never skips.
return _distribute_in_vocal_regions(U, vocal_regions, 0.5, audio_end - 0.3)
# Segment 0: description lines BEFORE the first anchor go in the time
# window [0, anchor[0].start], aligned to vocal regions there.
first_u, first_start, _ = anchors[0]
if first_u > 0 and first_start > 0.6:
out.extend(_distribute_in_vocal_regions(
U[0:first_u], vocal_regions, 0.0, first_start
))
# The anchor line itself uses whisper timing.
out.append(_build_line(U[first_u], first_start, anchors[0][2]))
# Middle segments: between each pair of anchors, distribute the lines
# between them across vocal regions in that window.
for i in range(1, len(anchors)):
prev_u, _, prev_end_t = anchors[i - 1]
cur_u, cur_start_t, cur_end_t = anchors[i]
gap_start = prev_end_t
gap_end = cur_start_t
between_lines = U[prev_u + 1 : cur_u]
if between_lines and gap_end - gap_start > 0.6:
out.extend(_distribute_in_vocal_regions(
between_lines, vocal_regions, gap_start, gap_end
))
out.append(_build_line(U[cur_u], cur_start_t, cur_end_t))
# Trailing segment: description lines after the last anchor distributed
# across the audio's remaining vocal regions.
last_u, _, last_end_t = anchors[-1]
trailing = U[last_u + 1:]
if trailing:
end_time = max(audio_end - 0.3, last_end_t + 2.0)
if end_time > last_end_t + 0.6:
out.extend(_distribute_in_vocal_regions(
trailing, vocal_regions, last_end_t, end_time
))
return out
def _build_line(text: str, start: float, end: float) -> dict:
"""Construct an output line dict with redistributed word timings."""
lang = _guess_lang_from_script(text)
s = round(float(start), 3)
e = round(max(float(end), s + 0.4), 3)
return {"start": s, "end": e, "text": text, "lang": lang,
"words": _redistribute_words(s, e, text, lang)}
def _spread_lines_evenly(lines: list, start: float, end: float) -> list:
"""Distribute `lines` evenly between [start, end]. Used as a last-resort
fallback when whisper produced no usable anchors at all."""
if not lines or end <= start: return []
slot = (end - start) / len(lines)
out = []
for k, ul in enumerate(lines):
s = start + k * slot
e = start + (k + 1) * slot
lang = _guess_lang_from_script(ul)
out.append({
"start": round(s, 3), "end": round(e, 3),
"text": ul, "lang": lang,
"words": _redistribute_words(s, e, ul, lang),
})
return out
def align_user_lyrics(user_lines: list, whisper_lines: list) -> list:
"""Legacy: project user lines onto whisper anchors with N-W DP. Kept for
reference; the active pipeline uses correct_whisper_with_description()
because it preserves whisper's natural timing instead of squeezing all
description lines into whatever anchors were found."""
from difflib import SequenceMatcher
if not user_lines:
return whisper_lines
if not whisper_lines:
return []
U = [u for u in user_lines if u.strip()]
W = whisper_lines
nU, nW = len(U), len(W)
if nU == 0:
return []
user_norm = [_norm_for_match(u) for u in U]
whisper_norm = [_norm_for_match(w.get("text", "")) for w in W]
# Script of each user line and each whisper line. For multilingual songs
# an English user line MUST anchor to an English whisper segment and a Thai
# user line MUST anchor to a Thai whisper segment — otherwise the DP forces
# a Thai user line onto an English anchor (or vice-versa) and the whole
# block of mismatched-language user lines collapses into the wrong region.
user_script = [_guess_lang_from_script(u) for u in U]
whisper_script = [(w.get("lang") or _guess_lang_from_script(w.get("text", ""))) for w in W]
def _same_script(a: str, b: str) -> bool:
# Coarse equivalence — collapse all Latin-script European languages
# together, all CJK together, etc. so e.g. an English user line still
# matches a Spanish whisper anchor if that's all we have.
LATIN = {"en", "es", "pt", "it", "fr", "de", "nl", "ca", "ro", "tr", "vi", "id", "ms"}
if a in LATIN and b in LATIN: return True
return a == b
# Similarity matrix (cached lookups via SequenceMatcher). Cross-script
# pairs are zeroed so the DP can never anchor across languages.
sim = [[0.0] * nW for _ in range(nU)]
for i in range(nU):
if not user_norm[i]:
continue
sm = SequenceMatcher(None, user_norm[i], "")
sm.set_seq1(user_norm[i])
for j in range(nW):
if not whisper_norm[j]:
continue
if not _same_script(user_script[i], whisper_script[j]):
continue # different script → can't be the same line
sm.set_seq2(whisper_norm[j])
sim[i][j] = sm.ratio()
# Higher threshold prevents the DP from anchoring a user line to a weakly-
# similar whisper segment in the wrong region of the song. Weak matches get
# interpolated between confident anchors instead, which spreads lyric lines
# over the right time window.
MATCH_THRESHOLD = 0.35
GAP_USER = -0.10 # cost of leaving a user line unmatched
GAP_WHISPER = -0.04 # cost of skipping a whisper line
SOFT_DIAG = -0.04 # diagonal move with too-low similarity (no match credit)
# DP table: dp[i][j] = best score aligning U[:i] vs W[:j].
dp = [[0.0] * (nW + 1) for _ in range(nU + 1)]
for i in range(1, nU + 1):
dp[i][0] = dp[i - 1][0] + GAP_USER
for j in range(1, nW + 1):
dp[0][j] = dp[0][j - 1] + GAP_WHISPER
for i in range(1, nU + 1):
for j in range(1, nW + 1):
s = sim[i - 1][j - 1]
match_score = dp[i - 1][j - 1] + (s if s >= MATCH_THRESHOLD else SOFT_DIAG)
user_gap = dp[i - 1][j] + GAP_USER
whisper_gap = dp[i][j - 1] + GAP_WHISPER
dp[i][j] = max(match_score, user_gap, whisper_gap)
# Traceback to recover the matched pairs (user_idx → whisper_idx).
matches = {}
i, j = nU, nW
while i > 0 and j > 0:
s = sim[i - 1][j - 1]
eff = (s if s >= MATCH_THRESHOLD else SOFT_DIAG)
if abs(dp[i][j] - (dp[i - 1][j - 1] + eff)) < 1e-9:
if s >= MATCH_THRESHOLD:
matches[i - 1] = j - 1
i -= 1; j -= 1
elif abs(dp[i][j] - (dp[i - 1][j] + GAP_USER)) < 1e-9:
i -= 1
else:
j -= 1
# Build aligned output: matched lines get the whisper timing; unmatched user
# lines get evenly interpolated between their nearest matched neighbours.
out = []
pending = []
last_end = 0.0
def flush(next_start):
if not pending:
return
n = len(pending)
span = max(0.0, next_start - last_end)
slot = (span / (n + 1)) if span > 0 else 0.6
for k, (pt, pl) in enumerate(pending):
s = last_end + (k + 0.5) * slot
e = last_end + (k + 1.5) * slot
out.append({"start": round(s, 3), "end": round(e, 3),
"text": pt, "lang": pl,
"words": _redistribute_words(s, e, pt, pl)})
pending.clear()
for ui, u in enumerate(U):
if ui in matches:
wl = W[matches[ui]]
start = float(wl["start"])
end = float(wl["end"])
lang = wl.get("lang") or _guess_lang_from_script(u)
flush(start)
out.append({"start": round(start, 3), "end": round(end, 3),
"text": u, "lang": lang,
"words": _redistribute_words(start, end, u, lang)})
last_end = end
else:
pending.append((u, _guess_lang_from_script(u)))
if pending:
anchor_end = max(last_end + 1.0, float(W[-1]["end"]))
flush(anchor_end)
return out
def merge_fragments(lines: list) -> list:
"""Stitch tiny leftover fragments (e.g. a lone 'The' or a 1-char Thai token)
into an adjacent same-language line when they're close in time."""
def tiny(ln):
if ln["lang"] in SPACELESS:
return len(ln["text"]) < 4
return len(ln["text"].split()) < 2
out = []
for ln in lines:
if out and out[-1]["lang"] == ln["lang"]:
prev = out[-1]
gap = ln["start"] - prev["end"]
if gap < 1.0 and (tiny(ln) or tiny(prev)):
sep = "" if ln["lang"] in SPACELESS else " "
prev["text"] = (prev["text"] + sep + ln["text"]).strip()
prev["end"] = ln["end"]
prev["words"] = (prev.get("words") or []) + (ln.get("words") or [])
continue
out.append(ln)
return out
def split_into_lines(words: list, lang: str) -> list:
"""Split one (single-language) segment's timed words into short karaoke lines."""
if not words:
return []
spaced = lang not in SPACELESS
lines, cur = [], [words[0]]
for prev, w in zip(words, words[1:]):
brk = (w["start"] - prev["end"]) >= LINE_GAP
if not brk and spaced and len(cur) >= LINE_MAX_WORDS:
brk = True
if not brk and not spaced and sum(len(x["text"]) for x in cur) >= LINE_MAX_CHARS:
brk = True
if not brk and spaced and len(cur) >= LINE_MIN_WORDS:
if prev["text"].endswith(PUNCT_END):
brk = True
else:
head = w["text"][:1]
if (head.isupper() and not head.isdigit()
and w["text"] not in ("I", "I'm", "I'll", "I've", "I'd", "Im", "Ill", "Ive", "Id")):
brk = True
if brk:
line = _emit(cur, lang)
if line:
lines.append(line)
cur = [w]
else:
cur.append(w)
line = _emit(cur, lang)
if line:
lines.append(line)
return lines
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--audio", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--language", default=None)
ap.add_argument("--gpu", type=int, default=None)
ap.add_argument("--model", default="large-v3")
ap.add_argument("--no-demucs", action="store_true")
ap.add_argument("--no-vad", action="store_true",
help="disable Silero VAD filter inside Whisper (transcribe full audio)")
ap.add_argument("--no-vocal-gapfill", action="store_true",
help="distribute gap-filled description lines evenly instead of snapping them "
"to vocal-active regions detected by Silero VAD")
ap.add_argument("--progress", default=None, help="path to write live progress JSON")
ap.add_argument("--user-lyrics", default=None,
help="path to a text file with one lyric line per line; the pipeline will "
"ALIGN these exact lines to the audio instead of producing its own text")
args = ap.parse_args()
global _PROGRESS_PATH
_PROGRESS_PATH = args.progress
if not os.path.isfile(args.audio):
log(f"audio not found: {args.audio}")
sys.exit(2)
write_progress(3, "Starting")
# GPU pinning must happen before torch is imported by whisperx.
if args.gpu is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
vocals_path = args.audio
used_demucs = False
if not args.no_demucs:
write_progress(8, "Separating vocals")
sep = isolate_vocals(args.audio, args.gpu)
if sep:
vocals_path = sep
used_demucs = True
write_progress(40, "Loading model")
from faster_whisper import WhisperModel, decode_audio
from faster_whisper.vad import get_speech_timestamps, VadOptions
from collections import defaultdict
import gc
SR = 16000
audio = decode_audio(vocals_path, sampling_rate=SR)
def is_oom(e):
s = str(e).lower()
return "out of memory" in s or "cuda failed" in s or "cublas" in s
def overlap_ratio(a, b):
o = min(a["end"], b["end"]) - max(a["start"], b["start"])
if o <= 0:
return 0.0
return o / max(1e-6, min(a["end"] - a["start"], b["end"] - b["start"]))
# Full multilingual transcription on a given device/precision. Raises on OOM
# so the caller can retry on a lighter config (cuda/fp16 → cuda/int8 → cpu).
#
# Strategy that handles bilingual duets WITHOUT skipping verses: transcribe the
# WHOLE song once per candidate language (full recall + sentence context), then
# for every time region keep whichever language's transcription is the most
# confident. English regions win in the English pass, Thai regions win in the
# Thai pass — nothing is dropped and each part is in its own script.
def transcribe_all(dev, ct):
log(f"[fw] loading {args.model} on {dev}/{ct}")
model = WhisperModel(args.model, device=dev, compute_type=ct)
try:
# ── Candidate languages: detect across several windows of the song ──
write_progress(46, "Detecting languages")
if args.language:
cands = [args.language]
else:
votes = defaultdict(float)
win = 30 * SR
positions = list(range(0, max(1, len(audio) - win + 1), max(win // 2, 1)))[:12] or [0]
for pos in positions:
sl = audio[pos:pos + win]
if len(sl) < SR:
continue
try:
lang, prob, _ = model.detect_language(sl, language_detection_segments=1)
except Exception as e:
if is_oom(e):
raise
lang, prob = None, 0.0
if lang and prob >= 0.5:
votes[lang] += prob
if not votes:
cands = ["en"]
else:
ranked = sorted(votes, key=votes.get, reverse=True)
top = votes[ranked[0]]
# Keep languages with ≥25% of the top vote mass (drops flukes).
cands = [l for l in ranked if votes[l] >= 0.25 * top][:3]
log(f"[lang] candidates={cands}")
# ── One full-song pass per candidate language ──────────────────────
# Loose VAD pass: drops obvious instrumental stretches but keeps soft
# sung vocals (threshold 0.20 vs default 0.5). Without it, Whisper
# invents lyrics over the intro/outro music. With it tuned too high
# it drops legitimate quiet singing — we erred on the loose side after
# users reported missing verses in the middle of long songs.
VAD_PARAMS = {
"threshold": 0.20,
"min_speech_duration_ms": 200,
"min_silence_duration_ms": 350,
"speech_pad_ms": 250,
}
# Common Whisper hallucinations on silence / music. If a segment IS
# one of these phrases (no extra content), it's a hallucination
# regardless of how confident the model was.
HALLUCINATIONS = {
"thank you", "thanks for watching", "thank you for watching",
"subscribe", "please subscribe", "like and subscribe",
"music", "[music]", "(music)", "", "",
"you", ".", "..", "...", "thank you.",
}
segs_all = []
for ci, L in enumerate(cands):
write_progress(50 + int(40 * ci / max(1, len(cands))), "Transcribing")
seg_iter, _ = model.transcribe(
audio, language=L, word_timestamps=True, beam_size=5,
vad_filter=(not args.no_vad), vad_parameters=VAD_PARAMS,
condition_on_previous_text=False,
no_speech_threshold=0.70,
log_prob_threshold=-1.4,
)
for s in seg_iter:
# Drop clear non-speech and low-confidence hallucinations on
# instrumental sections, but keep genuinely-sung (lower-conf) lines.
if getattr(s, "no_speech_prob", 0.0) > 0.70:
continue
if getattr(s, "avg_logprob", 0.0) < -1.4:
continue
text = (s.text or "").strip()
if not text:
continue
# Drop the well-known Whisper boilerplate hallucinations.
if text.lower().strip(".,!? ") in HALLUCINATIONS:
continue
# Drop "compression ratio" gibberish — pathological repeats.
if getattr(s, "compression_ratio", 1.0) > 2.4:
continue
segs_all.append({
"start": float(s.start), "end": float(s.end), "lang": L,
"score": float(getattr(s, "avg_logprob", -5.0)),
"text": text, "words": list(s.words or []),
})
# ── Resolve overlaps using OUTPUT SCRIPT as the language signal ─────
# avg_logprob alone is unreliable (the Thai pass can "win" English
# regions yet output Latin). The script actually produced is the
# truth: a non-Latin-language pass that emitted Latin text is a
# mis-forced English region — drop it. Native non-Latin script wins
# overlaps so Thai regions never get the romanised English version.
def nonlatin_frac(t):
letters = [c for c in t if c.isalpha()]
if not letters:
return 0.0
return sum(1 for c in letters if not ("a" <= c.lower() <= "z")) / len(letters)
kept = []
for s in segs_all:
nl = nonlatin_frac(s["text"])
s["native"] = 1 if nl >= 0.5 else 0
if s["lang"] in NONLATIN_LANGS and nl < 0.3:
continue # Thai (etc.) pass that produced Latin = mis-forced English
kept.append(s)
kept.sort(key=lambda x: (x["native"], x["score"]), reverse=True)
accepted = []
for s in kept:
if any(overlap_ratio(s, a) > 0.4 for a in accepted):
continue
accepted.append(s)
accepted.sort(key=lambda x: x["start"])
dur = defaultdict(float)
for s in accepted:
dur[s["lang"]] += s["end"] - s["start"]
dominant = max(dur, key=dur.get) if dur else (cands[0] if cands else "en")
trusted = set(dur.keys()) or set(cands)
# ── Build karaoke lines ────────────────────────────────────────────
lines = []
for s in accepted:
compact = s["text"].replace(" ", "")
if len(compact) >= 8 and len(set(compact)) <= 1: # degenerate "ㄷㄷㄷ"
continue
words = []
for w in s["words"]:
if w.start is None or w.end is None:
continue
tok = (w.word or "").strip()
if not tok:
continue
words.append({"start": round(float(w.start), 3),
"end": round(float(w.end), 3), "text": tok})
if words:
lines += split_into_lines(words, s["lang"])
else:
lines.append({"start": round(s["start"], 3), "end": round(s["end"], 3),
"text": s["text"], "lang": s["lang"], "words": []})
return lines, dominant, trusted
finally:
del model
gc.collect()
all_lines, dominant, trusted, last_err = [], "en", set(), None
for dev, ct in [("cuda", "float16"), ("cuda", "int8"), ("cpu", "int8")]:
try:
all_lines, dominant, trusted = transcribe_all(dev, ct)
break
except Exception as e:
last_err = e
if is_oom(e):
log(f"[fw] {dev}/{ct} ran out of memory; retrying lighter")
continue
raise
else:
raise last_err if last_err else RuntimeError("transcription failed")
all_lines.sort(key=lambda ln: ln["start"])
all_lines = merge_fragments(all_lines)
# If the uploader provided lyrics in the song description, ALIGN those exact
# lines to the audio (using the whisper timing) instead of using the noisier
# whisper text. The transcription pass still ran — it's what provides the
# anchoring timestamps the user lines snap to.
source = "faster-whisper"
if args.user_lyrics and os.path.isfile(args.user_lyrics):
write_progress(92, "Syncing description lyrics")
try:
user_lines = [l.strip() for l in open(args.user_lyrics, encoding="utf-8")
.read().splitlines() if l.strip()]
except Exception as e:
log(f"[user-lyrics] read failed ({e})")
user_lines = []
if user_lines:
# Hybrid alignment: whisper-anchored where whisper heard the song,
# description-filled where whisper missed. Gap-filled lines snap
# to vocal-active moments detected by Silero VAD so they sit on
# actual singing instead of drifting across instrumental beats.
audio_duration = len(audio) / SR
vocal_regions = []
if not args.no_vocal_gapfill:
try:
vad_opts = VadOptions(threshold=0.20,
min_speech_duration_ms=400,
min_silence_duration_ms=500,
speech_pad_ms=120)
raw = get_speech_timestamps(audio, vad_opts)
vocal_regions = [(r["start"] / SR, r["end"] / SR) for r in raw]
log(f"[vad] {len(vocal_regions)} vocal regions detected")
except Exception as e:
log(f"[vad] failed ({e}); falling back to even spread in gaps")
else:
log("[vad] vocal-region gap-fill disabled by admin toggle")
corrected = correct_whisper_with_description(
all_lines, user_lines, audio_duration, vocal_regions
)
if corrected:
all_lines = corrected
source = "description-aligned"
log(f"[user-lyrics] aligned: description={len(user_lines)} "
f"output={len(all_lines)} duration={audio_duration:.1f}s")
write_progress(95, "Finishing")
payload = {
"version": 1,
"language": dominant,
"source": source,
"model": args.model,
"demucs": used_demucs,
"multilingual": True,
"lines": all_lines,
}
out_dir = os.path.dirname(args.out)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
log(f"[done] wrote {len(payload['lines'])} lines ({sorted(trusted)}) -> {args.out}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,69 @@
@extends('admin.layout')
@section('title', 'Backup & Restore')
@section('page_title', 'Backup & Restore')
@section('extra_styles')
@include('admin.partials.settings-styles')
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-archive"></i> Backup & Restore</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-archive"></i>
Users &amp; Settings Backup
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Export users &amp; settings</strong>
<small>Downloads a JSON file containing all user accounts and system settings. Does not include media files.</small>
</div>
<div class="setting-control">
<a href="{{ route('admin.backup.users-settings') }}" class="adm-btn adm-btn-primary">
<i class="bi bi-download"></i> Download Backup
</a>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Restore users &amp; settings</strong>
<small>Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.</small>
</div>
<div class="setting-control">
<form method="POST" action="{{ route('admin.backup.restore') }}" enctype="multipart/form-data" id="restoreForm">
@csrf
<input type="file" name="backup" id="restoreFile" accept=".json" style="display:none"
onchange="document.getElementById('restoreForm').submit()">
<button type="button" class="adm-btn" onclick="document.getElementById('restoreFile').click()">
<i class="bi bi-upload"></i> Upload &amp; Restore
</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,283 @@
@extends('admin.layout')
@section('title', 'GPU Accelerator')
@section('page_title', 'GPU Accelerator')
@section('extra_styles')
@include('admin.partials.settings-styles')
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-gpu-card"></i> GPU Accelerator</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="gpuForm">
@csrf
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-gpu-card"></i>
GPU Accelerated Processing
<span id="gpuStatusChip" style="margin-left:6px;">
@if(count($gpus))
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
@endif
</span>
<span id="nvencStatusChip" style="margin-left:6px;">
@if($nvencWorks)
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding </span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding </span>
@endif
</span>
</div>
@if(!$nvencWorks && count($gpus))
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
<strong> NVENC is not working with the current FFmpeg binary.</strong><br>
The GPU is detected but FFmpeg cannot initialise CUDA on this driver.<br>
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
Until then, video encoding will automatically fall back to CPU (libx264).
</div>
@endif
<div class="settings-section-body">
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
</button>
</div>
<div id="gpuCardsWrap">
@if(count($gpus))
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
@else
<div class="no-gpu-state">
<i class="bi bi-gpu-card"></i>
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
</div>
@endif
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Enable GPU acceleration</strong>
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label" id="gpuEnabledLabel">
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
value="{{ $settings['gpu_enabled'] }}">
</div>
</div>
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
<div class="setting-row" id="gpuEncoderRow">
<div class="setting-label">
<strong>Video encoder</strong>
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
</div>
<div class="setting-control">
<div class="enc-grid">
@foreach([
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
['libx264', 'libx264', 'CPU · software fallback'],
] as [$val, $label, $desc])
<button type="button"
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
data-encoder="{{ $val }}"
onclick="selectEncoder(this)">
<span class="enc-card-name">{{ $label }}</span>
<span class="enc-card-desc">{{ $desc }}</span>
</button>
@endforeach
</div>
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
</div>
</div>
<div class="setting-row" id="gpuPresetRow">
<div class="setting-label">
<strong>Encoding preset</strong>
<small>NVENC presets: p1 (fastest) p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
</div>
<div class="setting-control">
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
<optgroup label="NVENC (GPU)">
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
</option>
@endforeach
</optgroup>
<optgroup label="libx264 (CPU)">
@foreach(['fast','medium','slow'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}
</option>
@endforeach
</optgroup>
</select>
</div>
</div>
<div class="setting-row" id="gpuHwaccelRow">
<div class="setting-label">
<strong>Hardware decode acceleration</strong>
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
</div>
<div class="setting-control">
<select name="gpu_hwaccel" class="adm-select-full">
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda GPU decode</option>
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none CPU decode</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>FFmpeg binary path</strong>
<small>
Absolute path to the <code>ffmpeg</code> executable.
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
</small>
</div>
<div class="setting-control">
<input type="text" name="ffmpeg_binary" class="adm-input-full"
value="{{ $settings['ffmpeg_binary'] }}"
placeholder="/usr/bin/ffmpeg">
</div>
</div>
</div>
</div>
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save GPU Settings
</button>
</div>
</form>
@endsection
@section('scripts')
<script>
function selectGpuCard(el) {
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuDeviceInput').value = el.dataset.index;
}
function selectEncoder(el) {
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
const isCpu = el.dataset.encoder === 'libx264';
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
const gpuLabel = document.getElementById('gpuEnabledLabel');
function applyGpuToggle() {
const on = gpuToggle.checked;
gpuHidden.value = on ? 'true' : 'false';
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
}
gpuToggle.addEventListener('change', applyGpuToggle);
async function detectGpus() {
const btn = document.getElementById('detectBtn');
const icon = document.getElementById('detectIcon');
btn.disabled = true;
icon.className = 'bi bi-arrow-repeat spin';
try {
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
const data = await res.json();
const gpus = data.gpus || [];
const wrap = document.getElementById('gpuCardsWrap');
const chip = document.getElementById('gpuStatusChip');
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
if (gpus.length === 0) {
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
} else {
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
}
} catch (e) { console.error(e); }
btn.disabled = false;
icon.className = 'bi bi-arrow-repeat';
}
function buildGpuCards(gpus, selectedDevice) {
if (!gpus.length) return '';
const grid = document.createElement('div');
grid.className = 'gpu-grid';
gpus.forEach(gpu => {
const used = gpu.mem_total - gpu.mem_free;
const usedPct = Math.round((used / gpu.mem_total) * 100);
const sel = gpu.index === selectedDevice;
const card = document.createElement('div');
card.className = 'gpu-card' + (sel ? ' selected' : '');
card.dataset.index = gpu.index;
card.onclick = function() { selectGpuCard(this); };
card.innerHTML = `
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
<div class="mem-bar-wrap"><div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div></div>`;
grid.appendChild(card);
});
return grid.outerHTML;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
@endsection

View File

@ -597,7 +597,23 @@
<div class="adm-nav-section" style="margin-top:8px;">System</div> <div class="adm-nav-section" style="margin-top:8px;">System</div>
<a href="{{ route('admin.settings') }}" <a href="{{ route('admin.settings') }}"
class="adm-nav-link {{ request()->routeIs('admin.settings*') ? 'active' : '' }}"> class="adm-nav-link {{ request()->routeIs('admin.settings*') ? 'active' : '' }}">
<i class="bi bi-gpu-card"></i> Settings <i class="bi bi-stars"></i> AI / LLM
</a>
<a href="{{ route('admin.lyrics') }}"
class="adm-nav-link {{ request()->routeIs('admin.lyrics') ? 'active' : '' }}">
<i class="bi bi-music-note-list"></i> Lyrics Pipeline
</a>
<a href="{{ route('admin.gpu') }}"
class="adm-nav-link {{ request()->routeIs('admin.gpu') ? 'active' : '' }}">
<i class="bi bi-gpu-card"></i> GPU Accelerator
</a>
<a href="{{ route('admin.nas-storage') }}"
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
<i class="bi bi-hdd-network"></i> NAS Storage
</a>
<a href="{{ route('admin.backup') }}"
class="adm-nav-link {{ request()->routeIs('admin.backup') ? 'active' : '' }}">
<i class="bi bi-archive"></i> Backup &amp; Restore
</a> </a>
<a href="{{ route('admin.audit') }}" <a href="{{ route('admin.audit') }}"
class="adm-nav-link {{ request()->routeIs('admin.audit') ? 'active' : '' }}"> class="adm-nav-link {{ request()->routeIs('admin.audit') ? 'active' : '' }}">
@ -607,10 +623,6 @@
class="adm-nav-link {{ request()->routeIs('admin.logs') ? 'active' : '' }}"> class="adm-nav-link {{ request()->routeIs('admin.logs') ? 'active' : '' }}">
<i class="bi bi-bug"></i> Error Logs <i class="bi bi-bug"></i> Error Logs
</a> </a>
<a href="{{ route('admin.nas-storage') }}"
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
<i class="bi bi-hdd-network"></i> NAS Storage
</a>
@endif @endif
<div class="adm-nav-divider"></div> <div class="adm-nav-divider"></div>

View File

@ -0,0 +1,191 @@
@extends('admin.layout')
@section('title', 'Lyrics Pipeline')
@section('page_title', 'Lyrics Pipeline')
@section('extra_styles')
@include('admin.partials.settings-styles')
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-music-note-list"></i> Lyrics Pipeline</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="lyricsForm">
@csrf
<input type="hidden" name="lyrics_section" value="1">
{{-- ── Master switch ─────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-toggle-on"></i>
Master
@if($settings['lyrics_enabled'] === 'true')
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
@else
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
@endif
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Lyrics generation</strong>
<small>
Master switch for the whole feature. When OFF, the Generate / Regenerate
button in the player gear is hidden and the API endpoint refuses new jobs.
Existing lyrics keep displaying only NEW generation is blocked.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_enabled" value="true" {{ $settings['lyrics_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Allow new lyrics jobs</span>
</label>
</div>
</div>
</div>
</div>
{{-- ── Pipeline steps ────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-diagram-3"></i>
Pipeline steps
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Description-based correction</strong>
<small>
Use the song description as the source of truth for lyric text. Whisper provides
timing anchors; the description provides the exact words. When OFF, the player
shows Whisper's raw transcription (may contain misheard words and miss verses).
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_use_description" value="true" {{ $settings['lyrics_use_description'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Use description as ground truth</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Voice activity filter (VAD)</strong>
<small>
Runs Silero VAD before Whisper to skip pure-instrumental sections. Without this,
Whisper invents lyrics over intro / outro / breakdown music. When OFF, the entire
audio is transcribed useful as a fallback if VAD is dropping legitimate quiet vocals.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_vad_enabled" value="true" {{ $settings['lyrics_vad_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Drop instrumentals via VAD</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Vocal-region gap-filling</strong>
<small>
For description lines Whisper missed, snap them to moments where vocals are
active (detected by VAD) instead of distributing evenly across time. Keeps
gap-filled lyrics on actual singing. When OFF, missing lines distribute uniformly
between Whisper anchors (faster but can drift over instrumental beats).
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_vocal_region_gapfill" value="true" {{ $settings['lyrics_vocal_region_gapfill'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Snap missing lines to vocal regions</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Vocal isolation (Demucs)</strong>
<small>
Two-stem source separation (vocals vs. instruments) before Whisper. Improves
transcription on noisy mixes but adds ~30s GPU time and has caused a CUDA-handoff
deadlock that hangs the pipeline at 50%. Currently auto-skipped when a description
is present (timing-only mode). Enable to also use Demucs when no description is given.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_demucs_enabled" value="true" {{ $settings['lyrics_demucs_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Use Demucs when no description</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>LLM emoji decoration</strong>
<small>
After lyrics are saved, run the active LLM provider to bake one or more emojis
into each line. Without this, the player still decorates lines using a built-in
keyword-to-emoji map (less varied). Provider is configured under
<a href="{{ route('admin.settings') }}" style="color:var(--brand)">AI / LLM Settings</a>.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_llm_decorate" value="true" {{ $settings['lyrics_llm_decorate'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Bake emojis via LLM</span>
</label>
</div>
</div>
</div>
</div>
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save Lyrics Settings
</button>
</div>
</form>
@endsection

View File

@ -1,23 +1,356 @@
@extends('admin.layout') @extends('admin.layout')
@section('title', 'NAS Storage') @section('title', 'NAS Storage')
@section('page_title', 'NAS Storage')
@section('extra_styles')
@include('admin.partials.settings-styles')
<style>
.nas-repair-result { padding: 14px 0 0; font-size: 13px; }
</style>
@endsection
@section('content') @section('content')
<div class="adm-page-header"> <div class="adm-page-header">
<h1 class="adm-page-title"> <h1 class="adm-page-title">
<i class="bi bi-hdd-network"></i> NAS Storage <i class="bi bi-hdd-network"></i> NAS Storage
@if($settings['nas_sync_enabled'] === 'true')
<span class="chip chip-green" style="margin-left:10px;vertical-align:middle;"><span class="chip-dot"></span> Enabled</span>
@else
<span class="chip chip-red" style="margin-left:10px;vertical-align:middle;"><span class="chip-dot"></span> Disabled</span>
@endif
</h1> </h1>
</div> </div>
<div class="adm-card"> @if(session('success'))
@include('nas-file-manager::file-manager', [ <div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
'nodes' => $nodes, <i class="bi bi-check-circle-fill"></i>
'canEdit' => true, <span>{{ session('success') }}</span>
'title' => 'NAS Storage Browser', <button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
]) </div>
@endif
{{-- ── NAS Settings ─────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-sliders"></i>
NAS Settings
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Use NAS as primary storage</strong>
<small>
When enabled, uploads go <strong>directly to the NAS</strong> no permanent local copy is kept.
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
When disabled, all files are served from local disk using the same directory schema.
<strong>Disabling NAS will prompt you to migrate files or start fresh.</strong>
</small>
</div>
<div class="setting-control">
@if($settings['nas_sync_enabled'] === 'true')
<button type="button" class="adm-btn adm-btn-danger" onclick="openNasDisableModal()">
<i class="bi bi-hdd-network"></i> Disable NAS
</button>
@else
<span style="font-size:13px;color:var(--text-2);">NAS is disabled. Re-enabling is handled by the system once a NAS endpoint is reachable.</span>
@endif
</div>
</div>
@if($settings['nas_sync_enabled'] === 'true')
<div class="setting-row">
<div class="setting-label">
<strong>Repair stuck files</strong>
<small>
Scans for files that were saved locally but never reached the NAS (e.g. due to a
connection error during upload or edit). Uploads them to the NAS, then removes the
local copies. Safe to run at any time nothing is deleted until the NAS confirms receipt.
</small>
</div>
<div class="setting-control">
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button type="button" id="nasRepairScanBtn" class="adm-btn" style="white-space:nowrap;">
<i class="bi bi-search" id="nasRepairScanIcon"></i> Scan
</button>
<button type="button" id="nasRepairFixBtn" class="adm-btn adm-btn-primary" style="white-space:nowrap;display:none;">
<i class="bi bi-arrow-repeat" id="nasRepairFixIcon"></i> Fix All
</button>
</div>
<div id="nasRepairResult" class="nas-repair-result" style="display:none;">
<div id="nasRepairResultInner"></div>
</div>
</div>
</div>
@endif
</div>
</div>
{{-- ── NAS File Browser ──────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-folder2-open"></i>
NAS File Browser
</div>
<div class="settings-section-body" style="padding: 0;">
@include('nas-file-manager::file-manager', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
</div>
</div>
{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
<div id="nasDisableModal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);width:min(520px,94vw);padding:28px;max-height:90vh;overflow-y:auto;">
<div id="nasDisableStep1">
<h3 style="margin:0 0 8px;font-size:17px;">Disable NAS Storage</h3>
<p style="margin:0 0 20px;font-size:13px;color:var(--text-2);">All your files currently live on the NAS. Choose what to do before disabling:</p>
<div style="display:grid;gap:12px;margin-bottom:24px;">
<div id="optMigrate" onclick="selectNasOpt('migrate')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<i class="bi bi-arrow-down-circle" style="color:var(--brand);font-size:18px;"></i>
<strong style="font-size:14px;">Copy all files to local disk</strong>
</div>
<p style="margin:0;font-size:12px;color:var(--text-2);">Downloads every video, thumbnail, avatar, and banner from the NAS to <code>storage/app/users/</code>. Same directory structure everything keeps working. May take a while.</p>
</div>
<div id="optFresh" onclick="selectNasOpt('fresh')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<i class="bi bi-trash3" style="color:#e74c3c;font-size:18px;"></i>
<strong style="font-size:14px;color:#e74c3c;">Delete all media, start fresh</strong>
</div>
<p style="margin:0;font-size:12px;color:var(--text-2);">Removes all videos, thumbnails, playlists, comments, and posts. <strong>User accounts are kept.</strong> Nothing is downloaded.</p>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
<button type="button" id="nasDisableNextBtn" class="adm-btn adm-btn-danger" disabled onclick="nasDisableNext()">Continue &rarr;</button>
</div>
</div>
<div id="nasDisableStep2Migrate" style="display:none;">
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk&hellip;</h3>
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting&hellip;</p>
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:8px;overflow:hidden;">
<div id="nasDisableBar" style="height:100%;background:var(--brand);border-radius:4px;width:0%;transition:width .3s;"></div>
</div>
<div id="nasDisableCount" style="font-size:12px;color:var(--text-2);margin-bottom:20px;">0 / 0</div>
<div id="nasDisableDone" style="display:none;">
<div style="color:#27ae60;font-size:13px;margin-bottom:16px;"><i class="bi bi-check-circle-fill"></i> Migration complete! NAS has been disabled. Reload the page to continue.</div>
<button type="button" class="adm-btn adm-btn-primary" onclick="location.reload()">Reload Page</button>
</div>
<div id="nasDisableError" style="display:none;color:#e74c3c;font-size:13px;margin-bottom:16px;"></div>
</div>
<div id="nasDisableStep2Fresh" style="display:none;">
<h3 style="margin:0 0 8px;font-size:17px;color:#e74c3c;">Delete all media?</h3>
<p style="margin:0 0 16px;font-size:13px;color:var(--text-2);">This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. <strong>This cannot be undone.</strong></p>
<p style="margin:0 0 8px;font-size:13px;">Type <strong>DELETE</strong> to confirm:</p>
<input type="text" id="nasDeleteConfirmInput" placeholder="DELETE" class="adm-input-full" style="margin-bottom:8px;">
<div id="nasDeleteConfirmError" style="display:none;color:#e74c3c;font-size:12px;margin-bottom:12px;"></div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
<button type="button" class="adm-btn adm-btn-danger" onclick="nasDisableFreshConfirm()">Delete &amp; Disable NAS</button>
</div>
</div>
</div>
</div> </div>
@endsection @endsection
@section('scripts') @section('scripts')
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── NAS Repair ────────────────────────────────────────────────
(function () {
const scanBtn = document.getElementById('nasRepairScanBtn');
const fixBtn = document.getElementById('nasRepairFixBtn');
const resultEl = document.getElementById('nasRepairResult');
const inner = document.getElementById('nasRepairResultInner');
const scanIcon = document.getElementById('nasRepairScanIcon');
const fixIcon = document.getElementById('nasRepairFixIcon');
if (! scanBtn) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
let stuckCount = 0;
function showResult(html, type = 'info') {
const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-2)' };
inner.innerHTML = `<div style="font-size:13px;color:${colours[type] ?? colours.info};padding:6px 0;">${html}</div>`;
resultEl.style.display = 'block';
}
scanBtn.addEventListener('click', async function () {
scanBtn.disabled = true;
fixBtn.style.display = 'none';
scanIcon.className = 'bi bi-arrow-repeat spin';
showResult('Scanning…', 'info');
try {
const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ scan_only: true }),
});
const data = await res.json();
stuckCount = data.stuck ?? 0;
if (stuckCount === 0) {
showResult('✅ All clear — no stuck local files found.', 'success');
} else {
let html = `<strong style="color:var(--brand);">⚠ ${stuckCount} video(s) have files stuck locally.</strong>`;
if (data.details && data.details.length) {
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
}
html += `<p style="margin-top:10px;color:var(--text-2);font-size:12px;">Click <strong>Fix All</strong> to upload them to the NAS and remove local copies.</p>`;
showResult(html, 'warning');
fixBtn.style.display = '';
}
} catch (e) {
showResult('❌ Scan failed: ' + escHtml(e.message), 'danger');
}
scanIcon.className = 'bi bi-search';
scanBtn.disabled = false;
});
fixBtn.addEventListener('click', async function () {
fixBtn.disabled = true;
scanBtn.disabled = true;
fixIcon.className = 'bi bi-arrow-repeat spin';
showResult('Uploading to NAS…', 'info');
try {
const res = await fetch('{{ url("/admin/nas-repair") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
const type = data.failed > 0 ? 'warning' : 'success';
let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`;
if (data.details && data.details.length) {
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
}
showResult(html, type);
fixBtn.style.display = 'none';
} catch (e) {
showResult('❌ Repair failed: ' + escHtml(e.message), 'danger');
}
fixIcon.className = 'bi bi-arrow-repeat';
fixBtn.disabled = false;
scanBtn.disabled = false;
});
})();
// ── NAS Disable Modal ────────────────────────────────────────
let _nasOpt = null;
let _nasPollTimer = null;
function openNasDisableModal() {
_nasOpt = null;
document.getElementById('nasDisableModal').style.display = 'flex';
document.getElementById('nasDisableStep1').style.display = 'block';
document.getElementById('nasDisableStep2Migrate').style.display = 'none';
document.getElementById('nasDisableStep2Fresh').style.display = 'none';
document.getElementById('nasDisableNextBtn').disabled = true;
['optMigrate','optFresh'].forEach(id => {
document.getElementById(id).style.borderColor = 'var(--border)';
});
}
function closeNasDisableModal() {
if (_nasPollTimer) clearInterval(_nasPollTimer);
document.getElementById('nasDisableModal').style.display = 'none';
}
function selectNasOpt(opt) {
_nasOpt = opt;
document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)';
document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)';
document.getElementById('nasDisableNextBtn').disabled = false;
}
function nasDisableNext() {
document.getElementById('nasDisableStep1').style.display = 'none';
if (_nasOpt === 'migrate') {
document.getElementById('nasDisableStep2Migrate').style.display = 'block';
nasStartMigration();
} else {
document.getElementById('nasDisableStep2Fresh').style.display = 'block';
}
}
async function nasStartMigration() {
try {
await fetch('{{ route("admin.nas.disable") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ mode: 'migrate' }),
});
} catch(e) {
document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message;
document.getElementById('nasDisableError').style.display = 'block';
return;
}
_nasPollTimer = setInterval(nasPollProgress, 2000);
}
async function nasPollProgress() {
try {
const r = await fetch('{{ route("admin.nas.migrate-progress") }}');
const d = await r.json();
const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0;
document.getElementById('nasDisableBar').style.width = pct + '%';
document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total;
document.getElementById('nasDisablePhase').textContent = d.phase || '';
if (d.error) {
clearInterval(_nasPollTimer);
document.getElementById('nasDisableError').textContent = 'Error: ' + d.error;
document.getElementById('nasDisableError').style.display = 'block';
}
if (d.done) {
clearInterval(_nasPollTimer);
document.getElementById('nasDisableBar').style.width = '100%';
document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total;
document.getElementById('nasDisableDone').style.display = 'block';
}
} catch(e) { /* network blip, keep polling */ }
}
async function nasDisableFreshConfirm() {
const val = document.getElementById('nasDeleteConfirmInput').value.trim();
const errEl = document.getElementById('nasDeleteConfirmError');
if (val !== 'DELETE') {
errEl.textContent = 'Type DELETE (all caps) to confirm.';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
try {
const r = await fetch('{{ route("admin.nas.disable") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ mode: 'fresh' }),
});
const d = await r.json();
if (d.ok) {
closeNasDisableModal();
location.reload();
} else {
errEl.textContent = d.message || 'An error occurred.';
errEl.style.display = 'block';
}
} catch(e) {
errEl.textContent = 'Failed: ' + e.message;
errEl.style.display = 'block';
}
}
</script>
@endsection @endsection

View File

@ -0,0 +1,124 @@
{{-- Shared styles for all admin settings-style pages: GPU, NAS, Backup, Settings. --}}
<style>
.settings-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 24px;
overflow: hidden;
}
.settings-section-header {
display: flex; align-items: center; gap: 10px;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
font-size: 14px; font-weight: 600;
}
.settings-section-header i { color: var(--brand); font-size: 16px; }
.settings-section-body { padding: 22px; }
.setting-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px; padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
.setting-row:first-child { padding-top: 0; }
.setting-label { flex: 1; min-width: 0; }
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
.setting-control { flex-shrink: 0; min-width: 220px; display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
.toggle-switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; display: inline-block; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border-light); border-radius: 12px;
transition: background .2s;
}
.toggle-thumb {
position: absolute; top: 3px; left: 3px;
width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
.toggle-label { font-size: 13px; color: var(--text-2); }
/* Select / text inputs in setting rows */
.adm-select-full,
.adm-input-full {
width: 100%; height: 38px; box-sizing: border-box;
background: var(--bg); border: 1px solid var(--border-light);
border-radius: 8px; color: var(--text); font-size: 13px;
padding: 0 12px; outline: none; font-family: inherit;
transition: border-color .15s;
}
.adm-select-full { cursor: pointer; }
.adm-select-full:focus,
.adm-input-full:focus { border-color: var(--brand); }
.adm-select-full option { background: #1e1e1e; }
/* GPU cards */
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
.gpu-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; padding: 16px;
cursor: pointer; transition: border-color .15s, background .15s;
position: relative;
}
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.gpu-card-check {
position: absolute; top: 10px; right: 10px;
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid var(--border); background: transparent;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #fff;
transition: background .15s, border-color .15s;
}
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.gpu-stat:last-child { margin-bottom: 0; }
.gpu-stat-val { color: var(--text); font-weight: 500; }
.mem-bar-wrap { margin-top: 8px; }
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
/* No GPU state */
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
/* Status chip */
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
}
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* Encoder option cards */
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.enc-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 14px;
cursor: pointer; transition: border-color .15s;
text-align: left;
}
.enc-card:hover { border-color: #444; }
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
.save-bar {
display: flex; align-items: center; justify-content: flex-end;
gap: 12px; padding: 4px 0 24px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin .6s linear infinite; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -14,113 +14,8 @@
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File $resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
@endphp @endphp
@once {{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}"> so they survive SPA-nav innerHTML swaps on #main. --}}
<script src="{{ asset('js/cropme.min.js') }}"></script>
<style>
/* ── TakeOne Cropper Modal ─────────────────────────── */
.tc-overlay {
display: none; position: fixed; inset: 0; z-index: 10100;
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
.tc-modal {
background: #141414; border: 1px solid rgba(255,255,255,.12);
border-radius: 18px; width: min(540px, 95vw);
box-shadow: 0 24px 80px rgba(0,0,0,.75);
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
}
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.tc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.tc-modal-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 8px;
}
.tc-modal-title i { color: #ef4444; }
.tc-modal-close {
background: none; border: none; color: rgba(255,255,255,.45);
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
border-radius: 6px; transition: color .15s, background .15s;
}
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.tc-modal-body { padding: 16px 20px; }
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tc-file-label {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
height: 36px; padding: 0 14px; border-radius: 8px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.tc-file-label:hover { background: rgba(255,255,255,.13); }
.tc-file-name {
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tc-canvas {
width: 100%; height: 320px; background: #0d0d0d;
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
overflow: hidden; position: relative;
}
.tc-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
}
.tc-placeholder i { font-size: 42px; }
.tc-placeholder span { font-size: 13px; }
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
.tc-control { flex: 1; }
.tc-control-label {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
}
.tc-range {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
}
.tc-range::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #ef4444; cursor: pointer;
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
}
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
.tc-range::-moz-range-thumb {
width: 15px; height: 15px; border: none;
border-radius: 50%; background: #ef4444; cursor: pointer;
}
.tc-modal-footer {
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
}
.tc-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 38px; padding: 0 18px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
transition: background .15s, transform .1s, opacity .15s;
}
.tc-btn-ghost {
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
border: 1px solid rgba(255,255,255,.12);
}
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
.tc-btn-as-is {
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
}
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
.tc-btn-primary { background: #ef4444; color: #fff; }
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
</style>
@endonce
{{-- Modal --}} {{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true"> <div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">

View File

@ -6,6 +6,8 @@
$plUrl = $firstVid $plUrl = $firstVid
? route('videos.show', $firstVid) . '?playlist=' . $pl->share_token ? route('videos.show', $firstVid) . '?playlist=' . $pl->share_token
: route('playlists.show', $pl->id); : route('playlists.show', $pl->id);
$plIsOwner = auth()->check() && auth()->id() === $pl->user_id;
$plShuffleUrl = $firstVid ? route('playlists.shuffle', $pl->id) : null;
@endphp @endphp
@once @once
@ -115,5 +117,84 @@
{{ number_format($pl->view_count) }} {{ Str::plural('view', $pl->view_count) }} · {{ $pl->created_at->diffForHumans() }} {{ number_format($pl->view_count) }} {{ Str::plural('view', $pl->view_count) }} · {{ $pl->created_at->diffForHumans() }}
</div> </div>
</div> </div>
<div class="position-relative">
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More options">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($firstVid)
<li>
<a class="dropdown-item" href="{{ $plUrl }}">
<i class="bi bi-play-fill"></i> Play all
</a>
</li>
<li>
<a class="dropdown-item" href="{{ $plShuffleUrl }}">
<i class="bi bi-shuffle"></i> Shuffle
</a>
</li>
@endif
<li>
<a class="dropdown-item" href="{{ route('playlists.show', $pl->id) }}">
<i class="bi bi-collection-play"></i> View playlist
</a>
</li>
@if($pl->visibility !== 'private' || $plIsOwner)
<li>
<button type="button" class="dropdown-item"
onclick="openShareModal({{ json_encode($pl->share_url) }}, {{ json_encode($pl->name) }}, {{ json_encode(route('playlists.recordShare', $pl->id)) }})">
<i class="bi bi-share"></i> Share
</button>
</li>
@endif
@if($plIsOwner)
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ route('playlists.show', $pl->id) }}#edit">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li>
<button type="button" class="dropdown-item text-danger"
onclick="plCardDelete({{ $pl->id }}, {{ json_encode($pl->name) }}, this)">
<i class="bi bi-trash"></i> Delete
</button>
</li>
@endif
</ul>
</div>
</div> </div>
</div> </div>
@once
<script>
function plCardDelete(plId, plName, btnEl) {
var done = function () {
fetch('/playlists/' + plId, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (r) { return r.json(); }).then(function (d) {
if (d && d.success) {
if (typeof showToast === 'function') showToast('Playlist deleted', 'success');
var card = btnEl && btnEl.closest('.yt-video-card');
if (card) card.remove();
else window.location.reload();
} else {
if (typeof showToast === 'function') showToast((d && d.message) || 'Failed to delete', 'error');
}
}).catch(function () {
if (typeof showToast === 'function') showToast('Failed to delete playlist', 'error');
});
};
if (typeof showConfirm === 'function') {
showConfirm('Delete "' + plName + '"?', done, 'Delete');
} else {
done();
}
}
</script>
@endonce

View File

@ -128,6 +128,8 @@
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
<span>Edit</span> <span>Edit</span>
</button> </button>
{{-- Lyrics generate/regenerate + edit now live inside the player's gear menu
so they're always reachable on both mobile and desktop. --}}
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})" <button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
style="color:#ef4444;border-color:rgba(239,68,68,.35);"> style="color:#ef4444;border-color:rgba(239,68,68,.35);">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
@ -542,9 +544,14 @@ if (!window._slideshowDlInit) {
// the selected language track (window._ytpTrackId; 0 = primary). // the selected language track (window._ytpTrackId; 0 = primary).
var vizOn = localStorage.getItem('audioBarsOn') === '1'; var vizOn = localStorage.getItem('audioBarsOn') === '1';
var trackId = window._ytpTrackId || 0; var trackId = window._ytpTrackId || 0;
// Burn lyrics into the download only when the viewer has them enabled AND
// they actually exist for the current track (the toggle button is visible).
var lyrBtn = document.getElementById('ytpLyricsBtn');
var lyrOn = localStorage.getItem('ytpLyricsOn') === '1' && lyrBtn && lyrBtn.style.display !== 'none';
var _p = []; var _p = [];
if (vizOn) _p.push('visualizer=1'); if (vizOn) _p.push('visualizer=1');
if (trackId) _p.push('track=' + trackId); if (trackId) _p.push('track=' + trackId);
if (lyrOn) _p.push('lyrics=1');
var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download
var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration= var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration=

View File

@ -87,10 +87,10 @@ $sizeClasses = match($size) {
@endif @endif
@if($video) @if($video)
<div class="yt-video-meta"> <div class="yt-video-meta">
@if($video->type && $video->type !== 'generic') @if($video->type)
<span class="yt-type-label yt-type-{{ $video->type }}"> <span class="yt-type-label yt-type-{{ $video->type }}">
<i class="bi {{ $typeIcon }}"></i> <i class="bi {{ $typeIcon }}"></i>
{{ ucfirst($video->type === 'match' ? 'Sports' : $video->type) }} {{ ucfirst($video->type === 'match' ? 'Sports' : ($video->type === 'generic' ? 'Video' : $video->type)) }}
</span> </span>
&nbsp;·&nbsp; &nbsp;·&nbsp;
@endif @endif
@ -269,6 +269,7 @@ $sizeClasses = match($size) {
</div> </div>
</div> </div>
@once
<style> <style>
/* Base styles for video card */ /* Base styles for video card */
.yt-video-card { .yt-video-card {
@ -531,8 +532,9 @@ $sizeClasses = match($size) {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.4px;
} }
.yt-type-music { color: #c084fc; } .yt-type-music { color: #c084fc; }
.yt-type-match { color: #60a5fa; } .yt-type-match { color: #60a5fa; }
.yt-type-generic { color: #f87171; }
/* More button — visible only on hover (touch devices always show it) */ /* More button — visible only on hover (touch devices always show it) */
.yt-video-card .yt-more-btn { .yt-video-card .yt-more-btn {
@ -932,6 +934,7 @@ $sizeClasses = match($size) {
} }
} }
</style> </style>
@endonce
@once @once
<script> <script>
@ -1087,6 +1090,11 @@ function closeEditVideoModal(videoId) {
} }
} }
// SPA navigation re-executes this once-protected script on every page swap.
// Guard the document-level listeners so they don't stack up across navs.
if (!window._videoCardListenersBound) {
window._videoCardListenersBound = true;
// Type option click handlers // Type option click handlers
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.closest('.cute-type-option')) { if (e.target.closest('.cute-type-option')) {
@ -1160,5 +1168,7 @@ document.addEventListener('submit', function(e) {
}); });
} }
}); });
} // end _videoCardListenersBound guard
</script> </script>
@endonce @endonce

View File

@ -134,6 +134,12 @@
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg> <svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button> </button>
<div class="ytp-settings-panel" id="ytpSettingsPanel"> <div class="ytp-settings-panel" id="ytpSettingsPanel">
{{-- Mini player toggle desktop-only, persisted in localStorage --}}
<div class="ytp-settings-item" id="ytpMiniToggleRow" onclick="(function(el){var on=!(window._ytpMiniEnabled&&window._ytpMiniEnabled());window._ytpMiniSetEnabled(on);el.querySelector('.ytp-settings-val').textContent=on?'On':'Off';})(this)">
<svg viewBox="0 0 24 24"><path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/></svg>
<span>Mini player</span>
<span class="ytp-settings-val">On</span>
</div>
<div class="ytp-settings-item" id="ytpSpeedRow"> <div class="ytp-settings-item" id="ytpSpeedRow">
<svg viewBox="0 0 24 24"><path d="M10 8v8l6-4-6-4zm6.5 4A6.5 6.5 0 1 1 9 6.04V4.02A8.5 8.5 0 1 0 18.5 12H16.5z"/></svg> <svg viewBox="0 0 24 24"><path d="M10 8v8l6-4-6-4zm6.5 4A6.5 6.5 0 1 1 9 6.04V4.02A8.5 8.5 0 1 0 18.5 12H16.5z"/></svg>
<span>Playback speed</span> <span>Playback speed</span>
@ -818,6 +824,19 @@ video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
function initSource() { function initSource() {
video.muted = true; video.muted = true;
video.autoplay = true; video.autoplay = true;
/* Resume handoff from the mini player: ?t=<sec> seeks the video to that
position once metadata is ready. One-shot only the initial load. */
try {
var _qs = new URLSearchParams(location.search);
var _t = parseInt(_qs.get('t') || '0', 10);
if (_t > 0) {
video.addEventListener('loadedmetadata', function () {
if (_t < (video.duration || Infinity)) {
try { video.currentTime = _t; } catch (e) {}
}
}, { once: true });
}
} catch (e) {}
if (HLS_URL && window.Hls && Hls.isSupported()) { if (HLS_URL && window.Hls && Hls.isSupported()) {
window._ytpHls = new Hls({ startLevel: -1 }); window._ytpHls = new Hls({ startLevel: -1 });
// Register MANIFEST_PARSED before loadSource to avoid cache race condition // Register MANIFEST_PARSED before loadSource to avoid cache race condition
@ -1077,6 +1096,17 @@ settingsBtn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const open = settingsPanel.classList.toggle('open'); const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; } if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
/* Sync the mini-player toggle row's label to the current preference each
time the gear opens, so reloading the page or toggling from the music
player keeps the indicator honest. */
if (open) {
const miniRow = document.getElementById('ytpMiniToggleRow');
if (miniRow) {
const v = miniRow.querySelector('.ytp-settings-val');
const on = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (v) v.textContent = on ? 'On' : 'Off';
}
}
showControls(); showControls();
clearTimeout(hideTimer); // keep controls visible while settings open clearTimeout(hideTimer); // keep controls visible while settings open
}); });
@ -1370,10 +1400,11 @@ function init() {
largePlay.classList.add('visible'); largePlay.classList.add('visible');
showControls(); showControls();
// Scroll-based mini player: watch when #ytpWrap leaves the viewport // Scroll-based mini player: watch when #ytpWrap leaves the viewport.
if (window.IntersectionObserver && window._miniPlayer) { // Desktop-only — on mobile the fixed bottom-nav + locked scroll model
/* On mobile, #main is the scroll container; on desktop the window scrolls */ // make a floating overlay disruptive.
var _scrollRoot = window.innerWidth <= 768 ? document.getElementById('main') : null; if (window.IntersectionObserver && window._miniPlayer && window.innerWidth > 768) {
var _scrollRoot = null; /* desktop: window scrolls */
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */ var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
new IntersectionObserver(function (entries) { new IntersectionObserver(function (entries) {
var e0 = entries[0]; var e0 = entries[0];
@ -1381,7 +1412,8 @@ function init() {
Using !video.paused was unreliable: autoplay fires asynchronously and the Using !video.paused was unreliable: autoplay fires asynchronously and the
initial IntersectionObserver callback could run before HLS.js even attaches, initial IntersectionObserver callback could run before HLS.js even attaches,
teleporting the element before it ever played in the main player. */ teleporting the element before it ever played in the main player. */
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && !window._miniPlayer.isNavMode()) { var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && miniAllowed && !window._miniPlayer.isNavMode()) {
_scrollMiniOn = true; _scrollMiniOn = true;
window._miniPlayer.activateScroll( window._miniPlayer.activateScroll(
document.title.replace(/\s*\|.*$/, '').trim(), document.title.replace(/\s*\|.*$/, '').trim(),
@ -1392,6 +1424,12 @@ function init() {
window._miniPlayer.deactivateScroll(); window._miniPlayer.deactivateScroll();
} }
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrap); }, { root: _scrollRoot, threshold: 0.15 }).observe(wrap);
/* User clicked the X on the mini while still on the video page
reset the flag so a subsequent scroll-away re-activates it. */
window.addEventListener('miniplayer:scroll-closed', function () {
_scrollMiniOn = false;
});
} }
} }

View File

@ -16,6 +16,118 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}"> <link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
{{-- Image cropper assets must be in the layout head (not inside the
x-image-cropper component) because page-level uses of the cropper
render those styles inside #main, which the SPA navigation later
wipes via innerHTML swap. The layout-level modals (upload,
sports-match) would then render their cropper overlays unstyled. --}}
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
<script src="{{ asset('js/cropme.min.js') }}"></script>
<style>
/* TakeOne Cropper Modal must be in the head, not in the component,
because page-level uses render those styles inside #main and SPA
navigation later wipes that scope. */
.tc-overlay {
display: none; position: fixed; inset: 0; z-index: 10100;
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
.tc-modal {
background: #141414; border: 1px solid rgba(255,255,255,.12);
border-radius: 18px; width: min(540px, 95vw);
box-shadow: 0 24px 80px rgba(0,0,0,.75);
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
}
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.tc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.tc-modal-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 8px;
}
.tc-modal-title i { color: #ef4444; }
.tc-modal-close {
background: none; border: none; color: rgba(255,255,255,.45);
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
border-radius: 6px; transition: color .15s, background .15s;
}
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.tc-modal-body { padding: 16px 20px; }
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tc-file-label {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
height: 36px; padding: 0 14px; border-radius: 8px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.tc-file-label:hover { background: rgba(255,255,255,.13); }
.tc-file-name {
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tc-canvas {
width: 100%; height: 320px; background: #0d0d0d;
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
overflow: hidden; position: relative;
}
.tc-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
}
.tc-placeholder i { font-size: 42px; }
.tc-placeholder span { font-size: 13px; }
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
.tc-control { flex: 1; }
.tc-control-label {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
}
.tc-range {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
}
.tc-range::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #ef4444; cursor: pointer;
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
}
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
.tc-range::-moz-range-thumb {
width: 15px; height: 15px; border: none;
border-radius: 50%; background: #ef4444; cursor: pointer;
}
.tc-modal-footer {
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
}
.tc-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 38px; padding: 0 18px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
transition: background .15s, transform .1s, opacity .15s;
}
.tc-btn-ghost {
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
border: 1px solid rgba(255,255,255,.12);
}
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
.tc-btn-as-is {
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
}
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
.tc-btn-primary { background: #ef4444; color: #fff; }
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
</style>
<style> <style>
:root { :root {
--brand-red: #e61e1e; --brand-red: #e61e1e;
@ -868,7 +980,12 @@
aspect-ratio: 16/9; aspect-ratio: 16/9;
background: #000; background: #000;
overflow: hidden; overflow: hidden;
cursor: move; /* drag handle */
user-select: none;
-webkit-user-select: none;
} }
#ytpMini.dragging { cursor: grabbing; opacity: .92; }
#ytpMini.dragging #ytpMiniVideo { cursor: grabbing; }
#ytpMiniVideo video { width:100%; height:100%; object-fit:contain; display:block; } #ytpMiniVideo video { width:100%; height:100%; object-fit:contain; display:block; }
#ytpMiniVideo .ytp-chrome-bottom, #ytpMiniVideo .ytp-chrome-bottom,
#ytpMiniVideo .ytp-gradient-bottom, #ytpMiniVideo .ytp-gradient-bottom,
@ -884,7 +1001,12 @@
gap: 6px; gap: 6px;
background: #1a1a1a; background: #1a1a1a;
} }
#ytpMiniInfo { flex:1; min-width:0; } #ytpMiniInfo {
flex:1; min-width:0;
cursor: move; /* secondary drag handle on the title area */
user-select: none;
-webkit-user-select: none;
}
#ytpMiniTitle { #ytpMiniTitle {
font-size: 12px; font-size: 12px;
color: #eee; color: #eee;
@ -948,12 +1070,24 @@
.yt-main.video-view-page { padding: 0 !important; } .yt-main.video-view-page { padding: 0 !important; }
.yt-header-right .yt-icon-btn:not(:first-child) { display: none; } .yt-header-right .yt-icon-btn:not(:first-child) { display: none; }
} }
/* Base grid kept in the layout (not the per-page extra_styles block)
so SPA navigations from a video page back to a gallery still get it.
No !important: pages with their own .yt-video-grid rules (e.g. the
channel page) override these via normal cascade since their <style>
comes from @section('extra_styles') in <body>. */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
@media (max-height: 500px) and (orientation: landscape) { @media (max-height: 500px) and (orientation: landscape) {
.yt-sidebar { width: 200px; } .yt-sidebar { width: 200px; }
.yt-video-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } .yt-video-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
} }
@media (min-width: 1440px) { @media (min-width: 1440px) {
.yt-video-grid { grid-template-columns: repeat(4, 1fr) !important; } .yt-video-grid { grid-template-columns: repeat(4, 1fr); }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.video-container { .video-container {
@ -1771,7 +1905,14 @@
} }
</script> </script>
@yield('scripts') {{-- Page-specific scripts come from each view's @section('scripts').
Wrapped in a marker element so the SPA navigator can swap & re-run
them without this, SPA nav into a page whose JS defines functions
like switchTab() leaves those functions undefined, and any onclick
handler that calls them silently fails. --}}
<div id="page-scripts">
@yield('scripts')
</div>
{{-- ═══════════════════════════════════════════════════════════ {{-- ═══════════════════════════════════════════════════════════
ADMIN ERROR CATCHER only rendered for super_admins ADMIN ERROR CATCHER only rendered for super_admins
@ -1907,6 +2048,21 @@
'scroll' player scrolled out of viewport on the video page 'scroll' player scrolled out of viewport on the video page
'nav' user navigated to a non-video page via SPA 'nav' user navigated to a non-video page via SPA
───────────────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────────────── */
/* Global on/off for the floating mini player. Persisted in localStorage so
the user's choice survives reloads and applies across video AND music
players. Default ON. The gear-menu toggles in each player flip this. */
window._ytpMiniEnabled = function () {
try { return localStorage.getItem('ytpMiniEnabled') !== '0'; }
catch (e) { return true; }
};
window._ytpMiniSetEnabled = function (on) {
try { localStorage.setItem('ytpMiniEnabled', on ? '1' : '0'); } catch (e) {}
/* Closing the mini cleanly if the user disabled it while it was active. */
if (!on && window._miniPlayer && window._miniPlayer.isActive()) {
window._miniPlayer.deactivate();
}
};
window._miniPlayer = (function () { window._miniPlayer = (function () {
var wrap = document.getElementById('ytpMini'); var wrap = document.getElementById('ytpMini');
var slot = document.getElementById('ytpMiniVideo'); var slot = document.getElementById('ytpMiniVideo');
@ -1916,50 +2072,99 @@
var closeBtn = document.getElementById('ytpMiniClose'); var closeBtn = document.getElementById('ytpMiniClose');
var _mode = null; /* 'scroll' | 'nav' | null */ var _mode = null; /* 'scroll' | 'nav' | null */
var _kind = null; /* 'video' | 'audio' | null */
var _origParent = null; var _origParent = null;
var _origNext = null; var _origNext = null;
function getVid() { return document.getElementById('videoPlayer'); } function getVid() { return document.getElementById('videoPlayer'); }
function getAudio() { return document.getElementById('audioEl'); }
/* The element the mini player drives video element if present, else the
page's <audio>. Returned as a generic HTMLMediaElement either way. */
function getMedia() { return getVid() || getAudio(); }
function syncBtn() { function syncBtn() {
var v = getVid(); var m = getMedia();
if (!playBtn) return; if (!playBtn) return;
playBtn.querySelector('i').className = (!v || v.paused) playBtn.querySelector('i').className = (!m || m.paused)
? 'bi bi-play-fill' : 'bi bi-pause-fill'; ? 'bi bi-play-fill' : 'bi bi-pause-fill';
} }
function activate(title, url, mode) { function activate(title, url, mode) {
if (_mode !== null) return false; /* already active — prevent re-entry */ if (_mode !== null) return false; /* already active — prevent re-entry */
if (!slot) return false;
var v = getVid(); var v = getVid();
if (!v || !slot) return false; if (v) {
/* VIDEO MODE — teleport the <video> element so HLS.js stays attached */
_kind = 'video';
_origParent = v.parentNode;
_origNext = v.nextSibling;
slot.appendChild(v);
} else {
/* AUDIO MODE teleport the <audio> element OUT of #main so SPA
navigation (which replaces #main.innerHTML) cannot destroy it,
and playback continues uninterrupted. Show the current cover
art (or active slide) inside the visible slot. */
var a = getAudio();
if (!a) return false;
_kind = 'audio';
var coverSrc = '';
var slideA = document.getElementById('slideA');
if (slideA && slideA.offsetParent !== null && slideA.src) coverSrc = slideA.src;
if (!coverSrc) {
var cover = document.getElementById('audioCoverImg');
if (cover && cover.src) coverSrc = cover.src;
}
slot.innerHTML = coverSrc
? '<img id="ytpMiniCover" src="' + coverSrc + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block;">'
: '<div style="width:100%;height:100%;background:#1a1a1a;display:flex;align-items:center;justify-content:center;color:#666;"><i class="bi bi-music-note-beamed" style="font-size:32px;"></i></div>';
/* Remember original DOM position to restore later */ /* Teleport the audio element to the mini wrap. <audio> is
_origParent = v.parentNode; invisible, so visual layout is unaffected. */
_origNext = v.nextSibling; _origParent = a.parentNode;
_origNext = a.nextSibling;
wrap.appendChild(a);
}
/* Teleport — HLS.js stays attached, playback uninterrupted */ titleEl.textContent = title || 'Now playing';
slot.appendChild(v);
titleEl.textContent = title || 'Video';
expandBtn.href = url || '#'; expandBtn.href = url || '#';
_mode = mode; _mode = mode;
wrap.style.display = 'block'; wrap.style.display = 'block';
syncBtn(); syncBtn();
v.addEventListener('play', syncBtn); var m = getMedia();
v.addEventListener('pause', syncBtn); if (m) {
m.addEventListener('play', syncBtn);
m.addEventListener('pause', syncBtn);
}
return true; return true;
} }
function restore() { function restore() {
var v = getVid(); if (_kind === 'video') {
if (!v || !_origParent) return; var v = getVid();
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) { if (!v || !_origParent) return;
_origParent.insertBefore(v, _origNext); if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
} else { _origParent.insertBefore(v, _origNext);
_origParent.appendChild(v); } else {
_origParent.appendChild(v);
}
} else if (_kind === 'audio') {
/* Move <audio> back to its original parent if it still exists
(e.g. scroll-mode user scrolled back to the player). If the
parent was wiped by an SPA nav, leave the audio in the mini. */
var a = getAudio();
if (a && _origParent && _origParent.isConnected) {
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(a, _origNext);
} else {
_origParent.appendChild(a);
}
}
if (slot) slot.innerHTML = '';
} }
_origParent = null; _origParent = null;
_origNext = null; _origNext = null;
_kind = null;
} }
function deactivate() { function deactivate() {
@ -1970,10 +2175,10 @@
if (playBtn) { if (playBtn) {
playBtn.addEventListener('click', function () { playBtn.addEventListener('click', function () {
var v = getVid(); var m = getMedia();
if (!v) return; if (!m) return;
if (v.paused) v.play().catch(function(){}); if (m.paused) m.play().catch(function(){});
else v.pause(); else m.pause();
}); });
} }
@ -1985,24 +2190,148 @@
var main = document.getElementById('main'); var main = document.getElementById('main');
if (main) main.scrollTo({ top: 0, behavior: 'smooth' }); if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
deactivate(); deactivate();
return;
} }
/* nav mode: follow href — full page navigation to video */ /* nav mode hand off the playhead so the destination page
resumes playback from where the mini left off. The expand
target is the original player URL; we append:
resume=1 tells the player to auto-start
t=<sec> current playhead position
The video/audio player reads these query params on init. */
var m = getMedia();
if (!m) return; /* fall through to default nav */
var href = expandBtn.getAttribute('href') || '';
if (!href || href === '#') return;
try {
var u = new URL(href, location.href);
u.searchParams.set('resume', '1');
if (!isNaN(m.currentTime) && m.currentTime > 0) {
u.searchParams.set('t', Math.floor(m.currentTime).toString());
}
expandBtn.setAttribute('href', u.toString());
} catch (err) { /* leave href as-is */ }
}); });
} }
if (closeBtn) { if (closeBtn) {
closeBtn.addEventListener('click', function () { closeBtn.addEventListener('click', function () {
var v = getVid(); if (_mode === 'scroll') {
if (v) v.pause(); /* User is still on the player's own page. Close the mini
if (_mode === 'scroll') restore(); and put the media element back in its original box so it
keeps playing like a background tab no pause, no scroll
back up. We dispatch a custom event so the per-page
IntersectionObservers can reset their local "is the mini
on?" flag — otherwise scrolling away again wouldn't
re-trigger the mini until the user scrolls back over the
player first. */
restore();
wrap.style.display = 'none';
_mode = null;
window.dispatchEvent(new CustomEvent('miniplayer:scroll-closed'));
return;
}
/* nav mode user has navigated away from the player's page.
Pause and fully tear down; the original player no longer
exists in the DOM to receive the media element. */
var m = getMedia();
if (m) m.pause();
wrap.style.display = 'none'; wrap.style.display = 'none';
_mode = null; _mode = null;
_kind = null;
}); });
} }
/* ── Drag-to-reposition ────────────────────────────────────────────
Desktop-only (mobile mini is already disabled). Persists the chosen
position in localStorage so the next session keeps it. Buttons /
anchors inside the bar are NOT drag handles pointerdown on those
goes through to their click handler. */
var _drag = null;
var POS_KEY = 'ytpMiniPos';
function clampToViewport(left, top) {
var r = wrap.getBoundingClientRect();
var maxL = window.innerWidth - r.width - 4;
var maxT = window.innerHeight - r.height - 4;
return {
left: Math.max(4, Math.min(left, maxL)),
top: Math.max(4, Math.min(top, maxT)),
};
}
function applyPos(left, top) {
var c = clampToViewport(left, top);
wrap.style.left = c.left + 'px';
wrap.style.top = c.top + 'px';
wrap.style.right = 'auto';
wrap.style.bottom = 'auto';
}
function loadSavedPos() {
try {
var raw = localStorage.getItem(POS_KEY);
if (!raw) return;
var p = JSON.parse(raw);
if (typeof p.left === 'number' && typeof p.top === 'number') {
applyPos(p.left, p.top);
}
} catch (e) {}
}
function startDrag(e) {
/* Ignore drag attempts on interactive children buttons/anchors
keep their click semantics. */
if (e.target.closest('button, a')) return;
if (e.button !== undefined && e.button !== 0) return;
var r = wrap.getBoundingClientRect();
_drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
wrap.classList.add('dragging');
/* Lock in pixel coords for the first move so the wrap stops
relying on right/bottom anchoring. */
applyPos(r.left, r.top);
try { wrap.setPointerCapture(e.pointerId); } catch (er) {}
e.preventDefault();
}
function moveDrag(e) {
if (!_drag) return;
applyPos(e.clientX - _drag.dx, e.clientY - _drag.dy);
}
function endDrag(e) {
if (!_drag) return;
_drag = null;
wrap.classList.remove('dragging');
try { wrap.releasePointerCapture(e.pointerId); } catch (er) {}
try {
var r = wrap.getBoundingClientRect();
localStorage.setItem(POS_KEY, JSON.stringify({ left: r.left, top: r.top }));
} catch (er) {}
}
wrap.addEventListener('pointerdown', startDrag);
wrap.addEventListener('pointermove', moveDrag);
wrap.addEventListener('pointerup', endDrag);
wrap.addEventListener('pointercancel', endDrag);
/* Re-clamp on resize so the mini doesn't get stranded off-screen. */
window.addEventListener('resize', function () {
if (wrap.style.display === 'none') return;
var r = wrap.getBoundingClientRect();
applyPos(r.left, r.top);
});
/* Apply saved position when the mini activates (no point reading it
while the wrap is display:none getBoundingClientRect would be 0). */
function _activateAndPosition(title, url, mode) {
var ok = activate(title, url, mode);
if (ok) loadSavedPos();
return ok;
}
return { return {
activate: function (t, u) { return activate(t, u, 'nav'); }, activate: function (t, u) { return _activateAndPosition(t, u, 'nav'); },
activateScroll: function (t, u) { return activate(t, u, 'scroll'); }, activateScroll: function (t, u) { return _activateAndPosition(t, u, 'scroll'); },
deactivate: deactivate, deactivate: deactivate,
deactivateScroll: function () { if (_mode === 'scroll') deactivate(); }, deactivateScroll: function () { if (_mode === 'scroll') deactivate(); },
isActive: function () { return _mode !== null; }, isActive: function () { return _mode !== null; },
@ -2010,6 +2339,19 @@
isNavMode: function () { return _mode === 'nav'; }, isNavMode: function () { return _mode === 'nav'; },
setUrl: function (u) { if (expandBtn) expandBtn.href = u; }, setUrl: function (u) { if (expandBtn) expandBtn.href = u; },
setTitle: function (t) { if (titleEl) titleEl.textContent = t || 'Video'; }, setTitle: function (t) { if (titleEl) titleEl.textContent = t || 'Video'; },
/* Called when the user SPA-navigates away while the mini is in
scroll mode converts it to nav mode so the expand button
returns to the player's original URL instead of scrolling. */
convertToNav: function (u) {
if (_mode !== 'scroll') return;
_mode = 'nav';
if (u && expandBtn) expandBtn.href = u;
/* Audio mode: the original parent is about to be wiped by the
SPA innerHTML swap, so forget it restore() will then leave
the audio in the mini wrap on deactivate. */
_origParent = null;
_origNext = null;
},
syncBtn: syncBtn syncBtn: syncBtn
}; };
})(); })();
@ -2054,7 +2396,67 @@
}); });
} }
/* Import <style> blocks from the destination doc's <head> that
aren't already present in the current head. Idempotent: identical
textContent is only added once across the SPA session, so navigating
between pages doesn't keep growing the head. */
function importHeadStyles(doc) {
try {
var have = {};
document.head.querySelectorAll('style[data-spa-style]').forEach(function (s) {
have[s.dataset.spaStyle] = true;
});
var srcStyles = doc.head ? doc.head.querySelectorAll('style') : [];
Array.prototype.forEach.call(srcStyles, function (s) {
var txt = s.textContent || '';
if (!txt.trim()) return;
/* Hash via length + first/last bytes — cheap dedupe key */
var key = txt.length + ':' + txt.slice(0, 80) + ':' + txt.slice(-40);
if (have[key]) return;
have[key] = true;
var n = document.createElement('style');
n.dataset.spaStyle = key;
n.textContent = txt;
document.head.appendChild(n);
});
} catch (e) { /* non-fatal */ }
}
/* Top progress bar gives the user immediate visual feedback that
the SPA navigation is in flight. The actual DOM swap can take
a noticeable beat on big pages; without this the click feels dead. */
var _spaBar = null;
function spaBarStart() {
if (!_spaBar) {
_spaBar = document.createElement('div');
_spaBar.style.cssText = 'position:fixed;top:0;left:0;height:2px;background:var(--brand-red,#e61e1e);z-index:99999;width:0%;transition:width .2s ease,opacity .25s ease;pointer-events:none;box-shadow:0 0 8px rgba(230,30,30,.6);';
document.body.appendChild(_spaBar);
}
_spaBar.style.opacity = '1';
_spaBar.style.width = '0%';
/* Two-stage trickle: jump to 25% immediately, creep to 70% while
waiting on network. spaBarDone() finishes the run. */
requestAnimationFrame(function () { _spaBar.style.width = '25%'; });
setTimeout(function () { if (_spaBar) _spaBar.style.width = '70%'; }, 300);
}
function spaBarDone() {
if (!_spaBar) return;
_spaBar.style.width = '100%';
setTimeout(function () {
if (!_spaBar) return;
_spaBar.style.opacity = '0';
setTimeout(function () { if (_spaBar) _spaBar.style.width = '0%'; }, 250);
}, 150);
}
function spaGo(url) { function spaGo(url) {
spaBarStart();
/* Update the URL immediately so the address bar reflects the click.
If the load fails, popstate-like recovery isn't needed: the catch
below falls back to a hard nav which corrects the URL again. */
try { history.pushState({ spa: true, url: url, pending: true }, '', url); } catch (e) {}
updateNavStates(url);
fetch(url, { headers: { 'X-SPA-Nav': '1' }, credentials: 'same-origin' }) fetch(url, { headers: { 'X-SPA-Nav': '1' }, credentials: 'same-origin' })
.then(function (r) { return r.text(); }) .then(function (r) { return r.text(); })
.then(function (html) { .then(function (html) {
@ -2073,16 +2475,36 @@
curM.className = newM.className; curM.className = newM.className;
curM.innerHTML = newM.innerHTML; curM.innerHTML = newM.innerHTML;
reExecScripts(curM); reExecScripts(curM);
/* Pages render @section('extra_styles') into <head>, so a
plain #main swap loses the destination's styles. Copy
any <style> blocks from the new doc's <head> that we
don't already have. Identified by data-spa-style (set
below on first import) or by content hash. */
importHeadStyles(doc);
/* Page-level scripts live in #page-scripts (the wrapper
around the per-page scripts section) swap and re-
execute so per-page helpers like channel's switchTab()
get defined again on this navigation. */
var srcPS = doc.getElementById('page-scripts');
var curPS = document.getElementById('page-scripts');
if (srcPS && curPS) {
curPS.innerHTML = srcPS.innerHTML;
reExecScripts(curPS);
}
curM.scrollTop = 0; curM.scrollTop = 0;
history.pushState({ spa: true, url: url }, doc.title, url); /* URL was already pushed at the top; replace state to drop
updateNavStates(url); the `pending` flag now that the load succeeded. */
history.replaceState({ spa: true, url: url }, doc.title, url);
spaBarDone();
}) })
.catch(function () { location.href = url; }); .catch(function () { location.href = url; });
} }
function stopMiniAndNavigate(url) { function stopMiniAndNavigate(url) {
var v = document.getElementById('videoPlayer'); var v = document.getElementById('videoPlayer');
var a = document.getElementById('audioEl');
if (v) v.pause(); if (v) v.pause();
if (a) a.pause();
document.getElementById('ytpMini').style.display = 'none'; document.getElementById('ytpMini').style.display = 'none';
/* Allow browser to do a normal full-page load */ /* Allow browser to do a normal full-page load */
} }
@ -2100,23 +2522,47 @@
var destUrl = new URL(href, location.href).href; var destUrl = new URL(href, location.href).href;
var v = document.getElementById('videoPlayer'); var v = document.getElementById('videoPlayer');
var playing = v && (window._ytpWasPlaying || !v.paused); var aEl = document.getElementById('audioEl');
var playing = (v && (window._ytpWasPlaying || !v.paused))
|| (aEl && !aEl.paused);
var miniOn = window._miniPlayer && window._miniPlayer.isActive(); var miniOn = window._miniPlayer && window._miniPlayer.isActive();
if (!playing && !miniOn) return; if (!playing && !miniOn) return;
/* Mobile: no floating mini player. Let the browser do a normal
full-page navigation; playback stops with the page like any
other site. The desktop-only mini is the only place where a
persistent floating bar makes sense. */
if (window.innerWidth <= 768) {
if (miniOn) stopMiniAndNavigate(destUrl);
return;
}
/* Going to a video page: stop mini, let browser do a full load */ /* Going to a video page: stop mini, let browser do a full load */
if (isVideoShowPage(destUrl)) { if (isVideoShowPage(destUrl)) {
if (miniOn) stopMiniAndNavigate(destUrl); if (miniOn) stopMiniAndNavigate(destUrl);
return; return;
} }
/* Mini disabled in the user's gear preference: treat as a normal
full navigation pause and let the page change cleanly. Must
come BEFORE preventDefault(), otherwise we cancel the browser
click without running spaGo() and the link goes nowhere. */
if (!window._ytpMiniEnabled()) {
if (miniOn) stopMiniAndNavigate(destUrl);
return;
}
e.preventDefault(); e.preventDefault();
/* Activate mini if the video is live in the page (not already in mini) */ /* Activate mini if the video is live in the page (not already in mini) */
if (playing && !miniOn) { if (playing && !miniOn) {
var title = document.title.replace(/\s*\|.*$/, '').trim(); var title = document.title.replace(/\s*\|.*$/, '').trim();
window._miniPlayer.activate(title, location.href); window._miniPlayer.activate(title, location.href);
} else if (miniOn && window._miniPlayer.isScrollMode()) {
/* Already in scroll mode convert to nav mode so the expand
button returns to the original player URL after SPA nav. */
window._miniPlayer.convertToNav(location.href);
} }
spaGo(destUrl); spaGo(destUrl);

View File

@ -246,7 +246,7 @@
<div class="playlist-item" style="display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border-radius: 8px; cursor: pointer; transition: background 0.2s; margin-bottom: 8px;" <div class="playlist-item" style="display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border-radius: 8px; cursor: pointer; transition: background 0.2s; margin-bottom: 8px;"
onmouseover="this.style.background='#3f3f3f'" onmouseover="this.style.background='#3f3f3f'"
onmouseout="this.style.background='transparent'" onmouseout="this.style.background='transparent'"
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId})"> onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId}, ${isInPlaylist ? 'true' : 'false'})">
<div style="display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0;"> <div style="display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0;">
<div style="width: 100px; height: 56px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;"> <div style="width: 100px; height: 56px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;">
${playlist.thumbnail_url ${playlist.thumbnail_url
@ -312,33 +312,38 @@
@endauth @endauth
} }
// Toggle video in playlist // Toggle video in/out of a playlist. `currentlyIn` reflects what the row
function toggleVideoInPlaylist(playlistId, videoId) { // showed when the user clicked, so a single click flips the state via the
// matching REST endpoint (POST to add, DELETE to remove).
function toggleVideoInPlaylist(playlistId, videoId, currentlyIn) {
if (!videoId) return; if (!videoId) return;
// Check authentication before adding
if (!isAuthenticated()) { if (!isAuthenticated()) {
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href); window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
return; return;
} }
fetch(`/playlists/${playlistId}/videos`, { // Both add and remove go through the same /playlists/{id}/videos URL with
method: 'POST', // a JSON body carrying the numeric video id — keeps the front-end ignorant
// of the encoded route key used by GET /videos/{video}.
var url = `/playlists/${playlistId}/videos`;
var method = currentlyIn ? 'DELETE' : 'POST';
var body = JSON.stringify({ video_id: videoId });
fetch(url, {
method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}', 'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json' 'Accept': 'application/json'
}, },
body: JSON.stringify({ body
video_id: videoId
})
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showToast(data.message || 'Video added to playlist'); showToast(data.message || (currentlyIn ? 'Removed from playlist' : 'Added to playlist'));
// Reload playlists to update checkmarks loadPlaylistsForModal(videoId); // refresh checkmarks
loadPlaylistsForModal(videoId);
} }
}) })
.catch(error => console.error('Error:', error)); .catch(error => console.error('Error:', error));

View File

@ -1661,11 +1661,18 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
const formData = new FormData(this); const formData = new FormData(this);
if (_isMusicMode) { if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f)); (_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_slidesData)) { // Walk extra-track form sections in DOM order so the slide index lines up
if (tid === 't1') continue; // with the positional `extra_track_files[]` the form already submits.
const n = parseInt(tid.replace('e', '')); var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f)); extraForms.forEach(function (el, domIdx) {
} var m = el.id.match(/^um-tf-(e\d+)$/);
if (!m) return;
var tid = m[1];
var files = _slidesData[tid] || [];
files.forEach(function (f) {
formData.append('extra_track_slides[' + domIdx + '][]', f);
});
});
} }
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();

View File

@ -178,7 +178,7 @@
<div class="playlist-info"> <div class="playlist-info">
<div class="playlist-name">{{ $playlist->name }}</div> <div class="playlist-name">{{ $playlist->name }}</div>
<div class="playlist-meta"> <div class="playlist-meta">
{{ $playlist->video_count }} videos {{ $playlist->formatted_duration }} {{ $playlist->video_count }} videos {{ $playlist->formatted_duration }} {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }} views
</div> </div>
@if($playlist->description) @if($playlist->description)
<div class="playlist-description">{{ $playlist->description }}</div> <div class="playlist-description">{{ $playlist->description }}</div>

View File

@ -319,6 +319,8 @@
<span class="sep">·</span> <span class="sep">·</span>
<span>{{ $playlist->formatted_duration }}</span> <span>{{ $playlist->formatted_duration }}</span>
@endif @endif
<span class="sep">·</span>
<span><i class="bi bi-eye"></i> {{ number_format($playlist->view_count) }} {{ Str::plural('view', $playlist->view_count) }}</span>
@if($playlist->is_default) @if($playlist->is_default)
<span class="pl-hero-badge" style="color:#60a5fa;"> <span class="pl-hero-badge" style="color:#60a5fa;">
<i class="bi bi-clock"></i> Watch Later <i class="bi bi-clock"></i> Watch Later
@ -630,6 +632,12 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
function openEditPlaylistModal() { const m = document.getElementById('editPlaylistModal'); if (m) m.style.display = 'flex'; } function openEditPlaylistModal() { const m = document.getElementById('editPlaylistModal'); if (m) m.style.display = 'flex'; }
// Auto-open the edit modal when arriving with #edit (e.g. from playlist-card menu).
@if($canEdit ?? false)
if (window.location.hash === '#edit') {
document.addEventListener('DOMContentLoaded', function () { openEditPlaylistModal(); });
}
@endif
function closeEditPlaylistModal() { const m = document.getElementById('editPlaylistModal'); if (m) m.style.display = 'none'; } function closeEditPlaylistModal() { const m = document.getElementById('editPlaylistModal'); if (m) m.style.display = 'none'; }
function handleThumbUpload(input) { function handleThumbUpload(input) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,150 @@
{{-- ===========================================================
Channel page MOBILE overrides
This file owns every mobile/touch-screen rule. All selectors
are wrapped in @media (max-width: 768px) or smaller so they
cannot affect desktop. Editing here is safe.
-----------------------------------------------------------
Edit here for: phone layout, tap-target sizing, mobile
hero, mobile tabs, anything that should only apply on
small viewports.
Add new mobile rules INSIDE one of the @media blocks below.
=========================================================== --}}
<style>
/* ══════════════════════════════════════════════════
MOBILE
══════════════════════════════════════════════════ */
/* ══════════════════════════════════════════════════
MOBILE full redesign of the channel header
Strategy: avatar floats over the banner, then name +
meta + actions stack vertically below in a single
column so nothing has to compete for width.
══════════════════════════════════════════════════ */
@media (max-width: 768px) {
.ch-banner { height: 130px; }
.ch-header { padding: 0 14px 18px; }
/* Stack avatar + info instead of inline */
.ch-header-inner {
display: block; gap: 0;
margin-top: -42px;
}
/* Centred floating avatar at top-left, overlapping the banner */
.ch-avatar-wrap { display: inline-block; margin-bottom: 12px; }
.ch-avatar {
width: 84px; height: 84px; border-radius: 14px;
box-shadow: 0 0 0 3px hsl({{ $hue }}, 55%, 42%),
0 8px 24px rgba(0,0,0,.6),
0 0 0 7px rgba(0,0,0,.45);
}
.ch-avatar-edit-btn { border-radius: 14px; font-size: 18px; opacity: .85; }
/* Hide hover-only banner "Change banner" text — keep camera icon */
.ch-banner-edit-btn {
opacity: 1; padding: 0; width: 34px; height: 34px;
justify-content: center; bottom: 10px; right: 10px;
background: rgba(0,0,0,.55);
}
.ch-banner-edit-btn span { display: none; }
.ch-banner-edit-btn i { font-size: 14px; }
/* Info column takes full width */
.ch-info { padding-top: 0; width: 100%; }
/* Name big and tight */
.ch-name-row { gap: 8px; margin-bottom: 6px; }
.ch-name { font-size: 22px; line-height: 1.1; letter-spacing: -.3px; }
.ch-verified-badge { width: 18px; height: 18px; font-size: 10px; }
/* Meta pill — denser, horizontally scrolls if it would wrap awkwardly */
.ch-meta-row {
display: flex; flex-wrap: nowrap; overflow-x: auto;
scrollbar-width: none;
font-size: 12px; padding: 6px 12px;
margin-bottom: 10px;
-webkit-overflow-scrolling: touch;
}
.ch-meta-row::-webkit-scrollbar { display: none; }
.ch-meta-sep { margin: 0 8px; font-size: 11px; }
.ch-meta-item { font-size: 12px; }
.ch-meta-item i { font-size: 10px; }
/* Bio readable on small screens */
.ch-bio { font-size: 13px; line-height: 1.55; margin-bottom: 12px; }
.ch-bio-short { -webkit-line-clamp: 3; }
/* Horoscope strip = full-width row, action row beneath */
.ch-horo-strip { padding: 7px 11px; gap: 4px 10px; width: 100%; box-sizing: border-box; }
.ch-horo-name { font-size: 13px; }
.ch-horo-emoji { font-size: 20px; }
/* Owner action row (horoscope-wrap parent flex) wraps on mobile */
.ch-info > div[style*="display:flex"] { row-gap: 8px; }
/* Manage/Upload/Preview row: full-width, evenly distributed */
.ch-manage-wrap, .ch-btn-ghost, .ch-btn-icon { flex: 0 0 auto; }
.ch-manage-btn, .ch-btn-ghost {
height: 38px; padding: 0 13px; font-size: 13px; gap: 6px;
}
.ch-manage-btn span, .ch-btn-ghost span { display: inline; } /* keep labels readable */
.ch-btn-icon {
width: 38px; height: 38px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.14);
color: var(--text-primary); text-decoration: none;
}
/* Social row */
.ch-social-row { gap: 6px; margin-bottom: 12px; }
.ch-social-btn { width: 38px; height: 38px; font-size: 14px; }
/* sticky doesn't work inside .yt-main on mobile — use fixed instead */
.ch-tabs-wrap {
position: fixed !important;
top: 56px;
left: 0; right: 0;
z-index: 80;
padding: 0 12px;
background: var(--bg-dark);
border-bottom: 1px solid rgba(255,255,255,.07);
}
/* push tab content below the fixed tabs bar (46px height + 1px border) */
.ch-tabs-spacer { display: block; height: 47px; }
.ch-tab { padding: 0 14px; font-size: 12px; min-width: 0; }
.ch-tab i { font-size: 13px; margin-right: 4px; }
.ch-tab-badge { font-size: 10px; padding: 1px 5px; }
.ch-tab-content { padding: 14px; }
.ch-sort-bar { gap: 8px; flex-wrap: wrap; }
.ch-about-card { padding: 16px; }
.ch-stat-cards { grid-template-columns: 1fr 1fr; gap: 10px; }
.ch-stat-card { padding: 14px 12px; }
.ch-stat-card-val { font-size: 22px; }
.ch-wall-layout { grid-template-columns: 1fr; gap: 16px; }
.ch-vid-search-wrap { max-width: 100%; }
/* Manage dropdown — make taps comfortable on phones */
.ch-manage-menu { min-width: 240px; padding: 8px; }
.ch-manage-item { padding: 12px 14px; font-size: 14px; }
}
@media (max-width: 480px) {
.ch-banner { height: 110px; }
.ch-header { padding: 0 12px 16px; }
.ch-header-inner { margin-top: -36px; }
.ch-avatar { width: 72px; height: 72px; border-radius: 12px; }
.ch-name { font-size: 20px; }
.ch-horo-divider { display: none; }
/* Stack the owner action row 2-up so labels stay visible */
.ch-info > div[style*="display:flex"][style*="flex-wrap:wrap"] { gap: 8px; }
.ch-meta-row { font-size: 11.5px; padding: 5px 11px; }
.ch-meta-sep { margin: 0 7px; }
.ch-tab { padding: 0 12px; font-size: 11.5px; }
.ch-stat-cards { grid-template-columns: 1fr 1fr; }
}
</style>

View File

@ -823,11 +823,18 @@
const formData = new FormData(this); const formData = new FormData(this);
if (_isAudioSubmitC) { if (_isAudioSubmitC) {
(_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f)); (_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_cSlidesData)) { // Walk extra-track sections in DOM order so slide index matches
if (tid === 'ct1') continue; // the positional `extra_track_files[]` order the backend iterates.
const n = parseInt(tid.replace('ce', '')); var extraEls = document.querySelectorAll('#ltac-extra .ltac-item');
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f)); extraEls.forEach(function (el, domIdx) {
} var m = el.id.match(/^ltac-(e\d+)$/);
if (!m) return;
var tid = 'c' + m[1];
var files = _cSlidesData[tid] || [];
files.forEach(function (f) {
formData.append('extra_track_slides[' + domIdx + '][]', f);
});
});
} }
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.timeout = 0; // no timeout — unlimited file size xhr.timeout = 0; // no timeout — unlimited file size

View File

@ -3,9 +3,18 @@
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png'); $coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null; $nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null; $prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$slideUrls = $video->slides->count() > 1
? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all() // Per-track slide URL map. Key "0" = primary, other keys = audio_track_id.
: []; // Slide sharing rule: if a track has no slides, fall back to primary, then to any
// other track that has them (Video::slidesForTrack handles the resolution).
$slideMap = ['0' => $video->slidesForTrack(null)
->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()];
foreach ($video->audioTracks as $_t) {
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all();
}
// Initial set (primary) — used for first paint before JS runs.
$slideUrls = $slideMap['0'] ?? [];
// Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language) // Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language)
$primaryLang = $video->language ?? 'default'; $primaryLang = $video->language ?? 'default';
$allLangData = \App\Data\Languages::all(); $allLangData = \App\Data\Languages::all();
@ -32,6 +41,16 @@
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp, 'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])); ]));
$hasMultipleTracks = $allAudioTracks->count() > 1; $hasMultipleTracks = $allAudioTracks->count() > 1;
// Synced lyrics, embedded inline (no separate request). Keyed by track id; "0" = primary.
// Local mirror only — must not block page render on NAS I/O.
$lyricsSvc = app(\App\Services\NasSyncService::class);
$inlineLyrics = ['0' => $lyricsSvc->getLocalLyrics($video, null)];
foreach ($video->audioTracks as $lt) {
$inlineLyrics[(string) $lt->id] = $lyricsSvc->getLocalLyrics($video, $lt);
}
$lyricsOwner = \Illuminate\Support\Facades\Auth::id() === $video->user_id;
$lyricsAllowed = \App\Models\Setting::get('lyrics_enabled', 'true') === 'true';
@endphp @endphp
<div class="ytp-wrap" id="ytpWrap"> <div class="ytp-wrap" id="ytpWrap">
@ -47,6 +66,27 @@
{{-- Bars canvas overlay --}} {{-- Bars canvas overlay --}}
<canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas> <canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas>
@if($lyricsAllowed)
{{-- Synced lyrics overlay one line at a time, anchored to the bottom --}}
<div class="ytp-lyrics-overlay" id="ytpLyricsOverlay" style="display:none">
<div class="ytp-lyrics-panel" id="ytpLyricsPanel">
<div class="ytp-lyrics-cur" id="ytpLyrCur"></div>
</div>
</div>
{{-- Live lyrics-generation progress (owner) --}}
<div class="ytp-lyrics-gen" id="ytpLyrGen" style="display:none">
<div class="ytp-lyrics-gen-inner">
<div class="ytp-lyrics-gen-row">
<span class="ytp-lyrics-gen-spark">🎤</span>
<span class="ytp-lyrics-gen-label" id="ytpLyrGenLabel">Generating lyrics…</span>
<span class="ytp-lyrics-gen-pct" id="ytpLyrGenPct">0%</span>
</div>
<div class="ytp-lyrics-gen-track"><div class="ytp-lyrics-gen-bar" id="ytpLyrGenBar"></div></div>
</div>
</div>
@endif
{{-- Gradient --}} {{-- Gradient --}}
<div class="ytp-gradient-bottom"></div> <div class="ytp-gradient-bottom"></div>
@ -146,6 +186,31 @@
<span class="ytp-settings-val" id="ytpSpeedLabel">Normal</span> <span class="ytp-settings-val" id="ytpSpeedLabel">Normal</span>
<svg class="ytp-chevron" viewBox="0 0 24 24"><path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> <svg class="ytp-chevron" viewBox="0 0 24 24"><path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div> </div>
{{-- Mini player toggle desktop-only, persisted in localStorage --}}
<div class="ytp-settings-item" id="ytpMiniToggleRow" onclick="(function(el){var on=!(window._ytpMiniEnabled&&window._ytpMiniEnabled());window._ytpMiniSetEnabled(on);el.querySelector('.ytp-settings-val').textContent=on?'On':'Off';})(this)">
<svg viewBox="0 0 24 24"><path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/></svg>
<span>Mini player</span>
<span class="ytp-settings-val">On</span>
</div>
@if($lyricsOwner && $lyricsAllowed)
{{-- Owner-only: generate/regenerate and edit lyrics live inside the gear so
they're always reachable on mobile and don't crowd the control bar. --}}
<div class="ytp-settings-item" id="ytpGenLyricsBtn" style="display:none"
data-gen-url="{{ route('videos.lyrics.generate', $video) }}">
<svg viewBox="0 0 24 24"><path d="M4 6h9v2H4V6zm0 4h9v2H4v-2zm0 4h6v2H4v-2zm13-9 1.2 2.8L21 9l-2.8 1.2L17 13l-1.2-2.8L13 9l2.8-1.2L17 5z"/></svg>
<span class="genlyr-label">Generate lyrics</span>
</div>
<div class="ytp-settings-item" id="ytpEditLyricsBtn" style="display:none"
onclick="if(window.openLyricsEditor){window.openLyricsEditor();var p=document.getElementById('ytpSettingsPanel');if(p)p.classList.remove('open');}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.51-2.51a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 2-2.16z"/></svg>
<span>Edit lyrics</span>
</div>
<div class="ytp-settings-item" id="ytpDeleteLyricsBtn" style="display:none"
data-del-url="{{ route('videos.lyrics.delete', $video) }}">
<svg viewBox="0 0 24 24"><path d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
<span>Delete lyrics</span>
</div>
@endif
<div class="ytp-speed-panel" id="ytpSpeedPanel"> <div class="ytp-speed-panel" id="ytpSpeedPanel">
<div class="ytp-speed-back" id="ytpSpeedBack"> <div class="ytp-speed-back" id="ytpSpeedBack">
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg> <svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
@ -166,6 +231,15 @@
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg> <svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button> </button>
@if($lyricsAllowed)
{{-- Lyrics toggle (hidden until lyrics are available) --}}
<button class="ytp-button ytp-lyrics-btn" id="ytpLyricsBtn" title="Lyrics" style="display:none">
<svg viewBox="0 0 24 24"><path d="M4 6h11v2H4V6zm0 5h11v2H4v-2zm0 5h7v2H4v-2zm15.5-6.5 1.5 1.5-6 6L12 18l1-3 5.5-5.5z"/></svg>
</button>
@endif
{{-- Owner lyrics generate/regenerate button lives in video-actions (next to Edit), not here. --}}
{{-- Bars visualiser toggle --}} {{-- Bars visualiser toggle --}}
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off"> <button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
@ -191,8 +265,48 @@
{{-- Hidden audio element --}} {{-- Hidden audio element --}}
<audio id="audioEl" src="{{ $audioUrl }}" preload="metadata"></audio> <audio id="audioEl" src="{{ $audioUrl }}" preload="metadata"></audio>
@if($lyricsOwner && $lyricsAllowed)
{{-- Lyrics editor modal (owner) lives outside the player box --}}
<div id="lyrEditorOverlay" class="lyr-editor-overlay" style="display:none">
<div class="lyr-editor" role="dialog" aria-modal="true" aria-label="Edit lyrics">
<div class="lyr-editor-hdr">
<span>Edit Lyrics</span>
<button type="button" class="lyr-editor-x" id="lyrEditorClose" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
<p class="lyr-editor-hint">Fix any misspelled words. Timing is preserved for lines you don't change.</p>
<div class="lyr-editor-body" id="lyrEditorBody"></div>
<div class="lyr-editor-ftr">
<button type="button" class="action-btn" id="lyrEditorCancel"><span>Cancel</span></button>
<button type="button" class="action-btn action-btn-primary" id="lyrEditorSave"><span>Save lyrics</span></button>
</div>
</div>
</div>
@endif
{{-- ══ CSS ══ --}} {{-- ══ CSS ══ --}}
<style> <style>
/* Lyrics editor modal */
.lyr-editor-overlay { position: fixed; inset: 0; z-index: 100000; background: rgba(0,0,0,.7);
display: flex; align-items: center; justify-content: center; padding: 16px; }
.lyr-editor { background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #333);
border-radius: 16px; width: 100%; max-width: 640px; max-height: 86vh; display: flex; flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,.6); }
.lyr-editor-hdr { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #333); font-weight: 700; font-size: 16px; color: var(--text-primary, #fff); }
.lyr-editor-x { background: none; border: none; color: var(--text-primary, #fff); font-size: 16px; cursor: pointer; opacity: .7; }
.lyr-editor-x:hover { opacity: 1; }
.lyr-editor-hint { margin: 0; padding: 10px 20px; font-size: 12px; color: var(--text-secondary, #aaa); }
.lyr-editor-body { overflow-y: auto; padding: 4px 20px 12px; flex: 1; }
.lyr-editor-row { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
.lyr-editor-time { flex-shrink: 0; width: 48px; font-size: 11px; color: var(--text-secondary, #999);
font-variant-numeric: tabular-nums; cursor: pointer; }
.lyr-editor-time:hover { color: var(--brand-red, #e61e1e); }
.lyr-editor-input { flex: 1; background: var(--bg-primary, #121212); border: 1px solid var(--border-color, #333);
border-radius: 8px; padding: 8px 10px; color: var(--text-primary, #fff); font-size: 14px; }
.lyr-editor-input:focus { outline: none; border-color: var(--brand-red, #e61e1e); }
.lyr-editor-input[lang-active="1"] { border-color: var(--brand-red, #e61e1e); }
.lyr-editor-ftr { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px;
border-top: 1px solid var(--border-color, #333); }
.audio-ytp { cursor: default; } .audio-ytp { cursor: default; }
.audio-cover-img { .audio-cover-img {
position: absolute; position: absolute;
@ -349,6 +463,69 @@
.ytp-lang-check { width: 16px; height: 16px; fill: #fff; opacity: 0; flex-shrink: 0; } .ytp-lang-check { width: 16px; height: 16px; fill: #fff; opacity: 0; flex-shrink: 0; }
.ytp-lang-option.active .ytp-lang-check { opacity: 1; } .ytp-lang-option.active .ytp-lang-check { opacity: 1; }
/* ── Synced lyrics overlay (one line at a time, bottom-anchored) ── */
.ytp-lyrics-overlay {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 4;
display: flex; align-items: flex-end; justify-content: center;
pointer-events: none; padding: 0 4% 6%;
}
.ytp-lyrics-panel {
max-width: 92%; text-align: center;
padding: 12px 26px; border-radius: 14px;
background: rgba(0,0,0,.5);
-webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
box-shadow: 0 8px 30px rgba(0,0,0,.45);
transition: opacity .25s ease;
}
.ytp-lyrics-cur {
font-family: 'Poppins', system-ui, -apple-system, sans-serif; font-weight: 800;
font-size: clamp(18px, 2.7vw, 30px); line-height: 1.3; letter-spacing: .2px;
text-shadow: 0 2px 10px rgba(0,0,0,.85);
}
.ytp-lyrics-word { color: rgba(255,255,255,.5); transition: color .12s ease, text-shadow .12s ease; }
.ytp-lyrics-word.sung { color: #fff; text-shadow: 0 0 14px rgba(255,45,45,.55), 0 2px 10px rgba(0,0,0,.85); }
.ytp-lyrics-deco {
display: inline-block; opacity: .9; margin: 0 .12em;
filter: drop-shadow(0 2px 6px rgba(0,0,0,.6));
animation: ytpLyrPulse 1.8s ease-in-out infinite;
}
.ytp-lyrics-deco.trail { animation-delay: .9s; }
@keyframes ytpLyrPulse { 0%,100% { transform: scale(1); opacity: .85; } 50% { transform: scale(1.18); opacity: 1; } }
.ytp-lyrics-status {
font-family: 'Poppins', system-ui, sans-serif; font-weight: 600; font-size: 15px;
color: rgba(255,255,255,.85); display: flex; align-items: center; gap: 10px; justify-content: center;
}
.ytp-lyrics-status .spin {
width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff; border-radius: 50%; animation: ytpLyrSpin .8s linear infinite;
}
@keyframes ytpLyrSpin { to { transform: rotate(360deg); } }
.ytp-lyrics-btn.active svg { fill: #ff2d2d; }
/* Live generation progress bar */
.ytp-lyrics-gen {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 4;
display: flex; align-items: flex-end; justify-content: center;
pointer-events: none; padding: 0 4% 6%;
}
.ytp-lyrics-gen-inner {
min-width: 280px; max-width: 84%;
background: rgba(0,0,0,.55); -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
border-radius: 14px; padding: 12px 20px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
}
.ytp-lyrics-gen-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 9px;
color: #fff; font-family: 'Poppins', system-ui, sans-serif; font-weight: 600; font-size: 14px;
}
.ytp-lyrics-gen-label { flex: 1; text-align: left; }
.ytp-lyrics-gen-pct { color: #ff6b6b; font-weight: 700; }
.ytp-lyrics-gen-spark { animation: ytpLyrPulse 1.4s ease-in-out infinite; }
.ytp-lyrics-gen-track { height: 6px; background: rgba(255,255,255,.18); border-radius: 4px; overflow: hidden; }
.ytp-lyrics-gen-bar {
height: 100%; width: 0%; border-radius: 4px;
background: linear-gradient(90deg, #e61e1e, #ff6b6b);
transition: width .35s ease;
}
.ytp-settings-wrap { position: relative; } .ytp-settings-wrap { position: relative; }
.ytp-settings-panel { .ytp-settings-panel {
display: none; position: absolute; bottom: 44px; right: 0; display: none; position: absolute; bottom: 44px; right: 0;
@ -419,7 +596,20 @@
.ytp-wrap { border-radius: 0; } .ytp-wrap { border-radius: 0; }
.ytp-button { width: 32px; height: 32px; } .ytp-button { width: 32px; height: 32px; }
.ytp-button svg { width: 18px; height: 18px; } .ytp-button svg { width: 18px; height: 18px; }
.ytp-time-display { font-size: 11px; padding: 0 4px; } .ytp-time-display { font-size: 11px; padding: 0 3px; }
.ytp-left-controls, .ytp-right-controls { gap: 1px; }
/* The control bar was overflowing on phones and pushing the fullscreen button
off-screen. Hide the two decorative/niche controls (visualiser + loop) on
mobile so play, lyrics, language, speed and FULLSCREEN always fit. */
.audio-bars-btn, .ytp-loop-btn { display: none !important; }
/* Lyrics overlay: smaller font + single line on phones so a long verse never
wraps and pushes the panel off the bottom of the artwork. */
.ytp-lyrics-cur {
font-size: clamp(12px, 3.6vw, 16px);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ytp-lyrics-panel { padding: 8px 14px; max-width: 96%; }
.ytp-lyrics-overlay { padding: 0 2% 5%; }
} }
</style> </style>
@ -561,6 +751,17 @@ settingsBtn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const open = settingsPanel.classList.toggle('open'); const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; } if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
/* Sync the mini-player toggle row's label to the current preference each
time the gear opens, so reloading the page or changing it from another
player keeps the indicator honest. */
if (open) {
const miniRow = document.getElementById('ytpMiniToggleRow');
if (miniRow) {
const v = miniRow.querySelector('.ytp-settings-val');
const on = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (v) v.textContent = on ? 'On' : 'Off';
}
}
clearTimeout(hideTimer); clearTimeout(hideTimer);
}); });
document.addEventListener('click', e => { document.addEventListener('click', e => {
@ -654,6 +855,12 @@ if (langBtn && langPopup) {
// Update download links // Update download links
const dlUrl = opt.dataset.langDlUrl; const dlUrl = opt.dataset.langDlUrl;
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; }); if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
// Show this track's lyrics (from inline data)
if (window._lyricsShow) window._lyricsShow(parseInt(opt.dataset.langId, 10) || 0);
// Swap the slideshow to this track's slides (with server-side fallback).
if (window._applySlidesForTrack) window._applySlidesForTrack(opt.dataset.langId);
}); });
}); });
@ -957,8 +1164,12 @@ function stopBars() {
} }
// ── Crossfade slideshow ─────────────────────────────────────── // ── Crossfade slideshow ───────────────────────────────────────
// Variables hoisted outside the if-block so the SPA update hook can access them // Variables hoisted outside the if-block so the SPA update hook can access them.
const SLIDE_URLS = @json($slideUrls); // SLIDE_MAP is keyed by track id ("0" = primary). Each entry already encodes the
// fallback decided server-side by Video::slidesForTrack(), so a track with no
// slides of its own gets the primary's (or a sibling's) automatically.
window._SLIDE_MAP = @json($slideMap);
const SLIDE_URLS = (window._SLIDE_MAP['0'] || []).slice();
const slideA = document.getElementById('slideA'); const slideA = document.getElementById('slideA');
const slideB = document.getElementById('slideB'); const slideB = document.getElementById('slideB');
let currentSlide = 0; let currentSlide = 0;
@ -997,6 +1208,40 @@ function stopSlideshow() {
slideshowTimer = null; slideshowTimer = null;
} }
// Replace the slide list at runtime — called when the user switches audio tracks.
// Server-side fallback already applied (Video::slidesForTrack), so we trust the
// incoming list verbatim. Empty → hide slideshow and show the cover image.
window._applySlidesForTrack = function (trackId) {
var key = String(parseInt(trackId, 10) || 0);
var next = (window._SLIDE_MAP && window._SLIDE_MAP[key]) ? window._SLIDE_MAP[key].slice() : [];
var coverEl = document.getElementById('audioCoverImg');
var slideshowEl = document.getElementById('slideshowWrap');
stopSlideshow();
SLIDE_URLS.length = 0;
next.forEach(function (u) { SLIDE_URLS.push(u); });
slideOrientations.length = 0;
for (var i = 0; i < Math.max(SLIDE_URLS.length, 1); i++) slideOrientations.push(false);
if (SLIDE_URLS.length > 1) {
if (coverEl) coverEl.style.display = 'none';
if (slideshowEl) slideshowEl.style.display = '';
currentSlide = 0; aIsTop = true;
if (slideA) { slideA.style.transition = 'none'; slideA.src = SLIDE_URLS[0]; slideA.style.opacity = '1'; slideA.style.zIndex = '2'; }
if (slideB) { slideB.style.transition = 'none'; slideB.src = SLIDE_URLS[1] || SLIDE_URLS[0]; slideB.style.opacity = '0'; slideB.style.zIndex = '1'; }
requestAnimationFrame(function () { if (slideA) slideA.style.transition = ''; if (slideB) slideB.style.transition = ''; });
SLIDE_URLS.forEach(function (url, idx) {
var img = new Image();
img.onload = function () { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
img.src = url;
});
if (!audio.paused) startSlideshow();
} else {
if (slideshowEl) slideshowEl.style.display = 'none';
if (coverEl) coverEl.style.display = '';
}
};
// Always attach listeners; functions guard themselves with SLIDE_URLS.length check // Always attach listeners; functions guard themselves with SLIDE_URLS.length check
audio.addEventListener('play', startSlideshow); audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow); audio.addEventListener('pause', stopSlideshow);
@ -1038,11 +1283,15 @@ if (SLIDE_URLS.length > 1 && slideA) {
// ── SPA update hook — called by recTransitionTo / plTransitionTo ────────── // ── SPA update hook — called by recTransitionTo / plTransitionTo ──────────
window._audioPlayerUpdate = function(d) { window._audioPlayerUpdate = function(d) {
var newSlides = (d.slides && d.slides.length > 1) ? d.slides : []; // Adopt the new song's per-track slide map. Fallback already applied server-side.
window._SLIDE_MAP = d.slide_map || { '0': (d.slides || []) };
var newSlides = (window._SLIDE_MAP['0'] && window._SLIDE_MAP['0'].length > 1)
? window._SLIDE_MAP['0'] : [];
var coverEl = document.getElementById('audioCoverImg'); var coverEl = document.getElementById('audioCoverImg');
var slideshowEl = document.getElementById('slideshowWrap'); var slideshowEl = document.getElementById('slideshowWrap');
stopSlideshow(); stopSlideshow();
if (window._lyricsStop) window._lyricsStop(); // kill prev song's lyrics polling — don't pile up
SLIDE_URLS.length = 0; SLIDE_URLS.length = 0;
if (newSlides.length > 1) { if (newSlides.length > 1) {
@ -1175,6 +1424,10 @@ window._audioPlayerUpdate = function(d) {
// Update download links // Update download links
var dlUrl = opt.dataset.langDlUrl; var dlUrl = opt.dataset.langDlUrl;
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(function(l){ l.href = dlUrl; }); if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(function(l){ l.href = dlUrl; });
// Show this track's lyrics (from inline data)
if (window._lyricsShow) window._lyricsShow(parseInt(opt.dataset.langId, 10) || 0);
// Swap the slideshow to this track's slides (with server-side fallback).
if (window._applySlidesForTrack) window._applySlidesForTrack(opt.dataset.langId);
}); });
}); });
@ -1200,6 +1453,19 @@ window._audioPlayerUpdate = function(d) {
if (scrubber) scrubber.parentElement.style.left = '0%'; if (scrubber) scrubber.parentElement.style.left = '0%';
if (timeCur) timeCur.textContent = '0:00'; if (timeCur) timeCur.textContent = '0:00';
if (timeDur && d.duration) timeDur.textContent = fmt(d.duration); if (timeDur && d.duration) timeDur.textContent = fmt(d.duration);
// New song → retarget the lyrics generate/poll endpoints to THIS song.
// (The button's URL + poll key were baked in at server render for the first
// song; without this, generating after navigation hits the previous song.)
if (d.key) {
window._ROUTE_KEY = d.key;
var _genBtn = document.getElementById('ytpGenLyricsBtn');
if (_genBtn) _genBtn.dataset.genUrl = '/videos/' + d.key + '/lyrics/generate';
}
// New song → swap in its lyrics map (embedded in player-data) and show primary
window._LYRICS = d.lyrics || {};
if (window._lyricsShow) window._lyricsShow(0);
}; };
// ── Init ───────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────
@ -1222,6 +1488,14 @@ audio.addEventListener('playing', function restoreSound() {
audio.addEventListener('loadedmetadata', () => { audio.addEventListener('loadedmetadata', () => {
timeDur.textContent = fmt(audio.duration); timeDur.textContent = fmt(audio.duration);
/* Resume handoff from the mini player: ?t=<sec> seeks to that position
before play starts. ?resume=1 is implicit (the audio player already
autoplays); we only need to honor the time. */
try {
const qs = new URLSearchParams(location.search);
const t = parseInt(qs.get('t') || '0', 10);
if (t > 0 && t < audio.duration) audio.currentTime = t;
} catch (e) {}
const p = audio.play(); const p = audio.play();
if (p) p.catch(() => { if (p) p.catch(() => {
audio.muted = true; audio.muted = true;
@ -1229,5 +1503,466 @@ audio.addEventListener('loadedmetadata', () => {
}); });
}); });
// ── Synced lyrics overlay (one line at a time, bottom-anchored) ──
(function initLyrics() {
const overlay = document.getElementById('ytpLyricsOverlay');
const panel = document.getElementById('ytpLyricsPanel');
const curEl = document.getElementById('ytpLyrCur');
const btn = document.getElementById('ytpLyricsBtn');
// The owner generate/regenerate button now lives in video-actions (next to
// Edit) — look it up lazily so it works regardless of DOM order / SPA swaps.
const gb = () => document.getElementById('ytpGenLyricsBtn');
if (!overlay || !curEl || !btn) return;
let lines = [], activeLine = -2, curWordEls = [];
let enabled = localStorage.getItem('ytpLyricsOn') === '1';
let genPoll = null;
// Pick an emoji that reflects what the line is ABOUT (first keyword match
// wins; more specific themes are listed first). Falls back to a music note.
const EMOJI_MAP = [
[/\b(heart\s?broken|heartbreak|broke\w*\s+\w*\s*heart|broken heart)\b/i, '💔'],
[/\b(love|lovin|loving|adore|sweetheart|my heart|in love|darling)\b/i, '❤️'],
[/\b(kiss|kisses|kissing|lips|lip gloss)\b/i, '💋'],
[/\b(baby|babe|honey|boo)\b/i, '💕'],
[/\b(fire|flame|flames|burn|burning|burns|lit|blaze|blazing)\b/i, '🔥'],
[/\b(cry|crying|cried|tears|teardrop|weep|weeping)\b/i, '😢'],
[/\b(sad|pain|painful|hurt|hurts|hurting|sorrow|lonely|alone|broken)\b/i, '😔'],
[/\b(smile|smiling|happy|happiness|joy|joyful|laugh|laughing)\b/i, '😊'],
[/\b(dance|dancing|dancin|groove|sway|move your)\b/i, '💃'],
[/\b(party|partying|club|celebrate|turn up|tonight we)\b/i, '🎉'],
[/\b(money|cash|rich|dollars?|gold|paid|bands|wealth|diamonds?)\b/i, '💰'],
[/\b(king|queen|crown|royal|royalty|throne)\b/i, '👑'],
[/\b(night|midnight|tonight|nighttime|dark|darkness|shadows?)\b/i, '🌙'],
[/\b(sun|sunshine|sunrise|daylight|bright)\b/i, '☀️'],
[/\b(star|stars|shine|shining|shinin|sparkle|glitter|glow)\b/i, '✨'],
[/\b(heaven|heavens|sky|skies|clouds?)\b/i, '☁️'],
[/\b(rain|raining|rainin|storm|stormy|thunder)\b/i, '🌧️'],
[/\b(cold|ice|icy|frozen|freeze|winter|snow)\b/i, '❄️'],
[/\b(ocean|sea|waves?|water|river|drown|drowning)\b/i, '🌊'],
[/\b(rose|roses|flower|flowers|bloom|petals?)\b/i, '🌹'],
[/\b(dream|dreams|dreaming|dreamin|asleep|sleep)\b/i, '💭'],
[/\b(fly|flying|flyin|wings|soar|rise|rising)\b/i, '🕊️'],
[/\b(rocket|space|moon|sky high|to the moon)\b/i, '🚀'],
[/\b(run|running|runnin|ran|escape|escaping|away)\b/i, '🏃'],
[/\b(time|clock|hours?|minutes?|forever|seconds?)\b/i, '⏳'],
[/\b(god|pray|prayin|prayer|soul|angel|angels|amen|bless)\b/i, '🙏'],
[/\b(home|house|hometown)\b/i, '🏠'],
[/\b(road|drive|driving|drivin|car|ride|riding|highway)\b/i, '🚗'],
[/\b(wild|crazy|insane|reckless|savage|chaos)\b/i, '🤪'],
[/\b(strong|power|powerful|stronger|unstoppable)\b/i, '💪'],
[/\b(eyes?|look|looking|lookin|stare|staring|gaze)\b/i, '👀'],
[/\b(drink|wine|champagne|whiskey|drunk|toast|cheers)\b/i, '🥂'],
[/\b(war|fight|fighting|battle|enemy|enemies|trouble)\b/i, '⚔️'],
[/\b(devil|hell|sin|sinner|demons?)\b/i, '😈'],
[/\b(phone|call|calling|text|message|ring)\b/i, '📱'],
[/\b(music|song|sing|singing|singin|melody|beat|rhythm|sound)\b/i, '🎶'],
];
function emojiForLine(text) {
const s = ' ' + String(text || '') + ' ';
for (let i = 0; i < EMOJI_MAP.length; i++) { if (EMOJI_MAP[i][0].test(s)) return EMOJI_MAP[i][1]; }
return '🎵';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}
function showBtn(show) {
btn.style.display = show ? '' : 'none';
if (!show) { overlay.style.display = 'none'; btn.classList.remove('active'); }
}
function applyEnabled() {
btn.classList.toggle('active', enabled);
overlay.style.display = (enabled && lines.length) ? 'flex' : 'none';
}
function render(data) {
lines = (data && data.lines) ? data.lines : [];
activeLine = -2; curWordEls = [];
curEl.innerHTML = '';
if (!lines.length) { showBtn(false); return; }
showBtn(true);
applyEnabled();
sync(true);
}
function findLine(t) {
let idx = -1;
for (let i = 0; i < lines.length; i++) { if (lines[i].start <= t) idx = i; else break; }
return idx;
}
// Render only the single active line, wrapped in cycling decorative emojis.
function paintLine(idx) {
if (idx < 0 || !lines[idx]) {
// No line yet (before the song's first lyric) — hide the panel so
// there's no empty black box sitting on the artwork.
curEl.innerHTML = '';
curWordEls = [];
if (panel) panel.style.display = 'none';
return;
}
if (panel) panel.style.display = '';
const ln = lines[idx];
const words = (ln.words && ln.words.length) ? ln.words : null;
const inner = words
? words.map((w, j) => '<span class="ytp-lyrics-word" data-i="' + j + '">' + escapeHtml(w.text) + '</span>').join(' ')
: '<span class="ytp-lyrics-word">' + escapeHtml(ln.text || '') + '</span>';
// If the LLM decorated the line at generation time, emojis are already
// inside ln.text — render it bare without the lead/trail wrap so we
// don't double-stack decorations. Old un-decorated lyrics keep the
// keyword-emoji fallback (or a baked single emoji from the v1 format).
if (ln.decorated) {
curEl.innerHTML = inner;
} else {
const e = (ln.emoji && String(ln.emoji).trim()) || emojiForLine(ln.text);
curEl.innerHTML = '<span class="ytp-lyrics-deco lead">' + e + '</span>' + inner
+ '<span class="ytp-lyrics-deco trail">' + e + '</span>';
}
curWordEls = [].slice.call(curEl.querySelectorAll('.ytp-lyrics-word'));
}
function sync(force) {
if (!enabled || !lines.length) return;
const t = audio.currentTime || 0;
let idx = findLine(t);
// During an instrumental gap (well past the current line and the next one
// hasn't started) hide the line instead of leaving it frozen on screen.
if (idx >= 0 && t > lines[idx].end + 2.5) {
const next = lines[idx + 1];
if (!next || next.start - t > 0.4) idx = -1;
}
if (idx !== activeLine || force) {
paintLine(idx);
activeLine = idx;
}
if (idx >= 0 && curWordEls.length) {
const ws = lines[idx].words || [];
curWordEls.forEach((span, j) => { const w = ws[j]; if (w) span.classList.toggle('sung', t >= w.start); });
}
}
audio.addEventListener('timeupdate', () => sync(false));
audio.addEventListener('seeked', () => sync(true));
btn.addEventListener('click', () => {
enabled = !enabled;
localStorage.setItem('ytpLyricsOn', enabled ? '1' : '0');
applyEnabled();
if (enabled) sync(true);
});
// Owner-only generate button + live progress panel.
const genWrap = document.getElementById('ytpLyrGen');
const genBar = document.getElementById('ytpLyrGenBar');
const genLabel = document.getElementById('ytpLyrGenLabel');
const genPct = document.getElementById('ytpLyrGenPct');
let dispPct = 0, targetPct = 0, creepTimer = null;
function showGen(show, mode) {
const b = gb();
if (!b) return;
b.style.display = show ? '' : 'none';
const label = mode === 'regen' ? 'Regenerate lyrics' : 'Generate lyrics';
b.title = label;
b.classList.toggle('is-regen', mode === 'regen');
const lbl = b.querySelector('.genlyr-label');
if (lbl) lbl.textContent = label;
}
function showGenProgress(on) {
if (genWrap) genWrap.style.display = on ? 'flex' : 'none';
if (on && overlay) overlay.style.display = 'none'; // avoid overlapping the lyrics panel
if (!on && creepTimer) { clearInterval(creepTimer); creepTimer = null; }
}
function setBar(p) {
if (genBar) genBar.style.width = p + '%';
if (genPct) genPct.textContent = Math.round(p) + '%';
}
// Stop any in-flight generation polling/timers (called on navigation + track
// switches so nothing piles up as the user moves between songs).
window._lyricsStop = function () {
if (genPoll) { clearInterval(genPoll.timer); genPoll = null; }
if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
showGenProgress(false);
};
// Show lyrics for a track id (0 = primary) straight from the inline map —
// no network request. window._LYRICS is keyed by track id as a string.
window._lyricsShow = function (trackId) {
const tid = parseInt(trackId, 10) || 0;
// Switching to a different track → kill a poll left running for another track.
if (genPoll && genPoll.track !== tid) window._lyricsStop();
const d = (window._LYRICS || {})[String(tid)];
if (d && d.status === 'ready' && d.lines && d.lines.length) {
// Lyrics exist → show them, keep the owner's "Regenerate" + "Edit" buttons.
render(d); showGenProgress(false); showGen(true, 'regen'); showEdit(true); showDelete(true);
} else if (d && d.status === 'processing') {
// Generation already running (e.g. right after upload) — show the live bar.
lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
if (genWrap) { if (!genPoll || genPoll.track !== tid) startGenPoll(tid); }
else { showGen(false); }
} else {
lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
if (genPoll && genPoll.track === tid) { /* in flight — keep the bar */ }
else { showGenProgress(false); showGen(!!gb()); }
}
};
// Owner clicks the generate/regenerate button (delegated so it works wherever
// the button is rendered and after SPA content swaps) → kick off generation,
// then watch the live progress bar in the player.
document.addEventListener('click', function (ev) {
const b = ev.target.closest && ev.target.closest('#ytpGenLyricsBtn');
if (!b) return;
ev.preventDefault();
// Close the settings panel if the click came from inside the gear menu.
const sp = document.getElementById('ytpSettingsPanel');
if (sp) sp.classList.remove('open');
const track = window._ytpTrackId || 0;
const existing = (window._LYRICS || {})[String(track)];
const isRegen = !!(existing && existing.status === 'ready' && existing.lines && existing.lines.length);
const restore = () => showGen(true, isRegen ? 'regen' : 'gen');
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
const url = b.dataset.genUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics/generate');
showGen(false); showGenProgress(true);
dispPct = 1; targetPct = 1; setBar(1);
if (genLabel) genLabel.textContent = isRegen ? 'Regenerating…' : 'Starting…';
if (window.showToast) window.showToast((isRegen ? 'Regenerating' : 'Generating') + ' lyrics…', 'info');
fetch(url + '?track=' + track, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
})
.then(r => r.json())
.then(res => {
if (res.error) { showGenProgress(false); restore(); if (window.showToast) window.showToast(res.error, 'error'); return; }
startGenPoll(track);
})
.catch(() => { showGenProgress(false); restore(); if (window.showToast) window.showToast('Could not start generation.', 'error'); });
});
// Poll the live progress endpoint; creep the bar between polls so it always moves.
function startGenPoll(track) {
if (genPoll) clearInterval(genPoll.timer);
showGen(false); showGenProgress(true);
dispPct = Math.max(dispPct, 1); targetPct = Math.max(targetPct, 1);
if (genLabel) genLabel.textContent = 'Generating lyrics…';
setBar(dispPct);
if (creepTimer) clearInterval(creepTimer);
creepTimer = setInterval(() => {
const soft = Math.min(targetPct + 6, 97);
if (dispPct < soft) { dispPct = Math.min(soft, dispPct + 0.6); setBar(dispPct); }
}, 220);
const key = window._ROUTE_KEY || @json($video->getRouteKey());
let misses = 0, ticks = 0;
const timer = setInterval(() => {
// Hard cap (~8 min) so a stuck job can never leave a poll running forever.
if (++ticks > 320) { window._lyricsStop(); showGen(!!gb()); return; }
fetch('/videos/' + key + '/lyrics/progress?track=' + track, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(p => {
if (p.status === 'ready') {
targetPct = 100; dispPct = 100; setBar(100);
clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
genPoll = null;
fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(d => {
window._LYRICS = d.lyrics || window._LYRICS;
setTimeout(() => {
showGenProgress(false);
if ((window._ytpTrackId || 0) === track) window._lyricsShow(track); else showGen(true);
if (window.showToast) window.showToast('Lyrics ready!', 'success');
}, 400);
})
.catch(() => { showGenProgress(false); window._lyricsShow(track); });
} else if (p.status === 'failed') {
clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
genPoll = null; showGenProgress(false); showGen(true);
if (window.showToast) window.showToast('Lyrics generation failed.', 'error');
} else if (p.status === 'none') {
if (++misses > 6) { clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; } genPoll = null; showGenProgress(false); showGen(true); }
} else {
misses = 0;
if (typeof p.pct === 'number') targetPct = Math.max(targetPct, p.pct);
if (genLabel && p.stage) genLabel.textContent = p.stage + '…';
}
})
.catch(() => {});
}, 1500);
genPoll = { track: track, timer: timer };
}
// ── Lyrics editor (owner) ──────────────────────────────────────────────
function showEdit(show) {
const e = document.getElementById('ytpEditLyricsBtn');
if (e) e.style.display = show ? '' : 'none';
}
function showDelete(show) {
const e = document.getElementById('ytpDeleteLyricsBtn');
if (e) e.style.display = show ? '' : 'none';
}
// Owner clicks Delete lyrics (gear menu) → wipe local + NAS copy, reset the
// overlay so they can regenerate from scratch. Confirmation via toast-style
// inline question; we never use alert/confirm (project rule).
document.addEventListener('click', function (ev) {
const b = ev.target.closest && ev.target.closest('#ytpDeleteLyricsBtn');
if (!b) return;
ev.preventDefault();
const sp = document.getElementById('ytpSettingsPanel');
if (sp) sp.classList.remove('open');
const track = window._ytpTrackId || 0;
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
const url = b.dataset.delUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics');
if (window.showToast) window.showToast('Deleting lyrics…', 'info');
fetch(url + '?track=' + track, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' },
})
.then(r => r.json())
.then(res => {
if (res.error) {
if (window.showToast) window.showToast(res.error, 'error');
return;
}
// Clear cached lyrics for this track so the overlay disappears and
// the gear button flips back to "Generate" (not "Regenerate").
if (window._LYRICS) delete window._LYRICS[String(track)];
lines = []; activeLine = -2; curWordEls = [];
curEl.innerHTML = '';
if (panel) panel.style.display = 'none';
overlay.style.display = 'none';
showBtn(false); showEdit(false); showDelete(false);
showGenProgress(false); showGen(!!gb(), 'gen');
if (window.showToast) window.showToast('Lyrics deleted. Click Generate to start fresh.', 'success');
})
.catch(() => { if (window.showToast) window.showToast('Could not delete lyrics.', 'error'); });
});
const edOverlay = document.getElementById('lyrEditorOverlay');
const edBody = document.getElementById('lyrEditorBody');
let edTrack = 0, edLines = [];
function fmtTime(s) {
s = Math.max(0, Math.floor(s || 0));
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
}
window.openLyricsEditor = function () {
const tid = window._ytpTrackId || 0;
const d = (window._LYRICS || {})[String(tid)];
if (!d || d.status !== 'ready' || !d.lines || !d.lines.length) {
if (window.showToast) window.showToast('Generate lyrics first.', 'info');
return;
}
edTrack = tid;
edLines = JSON.parse(JSON.stringify(d.lines)); // editable copy (keeps words)
edBody.innerHTML = '';
edLines.forEach((ln, i) => {
const row = document.createElement('div');
row.className = 'lyr-editor-row';
const time = document.createElement('span');
time.className = 'lyr-editor-time';
time.textContent = fmtTime(ln.start);
time.title = 'Jump to this line';
time.addEventListener('click', () => { try { audio.currentTime = ln.start; audio.play().catch(()=>{}); } catch(e){} });
const inp = document.createElement('input');
inp.className = 'lyr-editor-input';
inp.type = 'text';
inp.value = ln.text || '';
inp.addEventListener('input', () => { edLines[i].text = inp.value; });
row.appendChild(time); row.appendChild(inp);
edBody.appendChild(row);
});
edOverlay.style.display = 'flex';
};
function closeEditor() { if (edOverlay) edOverlay.style.display = 'none'; }
if (edOverlay) {
document.getElementById('lyrEditorClose').addEventListener('click', closeEditor);
document.getElementById('lyrEditorCancel').addEventListener('click', closeEditor);
edOverlay.addEventListener('click', e => { if (e.target === edOverlay) closeEditor(); });
document.getElementById('lyrEditorSave').addEventListener('click', () => {
const saveBtn = document.getElementById('lyrEditorSave');
saveBtn.disabled = true;
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
const key = window._ROUTE_KEY || @json($video->getRouteKey());
fetch('/videos/' + key + '/lyrics/save', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ track: edTrack, lines: edLines })
})
.then(r => r.json())
.then(res => {
saveBtn.disabled = false;
if (res.error) { if (window.showToast) window.showToast(res.error, 'error'); return; }
// Reload the fresh (re-timed) lyrics and re-render.
fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(d => {
window._LYRICS = d.lyrics || window._LYRICS;
if ((window._ytpTrackId || 0) === edTrack) window._lyricsShow(edTrack);
})
.catch(() => {});
closeEditor();
if (window.showToast) window.showToast('Lyrics saved.', 'success');
})
.catch(() => { saveBtn.disabled = false; if (window.showToast) window.showToast('Could not save lyrics.', 'error'); });
});
}
// Lyrics for the server-rendered song, embedded inline (keyed by track id).
window._LYRICS = @json($inlineLyrics, JSON_UNESCAPED_UNICODE);
window._lyricsShow(window._ytpTrackId || 0);
})();
/* ── Scroll-based mini player for the music view ────────────────────────
Mirrors the IntersectionObserver in video-player.blade.php. Activates the
global #ytpMini once playback has started AND the player wrap leaves the
viewport; deactivates when it scrolls back in.
Deferred to DOMContentLoaded because window._miniPlayer is defined in the
layout's footer scripts, which parse after this partial. */
function _setupAudioMiniObserver() {
if (!window.IntersectionObserver || !window._miniPlayer) return;
var wrapEl = document.getElementById('ytpWrap');
var aEl = document.getElementById('audioEl');
if (!wrapEl || !aEl) return;
/* Floating mini player is desktop-only on mobile the fixed bottom-nav
and the locked scroll model make a floating overlay disruptive. */
if (window.innerWidth <= 768) return;
var _scrollRoot = null; /* desktop: window scrolls */
var _scrollMiniOn = false;
var _hasPlayed = !aEl.paused;
aEl.addEventListener('play', function () { _hasPlayed = true; });
new IntersectionObserver(function (entries) {
var e0 = entries[0];
var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (!e0.isIntersecting && !_scrollMiniOn && _hasPlayed && miniAllowed && !window._miniPlayer.isNavMode()) {
_scrollMiniOn = true;
window._miniPlayer.activateScroll(
document.title.replace(/\s*\|.*$/, '').trim(),
window.location.href
);
} else if (e0.isIntersecting && _scrollMiniOn) {
_scrollMiniOn = false;
window._miniPlayer.deactivateScroll();
}
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrapEl);
/* User clicked the X on the mini while still on this page reset our
local flag so a subsequent scroll-away re-activates the mini. */
window.addEventListener('miniplayer:scroll-closed', function () {
_scrollMiniOn = false;
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _setupAudioMiniObserver);
} else {
_setupAudioMiniObserver();
}
})(); })();
</script> </script>

View File

@ -1,20 +1,25 @@
@extends('layouts.app') @extends('layouts.app')
@section('main_class', 'video-view-page') @section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name')) @php
$metaTitle = $shareTitle ?? $video->title;
$metaDesc = $shareDescription ?? $video->description;
$metaUrl = $video->share_url;
@endphp
@section('title', $metaTitle . ' | ' . config('app.name'))
@push('head') @push('head')
<meta property="og:title" content="{{ $video->title }}"> <meta property="og:title" content="{{ $metaTitle }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}"> <meta property="og:description" content="{{ Str::limit(strip_tags($metaDesc ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}"> <meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}"> <meta property="og:url" content="{{ $metaUrl }}">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}"> <meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}"> <meta name="twitter:title" content="{{ $metaTitle }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}"> <meta name="twitter:description" content="{{ Str::limit(strip_tags($metaDesc ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}"> <meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush @endpush
@ -226,6 +231,52 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.video-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn,
.comments-section .action-btn {
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.action-btn:hover,
.comments-section .action-btn:hover {
background: var(--border-color);
transform: translateY(-1px);
}
.action-btn:active,
.comments-section .action-btn:active {
transform: translateY(0);
}
.action-btn svg,
.action-btn i,
.comments-section .action-btn svg,
.comments-section .action-btn i {
flex-shrink: 0;
}
.action-btn.comment-btn {
background: var(--brand-red);
color: white;
border-color: var(--brand-red);
}
/* Description */ /* Description */
@ -374,7 +425,15 @@
margin: 12px 0 6px !important; margin: 12px 0 6px !important;
} }
.channel-row {
flex-direction: column;
align-items: flex-start !important;
gap: 12px;
}
.channel-info {
width: 100%;
}
.subscribe-btn { .subscribe-btn {
width: 100%; width: 100%;
@ -418,7 +477,7 @@
<!-- Video Section --> <!-- Video Section -->
<div class="yt-video-section"> <div class="yt-video-section">
<!-- Video Player --> {{-- Generic video: always a single video track (mp4/HLS), GPU-encoded server-side --}}
<x-video-player :video="$video" <x-video-player :video="$video"
:next-video="$nextVideo ?? null" :next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null" :previous-video="$previousVideo ?? null"
@ -426,9 +485,11 @@
:playlist-videos="$playlistVideos ?? null" /> :playlist-videos="$playlistVideos ?? null" />
<!-- Video Title with Film Icon (Generic Type) --> <!-- Video Title with Film Icon (Generic Type) -->
@php $titleLangFlag = \App\Data\Languages::flag($video->language); @endphp
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;"> <h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
<i class="bi bi-film" style="color: #ef4444;"></i> <i class="bi bi-film" style="color: #ef4444; flex-shrink:0;"></i>
<span>{{ $video->title }}</span> <span class="fi fi-{{ $titleLangFlag ?: 'xx' }}" id="videoTitleFlag" style="width:22px;height:16px;border-radius:2px;display:{{ $titleLangFlag ? 'inline-block' : 'none' }};flex-shrink:0;"></span>
<span id="videoTitleText" data-primary-title="{{ $video->title }}">{{ $video->title }}</span>
</h1> </h1>
<!-- Stats Row - Hidden, shown in description box --> <!-- Stats Row - Hidden, shown in description box -->
@ -440,11 +501,13 @@
</div> </div>
</div> </div>
<!-- Channel Row - All in one line -->
<x-channel-row :video="$video" /> <x-channel-row :video="$video" />
@include('videos.partials.description-box', ['video' => $video]) @include('videos.partials.description-box', ['video' => $video])
<x-video-comments :video="$video" /> <x-video-comments :video="$video" />
</div> </div>
<!-- Sidebar - Up Next / Recommendations --> <!-- Sidebar - Up Next / Recommendations -->
@ -493,22 +556,24 @@
function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; } function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; }
function plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); } function plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); }
var plAutoplay = plGet('autoplay','1'); var plAutoplay = plGet('autoplay','1');
var plLoop = plGet('loop','off'); var plLoop = plGet('loop','off');
var plShuffle = plGet('shuffle','0'); var plShuffle = plGet('shuffle','0');
var plTransiting = false; var plTransiting = false;
// ── shuffle helpers ──────────────────────────────────────
function plShuffleOrder(){ var o=PL_VIDEOS.map(function(_,i){return i;}); for(var i=o.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=o[i];o[i]=o[j];o[j]=t;} localStorage.setItem('pl_shuffleOrder_'+PL_ID,JSON.stringify(o)); return o; } function plShuffleOrder(){ var o=PL_VIDEOS.map(function(_,i){return i;}); for(var i=o.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=o[i];o[i]=o[j];o[j]=t;} localStorage.setItem('pl_shuffleOrder_'+PL_ID,JSON.stringify(o)); return o; }
function plGetOrder(){ var r=localStorage.getItem('pl_shuffleOrder_'+PL_ID); if(r){try{return JSON.parse(r);}catch(e){}} return null; } function plGetOrder(){ var r=localStorage.getItem('pl_shuffleOrder_'+PL_ID); if(r){try{return JSON.parse(r);}catch(e){}} return null; }
// ── compute adjacent URLs from current state ──────────────
function plAdj(curId) { function plAdj(curId) {
var idx=PL_VIDEOS.findIndex(function(v){ return v.id===curId; }); var idx = PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
if(plShuffle==='1'){ if (plShuffle==='1') {
var ord=plGetOrder()||plShuffleOrder(); var ord=plGetOrder()||plShuffleOrder();
var pos=ord.indexOf(idx); var pos=ord.indexOf(idx);
var pp=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1); var prevPos=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
var np=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1); var nextPos=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1);
return { prev:pp>=0?PL_VIDEOS[ord[pp]].url:'', next:np>=0?PL_VIDEOS[ord[np]].url:'' }; return { prev: prevPos>=0?PL_VIDEOS[ord[prevPos]].url:'', next: nextPos>=0?PL_VIDEOS[ord[nextPos]].url:'' };
} }
return { return {
prev: idx>0?PL_VIDEOS[idx-1].url:'', prev: idx>0?PL_VIDEOS[idx-1].url:'',
@ -516,27 +581,40 @@
}; };
} }
// ── time formatter ───────────────────────────────────────
function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; } function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
// ── SPA transition — load new video source + update UI ────
async function plTransitionTo(url, pushHist) { async function plTransitionTo(url, pushHist) {
if(!url||plTransiting) return; if (!url || plTransiting) return;
plTransiting=true; plTransiting = true;
if(window._spaScrollToVideo) window._spaScrollToVideo(); if (window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var m=url.match(/\/videos\/([^/?#]+)/); var m = url.match(/\/videos\/([^/?#]+)/);
if(!m){ window.location.href=url; return; } if (!m) { window.location.href=url; return; }
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):''; var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
var resp; var resp;
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); } try { resp = await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e){ window.location.href=url; return; } catch(e) { window.location.href=url; return; }
if(!resp.ok){ window.location.href=url; return; } if (!resp.ok) { window.location.href=url; return; }
var d; var d;
try { d=await resp.json(); } try { d = await resp.json(); }
catch(e){ window.location.href=url; return; } catch(e) { window.location.href=url; return; }
// reload video source (HLS or MP4) // Different player type? Fall back to hard nav so the
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); } // correct type view (music/match) handles playback.
catch(e){ console.warn('_ytpLoadSource', e); } if (d.type && d.type !== 'generic') { window.location.href = url; return; }
// preserve volume/muted across source swap
var vid = document.getElementById('videoPlayer');
var _savedVol = vid ? vid.volume : null;
var _savedMuted = vid ? vid.muted : null;
// swap video source (HLS preferred, MP4 fallback)
try { if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e) { console.warn('_ytpLoadSource', e); }
if (vid && _savedVol !== null) { vid.volume = _savedVol; vid.muted = _savedMuted; }
// reset progress bar // reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber'); var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
@ -547,40 +625,47 @@
if(dr&&d.duration) dr.textContent=plFmt(d.duration); if(dr&&d.duration) dr.textContent=plFmt(d.duration);
// update page title // update page title
var ts=document.querySelector('.video-title span'); var ts=document.getElementById('videoTitleText');
if(ts) ts.textContent=d.title; if(ts){ ts.textContent=d.title; ts.dataset.primaryTitle=d.title; }
document.title=d.title+' | {{ config("app.name") }}'; document.title=d.title+' | {{ config("app.name") }}';
// update loop state on video element // update loop state on video element
var vid=document.getElementById('videoPlayer');
if(vid) vid.loop=(plLoop==='one'); if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id; // update state
try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); } PL_CURRENT = d.id;
try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
// history
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url); if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
// async: swap description + comments from fetched page
plSwapContent(url); plSwapContent(url);
} catch(e){ } catch(e) {
console.warn('plTransitionTo',e); console.warn('plTransitionTo',e);
} finally { } finally {
plTransiting=false; plTransiting=false;
} }
} }
// ── background page swap: description + comments ──────────
async function plSwapContent(url) { async function plSwapContent(url) {
try { try {
var resp=await fetch(url,{headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}}); var resp = await fetch(url, {headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
var html=await resp.text(); var html = await resp.text();
var doc=new DOMParser().parseFromString(html,'text/html'); var doc = new DOMParser().parseFromString(html,'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap'); // swap description box
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();} var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row'); // swap channel row
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML; if(nc&&oc) oc.innerHTML=nc.innerHTML;
var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection'); // swap comments (HTML + re-run its init script)
if(ny&&oy){ var ny=doc.getElementById('ytcSection'), oy=document.getElementById('ytcSection');
if(ny&&oy) {
oy.innerHTML=ny.innerHTML; oy.innerHTML=ny.innerHTML;
var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');}); var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');});
if(ytcScript){ if(ytcScript){
@ -593,13 +678,27 @@
} catch(e){ console.warn('plSwapContent',e); } } catch(e){ console.warn('plSwapContent',e); }
} }
function plHighlight(activeId, scroll){ // ── sidebar highlight ────────────────────────────────────
function plHighlight(activeId, scroll) {
document.querySelectorAll('.sidebar-video-card[data-pl-id]').forEach(function(c){ document.querySelectorAll('.sidebar-video-card[data-pl-id]').forEach(function(c){
c.classList.toggle('current-video',parseInt(c.dataset.plId)===activeId); c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId);
}); });
if(scroll){ var a=document.querySelector('.sidebar-video-card.current-video'); if(a) a.scrollIntoView({behavior:'smooth',block:'nearest'}); } if(scroll) {
// Scroll only inside the sidebar list container — never the page —
// so focus stays on the player when the track changes.
var active = document.querySelector('.sidebar-video-card.current-video');
if (!active) return;
var list = active.closest('.recommended-videos-list') || active.parentElement;
if (list) {
var lr = list.getBoundingClientRect();
var ar = active.getBoundingClientRect();
var delta = (ar.top + ar.height/2) - (lr.top + lr.height/2);
list.scrollTo({ top: list.scrollTop + delta, behavior: 'smooth' });
}
}
} }
// ── render control button states ─────────────────────────
function plRender(){ function plRender(){
var adj=plAdj(PL_CURRENT); var adj=plAdj(PL_CURRENT);
var sb=document.getElementById('plShuffleBtn'),lb=document.getElementById('plLoopBtn'); var sb=document.getElementById('plShuffleBtn'),lb=document.getElementById('plLoopBtn');
@ -614,14 +713,15 @@
if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); } if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); }
if(nb) nb.disabled=!adj.next; if(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!adj.prev; if(pb) pb.disabled=!adj.prev;
// In-player prev/next buttons
document.querySelectorAll('.ytp-prev-btn').forEach(function(b){ b.disabled=!adj.prev; b.style.opacity=adj.prev?'':'0.4'; });
document.querySelectorAll('.ytp-next-btn').forEach(function(b){ b.disabled=!adj.next; b.style.opacity=adj.next?'':'0.4'; });
} }
window.plGoTo = function(url){ if(url) plTransitionTo(url); }; // ── public API ────────────────────────────────────────────
window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); }; window.plGoTo = function(url){ if(url) plTransitionTo(url); };
window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); }; window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); }; window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
// hook into video player: intercept next/prev and ended // hook into video player: intercept next/prev and ended
window._ytpNavOverride = { window._ytpNavOverride = {
@ -630,8 +730,14 @@
}; };
window._plOnVideoEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); }; window._plOnVideoEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); };
window.addEventListener('popstate',function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url,false); }); window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
// ── browser back/forward ──────────────────────────────────
window.addEventListener('popstate', function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url, false); });
// ── init ──────────────────────────────────────────────────
function plInit(){ function plInit(){
var v=document.getElementById('videoPlayer'); var v=document.getElementById('videoPlayer');
if(v&&plLoop==='one') v.loop=true; if(v&&plLoop==='one') v.loop=true;
@ -694,26 +800,164 @@
</a> </a>
@endif @endif
@else @else
{{-- Up Next header + autoplay toggle --}} <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;"> <h3 style="font-size:16px; font-weight:500; margin:0;">Up Next</h3>
<h3 style="font-size:16px; font-weight:500; margin:0;"> <button id="recAutoplayBtn" class="pl-ctrl-btn pl-ctrl-autoplay" title="Autoplay" onclick="recToggleAutoplay()" style="margin-left:auto;">
<i class="bi bi-collection-play" style="margin-right:6px;"></i>Up Next <i class="bi bi-play-circle"></i>
</h3> <span class="pl-autoplay-label">Autoplay</span>
<div class="pl-controls-bar" style="margin-bottom:0; flex-shrink:0;"> </button>
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="recAutoplayBtn"
title="Autoplay" onclick="recToggleAutoplay()">
<i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span>
</button>
</div>
</div> </div>
<script>
(function () {
var recTransiting = false;
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
// persists across sidebar swaps so already-autoplayed videos are not repeated
window._recWatched = window._recWatched || new Set();
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
function recNextUnwatched() {
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
for (var i = 0; i < cards.length; i++) {
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
}
return null;
}
function recRender() {
var btn = document.getElementById('recAutoplayBtn');
if (!btn) return;
btn.classList.toggle('pl-ctrl-active', recAutoplay);
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
// highlight the first unwatched card as "next up"
var cards = document.querySelectorAll('#recList .sidebar-video-card');
var nextUp = recNextUnwatched() || cards[0];
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
}
async function recTransitionTo(url, pushHist) {
if (recTransiting) return;
recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var dataUrl = url.split('?')[0] + '/player-data';
var resp = await fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!resp.ok) { window.location.href = url; return; }
var d = await resp.json();
// Different player type? Hard-nav so the correct view loads.
if (d.type && d.type !== 'generic') { window.location.href = url; return; }
// preserve volume/muted across source swap
var vid = document.getElementById('videoPlayer');
var _savedVol = vid ? vid.volume : null;
var _savedMuted = vid ? vid.muted : null;
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
if (vid && _savedVol !== null) { vid.volume = _savedVol; vid.muted = _savedMuted; }
// reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
if(pl) pl.style.width='0%'; if(sc) sc.style.left='0%';
if(cu) cu.textContent='0:00'; if(dr&&d.duration) dr.textContent=recFmt(d.duration);
// update title
var ts = document.getElementById('videoTitleText');
if (ts) { ts.textContent = d.title; ts.dataset.primaryTitle = d.title; }
document.title = d.title + ' | {{ config("app.name") }}';
if (pushHist !== false) history.pushState({ url: url }, '', url);
recSwapContent(url);
} catch(e) {
console.warn('recTransitionTo', e);
window.location.href = url;
} finally {
recTransiting = false;
}
}
async function recSwapContent(url) {
try {
var resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!resp.ok) return;
var html = await resp.text();
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var newVdb = doc.getElementById('vdbWrap');
var oldVdb = document.getElementById('vdbWrap');
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');
if (newCh && oldCh) oldCh.outerHTML = newCh.outerHTML;
var newYtc = doc.getElementById('ytcSection');
var oldYtc = document.getElementById('ytcSection');
if (newYtc && oldYtc) {
oldYtc.innerHTML = newYtc.innerHTML;
var s = oldYtc.querySelector('script');
if (s) { var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns); }
}
var newSidebar = doc.querySelector('.yt-sidebar-container');
var oldSidebar = document.querySelector('.yt-sidebar-container');
if (newSidebar && oldSidebar) {
oldSidebar.innerHTML = newSidebar.innerHTML;
oldSidebar.querySelectorAll('script').forEach(function(s){
var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns);
});
}
} catch(e) { console.warn('recSwapContent', e); }
}
// user manually picks a video — reset watch history so next autoplay starts fresh
window.recGoTo = function(url) {
if (!url) return;
window._recWatched = new Set();
recTransitionTo(url);
};
window.recToggleAutoplay = function() {
recAutoplay = !recAutoplay;
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
recRender();
};
// autoplay: mark current video watched, then play first unwatched rec card
window._plOnVideoEnd = function() {
if (!recAutoplay) return;
window._recWatched.add(window.location.href);
var next = recNextUnwatched();
if (next) {
recTransitionTo(next.dataset.recUrl);
} else {
// all recommendations watched — notify and restart the cycle
showToast('All recommendations watched — starting over', 'info');
window._recWatched = new Set();
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
}
};
window.addEventListener('popstate', function(e) { if (e.state && e.state.url) recTransitionTo(e.state.url, false); });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', recRender); else recRender();
})();
</script>
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" <div class="sidebar-video-card"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"> onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}" <img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
@ -752,151 +996,6 @@
@else @else
<div class="text-secondary" id="recList">No recommendations available yet. Check back later!</div> <div class="text-secondary" id="recList">No recommendations available yet. Check back later!</div>
@endif @endif
<script>
(function () {
var recTransiting = false;
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') === '1';
// persists across sidebar swaps so already-autoplayed videos are not repeated
window._recWatched = window._recWatched || new Set();
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
function recNextUnwatched() {
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
for (var i = 0; i < cards.length; i++) {
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
}
return null;
}
function recRender() {
var btn = document.getElementById('recAutoplayBtn');
if (!btn) return;
btn.classList.toggle('pl-ctrl-active', recAutoplay);
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
// highlight the first unwatched card as "next up"
var cards = document.querySelectorAll('#recList .sidebar-video-card');
var nextUp = recNextUnwatched() || cards[0];
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
}
window.recToggleAutoplay = function() {
recAutoplay = !recAutoplay;
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
recRender();
};
async function recTransitionTo(url, pushHist) {
if (!url || recTransiting) return;
recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href = url; return; }
var resp = await fetch('/videos/' + m[1] + '/player-data');
if (!resp.ok) { window.location.href = url; return; }
var d = await resp.json();
// load new video source
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
// reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
if(pl) pl.style.width='0%'; if(sc) sc.style.left='0%';
if(cu) cu.textContent='0:00'; if(dr&&d.duration) dr.textContent=recFmt(d.duration);
// update title
var ts = document.querySelector('.video-title span');
if (ts) ts.textContent = d.title;
document.title = d.title + ' | {{ config("app.name") }}';
if (pushHist !== false) history.pushState({recUrl: url}, '', url);
// async: swap description + comments + sidebar
recSwapContent(url);
} catch(e) {
console.warn('recTransitionTo', e);
window.location.href = url;
} finally {
recTransiting = false;
}
}
async function recSwapContent(url) {
try {
var resp = await fetch(url, {headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
var html = await resp.text();
var doc = new DOMParser().parseFromString(html, 'text/html');
// description
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
// channel row
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
// comments
var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection');
if(ny&&oy){
oy.innerHTML=ny.innerHTML;
var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');});
if(ytcScript){
var ns=document.createElement('script');
ns.textContent=ytcScript.textContent;
document.body.appendChild(ns);
document.body.removeChild(ns);
}
}
// sidebar: swap inner content and re-run init scripts
var nSide=doc.querySelector('.yt-sidebar-container');
var oSide=document.querySelector('.yt-sidebar-container');
if(nSide&&oSide){
oSide.innerHTML=nSide.innerHTML;
// re-execute any inline scripts (rec init, playlist init, etc.)
Array.from(oSide.querySelectorAll('script')).forEach(function(s){
var ns=document.createElement('script');
ns.textContent=s.textContent;
document.body.appendChild(ns);
document.body.removeChild(ns);
});
}
} catch(e){ console.warn('recSwapContent', e); }
}
// user manually picks a video — reset watch history so next autoplay starts fresh
window.recGoTo = function(url) {
if (!url) return;
window._recWatched = new Set();
recTransitionTo(url);
};
// autoplay: mark current video watched, then play first unwatched rec card
window._plOnVideoEnd = function() {
if (!recAutoplay) return;
window._recWatched.add(window.location.href);
var next = recNextUnwatched();
if (next) {
recTransitionTo(next.dataset.recUrl);
} else {
// all recommendations watched — notify and restart the cycle
showToast('All recommendations watched — starting over', 'info');
window._recWatched = new Set();
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
}
};
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',recRender); else recRender();
})();
</script>
@endif @endif
</div> </div>

View File

@ -2612,6 +2612,9 @@
try { d=await resp.json(); } try { d=await resp.json(); }
catch(e){ window.location.href=url; return; } catch(e){ window.location.href=url; return; }
// Different player type? Hard-nav so the correct view loads.
if (d.type && d.type !== 'match') { window.location.href = url; return; }
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); } try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); } catch(e){ console.warn('_ytpLoadSource', e); }
@ -2873,6 +2876,9 @@
if (!resp.ok) { window.location.href = url; return; } if (!resp.ok) { window.location.href = url; return; }
var d = await resp.json(); var d = await resp.json();
// Different player type? Hard-nav so the correct view loads.
if (d.type && d.type !== 'match') { window.location.href = url; return; }
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber'); var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');

View File

@ -572,6 +572,9 @@
try { d = await resp.json(); } try { d = await resp.json(); }
catch(e) { window.location.href=url; return; } catch(e) { window.location.href=url; return; }
// Different player type? Hard-nav so the correct view (generic/match) loads.
if (d.type && d.type !== 'music') { window.location.href = url; return; }
// swap audio src (keeps browser autoplay permission) // swap audio src (keeps browser autoplay permission)
var audio = document.getElementById('audioEl'); var audio = document.getElementById('audioEl');
if (audio) { if (audio) {
@ -631,6 +634,9 @@
document.body.removeChild(ns); document.body.removeChild(ns);
} }
} }
// The swapped content re-renders video-actions (incl. the
// owner lyrics button) fresh+hidden — re-apply its state.
if(window._lyricsShow) window._lyricsShow(window._ytpTrackId||0);
} catch(e){ console.warn('plSwapContent',e); } } catch(e){ console.warn('plSwapContent',e); }
} }
@ -796,6 +802,9 @@
if (!resp.ok) { window.location.href = url; return; } if (!resp.ok) { window.location.href = url; return; }
var d = await resp.json(); var d = await resp.json();
// Different player type? Hard-nav so the correct view loads.
if (d.type && d.type !== 'music') { window.location.href = url; return; }
var audio = document.getElementById('audioEl'); var audio = document.getElementById('audioEl');
if (audio) { if (audio) {
var _savedVol = audio.volume; var _savedMuted = audio.muted; var _savedVol = audio.volume; var _savedMuted = audio.muted;
@ -850,6 +859,8 @@
var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns); var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns);
}); });
} }
// Re-apply the owner lyrics button state after the swap re-renders it.
if(window._lyricsShow) window._lyricsShow(window._ytpTrackId||0);
} catch(e) { console.warn('recSwapContent', e); } } catch(e) { console.warn('recSwapContent', e); }
} }

View File

@ -49,6 +49,10 @@ Route::post('/videos/{video}/slideshow/generate', [VideoController::class, 'slid
Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress'); Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress');
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations'); Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData'); Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
Route::post('/videos/{video}/lyrics/generate', [VideoController::class, 'generateLyrics'])->name('videos.lyrics.generate')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsProgress'])->name('videos.lyrics.progress');
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare'); Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']); Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']); Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
@ -161,6 +165,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Playlist video management // Playlist video management
Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo'); Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo');
Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo'); Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo');
// Body-based remove (mirror of addVideo) — used by the add-to-playlist modal
// where the front-end only has the numeric video id, not the encoded route key.
Route::delete('/playlists/{playlist}/videos', [PlaylistController::class, 'removeVideoByBody'])->name('playlists.removeVideoByBody');
Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder'); Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder');
// Playlist actions // Playlist actions
@ -227,6 +234,16 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
Route::get('/settings', [SuperAdminController::class, 'settings'])->name('settings'); Route::get('/settings', [SuperAdminController::class, 'settings'])->name('settings');
Route::post('/settings', [SuperAdminController::class, 'updateSettings'])->name('settings.update'); Route::post('/settings', [SuperAdminController::class, 'updateSettings'])->name('settings.update');
Route::get('/settings/detect-gpu', [SuperAdminController::class, 'detectGpu'])->name('settings.detect-gpu'); Route::get('/settings/detect-gpu', [SuperAdminController::class, 'detectGpu'])->name('settings.detect-gpu');
Route::post('/settings/llm-test', [SuperAdminController::class, 'llmProviderTest'])->name('settings.llm-test');
// Lyrics Pipeline toggles (own page)
Route::get('/lyrics', [SuperAdminController::class, 'lyrics'])->name('lyrics');
// GPU Accelerator (own page)
Route::get('/gpu', [SuperAdminController::class, 'gpu'])->name('gpu');
// Backup & Restore (own page)
Route::get('/backup', [SuperAdminController::class, 'backup'])->name('backup');
// NAS Storage // NAS Storage
Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage'); Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

View File

@ -1,59 +0,0 @@
#!/bin/bash
# ============================================================
# fix-gpu-host.sh — Run this on the PROXMOX HOST
# Fixes NVIDIA driver version mismatch so GPU encoding works
# ============================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
echo ""
echo "======================================================"
echo " NVIDIA Driver Fix — Proxmox Host"
echo "======================================================"
echo ""
# Must be root
[[ $EUID -ne 0 ]] && fail "Please run as root: sudo bash fix-gpu-host.sh"
TARGET="580.65.06-1"
CURRENT=$(modinfo nvidia 2>/dev/null | awk '/^version:/{print $2}' || echo "none")
log "Current kernel module version: ${CURRENT}"
log "Target version: 580.65.06"
echo ""
if [[ "$CURRENT" == "580.65.06" ]]; then
log "Already at correct version. Nothing to do."
echo ""
echo "Now run fix-gpu-vm.sh inside the VM."
exit 0
fi
warn "The host driver (${CURRENT}) does not match the VM userspace (580.126.09)."
warn "This script will install nvidia-open-580=580.65.06 and reboot the host."
echo ""
read -p "Continue? (yes/no): " CONFIRM
[[ "$CONFIRM" != "yes" ]] && { warn "Aborted."; exit 0; }
echo ""
log "Updating package lists..."
apt-get update -qq
log "Installing nvidia-open-580=580.65.06-1 ..."
apt-get install -y "nvidia-open-580=${TARGET}" || \
fail "Installation failed. Check that the CUDA local repo is still configured."
log "Pinning package to prevent accidental upgrades..."
apt-mark hold nvidia-open-580
echo ""
log "Done! The host will reboot in 10 seconds."
warn "After reboot, SSH back in and run fix-gpu-vm.sh inside the VM."
echo ""
sleep 10
reboot

View File

@ -1,132 +0,0 @@
#!/bin/bash
# ============================================================
# fix-gpu-vm.sh — Run this INSIDE the VM (your app server)
# Run AFTER fix-gpu-host.sh has completed and host has rebooted
# ============================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
info() { echo -e "${BLUE}[i]${NC} $1"; }
echo ""
echo "======================================================"
echo " NVIDIA Driver Fix — VM Userspace"
echo "======================================================"
echo ""
[[ $EUID -ne 0 ]] && fail "Please run as root: sudo bash fix-gpu-vm.sh"
FFMPEG="/usr/lib/jellyfin-ffmpeg/ffmpeg"
TARGET="580.65.06"
APP_DIR="/var/www/videoplatform"
# ── 1. Check Proxmox host kernel module ──────────────────────────────────────
HOST_VER=$(cat /proc/driver/nvidia/version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
info "Proxmox host kernel module: ${HOST_VER}"
if [[ "$HOST_VER" != "580.65.06" ]]; then
warn "Host kernel module is ${HOST_VER}, not 580.65.06."
warn "Please run fix-gpu-host.sh on the Proxmox host first, then reboot the host."
read -p "Continue anyway? (yes/no): " FORCE
[[ "$FORCE" != "yes" ]] && { warn "Aborted."; exit 0; }
fi
# ── 2. Check jellyfin-ffmpeg is installed ────────────────────────────────────
[[ -x "$FFMPEG" ]] || fail "jellyfin-ffmpeg not found at ${FFMPEG}. Install it first: dpkg -i /tmp/jellyfin-ffmpeg7.deb"
log "jellyfin-ffmpeg found: $($FFMPEG -version 2>&1 | head -1)"
# ── 3. Downgrade NVIDIA userspace packages ───────────────────────────────────
CURRENT_COMPUTE=$(dpkg -l libnvidia-compute-580 2>/dev/null | awk '/^ii/{print $3}' | cut -d- -f1 || echo "none")
info "Current libnvidia-compute-580 version: ${CURRENT_COMPUTE}"
if [[ "$CURRENT_COMPUTE" == "$TARGET" ]]; then
log "NVIDIA userspace already at ${TARGET}. Skipping downgrade."
else
warn "Downgrading NVIDIA userspace from ${CURRENT_COMPUTE}${TARGET}..."
echo ""
UBUNTU_VER="${TARGET}-0ubuntu0.22.04.1"
apt-get install -y --allow-downgrades \
"libnvidia-compute-580=${UBUNTU_VER}" \
"libnvidia-encode-580=${UBUNTU_VER}" \
"libnvidia-decode-580=${UBUNTU_VER}" \
"nvidia-utils-580=${UBUNTU_VER}" || \
fail "Package downgrade failed. Try: apt-get update && then re-run this script."
log "Packages downgraded successfully."
# Pin to prevent apt from auto-upgrading and breaking things again
for pkg in libnvidia-compute-580 libnvidia-encode-580 libnvidia-decode-580 nvidia-utils-580; do
apt-mark hold "$pkg"
done
log "Packages pinned at ${TARGET} to prevent accidental upgrades."
fi
# ── 4. Test NVENC ─────────────────────────────────────────────────────────────
echo ""
info "Testing NVENC encoding with new setup..."
TMP=$(mktemp /tmp/nvenc_test_XXXX.mp4)
"$FFMPEG" -f lavfi -i color=c=black:s=128x72:r=25 -frames:v 1 \
-c:v h264_nvenc -y "$TMP" > /tmp/nvenc_test.log 2>&1
EXIT=$?
if [[ $EXIT -eq 0 && -s "$TMP" ]]; then
log "NVENC test PASSED! GPU encoding is working."
rm -f "$TMP"
NVENC_OK=true
else
warn "NVENC test failed (exit ${EXIT}). Falling back to CPU encoding."
cat /tmp/nvenc_test.log | tail -5
rm -f "$TMP"
NVENC_OK=false
fi
# ── 5. Update app settings ────────────────────────────────────────────────────
echo ""
info "Updating application settings..."
if [[ -d "$APP_DIR" ]]; then
# Set ffmpeg binary path
php "$APP_DIR/artisan" tinker --execute="
App\Models\Setting::set('ffmpeg_binary', '$FFMPEG');
echo 'ffmpeg_binary set to: ' . App\Models\Setting::ffmpegBinary();
" 2>/dev/null && log "App FFmpeg binary path set to: ${FFMPEG}"
if [[ "$NVENC_OK" == "true" ]]; then
php "$APP_DIR/artisan" tinker --execute="
App\Models\Setting::set('gpu_enabled', 'true');
echo 'gpu_enabled: ' . App\Models\Setting::get('gpu_enabled');
" 2>/dev/null && log "GPU encoding enabled in app settings."
else
php "$APP_DIR/artisan" tinker --execute="
App\Models\Setting::set('gpu_enabled', 'false');
echo 'gpu_enabled set to false (CPU fallback active)';
" 2>/dev/null && warn "GPU disabled in app settings — CPU fallback active."
fi
else
warn "App directory not found at ${APP_DIR}, skipping settings update."
fi
# ── 6. Summary ────────────────────────────────────────────────────────────────
echo ""
echo "======================================================"
if [[ "$NVENC_OK" == "true" ]]; then
echo -e "${GREEN} ALL DONE — GPU encoding is active!${NC}"
echo ""
echo " FFmpeg binary : ${FFMPEG}"
echo " GPU encoder : h264_nvenc (NVIDIA RTX 3060)"
echo " Driver match : kernel=${HOST_VER} / userspace=${TARGET}"
else
echo -e "${YELLOW} DONE — Running with CPU fallback (libx264)${NC}"
echo ""
echo " Videos will still encode correctly, just slightly slower."
echo " Check /tmp/nvenc_test.log for NVENC error details."
fi
echo "======================================================"
echo ""

View File

@ -1,3 +0,0 @@
*
!public/
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,9 +0,0 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

View File

@ -1,3 +0,0 @@
*
!data/
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore