Add lyrics pipeline, playlist views, admin toggles, and player polish

Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-31 22:01:47 +03:00
1 parent 73527f3781
commit f98e5415a3
60 files changed
+7920 -2675

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
@extends('admin.layout')
@section('title', 'Backup & Restore')
@section('page_title', 'Backup & Restore')
@section('extra_styles')
@include('admin.partials.settings-styles')
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-archive"></i> Backup & Restore</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-archive"></i>
Users &amp; Settings Backup
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Export users &amp; settings</strong>
<small>Downloads a JSON file containing all user accounts and system settings. Does not include media files.</small>
</div>
<div class="setting-control">
<a href="{{ route('admin.backup.users-settings') }}" class="adm-btn adm-btn-primary">
<i class="bi bi-download"></i> Download Backup
</a>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Restore users &amp; settings</strong>
<small>Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.</small>
</div>
<div class="setting-control">
<form method="POST" action="{{ route('admin.backup.restore') }}" enctype="multipart/form-data" id="restoreForm">
@csrf
<input type="file" name="backup" id="restoreFile" accept=".json" style="display:none"
onchange="document.getElementById('restoreForm').submit()">
<button type="button" class="adm-btn" onclick="document.getElementById('restoreFile').click()">
<i class="bi bi-upload"></i> Upload &amp; Restore
</button>
</form>
</div>
</div>
</div>
</div>
@endsection
+283
View File
@@ -0,0 +1,283 @@
@extends('admin.layout')
@section('title', 'GPU Accelerator')
@section('page_title', 'GPU Accelerator')
@section('extra_styles')
@include('admin.partials.settings-styles')
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-gpu-card"></i> GPU Accelerator</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="gpuForm">
@csrf
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-gpu-card"></i>
GPU Accelerated Processing
<span id="gpuStatusChip" style="margin-left:6px;">
@if(count($gpus))
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
@endif
</span>
<span id="nvencStatusChip" style="margin-left:6px;">
@if($nvencWorks)
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding ✓</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding ✗</span>
@endif
</span>
</div>
@if(!$nvencWorks && count($gpus))
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
<strong>⚠ NVENC is not working with the current FFmpeg binary.</strong><br>
The GPU is detected but FFmpeg cannot initialise CUDA on this driver.<br>
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
Until then, video encoding will automatically fall back to CPU (libx264).
</div>
@endif
<div class="settings-section-body">
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
</button>
</div>
<div id="gpuCardsWrap">
@if(count($gpus))
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
@else
<div class="no-gpu-state">
<i class="bi bi-gpu-card"></i>
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
</div>
@endif
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Enable GPU acceleration</strong>
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label" id="gpuEnabledLabel">
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
value="{{ $settings['gpu_enabled'] }}">
</div>
</div>
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
<div class="setting-row" id="gpuEncoderRow">
<div class="setting-label">
<strong>Video encoder</strong>
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
</div>
<div class="setting-control">
<div class="enc-grid">
@foreach([
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
['libx264', 'libx264', 'CPU · software fallback'],
] as [$val, $label, $desc])
<button type="button"
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
data-encoder="{{ $val }}"
onclick="selectEncoder(this)">
<span class="enc-card-name">{{ $label }}</span>
<span class="enc-card-desc">{{ $desc }}</span>
</button>
@endforeach
</div>
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
</div>
</div>
<div class="setting-row" id="gpuPresetRow">
<div class="setting-label">
<strong>Encoding preset</strong>
<small>NVENC presets: p1 (fastest) → p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
</div>
<div class="setting-control">
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
<optgroup label="NVENC (GPU)">
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
</option>
@endforeach
</optgroup>
<optgroup label="libx264 (CPU)">
@foreach(['fast','medium','slow'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}
</option>
@endforeach
</optgroup>
</select>
</div>
</div>
<div class="setting-row" id="gpuHwaccelRow">
<div class="setting-label">
<strong>Hardware decode acceleration</strong>
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
</div>
<div class="setting-control">
<select name="gpu_hwaccel" class="adm-select-full">
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda — GPU decode</option>
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none — CPU decode</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>FFmpeg binary path</strong>
<small>
Absolute path to the <code>ffmpeg</code> executable.
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
</small>
</div>
<div class="setting-control">
<input type="text" name="ffmpeg_binary" class="adm-input-full"
value="{{ $settings['ffmpeg_binary'] }}"
placeholder="/usr/bin/ffmpeg">
</div>
</div>
</div>
</div>
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save GPU Settings
</button>
</div>
</form>
@endsection
@section('scripts')
<script>
function selectGpuCard(el) {
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuDeviceInput').value = el.dataset.index;
}
function selectEncoder(el) {
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
const isCpu = el.dataset.encoder === 'libx264';
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
const gpuLabel = document.getElementById('gpuEnabledLabel');
function applyGpuToggle() {
const on = gpuToggle.checked;
gpuHidden.value = on ? 'true' : 'false';
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
}
gpuToggle.addEventListener('change', applyGpuToggle);
async function detectGpus() {
const btn = document.getElementById('detectBtn');
const icon = document.getElementById('detectIcon');
btn.disabled = true;
icon.className = 'bi bi-arrow-repeat spin';
try {
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
const data = await res.json();
const gpus = data.gpus || [];
const wrap = document.getElementById('gpuCardsWrap');
const chip = document.getElementById('gpuStatusChip');
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
if (gpus.length === 0) {
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
} else {
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
}
} catch (e) { console.error(e); }
btn.disabled = false;
icon.className = 'bi bi-arrow-repeat';
}
function buildGpuCards(gpus, selectedDevice) {
if (!gpus.length) return '';
const grid = document.createElement('div');
grid.className = 'gpu-grid';
gpus.forEach(gpu => {
const used = gpu.mem_total - gpu.mem_free;
const usedPct = Math.round((used / gpu.mem_total) * 100);
const sel = gpu.index === selectedDevice;
const card = document.createElement('div');
card.className = 'gpu-card' + (sel ? ' selected' : '');
card.dataset.index = gpu.index;
card.onclick = function() { selectGpuCard(this); };
card.innerHTML = `
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
<div class="mem-bar-wrap"><div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div></div>`;
grid.appendChild(card);
});
return grid.outerHTML;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
@endsection
+17 -5
View File
@@ -597,7 +597,23 @@
<div class="adm-nav-section" style="margin-top:8px;">System</div>
<a href="{{ route('admin.settings') }}"
class="adm-nav-link {{ request()->routeIs('admin.settings*') ? 'active' : '' }}">
<i class="bi bi-gpu-card"></i> Settings
<i class="bi bi-stars"></i> AI / LLM
</a>
<a href="{{ route('admin.lyrics') }}"
class="adm-nav-link {{ request()->routeIs('admin.lyrics') ? 'active' : '' }}">
<i class="bi bi-music-note-list"></i> Lyrics Pipeline
</a>
<a href="{{ route('admin.gpu') }}"
class="adm-nav-link {{ request()->routeIs('admin.gpu') ? 'active' : '' }}">
<i class="bi bi-gpu-card"></i> GPU Accelerator
</a>
<a href="{{ route('admin.nas-storage') }}"
class="adm-nav-link {{ request()->routeIs('admin.nas-storage') ? 'active' : '' }}">
<i class="bi bi-hdd-network"></i> NAS Storage
</a>
<a href="{{ route('admin.backup') }}"
class="adm-nav-link {{ request()->routeIs('admin.backup') ? 'active' : '' }}">
<i class="bi bi-archive"></i> Backup &amp; Restore
</a>
<a href="{{ route('admin.audit') }}"
class="adm-nav-link {{ request()->routeIs('admin.audit') ? 'active' : '' }}">
@@ -607,10 +623,6 @@
class="adm-nav-link {{ request()->routeIs('admin.logs') ? 'active' : '' }}">
<i class="bi bi-bug"></i> Error Logs
</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
<div class="adm-nav-divider"></div>
+191
View File
@@ -0,0 +1,191 @@
@extends('admin.layout')
@section('title', 'Lyrics Pipeline')
@section('page_title', 'Lyrics Pipeline')
@section('extra_styles')
@include('admin.partials.settings-styles')
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-music-note-list"></i> Lyrics Pipeline</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="lyricsForm">
@csrf
<input type="hidden" name="lyrics_section" value="1">
{{-- ── Master switch ─────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-toggle-on"></i>
Master
@if($settings['lyrics_enabled'] === 'true')
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
@else
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
@endif
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Lyrics generation</strong>
<small>
Master switch for the whole feature. When OFF, the Generate / Regenerate
button in the player gear is hidden and the API endpoint refuses new jobs.
Existing lyrics keep displaying — only NEW generation is blocked.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_enabled" value="true" {{ $settings['lyrics_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Allow new lyrics jobs</span>
</label>
</div>
</div>
</div>
</div>
{{-- ── Pipeline steps ────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-diagram-3"></i>
Pipeline steps
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-label">
<strong>Description-based correction</strong>
<small>
Use the song description as the source of truth for lyric text. Whisper provides
timing anchors; the description provides the exact words. When OFF, the player
shows Whisper's raw transcription (may contain misheard words and miss verses).
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_use_description" value="true" {{ $settings['lyrics_use_description'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Use description as ground truth</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Voice activity filter (VAD)</strong>
<small>
Runs Silero VAD before Whisper to skip pure-instrumental sections. Without this,
Whisper invents lyrics over intro / outro / breakdown music. When OFF, the entire
audio is transcribed — useful as a fallback if VAD is dropping legitimate quiet vocals.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_vad_enabled" value="true" {{ $settings['lyrics_vad_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Drop instrumentals via VAD</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Vocal-region gap-filling</strong>
<small>
For description lines Whisper missed, snap them to moments where vocals are
active (detected by VAD) instead of distributing evenly across time. Keeps
gap-filled lyrics on actual singing. When OFF, missing lines distribute uniformly
between Whisper anchors (faster but can drift over instrumental beats).
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_vocal_region_gapfill" value="true" {{ $settings['lyrics_vocal_region_gapfill'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Snap missing lines to vocal regions</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Vocal isolation (Demucs)</strong>
<small>
Two-stem source separation (vocals vs. instruments) before Whisper. Improves
transcription on noisy mixes but adds ~30s GPU time and has caused a CUDA-handoff
deadlock that hangs the pipeline at 50%. Currently auto-skipped when a description
is present (timing-only mode). Enable to also use Demucs when no description is given.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_demucs_enabled" value="true" {{ $settings['lyrics_demucs_enabled'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Use Demucs when no description</span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>LLM emoji decoration</strong>
<small>
After lyrics are saved, run the active LLM provider to bake one or more emojis
into each line. Without this, the player still decorates lines using a built-in
keyword-to-emoji map (less varied). Provider is configured under
<a href="{{ route('admin.settings') }}" style="color:var(--brand)">AI / LLM Settings</a>.
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<span class="toggle-switch">
<input type="checkbox" name="lyrics_llm_decorate" value="true" {{ $settings['lyrics_llm_decorate'] === 'true' ? 'checked' : '' }}>
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Bake emojis via LLM</span>
</label>
</div>
</div>
</div>
</div>
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save Lyrics Settings
</button>
</div>
</form>
@endsection
+339 -6
View File
@@ -1,23 +1,356 @@
@extends('admin.layout')
@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')
<div class="adm-page-header">
<h1 class="adm-page-title">
<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>
</div>
<div class="adm-card">
@include('nas-file-manager::file-manager', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
@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', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
</div>
</div>
{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
<div id="nasDisableModal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);width:min(520px,94vw);padding:28px;max-height:90vh;overflow-y:auto;">
<div id="nasDisableStep1">
<h3 style="margin:0 0 8px;font-size:17px;">Disable NAS Storage</h3>
<p style="margin:0 0 20px;font-size:13px;color:var(--text-2);">All your files currently live on the NAS. Choose what to do before disabling:</p>
<div style="display:grid;gap:12px;margin-bottom:24px;">
<div id="optMigrate" onclick="selectNasOpt('migrate')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<i class="bi bi-arrow-down-circle" style="color:var(--brand);font-size:18px;"></i>
<strong style="font-size:14px;">Copy all files to local disk</strong>
</div>
<p style="margin:0;font-size:12px;color:var(--text-2);">Downloads every video, thumbnail, avatar, and banner from the NAS to <code>storage/app/users/…</code>. Same directory structure — everything keeps working. May take a while.</p>
</div>
<div id="optFresh" onclick="selectNasOpt('fresh')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<i class="bi bi-trash3" style="color:#e74c3c;font-size:18px;"></i>
<strong style="font-size:14px;color:#e74c3c;">Delete all media, start fresh</strong>
</div>
<p style="margin:0;font-size:12px;color:var(--text-2);">Removes all videos, thumbnails, playlists, comments, and posts. <strong>User accounts are kept.</strong> Nothing is downloaded.</p>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
<button type="button" id="nasDisableNextBtn" class="adm-btn adm-btn-danger" disabled onclick="nasDisableNext()">Continue &rarr;</button>
</div>
</div>
<div id="nasDisableStep2Migrate" style="display:none;">
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk&hellip;</h3>
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting&hellip;</p>
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:8px;overflow:hidden;">
<div id="nasDisableBar" style="height:100%;background:var(--brand);border-radius:4px;width:0%;transition:width .3s;"></div>
</div>
<div id="nasDisableCount" style="font-size:12px;color:var(--text-2);margin-bottom:20px;">0 / 0</div>
<div id="nasDisableDone" style="display:none;">
<div style="color:#27ae60;font-size:13px;margin-bottom:16px;"><i class="bi bi-check-circle-fill"></i> Migration complete! NAS has been disabled. Reload the page to continue.</div>
<button type="button" class="adm-btn adm-btn-primary" onclick="location.reload()">Reload Page</button>
</div>
<div id="nasDisableError" style="display:none;color:#e74c3c;font-size:13px;margin-bottom:16px;"></div>
</div>
<div id="nasDisableStep2Fresh" style="display:none;">
<h3 style="margin:0 0 8px;font-size:17px;color:#e74c3c;">Delete all media?</h3>
<p style="margin:0 0 16px;font-size:13px;color:var(--text-2);">This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. <strong>This cannot be undone.</strong></p>
<p style="margin:0 0 8px;font-size:13px;">Type <strong>DELETE</strong> to confirm:</p>
<input type="text" id="nasDeleteConfirmInput" placeholder="DELETE" class="adm-input-full" style="margin-bottom:8px;">
<div id="nasDeleteConfirmError" style="display:none;color:#e74c3c;font-size:12px;margin-bottom:12px;"></div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
<button type="button" class="adm-btn adm-btn-danger" onclick="nasDisableFreshConfirm()">Delete &amp; Disable NAS</button>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js" defer></script>
<script>
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── NAS Repair ────────────────────────────────────────────────
(function () {
const scanBtn = document.getElementById('nasRepairScanBtn');
const fixBtn = document.getElementById('nasRepairFixBtn');
const resultEl = document.getElementById('nasRepairResult');
const inner = document.getElementById('nasRepairResultInner');
const scanIcon = document.getElementById('nasRepairScanIcon');
const fixIcon = document.getElementById('nasRepairFixIcon');
if (! scanBtn) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
let stuckCount = 0;
function showResult(html, type = 'info') {
const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-2)' };
inner.innerHTML = `<div style="font-size:13px;color:${colours[type] ?? colours.info};padding:6px 0;">${html}</div>`;
resultEl.style.display = 'block';
}
scanBtn.addEventListener('click', async function () {
scanBtn.disabled = true;
fixBtn.style.display = 'none';
scanIcon.className = 'bi bi-arrow-repeat spin';
showResult('Scanning…', 'info');
try {
const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ scan_only: true }),
});
const data = await res.json();
stuckCount = data.stuck ?? 0;
if (stuckCount === 0) {
showResult('✅ All clear — no stuck local files found.', 'success');
} else {
let html = `<strong style="color:var(--brand);">⚠ ${stuckCount} video(s) have files stuck locally.</strong>`;
if (data.details && data.details.length) {
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
}
html += `<p style="margin-top:10px;color:var(--text-2);font-size:12px;">Click <strong>Fix All</strong> to upload them to the NAS and remove local copies.</p>`;
showResult(html, 'warning');
fixBtn.style.display = '';
}
} catch (e) {
showResult('❌ Scan failed: ' + escHtml(e.message), 'danger');
}
scanIcon.className = 'bi bi-search';
scanBtn.disabled = false;
});
fixBtn.addEventListener('click', async function () {
fixBtn.disabled = true;
scanBtn.disabled = true;
fixIcon.className = 'bi bi-arrow-repeat spin';
showResult('Uploading to NAS…', 'info');
try {
const res = await fetch('{{ url("/admin/nas-repair") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
const type = data.failed > 0 ? 'warning' : 'success';
let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`;
if (data.details && data.details.length) {
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
}
showResult(html, type);
fixBtn.style.display = 'none';
} catch (e) {
showResult('❌ Repair failed: ' + escHtml(e.message), 'danger');
}
fixIcon.className = 'bi bi-arrow-repeat';
fixBtn.disabled = false;
scanBtn.disabled = false;
});
})();
// ── NAS Disable Modal ────────────────────────────────────────
let _nasOpt = null;
let _nasPollTimer = null;
function openNasDisableModal() {
_nasOpt = null;
document.getElementById('nasDisableModal').style.display = 'flex';
document.getElementById('nasDisableStep1').style.display = 'block';
document.getElementById('nasDisableStep2Migrate').style.display = 'none';
document.getElementById('nasDisableStep2Fresh').style.display = 'none';
document.getElementById('nasDisableNextBtn').disabled = true;
['optMigrate','optFresh'].forEach(id => {
document.getElementById(id).style.borderColor = 'var(--border)';
});
}
function closeNasDisableModal() {
if (_nasPollTimer) clearInterval(_nasPollTimer);
document.getElementById('nasDisableModal').style.display = 'none';
}
function selectNasOpt(opt) {
_nasOpt = opt;
document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)';
document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)';
document.getElementById('nasDisableNextBtn').disabled = false;
}
function nasDisableNext() {
document.getElementById('nasDisableStep1').style.display = 'none';
if (_nasOpt === 'migrate') {
document.getElementById('nasDisableStep2Migrate').style.display = 'block';
nasStartMigration();
} else {
document.getElementById('nasDisableStep2Fresh').style.display = 'block';
}
}
async function nasStartMigration() {
try {
await fetch('{{ route("admin.nas.disable") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ mode: 'migrate' }),
});
} catch(e) {
document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message;
document.getElementById('nasDisableError').style.display = 'block';
return;
}
_nasPollTimer = setInterval(nasPollProgress, 2000);
}
async function nasPollProgress() {
try {
const r = await fetch('{{ route("admin.nas.migrate-progress") }}');
const d = await r.json();
const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0;
document.getElementById('nasDisableBar').style.width = pct + '%';
document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total;
document.getElementById('nasDisablePhase').textContent = d.phase || '';
if (d.error) {
clearInterval(_nasPollTimer);
document.getElementById('nasDisableError').textContent = 'Error: ' + d.error;
document.getElementById('nasDisableError').style.display = 'block';
}
if (d.done) {
clearInterval(_nasPollTimer);
document.getElementById('nasDisableBar').style.width = '100%';
document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total;
document.getElementById('nasDisableDone').style.display = 'block';
}
} catch(e) { /* network blip, keep polling */ }
}
async function nasDisableFreshConfirm() {
const val = document.getElementById('nasDeleteConfirmInput').value.trim();
const errEl = document.getElementById('nasDeleteConfirmError');
if (val !== 'DELETE') {
errEl.textContent = 'Type DELETE (all caps) to confirm.';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
try {
const r = await fetch('{{ route("admin.nas.disable") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ mode: 'fresh' }),
});
const d = await r.json();
if (d.ok) {
closeNasDisableModal();
location.reload();
} else {
errEl.textContent = d.message || 'An error occurred.';
errEl.style.display = 'block';
}
} catch(e) {
errEl.textContent = 'Failed: ' + e.message;
errEl.style.display = 'block';
}
}
</script>
@endsection
@@ -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
@endphp
@once
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
<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
{{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
so they survive SPA-nav innerHTML swaps on #main. --}}
{{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
@@ -6,6 +6,8 @@
$plUrl = $firstVid
? route('videos.show', $firstVid) . '?playlist=' . $pl->share_token
: route('playlists.show', $pl->id);
$plIsOwner = auth()->check() && auth()->id() === $pl->user_id;
$plShuffleUrl = $firstVid ? route('playlists.shuffle', $pl->id) : null;
@endphp
@once
@@ -115,5 +117,84 @@
{{ number_format($pl->view_count) }} {{ Str::plural('view', $pl->view_count) }} · {{ $pl->created_at->diffForHumans() }}
</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>
@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>
<span>Edit</span>
</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) }})"
style="color:#ef4444;border-color:rgba(239,68,68,.35);">
<i class="bi bi-trash"></i>
@@ -542,9 +544,14 @@ if (!window._slideshowDlInit) {
// the selected language track (window._ytpTrackId; 0 = primary).
var vizOn = localStorage.getItem('audioBarsOn') === '1';
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 = [];
if (vizOn) _p.push('visualizer=1');
if (trackId) _p.push('track=' + trackId);
if (lyrOn) _p.push('lyrics=1');
var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download
var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration=
@@ -87,10 +87,10 @@ $sizeClasses = match($size) {
@endif
@if($video)
<div class="yt-video-meta">
@if($video->type && $video->type !== 'generic')
@if($video->type)
<span class="yt-type-label yt-type-{{ $video->type }}">
<i class="bi {{ $typeIcon }}"></i>
{{ ucfirst($video->type === 'match' ? 'Sports' : $video->type) }}
{{ ucfirst($video->type === 'match' ? 'Sports' : ($video->type === 'generic' ? 'Video' : $video->type)) }}
</span>
&nbsp;·&nbsp;
@endif
@@ -269,6 +269,7 @@ $sizeClasses = match($size) {
</div>
</div>
@once
<style>
/* Base styles for video card */
.yt-video-card {
@@ -531,8 +532,9 @@ $sizeClasses = match($size) {
text-transform: uppercase;
letter-spacing: 0.4px;
}
.yt-type-music { color: #c084fc; }
.yt-type-match { color: #60a5fa; }
.yt-type-music { color: #c084fc; }
.yt-type-match { color: #60a5fa; }
.yt-type-generic { color: #f87171; }
/* More button — visible only on hover (touch devices always show it) */
.yt-video-card .yt-more-btn {
@@ -932,6 +934,7 @@ $sizeClasses = match($size) {
}
}
</style>
@endonce
@once
<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
document.addEventListener('click', function(e) {
if (e.target.closest('.cute-type-option')) {
@@ -1160,5 +1168,7 @@ document.addEventListener('submit', function(e) {
});
}
});
} // end _videoCardListenersBound guard
</script>
@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>
</button>
<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">
<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>
@@ -818,6 +824,19 @@ video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
function initSource() {
video.muted = 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()) {
window._ytpHls = new Hls({ startLevel: -1 });
// Register MANIFEST_PARSED before loadSource to avoid cache race condition
@@ -1077,6 +1096,17 @@ settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
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();
clearTimeout(hideTimer); // keep controls visible while settings open
});
@@ -1370,10 +1400,11 @@ function init() {
largePlay.classList.add('visible');
showControls();
// Scroll-based mini player: watch when #ytpWrap leaves the viewport
if (window.IntersectionObserver && window._miniPlayer) {
/* On mobile, #main is the scroll container; on desktop the window scrolls */
var _scrollRoot = window.innerWidth <= 768 ? document.getElementById('main') : null;
// Scroll-based mini player: watch when #ytpWrap leaves the viewport.
// Desktop-only — on mobile the fixed bottom-nav + locked scroll model
// make a floating overlay disruptive.
if (window.IntersectionObserver && window._miniPlayer && window.innerWidth > 768) {
var _scrollRoot = null; /* desktop: window scrolls */
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
new IntersectionObserver(function (entries) {
var e0 = entries[0];
@@ -1381,7 +1412,8 @@ function init() {
Using !video.paused was unreliable: autoplay fires asynchronously and the
initial IntersectionObserver callback could run before HLS.js even attaches,
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;
window._miniPlayer.activateScroll(
document.title.replace(/\s*\|.*$/, '').trim(),
@@ -1392,6 +1424,12 @@ function init() {
window._miniPlayer.deactivateScroll();
}
}, { 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;
});
}
}
+481 -35
View File
@@ -16,6 +16,118 @@
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.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>
:root {
--brand-red: #e61e1e;
@@ -868,7 +980,12 @@
aspect-ratio: 16/9;
background: #000;
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 .ytp-chrome-bottom,
#ytpMiniVideo .ytp-gradient-bottom,
@@ -884,7 +1001,12 @@
gap: 6px;
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 {
font-size: 12px;
color: #eee;
@@ -948,12 +1070,24 @@
.yt-main.video-view-page { padding: 0 !important; }
.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) {
.yt-sidebar { width: 200px; }
.yt-video-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
}
@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) {
.video-container {
@@ -1771,7 +1905,14 @@
}
</script>
@yield('scripts')
{{-- Page-specific scripts come from each view's @section('scripts').
Wrapped in a marker element so the SPA navigator can swap & re-run
them — without this, SPA nav into a page whose JS defines functions
like switchTab() leaves those functions undefined, and any onclick
handler that calls them silently fails. --}}
<div id="page-scripts">
@yield('scripts')
</div>
{{-- ═══════════════════════════════════════════════════════════
ADMIN ERROR CATCHER — only rendered for super_admins
@@ -1907,6 +2048,21 @@
'scroll' — player scrolled out of viewport on the video page
'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 () {
var wrap = document.getElementById('ytpMini');
var slot = document.getElementById('ytpMiniVideo');
@@ -1916,50 +2072,99 @@
var closeBtn = document.getElementById('ytpMiniClose');
var _mode = null; /* 'scroll' | 'nav' | null */
var _kind = null; /* 'video' | 'audio' | null */
var _origParent = 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() {
var v = getVid();
var m = getMedia();
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';
}
function activate(title, url, mode) {
if (_mode !== null) return false; /* already active — prevent re-entry */
if (!slot) return false;
var v = getVid();
if (!v || !slot) return false;
if (v) {
/* VIDEO MODE — teleport the <video> element so HLS.js stays attached */
_kind = 'video';
_origParent = v.parentNode;
_origNext = v.nextSibling;
slot.appendChild(v);
} else {
/* AUDIO MODE — teleport the <audio> element OUT of #main so SPA
navigation (which replaces #main.innerHTML) cannot destroy it,
and playback continues uninterrupted. Show the current cover
art (or active slide) inside the visible slot. */
var a = getAudio();
if (!a) return false;
_kind = 'audio';
var coverSrc = '';
var slideA = document.getElementById('slideA');
if (slideA && slideA.offsetParent !== null && slideA.src) coverSrc = slideA.src;
if (!coverSrc) {
var cover = document.getElementById('audioCoverImg');
if (cover && cover.src) coverSrc = cover.src;
}
slot.innerHTML = coverSrc
? '<img id="ytpMiniCover" src="' + coverSrc + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block;">'
: '<div style="width:100%;height:100%;background:#1a1a1a;display:flex;align-items:center;justify-content:center;color:#666;"><i class="bi bi-music-note-beamed" style="font-size:32px;"></i></div>';
/* Remember original DOM position to restore later */
_origParent = v.parentNode;
_origNext = v.nextSibling;
/* 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);
}
/* Teleport — HLS.js stays attached, playback uninterrupted */
slot.appendChild(v);
titleEl.textContent = title || 'Video';
titleEl.textContent = title || 'Now playing';
expandBtn.href = url || '#';
_mode = mode;
wrap.style.display = 'block';
syncBtn();
v.addEventListener('play', syncBtn);
v.addEventListener('pause', syncBtn);
var m = getMedia();
if (m) {
m.addEventListener('play', syncBtn);
m.addEventListener('pause', syncBtn);
}
return true;
}
function restore() {
var v = getVid();
if (!v || !_origParent) return;
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(v, _origNext);
} else {
_origParent.appendChild(v);
if (_kind === 'video') {
var v = getVid();
if (!v || !_origParent) return;
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(v, _origNext);
} else {
_origParent.appendChild(v);
}
} else if (_kind === 'audio') {
/* Move <audio> back to its original parent if it still exists
(e.g. scroll-mode → user scrolled back to the player). If the
parent was wiped by an SPA nav, leave the audio in the mini. */
var a = getAudio();
if (a && _origParent && _origParent.isConnected) {
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(a, _origNext);
} else {
_origParent.appendChild(a);
}
}
if (slot) slot.innerHTML = '';
}
_origParent = null;
_origNext = null;
_kind = null;
}
function deactivate() {
@@ -1970,10 +2175,10 @@
if (playBtn) {
playBtn.addEventListener('click', function () {
var v = getVid();
if (!v) return;
if (v.paused) v.play().catch(function(){});
else v.pause();
var m = getMedia();
if (!m) return;
if (m.paused) m.play().catch(function(){});
else m.pause();
});
}
@@ -1985,24 +2190,148 @@
var main = document.getElementById('main');
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
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) {
closeBtn.addEventListener('click', function () {
var v = getVid();
if (v) v.pause();
if (_mode === 'scroll') restore();
if (_mode === 'scroll') {
/* User is still on the player's own page. Close the mini
and put the media element back in its original box so it
keeps playing like a background tab — no pause, no scroll
back up. We dispatch a custom event so the per-page
IntersectionObservers can reset their local "is the mini
on?" flag — otherwise scrolling away again wouldn't
re-trigger the mini until the user scrolls back over the
player first. */
restore();
wrap.style.display = 'none';
_mode = null;
window.dispatchEvent(new CustomEvent('miniplayer:scroll-closed'));
return;
}
/* nav mode — user has navigated away from the player's page.
Pause and fully tear down; the original player no longer
exists in the DOM to receive the media element. */
var m = getMedia();
if (m) m.pause();
wrap.style.display = 'none';
_mode = null;
_kind = null;
});
}
/* ── Drag-to-reposition ────────────────────────────────────────────
Desktop-only (mobile mini is already disabled). Persists the chosen
position in localStorage so the next session keeps it. Buttons /
anchors inside the bar are NOT drag handles — pointerdown on those
goes through to their click handler. */
var _drag = null;
var POS_KEY = 'ytpMiniPos';
function clampToViewport(left, top) {
var r = wrap.getBoundingClientRect();
var maxL = window.innerWidth - r.width - 4;
var maxT = window.innerHeight - r.height - 4;
return {
left: Math.max(4, Math.min(left, maxL)),
top: Math.max(4, Math.min(top, maxT)),
};
}
function applyPos(left, top) {
var c = clampToViewport(left, top);
wrap.style.left = c.left + 'px';
wrap.style.top = c.top + 'px';
wrap.style.right = 'auto';
wrap.style.bottom = 'auto';
}
function loadSavedPos() {
try {
var raw = localStorage.getItem(POS_KEY);
if (!raw) return;
var p = JSON.parse(raw);
if (typeof p.left === 'number' && typeof p.top === 'number') {
applyPos(p.left, p.top);
}
} catch (e) {}
}
function startDrag(e) {
/* Ignore drag attempts on interactive children — buttons/anchors
keep their click semantics. */
if (e.target.closest('button, a')) return;
if (e.button !== undefined && e.button !== 0) return;
var r = wrap.getBoundingClientRect();
_drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
wrap.classList.add('dragging');
/* Lock in pixel coords for the first move so the wrap stops
relying on right/bottom anchoring. */
applyPos(r.left, r.top);
try { wrap.setPointerCapture(e.pointerId); } catch (er) {}
e.preventDefault();
}
function moveDrag(e) {
if (!_drag) return;
applyPos(e.clientX - _drag.dx, e.clientY - _drag.dy);
}
function endDrag(e) {
if (!_drag) return;
_drag = null;
wrap.classList.remove('dragging');
try { wrap.releasePointerCapture(e.pointerId); } catch (er) {}
try {
var r = wrap.getBoundingClientRect();
localStorage.setItem(POS_KEY, JSON.stringify({ left: r.left, top: r.top }));
} catch (er) {}
}
wrap.addEventListener('pointerdown', startDrag);
wrap.addEventListener('pointermove', moveDrag);
wrap.addEventListener('pointerup', endDrag);
wrap.addEventListener('pointercancel', endDrag);
/* Re-clamp on resize so the mini doesn't get stranded off-screen. */
window.addEventListener('resize', function () {
if (wrap.style.display === 'none') return;
var r = wrap.getBoundingClientRect();
applyPos(r.left, r.top);
});
/* Apply saved position when the mini activates (no point reading it
while the wrap is display:none — getBoundingClientRect would be 0). */
function _activateAndPosition(title, url, mode) {
var ok = activate(title, url, mode);
if (ok) loadSavedPos();
return ok;
}
return {
activate: function (t, u) { return activate(t, u, 'nav'); },
activateScroll: function (t, u) { return activate(t, u, 'scroll'); },
activate: function (t, u) { return _activateAndPosition(t, u, 'nav'); },
activateScroll: function (t, u) { return _activateAndPosition(t, u, 'scroll'); },
deactivate: deactivate,
deactivateScroll: function () { if (_mode === 'scroll') deactivate(); },
isActive: function () { return _mode !== null; },
@@ -2010,6 +2339,19 @@
isNavMode: function () { return _mode === 'nav'; },
setUrl: function (u) { if (expandBtn) expandBtn.href = u; },
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
};
})();
@@ -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) {
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' })
.then(function (r) { return r.text(); })
.then(function (html) {
@@ -2073,16 +2475,36 @@
curM.className = newM.className;
curM.innerHTML = newM.innerHTML;
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;
history.pushState({ spa: true, url: url }, doc.title, url);
updateNavStates(url);
/* URL was already pushed at the top; replace state to drop
the `pending` flag now that the load succeeded. */
history.replaceState({ spa: true, url: url }, doc.title, url);
spaBarDone();
})
.catch(function () { location.href = url; });
}
function stopMiniAndNavigate(url) {
var v = document.getElementById('videoPlayer');
var a = document.getElementById('audioEl');
if (v) v.pause();
if (a) a.pause();
document.getElementById('ytpMini').style.display = 'none';
/* Allow browser to do a normal full-page load */
}
@@ -2100,23 +2522,47 @@
var destUrl = new URL(href, location.href).href;
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();
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 */
if (isVideoShowPage(destUrl)) {
if (miniOn) stopMiniAndNavigate(destUrl);
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();
/* Activate mini if the video is live in the page (not already in mini) */
if (playing && !miniOn) {
var title = document.title.replace(/\s*\|.*$/, '').trim();
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);
@@ -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;"
onmouseover="this.style.background='#3f3f3f'"
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="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
@@ -312,33 +312,38 @@
@endauth
}
// Toggle video in playlist
function toggleVideoInPlaylist(playlistId, videoId) {
// Toggle video in/out of a playlist. `currentlyIn` reflects what the row
// 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;
// Check authentication before adding
if (!isAuthenticated()) {
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
return;
}
fetch(`/playlists/${playlistId}/videos`, {
method: 'POST',
// Both add and remove go through the same /playlists/{id}/videos URL with
// 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: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({
video_id: videoId
})
body
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message || 'Video added to playlist');
// Reload playlists to update checkmarks
loadPlaylistsForModal(videoId);
showToast(data.message || (currentlyIn ? 'Removed from playlist' : 'Added to playlist'));
loadPlaylistsForModal(videoId); // refresh checkmarks
}
})
.catch(error => console.error('Error:', error));
@@ -1661,11 +1661,18 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
const formData = new FormData(this);
if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_slidesData)) {
if (tid === 't1') continue;
const n = parseInt(tid.replace('e', ''));
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
}
// Walk extra-track form sections in DOM order so the slide index lines up
// with the positional `extra_track_files[]` the form already submits.
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
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();
+1 -1
View File
@@ -178,7 +178,7 @@
<div class="playlist-info">
<div class="playlist-name">{{ $playlist->name }}</div>
<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>
@if($playlist->description)
<div class="playlist-description">{{ $playlist->description }}</div>
+8
View File
@@ -319,6 +319,8 @@
<span class="sep">·</span>
<span>{{ $playlist->formatted_duration }}</span>
@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)
<span class="pl-hero-badge" style="color:#60a5fa;">
<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'; }
// 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 handleThumbUpload(input) {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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>
+12 -5
View File
@@ -823,11 +823,18 @@
const formData = new FormData(this);
if (_isAudioSubmitC) {
(_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_cSlidesData)) {
if (tid === 'ct1') continue;
const n = parseInt(tid.replace('ce', ''));
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
}
// Walk extra-track sections in DOM order so slide index matches
// the positional `extra_track_files[]` order the backend iterates.
var extraEls = document.querySelectorAll('#ltac-extra .ltac-item');
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();
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');
$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;
$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)
$primaryLang = $video->language ?? 'default';
$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,
]));
$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
<div class="ytp-wrap" id="ytpWrap">
@@ -47,6 +66,27 @@
{{-- Bars canvas overlay --}}
<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 --}}
<div class="ytp-gradient-bottom"></div>
@@ -146,6 +186,31 @@
<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>
</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-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>
@@ -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>
</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 --}}
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
<svg viewBox="0 0 24 24">
@@ -191,8 +265,48 @@
{{-- Hidden audio element --}}
<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 ══ --}}
<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-cover-img {
position: absolute;
@@ -349,6 +463,69 @@
.ytp-lang-check { width: 16px; height: 16px; fill: #fff; opacity: 0; flex-shrink: 0; }
.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-panel {
display: none; position: absolute; bottom: 44px; right: 0;
@@ -419,7 +596,20 @@
.ytp-wrap { border-radius: 0; }
.ytp-button { width: 32px; height: 32px; }
.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>
@@ -561,6 +751,17 @@ settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
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);
});
document.addEventListener('click', e => {
@@ -654,6 +855,12 @@ if (langBtn && langPopup) {
// Update download links
const dlUrl = opt.dataset.langDlUrl;
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 ───────────────────────────────────────
// Variables hoisted outside the if-block so the SPA update hook can access them
const SLIDE_URLS = @json($slideUrls);
// Variables hoisted outside the if-block so the SPA update hook can access them.
// 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 slideB = document.getElementById('slideB');
let currentSlide = 0;
@@ -997,6 +1208,40 @@ function stopSlideshow() {
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
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
@@ -1038,11 +1283,15 @@ if (SLIDE_URLS.length > 1 && slideA) {
// ── SPA update hook — called by recTransitionTo / plTransitionTo ──────────
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 slideshowEl = document.getElementById('slideshowWrap');
stopSlideshow();
if (window._lyricsStop) window._lyricsStop(); // kill prev song's lyrics polling — don't pile up
SLIDE_URLS.length = 0;
if (newSlides.length > 1) {
@@ -1175,6 +1424,10 @@ window._audioPlayerUpdate = function(d) {
// Update download links
var dlUrl = opt.dataset.langDlUrl;
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 (timeCur) timeCur.textContent = '0:00';
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 ─────────────────────────────────────────────────────
@@ -1222,6 +1488,14 @@ audio.addEventListener('playing', function restoreSound() {
audio.addEventListener('loadedmetadata', () => {
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();
if (p) p.catch(() => {
audio.muted = true;
@@ -1229,5 +1503,466 @@ audio.addEventListener('loadedmetadata', () => {
});
});
// ── Synced lyrics overlay (one line at a time, bottom-anchored) ──
(function initLyrics() {
const overlay = document.getElementById('ytpLyricsOverlay');
const panel = document.getElementById('ytpLyricsPanel');
const curEl = document.getElementById('ytpLyrCur');
const btn = document.getElementById('ytpLyricsBtn');
// The owner generate/regenerate button now lives in video-actions (next to
// Edit) — look it up lazily so it works regardless of DOM order / SPA swaps.
const gb = () => document.getElementById('ytpGenLyricsBtn');
if (!overlay || !curEl || !btn) return;
let lines = [], activeLine = -2, curWordEls = [];
let enabled = localStorage.getItem('ytpLyricsOn') === '1';
let genPoll = null;
// Pick an emoji that reflects what the line is ABOUT (first keyword match
// wins; more specific themes are listed first). Falls back to a music note.
const EMOJI_MAP = [
[/\b(heart\s?broken|heartbreak|broke\w*\s+\w*\s*heart|broken heart)\b/i, '💔'],
[/\b(love|lovin|loving|adore|sweetheart|my heart|in love|darling)\b/i, '❤️'],
[/\b(kiss|kisses|kissing|lips|lip gloss)\b/i, '💋'],
[/\b(baby|babe|honey|boo)\b/i, '💕'],
[/\b(fire|flame|flames|burn|burning|burns|lit|blaze|blazing)\b/i, '🔥'],
[/\b(cry|crying|cried|tears|teardrop|weep|weeping)\b/i, '😢'],
[/\b(sad|pain|painful|hurt|hurts|hurting|sorrow|lonely|alone|broken)\b/i, '😔'],
[/\b(smile|smiling|happy|happiness|joy|joyful|laugh|laughing)\b/i, '😊'],
[/\b(dance|dancing|dancin|groove|sway|move your)\b/i, '💃'],
[/\b(party|partying|club|celebrate|turn up|tonight we)\b/i, '🎉'],
[/\b(money|cash|rich|dollars?|gold|paid|bands|wealth|diamonds?)\b/i, '💰'],
[/\b(king|queen|crown|royal|royalty|throne)\b/i, '👑'],
[/\b(night|midnight|tonight|nighttime|dark|darkness|shadows?)\b/i, '🌙'],
[/\b(sun|sunshine|sunrise|daylight|bright)\b/i, '☀️'],
[/\b(star|stars|shine|shining|shinin|sparkle|glitter|glow)\b/i, '✨'],
[/\b(heaven|heavens|sky|skies|clouds?)\b/i, '☁️'],
[/\b(rain|raining|rainin|storm|stormy|thunder)\b/i, '🌧️'],
[/\b(cold|ice|icy|frozen|freeze|winter|snow)\b/i, '❄️'],
[/\b(ocean|sea|waves?|water|river|drown|drowning)\b/i, '🌊'],
[/\b(rose|roses|flower|flowers|bloom|petals?)\b/i, '🌹'],
[/\b(dream|dreams|dreaming|dreamin|asleep|sleep)\b/i, '💭'],
[/\b(fly|flying|flyin|wings|soar|rise|rising)\b/i, '🕊️'],
[/\b(rocket|space|moon|sky high|to the moon)\b/i, '🚀'],
[/\b(run|running|runnin|ran|escape|escaping|away)\b/i, '🏃'],
[/\b(time|clock|hours?|minutes?|forever|seconds?)\b/i, '⏳'],
[/\b(god|pray|prayin|prayer|soul|angel|angels|amen|bless)\b/i, '🙏'],
[/\b(home|house|hometown)\b/i, '🏠'],
[/\b(road|drive|driving|drivin|car|ride|riding|highway)\b/i, '🚗'],
[/\b(wild|crazy|insane|reckless|savage|chaos)\b/i, '🤪'],
[/\b(strong|power|powerful|stronger|unstoppable)\b/i, '💪'],
[/\b(eyes?|look|looking|lookin|stare|staring|gaze)\b/i, '👀'],
[/\b(drink|wine|champagne|whiskey|drunk|toast|cheers)\b/i, '🥂'],
[/\b(war|fight|fighting|battle|enemy|enemies|trouble)\b/i, '⚔️'],
[/\b(devil|hell|sin|sinner|demons?)\b/i, '😈'],
[/\b(phone|call|calling|text|message|ring)\b/i, '📱'],
[/\b(music|song|sing|singing|singin|melody|beat|rhythm|sound)\b/i, '🎶'],
];
function emojiForLine(text) {
const s = ' ' + String(text || '') + ' ';
for (let i = 0; i < EMOJI_MAP.length; i++) { if (EMOJI_MAP[i][0].test(s)) return EMOJI_MAP[i][1]; }
return '🎵';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}
function showBtn(show) {
btn.style.display = show ? '' : 'none';
if (!show) { overlay.style.display = 'none'; btn.classList.remove('active'); }
}
function applyEnabled() {
btn.classList.toggle('active', enabled);
overlay.style.display = (enabled && lines.length) ? 'flex' : 'none';
}
function render(data) {
lines = (data && data.lines) ? data.lines : [];
activeLine = -2; curWordEls = [];
curEl.innerHTML = '';
if (!lines.length) { showBtn(false); return; }
showBtn(true);
applyEnabled();
sync(true);
}
function findLine(t) {
let idx = -1;
for (let i = 0; i < lines.length; i++) { if (lines[i].start <= t) idx = i; else break; }
return idx;
}
// Render only the single active line, wrapped in cycling decorative emojis.
function paintLine(idx) {
if (idx < 0 || !lines[idx]) {
// No line yet (before the song's first lyric) — hide the panel so
// there's no empty black box sitting on the artwork.
curEl.innerHTML = '';
curWordEls = [];
if (panel) panel.style.display = 'none';
return;
}
if (panel) panel.style.display = '';
const ln = lines[idx];
const words = (ln.words && ln.words.length) ? ln.words : null;
const inner = words
? words.map((w, j) => '<span class="ytp-lyrics-word" data-i="' + j + '">' + escapeHtml(w.text) + '</span>').join(' ')
: '<span class="ytp-lyrics-word">' + escapeHtml(ln.text || '') + '</span>';
// If the LLM decorated the line at generation time, emojis are already
// inside ln.text — render it bare without the lead/trail wrap so we
// don't double-stack decorations. Old un-decorated lyrics keep the
// keyword-emoji fallback (or a baked single emoji from the v1 format).
if (ln.decorated) {
curEl.innerHTML = inner;
} else {
const e = (ln.emoji && String(ln.emoji).trim()) || emojiForLine(ln.text);
curEl.innerHTML = '<span class="ytp-lyrics-deco lead">' + e + '</span>' + inner
+ '<span class="ytp-lyrics-deco trail">' + e + '</span>';
}
curWordEls = [].slice.call(curEl.querySelectorAll('.ytp-lyrics-word'));
}
function sync(force) {
if (!enabled || !lines.length) return;
const t = audio.currentTime || 0;
let idx = findLine(t);
// During an instrumental gap (well past the current line and the next one
// hasn't started) hide the line instead of leaving it frozen on screen.
if (idx >= 0 && t > lines[idx].end + 2.5) {
const next = lines[idx + 1];
if (!next || next.start - t > 0.4) idx = -1;
}
if (idx !== activeLine || force) {
paintLine(idx);
activeLine = idx;
}
if (idx >= 0 && curWordEls.length) {
const ws = lines[idx].words || [];
curWordEls.forEach((span, j) => { const w = ws[j]; if (w) span.classList.toggle('sung', t >= w.start); });
}
}
audio.addEventListener('timeupdate', () => sync(false));
audio.addEventListener('seeked', () => sync(true));
btn.addEventListener('click', () => {
enabled = !enabled;
localStorage.setItem('ytpLyricsOn', enabled ? '1' : '0');
applyEnabled();
if (enabled) sync(true);
});
// Owner-only generate button + live progress panel.
const genWrap = document.getElementById('ytpLyrGen');
const genBar = document.getElementById('ytpLyrGenBar');
const genLabel = document.getElementById('ytpLyrGenLabel');
const genPct = document.getElementById('ytpLyrGenPct');
let dispPct = 0, targetPct = 0, creepTimer = null;
function showGen(show, mode) {
const b = gb();
if (!b) return;
b.style.display = show ? '' : 'none';
const label = mode === 'regen' ? 'Regenerate lyrics' : 'Generate lyrics';
b.title = label;
b.classList.toggle('is-regen', mode === 'regen');
const lbl = b.querySelector('.genlyr-label');
if (lbl) lbl.textContent = label;
}
function showGenProgress(on) {
if (genWrap) genWrap.style.display = on ? 'flex' : 'none';
if (on && overlay) overlay.style.display = 'none'; // avoid overlapping the lyrics panel
if (!on && creepTimer) { clearInterval(creepTimer); creepTimer = null; }
}
function setBar(p) {
if (genBar) genBar.style.width = p + '%';
if (genPct) genPct.textContent = Math.round(p) + '%';
}
// Stop any in-flight generation polling/timers (called on navigation + track
// switches so nothing piles up as the user moves between songs).
window._lyricsStop = function () {
if (genPoll) { clearInterval(genPoll.timer); genPoll = null; }
if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
showGenProgress(false);
};
// Show lyrics for a track id (0 = primary) straight from the inline map —
// no network request. window._LYRICS is keyed by track id as a string.
window._lyricsShow = function (trackId) {
const tid = parseInt(trackId, 10) || 0;
// Switching to a different track → kill a poll left running for another track.
if (genPoll && genPoll.track !== tid) window._lyricsStop();
const d = (window._LYRICS || {})[String(tid)];
if (d && d.status === 'ready' && d.lines && d.lines.length) {
// Lyrics exist → show them, keep the owner's "Regenerate" + "Edit" buttons.
render(d); showGenProgress(false); showGen(true, 'regen'); showEdit(true); showDelete(true);
} else if (d && d.status === 'processing') {
// Generation already running (e.g. right after upload) — show the live bar.
lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
if (genWrap) { if (!genPoll || genPoll.track !== tid) startGenPoll(tid); }
else { showGen(false); }
} else {
lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
if (genPoll && genPoll.track === tid) { /* in flight — keep the bar */ }
else { showGenProgress(false); showGen(!!gb()); }
}
};
// Owner clicks the generate/regenerate button (delegated so it works wherever
// the button is rendered and after SPA content swaps) → kick off generation,
// then watch the live progress bar in the player.
document.addEventListener('click', function (ev) {
const b = ev.target.closest && ev.target.closest('#ytpGenLyricsBtn');
if (!b) return;
ev.preventDefault();
// Close the settings panel if the click came from inside the gear menu.
const sp = document.getElementById('ytpSettingsPanel');
if (sp) sp.classList.remove('open');
const track = window._ytpTrackId || 0;
const existing = (window._LYRICS || {})[String(track)];
const isRegen = !!(existing && existing.status === 'ready' && existing.lines && existing.lines.length);
const restore = () => showGen(true, isRegen ? 'regen' : 'gen');
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
const url = b.dataset.genUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics/generate');
showGen(false); showGenProgress(true);
dispPct = 1; targetPct = 1; setBar(1);
if (genLabel) genLabel.textContent = isRegen ? 'Regenerating…' : 'Starting…';
if (window.showToast) window.showToast((isRegen ? 'Regenerating' : 'Generating') + ' lyrics…', 'info');
fetch(url + '?track=' + track, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
})
.then(r => r.json())
.then(res => {
if (res.error) { showGenProgress(false); restore(); if (window.showToast) window.showToast(res.error, 'error'); return; }
startGenPoll(track);
})
.catch(() => { showGenProgress(false); restore(); if (window.showToast) window.showToast('Could not start generation.', 'error'); });
});
// Poll the live progress endpoint; creep the bar between polls so it always moves.
function startGenPoll(track) {
if (genPoll) clearInterval(genPoll.timer);
showGen(false); showGenProgress(true);
dispPct = Math.max(dispPct, 1); targetPct = Math.max(targetPct, 1);
if (genLabel) genLabel.textContent = 'Generating lyrics…';
setBar(dispPct);
if (creepTimer) clearInterval(creepTimer);
creepTimer = setInterval(() => {
const soft = Math.min(targetPct + 6, 97);
if (dispPct < soft) { dispPct = Math.min(soft, dispPct + 0.6); setBar(dispPct); }
}, 220);
const key = window._ROUTE_KEY || @json($video->getRouteKey());
let misses = 0, ticks = 0;
const timer = setInterval(() => {
// Hard cap (~8 min) so a stuck job can never leave a poll running forever.
if (++ticks > 320) { window._lyricsStop(); showGen(!!gb()); return; }
fetch('/videos/' + key + '/lyrics/progress?track=' + track, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(p => {
if (p.status === 'ready') {
targetPct = 100; dispPct = 100; setBar(100);
clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
genPoll = null;
fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(d => {
window._LYRICS = d.lyrics || window._LYRICS;
setTimeout(() => {
showGenProgress(false);
if ((window._ytpTrackId || 0) === track) window._lyricsShow(track); else showGen(true);
if (window.showToast) window.showToast('Lyrics ready!', 'success');
}, 400);
})
.catch(() => { showGenProgress(false); window._lyricsShow(track); });
} else if (p.status === 'failed') {
clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
genPoll = null; showGenProgress(false); showGen(true);
if (window.showToast) window.showToast('Lyrics generation failed.', 'error');
} else if (p.status === 'none') {
if (++misses > 6) { clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; } genPoll = null; showGenProgress(false); showGen(true); }
} else {
misses = 0;
if (typeof p.pct === 'number') targetPct = Math.max(targetPct, p.pct);
if (genLabel && p.stage) genLabel.textContent = p.stage + '…';
}
})
.catch(() => {});
}, 1500);
genPoll = { track: track, timer: timer };
}
// ── Lyrics editor (owner) ──────────────────────────────────────────────
function showEdit(show) {
const e = document.getElementById('ytpEditLyricsBtn');
if (e) e.style.display = show ? '' : 'none';
}
function showDelete(show) {
const e = document.getElementById('ytpDeleteLyricsBtn');
if (e) e.style.display = show ? '' : 'none';
}
// Owner clicks Delete lyrics (gear menu) → wipe local + NAS copy, reset the
// overlay so they can regenerate from scratch. Confirmation via toast-style
// inline question; we never use alert/confirm (project rule).
document.addEventListener('click', function (ev) {
const b = ev.target.closest && ev.target.closest('#ytpDeleteLyricsBtn');
if (!b) return;
ev.preventDefault();
const sp = document.getElementById('ytpSettingsPanel');
if (sp) sp.classList.remove('open');
const track = window._ytpTrackId || 0;
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
const url = b.dataset.delUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics');
if (window.showToast) window.showToast('Deleting lyrics…', 'info');
fetch(url + '?track=' + track, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' },
})
.then(r => r.json())
.then(res => {
if (res.error) {
if (window.showToast) window.showToast(res.error, 'error');
return;
}
// Clear cached lyrics for this track so the overlay disappears and
// the gear button flips back to "Generate" (not "Regenerate").
if (window._LYRICS) delete window._LYRICS[String(track)];
lines = []; activeLine = -2; curWordEls = [];
curEl.innerHTML = '';
if (panel) panel.style.display = 'none';
overlay.style.display = 'none';
showBtn(false); showEdit(false); showDelete(false);
showGenProgress(false); showGen(!!gb(), 'gen');
if (window.showToast) window.showToast('Lyrics deleted. Click Generate to start fresh.', 'success');
})
.catch(() => { if (window.showToast) window.showToast('Could not delete lyrics.', 'error'); });
});
const edOverlay = document.getElementById('lyrEditorOverlay');
const edBody = document.getElementById('lyrEditorBody');
let edTrack = 0, edLines = [];
function fmtTime(s) {
s = Math.max(0, Math.floor(s || 0));
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
}
window.openLyricsEditor = function () {
const tid = window._ytpTrackId || 0;
const d = (window._LYRICS || {})[String(tid)];
if (!d || d.status !== 'ready' || !d.lines || !d.lines.length) {
if (window.showToast) window.showToast('Generate lyrics first.', 'info');
return;
}
edTrack = tid;
edLines = JSON.parse(JSON.stringify(d.lines)); // editable copy (keeps words)
edBody.innerHTML = '';
edLines.forEach((ln, i) => {
const row = document.createElement('div');
row.className = 'lyr-editor-row';
const time = document.createElement('span');
time.className = 'lyr-editor-time';
time.textContent = fmtTime(ln.start);
time.title = 'Jump to this line';
time.addEventListener('click', () => { try { audio.currentTime = ln.start; audio.play().catch(()=>{}); } catch(e){} });
const inp = document.createElement('input');
inp.className = 'lyr-editor-input';
inp.type = 'text';
inp.value = ln.text || '';
inp.addEventListener('input', () => { edLines[i].text = inp.value; });
row.appendChild(time); row.appendChild(inp);
edBody.appendChild(row);
});
edOverlay.style.display = 'flex';
};
function closeEditor() { if (edOverlay) edOverlay.style.display = 'none'; }
if (edOverlay) {
document.getElementById('lyrEditorClose').addEventListener('click', closeEditor);
document.getElementById('lyrEditorCancel').addEventListener('click', closeEditor);
edOverlay.addEventListener('click', e => { if (e.target === edOverlay) closeEditor(); });
document.getElementById('lyrEditorSave').addEventListener('click', () => {
const saveBtn = document.getElementById('lyrEditorSave');
saveBtn.disabled = true;
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
const key = window._ROUTE_KEY || @json($video->getRouteKey());
fetch('/videos/' + key + '/lyrics/save', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ track: edTrack, lines: edLines })
})
.then(r => r.json())
.then(res => {
saveBtn.disabled = false;
if (res.error) { if (window.showToast) window.showToast(res.error, 'error'); return; }
// Reload the fresh (re-timed) lyrics and re-render.
fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(d => {
window._LYRICS = d.lyrics || window._LYRICS;
if ((window._ytpTrackId || 0) === edTrack) window._lyricsShow(edTrack);
})
.catch(() => {});
closeEditor();
if (window.showToast) window.showToast('Lyrics saved.', 'success');
})
.catch(() => { saveBtn.disabled = false; if (window.showToast) window.showToast('Could not save lyrics.', 'error'); });
});
}
// Lyrics for the server-rendered song, embedded inline (keyed by track id).
window._LYRICS = @json($inlineLyrics, JSON_UNESCAPED_UNICODE);
window._lyricsShow(window._ytpTrackId || 0);
})();
/* ── Scroll-based mini player for the music view ────────────────────────
Mirrors the IntersectionObserver in video-player.blade.php. Activates the
global #ytpMini once playback has started AND the player wrap leaves the
viewport; deactivates when it scrolls back in.
Deferred to DOMContentLoaded because window._miniPlayer is defined in the
layout's footer scripts, which parse after this partial. */
function _setupAudioMiniObserver() {
if (!window.IntersectionObserver || !window._miniPlayer) return;
var wrapEl = document.getElementById('ytpWrap');
var aEl = document.getElementById('audioEl');
if (!wrapEl || !aEl) return;
/* Floating mini player is desktop-only — on mobile the fixed bottom-nav
and the locked scroll model make a floating overlay disruptive. */
if (window.innerWidth <= 768) return;
var _scrollRoot = null; /* desktop: window scrolls */
var _scrollMiniOn = false;
var _hasPlayed = !aEl.paused;
aEl.addEventListener('play', function () { _hasPlayed = true; });
new IntersectionObserver(function (entries) {
var e0 = entries[0];
var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (!e0.isIntersecting && !_scrollMiniOn && _hasPlayed && miniAllowed && !window._miniPlayer.isNavMode()) {
_scrollMiniOn = true;
window._miniPlayer.activateScroll(
document.title.replace(/\s*\|.*$/, '').trim(),
window.location.href
);
} else if (e0.isIntersecting && _scrollMiniOn) {
_scrollMiniOn = false;
window._miniPlayer.deactivateScroll();
}
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrapEl);
/* User clicked the X on the mini while still on this page — reset our
local flag so a subsequent scroll-away re-activates the mini. */
window.addEventListener('miniplayer:scroll-closed', function () {
_scrollMiniOn = false;
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _setupAudioMiniObserver);
} else {
_setupAudioMiniObserver();
}
})();
</script>
+313 -214
View File
@@ -1,20 +1,25 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name'))
@php
$metaTitle = $shareTitle ?? $video->title;
$metaDesc = $shareDescription ?? $video->description;
$metaUrl = $video->share_url;
@endphp
@section('title', $metaTitle . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:title" content="{{ $metaTitle }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($metaDesc ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:url" content="{{ $metaUrl }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:title" content="{{ $metaTitle }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($metaDesc ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@@ -226,6 +231,52 @@
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 */
@@ -374,7 +425,15 @@
margin: 12px 0 6px !important;
}
.channel-row {
flex-direction: column;
align-items: flex-start !important;
gap: 12px;
}
.channel-info {
width: 100%;
}
.subscribe-btn {
width: 100%;
@@ -418,7 +477,7 @@
<!-- 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"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
@@ -426,9 +485,11 @@
:playlist-videos="$playlistVideos ?? null" />
<!-- 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;">
<i class="bi bi-film" style="color: #ef4444;"></i>
<span>{{ $video->title }}</span>
<i class="bi bi-film" style="color: #ef4444; flex-shrink:0;"></i>
<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>
<!-- Stats Row - Hidden, shown in description box -->
@@ -440,11 +501,13 @@
</div>
</div>
<!-- Channel Row - All in one line -->
<x-channel-row :video="$video" />
@include('videos.partials.description-box', ['video' => $video])
<x-video-comments :video="$video" />
</div>
<!-- Sidebar - Up Next / Recommendations -->
@@ -493,22 +556,24 @@
function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; }
function plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); }
var plAutoplay = plGet('autoplay','1');
var plLoop = plGet('loop','off');
var plShuffle = plGet('shuffle','0');
var plTransiting = false;
var plAutoplay = plGet('autoplay','1');
var plLoop = plGet('loop','off');
var plShuffle = plGet('shuffle','0');
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 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) {
var idx=PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
if(plShuffle==='1'){
var idx = PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
if (plShuffle==='1') {
var ord=plGetOrder()||plShuffleOrder();
var pos=ord.indexOf(idx);
var pp=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
var np=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:'' };
var prevPos=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
var nextPos=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1);
return { prev: prevPos>=0?PL_VIDEOS[ord[prevPos]].url:'', next: nextPos>=0?PL_VIDEOS[ord[nextPos]].url:'' };
}
return {
prev: idx>0?PL_VIDEOS[idx-1].url:'',
@@ -516,27 +581,40 @@
};
}
// ── time formatter ───────────────────────────────────────
function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
// ── SPA transition — load new video source + update UI ────
async function plTransitionTo(url, pushHist) {
if(!url||plTransiting) return;
plTransiting=true;
if(window._spaScrollToVideo) window._spaScrollToVideo();
if (!url || plTransiting) return;
plTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m=url.match(/\/videos\/([^/?#]+)/);
if(!m){ window.location.href=url; return; }
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href=url; return; }
var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
var resp;
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e){ window.location.href=url; return; }
if(!resp.ok){ window.location.href=url; return; }
try { resp = await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e) { window.location.href=url; return; }
if (!resp.ok) { window.location.href=url; return; }
var d;
try { d=await resp.json(); }
catch(e){ window.location.href=url; return; }
try { d = await resp.json(); }
catch(e) { window.location.href=url; return; }
// reload video source (HLS or MP4)
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); }
// 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); }
catch(e) { console.warn('_ytpLoadSource', e); }
if (vid && _savedVol !== null) { vid.volume = _savedVol; vid.muted = _savedMuted; }
// reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
@@ -547,40 +625,47 @@
if(dr&&d.duration) dr.textContent=plFmt(d.duration);
// update page title
var ts=document.querySelector('.video-title span');
if(ts) ts.textContent=d.title;
var ts=document.getElementById('videoTitleText');
if(ts){ ts.textContent=d.title; ts.dataset.primaryTitle=d.title; }
document.title=d.title+' | {{ config("app.name") }}';
// update loop state on video element
var vid=document.getElementById('videoPlayer');
if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id;
try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
// update state
PL_CURRENT = d.id;
try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
// history
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
// async: swap description + comments from fetched page
plSwapContent(url);
} catch(e){
} catch(e) {
console.warn('plTransitionTo',e);
} finally {
plTransiting=false;
}
}
// ── background page swap: description + comments ──────────
async function plSwapContent(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');
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');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
// swap description box
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
// swap channel row
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection');
if(ny&&oy){
// swap comments (HTML + re-run its init script)
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){
@@ -593,13 +678,27 @@
} catch(e){ console.warn('plSwapContent',e); }
}
function plHighlight(activeId, scroll){
// ── sidebar highlight ────────────────────────────────────
function plHighlight(activeId, scroll) {
document.querySelectorAll('.sidebar-video-card[data-pl-id]').forEach(function(c){
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(){
var adj=plAdj(PL_CURRENT);
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(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!adj.prev;
// In-player prev/next buttons
document.querySelectorAll('.ytp-prev-btn').forEach(function(b){ b.disabled=!adj.prev; b.style.opacity=adj.prev?'':'0.4'; });
document.querySelectorAll('.ytp-next-btn').forEach(function(b){ b.disabled=!adj.next; b.style.opacity=adj.next?'':'0.4'; });
}
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
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.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(); };
// ── public API ────────────────────────────────────────────
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
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); };
// hook into video player: intercept next/prev and ended
window._ytpNavOverride = {
@@ -630,8 +730,14 @@
};
window._plOnVideoEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); };
window.addEventListener('popstate',function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url,false); });
window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
// ── browser back/forward ──────────────────────────────────
window.addEventListener('popstate', function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url, false); });
// ── init ──────────────────────────────────────────────────
function plInit(){
var v=document.getElementById('videoPlayer');
if(v&&plLoop==='one') v.loop=true;
@@ -694,26 +800,164 @@
</a>
@endif
@else
{{-- Up Next header + autoplay toggle --}}
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<h3 style="font-size:16px; font-weight:500; margin:0;">
<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>
<span class="pl-autoplay-label">Autoplay</span>
</button>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
<h3 style="font-size:16px; font-weight:500; margin:0;">Up Next</h3>
<button id="recAutoplayBtn" class="pl-ctrl-btn pl-ctrl-autoplay" title="Autoplay" onclick="recToggleAutoplay()" style="margin-left:auto;">
<i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span>
</button>
</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)
<div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
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;">
@if ($recVideo->thumbnail)
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
@@ -752,151 +996,6 @@
@else
<div class="text-secondary" id="recList">No recommendations available yet. Check back later!</div>
@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
</div>
@@ -2612,6 +2612,9 @@
try { d=await resp.json(); }
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); }
catch(e){ console.warn('_ytpLoadSource', e); }
@@ -2873,6 +2876,9 @@
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 !== 'match') { window.location.href = url; return; }
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
@@ -572,6 +572,9 @@
try { d = await resp.json(); }
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)
var audio = document.getElementById('audioEl');
if (audio) {
@@ -631,6 +634,9 @@
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); }
}
@@ -796,6 +802,9 @@
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 !== 'music') { window.location.href = url; return; }
var audio = document.getElementById('audioEl');
if (audio) {
var _savedVol = audio.volume; var _savedMuted = audio.muted;
@@ -850,6 +859,8 @@
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); }
}