Merge branch 'lyrics' into master
This commit is contained in:
commit
80948efff7
11
.gitignore
vendored
11
.gitignore
vendored
@ -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
|
||||||
|
|||||||
184
CLAUDE.md
184
CLAUDE.md
@ -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,9 +233,42 @@ 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}/
|
||||||
@ -205,56 +281,96 @@ users/{user-slug}/
|
|||||||
├── 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.
|
||||||
|
|
||||||
|
|||||||
61
app/Console/Commands/GenerateLyrics.php
Normal file
61
app/Console/Commands/GenerateLyrics.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
241
app/Console/Commands/MigrateStorageLayout.php
Normal file
241
app/Console/Commands/MigrateStorageLayout.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -846,23 +846,25 @@ 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'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$gpus = $this->probeGpus();
|
return view('admin.settings', compact('settings'));
|
||||||
$nvencWorks = $this->probeNvenc();
|
|
||||||
|
|
||||||
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
public function updateSettings(Request $request)
|
||||||
{
|
{
|
||||||
|
// ── GPU section ──────────────────────────────────────────────────────
|
||||||
|
if ($request->has('gpu_enabled')) {
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'gpu_enabled' => 'required|in:true,false',
|
'gpu_enabled' => 'required|in:true,false',
|
||||||
'gpu_device' => 'required|integer|min:0|max:15',
|
'gpu_device' => 'required|integer|min:0|max:15',
|
||||||
@ -883,18 +885,233 @@ class SuperAdminController extends Controller
|
|||||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||||
Setting::set('gpu_preset', $request->gpu_preset);
|
Setting::set('gpu_preset', $request->gpu_preset);
|
||||||
Setting::set('ffmpeg_binary', $binary);
|
Setting::set('ffmpeg_binary', $binary);
|
||||||
|
|
||||||
// GPU config changed — drop the cached health-check so the next encode re-probes.
|
|
||||||
Setting::flushGpuProbe();
|
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.');
|
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();
|
||||||
|
$nvencWorks = $this->probeNvenc();
|
||||||
|
|
||||||
|
return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backup()
|
||||||
|
{
|
||||||
|
return view('admin.backup');
|
||||||
|
}
|
||||||
|
|
||||||
public function detectGpu()
|
public function detectGpu()
|
||||||
{
|
{
|
||||||
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)
|
||||||
|
|||||||
@ -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())) {
|
||||||
@ -641,6 +895,7 @@ 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
139
app/Jobs/DecorateLyricsJob.php
Normal file
139
app/Jobs/DecorateLyricsJob.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
app/Jobs/GenerateLyricsJob.php
Normal file
244
app/Jobs/GenerateLyricsJob.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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 ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
266
app/Services/LlmLyricsService.php
Normal file
266
app/Services/LlmLyricsService.php
Normal 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 2–4 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 5–6 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'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
$primaryRel = $isMusic
|
||||||
|
? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
|
||||||
|
: $videoRel;
|
||||||
$updates = [];
|
$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
109
app/Support/LyricsAss.php
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/Support/LyricsDescriptionParser.php
Normal file
144
app/Support/LyricsDescriptionParser.php
Normal 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 ( , &, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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
895
ml/transcribe.py
Normal 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", "I’m", "I’ll", "I’ve", "I’d")):
|
||||||
|
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()
|
||||||
69
resources/views/admin/backup.blade.php
Normal file
69
resources/views/admin/backup.blade.php
Normal 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 & Settings Backup
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body">
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<strong>Export users & 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 & 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 & Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
283
resources/views/admin/gpu.blade.php
Normal file
283
resources/views/admin/gpu.blade.php
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@ -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 & 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>
|
||||||
|
|||||||
191
resources/views/admin/lyrics.blade.php
Normal file
191
resources/views/admin/lyrics.blade.php
Normal 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
|
||||||
@ -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'))
|
||||||
|
<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
|
||||||
|
|
||||||
|
{{-- ── 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', [
|
@include('nas-file-manager::file-manager', [
|
||||||
'nodes' => $nodes,
|
'nodes' => $nodes,
|
||||||
'canEdit' => true,
|
'canEdit' => true,
|
||||||
'title' => 'NAS Storage Browser',
|
'title' => 'NAS Storage Browser',
|
||||||
])
|
])
|
||||||
</div>
|
</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 →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nasDisableStep2Migrate" style="display:none;">
|
||||||
|
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk…</h3>
|
||||||
|
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting…</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 & Disable NAS</button>
|
||||||
|
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
|||||||
124
resources/views/admin/partials/settings-styles.blade.php
Normal file
124
resources/views/admin/partials/settings-styles.blade.php
Normal 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
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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=
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
·
|
·
|
||||||
@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 {
|
||||||
@ -533,6 +534,7 @@ $sizeClasses = match($size) {
|
|||||||
}
|
}
|
||||||
.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
|
||||||
|
|||||||
@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
{{-- 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')
|
@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,41 +2072,75 @@
|
|||||||
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 */
|
||||||
var v = getVid();
|
if (!slot) return false;
|
||||||
if (!v || !slot) return false;
|
|
||||||
|
|
||||||
/* Remember original DOM position to restore later */
|
var v = getVid();
|
||||||
|
if (v) {
|
||||||
|
/* VIDEO MODE — teleport the <video> element so HLS.js stays attached */
|
||||||
|
_kind = 'video';
|
||||||
_origParent = v.parentNode;
|
_origParent = v.parentNode;
|
||||||
_origNext = v.nextSibling;
|
_origNext = v.nextSibling;
|
||||||
|
|
||||||
/* Teleport — HLS.js stays attached, playback uninterrupted */
|
|
||||||
slot.appendChild(v);
|
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>';
|
||||||
|
|
||||||
titleEl.textContent = title || 'Video';
|
/* Teleport the audio element to the mini wrap. <audio> is
|
||||||
|
invisible, so visual layout is unaffected. */
|
||||||
|
_origParent = a.parentNode;
|
||||||
|
_origNext = a.nextSibling;
|
||||||
|
wrap.appendChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
titleEl.textContent = title || 'Now playing';
|
||||||
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() {
|
||||||
|
if (_kind === 'video') {
|
||||||
var v = getVid();
|
var v = getVid();
|
||||||
if (!v || !_origParent) return;
|
if (!v || !_origParent) return;
|
||||||
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
|
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
|
||||||
@ -1958,8 +2148,23 @@
|
|||||||
} else {
|
} else {
|
||||||
_origParent.appendChild(v);
|
_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';
|
wrap.style.display = 'none';
|
||||||
_mode = null;
|
_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';
|
||||||
|
_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 {
|
return {
|
||||||
activate: function (t, u) { return activate(t, u, 'nav'); },
|
left: Math.max(4, Math.min(left, maxL)),
|
||||||
activateScroll: function (t, u) { return activate(t, u, 'scroll'); },
|
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 {
|
||||||
|
activate: function (t, u) { return _activateAndPosition(t, u, 'nav'); },
|
||||||
|
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);
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
1169
resources/views/user/partials/channel/styles/desktop.blade.php
Normal file
1169
resources/views/user/partials/channel/styles/desktop.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
150
resources/views/user/partials/channel/styles/mobile.blade.php
Normal file
150
resources/views/user/partials/channel/styles/mobile.blade.php
Normal 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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||||
|
|||||||
@ -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 -->
|
||||||
@ -498,17 +561,19 @@
|
|||||||
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,8 +581,10 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
@ -534,10 +601,21 @@
|
|||||||
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
|
||||||
|
// correct type view (music/match) handles playback.
|
||||||
|
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); }
|
try { if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
|
||||||
catch(e) { console.warn('_ytpLoadSource', e); }
|
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');
|
||||||
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
|
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
|
||||||
@ -547,18 +625,21 @@
|
|||||||
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');
|
||||||
|
|
||||||
|
// update state
|
||||||
PL_CURRENT = d.id;
|
PL_CURRENT = d.id;
|
||||||
try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
|
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);
|
||||||
@ -567,18 +648,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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');
|
||||||
|
|
||||||
|
// swap description box
|
||||||
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
|
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
|
||||||
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
|
if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
|
||||||
|
|
||||||
|
// swap channel row
|
||||||
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
|
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;
|
||||||
|
|
||||||
|
// swap comments (HTML + re-run its init script)
|
||||||
var ny=doc.getElementById('ytcSection'), oy=document.getElementById('ytcSection');
|
var ny=doc.getElementById('ytcSection'), oy=document.getElementById('ytcSection');
|
||||||
if(ny&&oy) {
|
if(ny&&oy) {
|
||||||
oy.innerHTML=ny.innerHTML;
|
oy.innerHTML=ny.innerHTML;
|
||||||
@ -593,13 +678,27 @@
|
|||||||
} catch(e){ console.warn('plSwapContent',e); }
|
} catch(e){ console.warn('plSwapContent',e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── sidebar highlight ────────────────────────────────────
|
||||||
function plHighlight(activeId, scroll) {
|
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'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── public API ────────────────────────────────────────────
|
||||||
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
|
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
|
||||||
window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
|
window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
|
||||||
window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
|
window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
|
||||||
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(); };
|
|
||||||
|
|
||||||
// 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.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); });
|
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
|
|
||||||
</h3>
|
|
||||||
<div class="pl-controls-bar" style="margin-bottom:0; flex-shrink:0;">
|
|
||||||
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="recAutoplayBtn"
|
|
||||||
title="Autoplay" onclick="recToggleAutoplay()">
|
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
<span class="pl-autoplay-label">Autoplay</span>
|
<span class="pl-autoplay-label">Autoplay</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 |
@ -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
|
|
||||||
@ -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 ""
|
|
||||||
3
storage/app/.gitignore
vendored
3
storage/app/.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
*
|
|
||||||
!public/
|
|
||||||
!.gitignore
|
|
||||||
2
storage/app/public/.gitignore
vendored
2
storage/app/public/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
9
storage/framework/.gitignore
vendored
9
storage/framework/.gitignore
vendored
@ -1,9 +0,0 @@
|
|||||||
compiled.php
|
|
||||||
config.php
|
|
||||||
down
|
|
||||||
events.scanned.php
|
|
||||||
maintenance.php
|
|
||||||
routes.php
|
|
||||||
routes.scanned.php
|
|
||||||
schedule-*
|
|
||||||
services.json
|
|
||||||
3
storage/framework/cache/.gitignore
vendored
3
storage/framework/cache/.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
*
|
|
||||||
!data/
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/cache/data/.gitignore
vendored
2
storage/framework/cache/data/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/sessions/.gitignore
vendored
2
storage/framework/sessions/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/testing/.gitignore
vendored
2
storage/framework/testing/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/views/.gitignore
vendored
2
storage/framework/views/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/logs/.gitignore
vendored
2
storage/logs/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
Loading…
x
Reference in New Issue
Block a user