Fix player UI: sports Highlights pane, scrubber knob, volume knob, description + save-to-playlist
Player/UI fixes:
- Sports (match) Highlights pane: move the toggle click-handler script out of
the always-false @if(false ...) legacy block so the "Highlights" button opens,
and emit videoId/isOwner with @json instead of {{ json_encode() }} (escaped
quotes crashed the data/tab/CRUD block once route keys became hashid strings).
- Progress scrubber knob (video + audio players): center on the track line with
translate(-50%, -50%); drop the margin-top hack that floated it above the line.
- Music player volume: give the slider wrap height so overflow:hidden no longer
crops the knob; add a subtle thumb shadow for visibility.
- Music language menu: cap height to available space above the header, sticky
header, internal scroll; keep controls from auto-hiding while the menu is open.
- Description box: re-check "Show more" overflow at every settle point (fonts,
images, rAF, tab reopen) so SPA autonext keeps the expand button.
Also bundles pending work: save-to-playlist button component, playlist/channel
views, PlaylistController + NasSyncService, and component-usage tracker updates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
80948efff7
commit
6b43a7060a
@ -271,6 +271,19 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `<x-save-to-playlist-button>`
|
||||||
|
|
||||||
|
**File:** `resources/views/components/save-to-playlist-button.blade.php`.
|
||||||
|
**Rule:** Canonical "Save to playlist" trigger. Opens the singleton `layouts/partials/add-to-playlist-modal.blade.php` (already included once via `layouts/app.blade.php`) by calling `openAddToPlaylistModal($video->id)`. **Always pass `$video->id`** — the backend (`PlaylistController::addVideo` / `removeVideoByBody`) validates `video_id` against `exists:videos,id`. Never pass `$video->getRouteKey()`. Props: `video` (required), `tag` (`button`|`a`); extra attributes forwarded; slot overrides the default label.
|
||||||
|
|
||||||
|
| View file | Usage | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `resources/views/components/video-card.blade.php` | `<x-save-to-playlist-button :video tag="a" class="dropdown-item">` | Card 3-dot menu item |
|
||||||
|
| `resources/views/components/video-actions.blade.php` | `<x-save-to-playlist-button :video class="action-btn desktop-action">` + dropdown-item | Watch-page actions (desktop + dropdown) |
|
||||||
|
| `resources/views/videos/show.blade.php` | `<x-save-to-playlist-button :video class="yt-action-btn">` (×2) | Watch page mobile + desktop save buttons |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Modification checklist
|
## Modification checklist
|
||||||
|
|
||||||
When you modify any of these components, work through this list:
|
When you modify any of these components, work through this list:
|
||||||
|
|||||||
@ -257,23 +257,19 @@ class PlaylistController extends Controller
|
|||||||
'visibility' => $request->visibility ?? 'private',
|
'visibility' => $request->visibility ?? 'private',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Handle thumbnail upload
|
// Thumbnail: an uploaded file always wins over remove_thumbnail. The
|
||||||
|
// modal sometimes sends both when a user clears the preview and then
|
||||||
|
// picks a new file — without this guard the new file would be silently
|
||||||
|
// discarded.
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
// Delete old thumbnail from NAS if exists
|
|
||||||
if ($playlist->thumbnail) {
|
if ($playlist->thumbnail) {
|
||||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||||
}
|
} elseif ($request->input('remove_thumbnail') == '1' && $playlist->thumbnail) {
|
||||||
|
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||||
// Handle thumbnail removal
|
$updateData['thumbnail'] = null;
|
||||||
if ($request->input('remove_thumbnail') == '1') {
|
|
||||||
if ($playlist->thumbnail) {
|
|
||||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
|
||||||
$updateData['thumbnail'] = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$playlist->update($updateData);
|
$playlist->update($updateData);
|
||||||
@ -519,16 +515,32 @@ class PlaylistController extends Controller
|
|||||||
|
|
||||||
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
|
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
|
||||||
{
|
{
|
||||||
$nas = app(\App\Services\NasSyncService::class);
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
$tmpName = self::generateFilename($ext);
|
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
|
||||||
$file->storeAs('public/thumbnails', $tmpName);
|
$localAbs = storage_path('app/' . $nasPath);
|
||||||
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
|
|
||||||
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
|
// When NAS is reachable, push directly and avoid the slow timeout path.
|
||||||
$dir = dirname($nasPath);
|
if ($nas->isEnabled()) {
|
||||||
$nas->mkdirp($dir);
|
$tmpName = self::generateFilename($ext);
|
||||||
$nas->putFile($tempAbs, $nasPath);
|
$file->storeAs('public/thumbnails', $tmpName);
|
||||||
@unlink($tempAbs);
|
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
|
||||||
|
$nas->mkdirp(dirname($nasPath));
|
||||||
|
if ($nas->putFile($tempAbs, $nasPath)) {
|
||||||
|
@unlink($tempAbs);
|
||||||
|
return $nasPath;
|
||||||
|
}
|
||||||
|
// NAS push failed mid-flight — fall through to local fallback so the
|
||||||
|
// file isn't lost. nas:auto-sync will retry when NAS comes back.
|
||||||
|
@mkdir(dirname($localAbs), 0755, true);
|
||||||
|
@rename($tempAbs, $localAbs);
|
||||||
|
return $nasPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NAS disabled / unreachable — keep on local disk at the same path
|
||||||
|
// MediaController expects, so it serves directly without a NAS round-trip.
|
||||||
|
@mkdir(dirname($localAbs), 0755, true);
|
||||||
|
$file->move(dirname($localAbs), basename($localAbs));
|
||||||
return $nasPath;
|
return $nasPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -997,12 +997,11 @@ class VideoController extends Controller
|
|||||||
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// NAS disabled — keep on local disk
|
// NAS disabled — keep on local disk, mirroring the NAS path
|
||||||
$localDir = $nas->localVideoDir($video);
|
$nasDir = $this->nasVideoDir($video, $nas);
|
||||||
|
$localDir = storage_path('app/' . $nasDir);
|
||||||
@mkdir($localDir, 0755, true);
|
@mkdir($localDir, 0755, true);
|
||||||
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
|
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
|
||||||
$userSlug = $nas->userSlug($video->user);
|
|
||||||
$nasDir = $this->nasVideoDir($video, $nas);
|
|
||||||
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1041,13 +1040,13 @@ class VideoController extends Controller
|
|||||||
$nasForSlides = app(\App\Services\NasSyncService::class);
|
$nasForSlides = app(\App\Services\NasSyncService::class);
|
||||||
$nasEnabled = $nasForSlides->isEnabled();
|
$nasEnabled = $nasForSlides->isEnabled();
|
||||||
|
|
||||||
// Derive NAS dir without scanning when possible
|
// Derive NAS dir without scanning — works regardless of NAS state
|
||||||
$nasDir = $nasEnabled ? $this->nasVideoDir($video, $nasForSlides) : null;
|
|
||||||
|
|
||||||
// Local dir used as fallback or when NAS is off
|
|
||||||
$localDir = $nasForSlides->localVideoDir($video);
|
|
||||||
$userSlug = $nasForSlides->userSlug($video->user);
|
|
||||||
$relBase = $this->nasVideoDir($video, $nasForSlides);
|
$relBase = $this->nasVideoDir($video, $nasForSlides);
|
||||||
|
$nasDir = $nasEnabled ? $relBase : null;
|
||||||
|
|
||||||
|
// Local dir mirrors the NAS path so MediaController can find files
|
||||||
|
// whether NAS is reachable or not.
|
||||||
|
$localDir = storage_path('app/' . $relBase);
|
||||||
|
|
||||||
if ($nasEnabled) {
|
if ($nasEnabled) {
|
||||||
$nasForSlides->mkdirp("{$nasDir}/slides");
|
$nasForSlides->mkdirp("{$nasDir}/slides");
|
||||||
|
|||||||
@ -490,6 +490,12 @@ class NasSyncService
|
|||||||
{
|
{
|
||||||
if (file_exists($localPath)) return true;
|
if (file_exists($localPath)) return true;
|
||||||
|
|
||||||
|
// Don't shell out to smbclient when we already know NAS is unreachable
|
||||||
|
// — smbclient hangs ~30 sec on the SMB timeout, multiplied by every
|
||||||
|
// thumbnail/avatar on the page. isEnabled() uses a 2-sec TCP probe
|
||||||
|
// cached for 120 sec, so this fails fast during an outage.
|
||||||
|
if (! $this->isEnabled()) return false;
|
||||||
|
|
||||||
$dir = dirname($localPath);
|
$dir = dirname($localPath);
|
||||||
if (! is_dir($dir)) @mkdir($dir, 0755, true);
|
if (! is_dir($dir)) @mkdir($dir, 0755, true);
|
||||||
|
|
||||||
@ -1015,6 +1021,7 @@ class NasSyncService
|
|||||||
|
|
||||||
public function mkdirp(string $path): void
|
public function mkdirp(string $path): void
|
||||||
{
|
{
|
||||||
|
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||||
$cfg = $this->cfg();
|
$cfg = $this->cfg();
|
||||||
$target = escapeshellarg($this->smbTarget($cfg));
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
@ -1033,6 +1040,7 @@ class NasSyncService
|
|||||||
|
|
||||||
public function putFile(string $localAbsPath, string $nasRelPath): bool
|
public function putFile(string $localAbsPath, string $nasRelPath): bool
|
||||||
{
|
{
|
||||||
|
if (! $this->isEnabled()) return false; // fail-fast when NAS unreachable
|
||||||
$cfg = $this->cfg();
|
$cfg = $this->cfg();
|
||||||
$target = escapeshellarg($this->smbTarget($cfg));
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
@ -1058,6 +1066,7 @@ class NasSyncService
|
|||||||
|
|
||||||
public function getContent(string $nasRelPath): ?string
|
public function getContent(string $nasRelPath): ?string
|
||||||
{
|
{
|
||||||
|
if (! $this->isEnabled()) return null; // fail-fast when NAS unreachable
|
||||||
$cfg = $this->cfg();
|
$cfg = $this->cfg();
|
||||||
$target = escapeshellarg($this->smbTarget($cfg));
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
@ -1202,6 +1211,7 @@ class NasSyncService
|
|||||||
|
|
||||||
public function deleteFile(string $nasRelPath): void
|
public function deleteFile(string $nasRelPath): void
|
||||||
{
|
{
|
||||||
|
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||||
$cfg = $this->cfg();
|
$cfg = $this->cfg();
|
||||||
$target = escapeshellarg($this->smbTarget($cfg));
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
|
|||||||
30
resources/views/components/save-to-playlist-button.blade.php
Normal file
30
resources/views/components/save-to-playlist-button.blade.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
@props([
|
||||||
|
'video',
|
||||||
|
'tag' => 'button', // 'button' or 'a'
|
||||||
|
])
|
||||||
|
|
||||||
|
{{-- Canonical "Save to playlist" trigger. Opens the single
|
||||||
|
<x-add-to-playlist-modal/> (included once in layouts/app.blade.php) via
|
||||||
|
openAddToPlaylistModal(numericId). Always passes $video->id, never the
|
||||||
|
route key — the backend validates against videos.id. --}}
|
||||||
|
@php
|
||||||
|
$onclick = "openAddToPlaylistModal({$video->id})";
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($tag === 'a')
|
||||||
|
<a href="javascript:void(0)" {{ $attributes }} onclick="{{ $onclick }}">
|
||||||
|
@if($slot->isEmpty())
|
||||||
|
<i class="bi bi-bookmark"></i> Save to playlist
|
||||||
|
@else
|
||||||
|
{{ $slot }}
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<button type="button" {{ $attributes }} onclick="{{ $onclick }}">
|
||||||
|
@if($slot->isEmpty())
|
||||||
|
<i class="bi bi-bookmark"></i> <span>Save to playlist</span>
|
||||||
|
@else
|
||||||
|
{{ $slot }}
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
@ -177,10 +177,10 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Save to Playlist Button -->
|
<!-- Save to Playlist Button -->
|
||||||
<button class="action-btn desktop-action" onclick="openAddToPlaylistModal({{ $video->id }})">
|
<x-save-to-playlist-button :video="$video" class="action-btn desktop-action">
|
||||||
<i class="bi bi-bookmark"></i>
|
<i class="bi bi-bookmark"></i>
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</x-save-to-playlist-button>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$dlAccess = $video->download_access ?? 'disabled';
|
$dlAccess = $video->download_access ?? 'disabled';
|
||||||
@ -280,9 +280,9 @@
|
|||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<button class="dropdown-item" onclick="openAddToPlaylistModal({{ $video->id }})">
|
<x-save-to-playlist-button :video="$video" class="dropdown-item">
|
||||||
<i class="bi bi-bookmark"></i> Save
|
<i class="bi bi-bookmark"></i> <span>Save</span>
|
||||||
</button>
|
</x-save-to-playlist-button>
|
||||||
@if($showDl)
|
@if($showDl)
|
||||||
@if($isAudioDl)
|
@if($isAudioDl)
|
||||||
<a class="dropdown-item" href="#"
|
<a class="dropdown-item" href="#"
|
||||||
|
|||||||
@ -128,9 +128,9 @@ $sizeClasses = match($size) {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="openPlaylistModal('{{ $video->getRouteKey() }}')">
|
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
|
||||||
<i class="bi bi-bookmark"></i> Save to playlist
|
<i class="bi bi-bookmark"></i> Save to playlist
|
||||||
</a>
|
</x-save-to-playlist-button>
|
||||||
</li>
|
</li>
|
||||||
@if($video->allow_download)
|
@if($video->allow_download)
|
||||||
<li>
|
<li>
|
||||||
@ -957,39 +957,6 @@ function saveToWatchLater(videoId) {
|
|||||||
.catch(error => console.error('Error:', error));
|
.catch(error => console.error('Error:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global function to open playlist modal
|
|
||||||
function openPlaylistModal(videoId) {
|
|
||||||
// Set the current video ID for the modal as global variable
|
|
||||||
window.currentVideoIdForModal = videoId;
|
|
||||||
|
|
||||||
// Close any open dropdown menus first
|
|
||||||
const activeDropdowns = document.querySelectorAll('.dropdown-menu.show');
|
|
||||||
activeDropdowns.forEach(function(dropdown) {
|
|
||||||
dropdown.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also close Bootstrap dropdowns by clicking the toggle
|
|
||||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle[aria-expanded="true"]');
|
|
||||||
dropdownToggles.forEach(function(toggle) {
|
|
||||||
toggle.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to open the add to playlist modal
|
|
||||||
if (typeof openAddToPlaylistModal === 'function') {
|
|
||||||
openAddToPlaylistModal(videoId);
|
|
||||||
} else {
|
|
||||||
// Modal might not be loaded, try to find and show it directly
|
|
||||||
const modal = document.getElementById('addToPlaylistModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
modal.style.opacity = '1';
|
|
||||||
} else {
|
|
||||||
// Fallback - redirect to login
|
|
||||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global function to add to queue
|
// Global function to add to queue
|
||||||
function addToQueue(videoId) {
|
function addToQueue(videoId) {
|
||||||
showToast('Queue feature coming soon!', 'info');
|
showToast('Queue feature coming soon!', 'info');
|
||||||
|
|||||||
@ -397,7 +397,6 @@
|
|||||||
.ytp-scrubber-container {
|
.ytp-scrubber-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 0;
|
width: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@ -406,12 +405,13 @@
|
|||||||
height: 13px;
|
height: 13px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f00;
|
background: #f00;
|
||||||
transform: translate(-50%, 0) scale(0);
|
/* Center the knob on the track: -50% X sits it on the play head, -50% Y keeps it
|
||||||
|
vertically centered on the bar's mid-line (container top:50%) at any bar height. */
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
transition: transform .1s;
|
transition: transform .1s;
|
||||||
margin-top: -4px;
|
|
||||||
}
|
}
|
||||||
.ytp-progress-bar-container:hover .ytp-scrubber-button,
|
.ytp-progress-bar-container:hover .ytp-scrubber-button,
|
||||||
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); }
|
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, -50%) scale(1); }
|
||||||
|
|
||||||
.ytp-hover-time {
|
.ytp-hover-time {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -631,7 +631,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeEditPlaylistModal(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeEditPlaylistModal(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
function openEditPlaylistModal() { const m = document.getElementById('editPlaylistModal'); if (m) m.style.display = 'flex'; }
|
function openEditPlaylistModal() {
|
||||||
|
// Reset transient state every time the modal opens so flags from a previous
|
||||||
|
// open (e.g. the user clicked X then closed without saving) don't leak.
|
||||||
|
window._removePlThumb = false;
|
||||||
|
const fi = document.getElementById('playlistThumbnailInput');
|
||||||
|
if (fi) fi.value = '';
|
||||||
|
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).
|
// Auto-open the edit modal when arriving with #edit (e.g. from playlist-card menu).
|
||||||
@if($canEdit ?? false)
|
@if($canEdit ?? false)
|
||||||
if (window.location.hash === '#edit') {
|
if (window.location.hash === '#edit') {
|
||||||
@ -645,6 +653,9 @@ function handleThumbUpload(input) {
|
|||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file.type.startsWith('image/')) { showToast('Please select an image file', 'error'); return; }
|
if (!file.type.startsWith('image/')) { showToast('Please select an image file', 'error'); return; }
|
||||||
if (file.size > 20 * 1024 * 1024) { showToast('Image must be under 20 MB', 'error'); return; }
|
if (file.size > 20 * 1024 * 1024) { showToast('Image must be under 20 MB', 'error'); return; }
|
||||||
|
// Picking a new file cancels any pending "remove" intent — otherwise the
|
||||||
|
// controller would receive both `thumbnail` and `remove_thumbnail=1`.
|
||||||
|
window._removePlThumb = false;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = e => {
|
reader.onload = e => {
|
||||||
document.getElementById('playlistThumbnailPreview').src = e.target.result;
|
document.getElementById('playlistThumbnailPreview').src = e.target.result;
|
||||||
|
|||||||
@ -1860,18 +1860,7 @@ function openLightbox(src) {
|
|||||||
lb.style.display = 'flex';
|
lb.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Video hover-preview ── */
|
/* Video hover-preview is wired by the video-card component's inline onmouseenter/onmouseleave (playVideo/stopVideo). Those handlers are audio-aware and keep the thumbnail visible for music tracks. Don't add a second listener here that overrides them. */
|
||||||
document.querySelectorAll('.yt-video-card').forEach(card => {
|
|
||||||
card.addEventListener('mouseenter', () => {
|
|
||||||
if ('ontouchstart' in window) return;
|
|
||||||
const v = card.querySelector('video');
|
|
||||||
if (v) { v.currentTime = 0; v.volume = 0.1; v.play().catch(()=>{}); v.classList.add('active'); }
|
|
||||||
});
|
|
||||||
card.addEventListener('mouseleave', () => {
|
|
||||||
const v = card.querySelector('video');
|
|
||||||
if (v) { v.pause(); v.currentTime = 0; v.classList.remove('active'); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Subscribe toggle ── */
|
/* ── Subscribe toggle ── */
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@ -384,15 +384,17 @@
|
|||||||
background: #f00; border-radius: 2px; width: 0; pointer-events: none;
|
background: #f00; border-radius: 2px; width: 0; pointer-events: none;
|
||||||
}
|
}
|
||||||
.ytp-scrubber-container {
|
.ytp-scrubber-container {
|
||||||
position: absolute; top: 50%; transform: translateY(-50%);
|
position: absolute; top: 50%;
|
||||||
width: 0; pointer-events: none;
|
width: 0; pointer-events: none;
|
||||||
}
|
}
|
||||||
.ytp-scrubber-button {
|
.ytp-scrubber-button {
|
||||||
width: 13px; height: 13px; border-radius: 50%; background: #f00;
|
width: 13px; height: 13px; border-radius: 50%; background: #f00;
|
||||||
transform: translate(-50%, 0) scale(0); transition: transform .1s; margin-top: -4px;
|
/* Center the knob on the play head: -50% X on the track position, -50% Y so the
|
||||||
|
track line passes exactly through the knob's center at any bar height. */
|
||||||
|
transform: translate(-50%, -50%) scale(0); transition: transform .1s;
|
||||||
}
|
}
|
||||||
.ytp-progress-bar-container:hover .ytp-scrubber-button,
|
.ytp-progress-bar-container:hover .ytp-scrubber-button,
|
||||||
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); }
|
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, -50%) scale(1); }
|
||||||
.ytp-hover-time {
|
.ytp-hover-time {
|
||||||
position: absolute; bottom: 16px;
|
position: absolute; bottom: 16px;
|
||||||
background: rgba(28,28,28,.9); color: #fff;
|
background: rgba(28,28,28,.9); color: #fff;
|
||||||
@ -422,6 +424,9 @@
|
|||||||
.ytp-volume-slider-wrap {
|
.ytp-volume-slider-wrap {
|
||||||
overflow: hidden; width: 0; transition: width .2s;
|
overflow: hidden; width: 0; transition: width .2s;
|
||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
|
/* Height must clear the 13px thumb — the 3px track alone leaves the wrap ~3px
|
||||||
|
tall, and overflow:hidden (kept for the width collapse) would crop the knob. */
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
.ytp-volume-area:hover .ytp-volume-slider-wrap,
|
.ytp-volume-area:hover .ytp-volume-slider-wrap,
|
||||||
.ytp-volume-area:focus-within .ytp-volume-slider-wrap { width: 60px; }
|
.ytp-volume-area:focus-within .ytp-volume-slider-wrap { width: 60px; }
|
||||||
@ -431,8 +436,8 @@
|
|||||||
background: linear-gradient(to right, #fff var(--vol,50%), rgba(255,255,255,.3) var(--vol,50%));
|
background: linear-gradient(to right, #fff var(--vol,50%), rgba(255,255,255,.3) var(--vol,50%));
|
||||||
outline: none; cursor: pointer; margin: 0 4px;
|
outline: none; cursor: pointer; margin: 0 4px;
|
||||||
}
|
}
|
||||||
.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; }
|
.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 0 2px rgba(0,0,0,.6); }
|
||||||
.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; }
|
.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; box-shadow: 0 0 2px rgba(0,0,0,.6); }
|
||||||
|
|
||||||
.ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; }
|
.ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; }
|
||||||
.ytp-time-sep { opacity: .6; margin: 0 2px; }
|
.ytp-time-sep { opacity: .6; margin: 0 2px; }
|
||||||
@ -444,11 +449,14 @@
|
|||||||
.ytp-lang-popup {
|
.ytp-lang-popup {
|
||||||
display: none; position: absolute; bottom: 48px; right: 0;
|
display: none; position: absolute; bottom: 48px; right: 0;
|
||||||
background: rgba(28,28,28,.97); border-radius: 12px;
|
background: rgba(28,28,28,.97); border-radius: 12px;
|
||||||
min-width: 180px; overflow: hidden;
|
min-width: 180px; overflow-x: hidden; overflow-y: auto;
|
||||||
|
max-height: 320px; /* JS narrows this on open to fit above the header/player top */
|
||||||
|
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,.7); z-index: 100;
|
box-shadow: 0 4px 24px rgba(0,0,0,.7); z-index: 100;
|
||||||
}
|
}
|
||||||
.ytp-lang-popup.open { display: block; }
|
.ytp-lang-popup.open { display: block; }
|
||||||
.ytp-lang-popup-hdr {
|
.ytp-lang-popup-hdr {
|
||||||
|
position: sticky; top: 0; z-index: 1; background: rgba(28,28,28,.97);
|
||||||
padding: 10px 14px 8px; font-size: 11px; font-weight: 700; letter-spacing: .5px;
|
padding: 10px 14px 8px; font-size: 11px; font-weight: 700; letter-spacing: .5px;
|
||||||
text-transform: uppercase; color: rgba(255,255,255,.45);
|
text-transform: uppercase; color: rgba(255,255,255,.45);
|
||||||
border-bottom: 1px solid rgba(255,255,255,.1);
|
border-bottom: 1px solid rgba(255,255,255,.1);
|
||||||
@ -682,10 +690,15 @@ function updateProgress() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Controls visibility ──────────────────────────────────────
|
// ── Controls visibility ──────────────────────────────────────
|
||||||
|
// True while any control menu is open — the chrome must not auto-hide underneath
|
||||||
|
// an open menu (it would fade the language/settings popup out mid-selection).
|
||||||
|
function isMenuOpen() {
|
||||||
|
return document.querySelector('.ytp-lang-popup.open, .ytp-settings-panel.open') !== null;
|
||||||
|
}
|
||||||
function showControls() {
|
function showControls() {
|
||||||
player.classList.remove('controls-hidden');
|
player.classList.remove('controls-hidden');
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
if (!audio.paused) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
|
if (!audio.paused && !isMenuOpen()) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Play / Pause ─────────────────────────────────────────────
|
// ── Play / Pause ─────────────────────────────────────────────
|
||||||
@ -797,14 +810,34 @@ const langOpts = document.querySelectorAll('.ytp-lang-option');
|
|||||||
window._ytpTrackId = 0;
|
window._ytpTrackId = 0;
|
||||||
|
|
||||||
if (langBtn && langPopup) {
|
if (langBtn && langPopup) {
|
||||||
|
// Cap the popup so it can never grow past the top of the video player or under
|
||||||
|
// the fixed site header — whichever edge is lower. It opens upward from the
|
||||||
|
// control bar, so without this a long track list slides its first rows off-screen.
|
||||||
|
const sizeLangPopup = () => {
|
||||||
|
const btnRect = langBtn.getBoundingClientRect();
|
||||||
|
// 48px = the popup's `bottom` offset above the control-bar button row.
|
||||||
|
const popupBottomY = btnRect.top - 8;
|
||||||
|
// Never rise above the player's own top edge, nor above the fixed header (~56px).
|
||||||
|
const playerTop = wrap.getBoundingClientRect().top;
|
||||||
|
const topBoundary = Math.max(playerTop, 56) + 8;
|
||||||
|
const avail = Math.max(120, popupBottomY - topBoundary);
|
||||||
|
langPopup.style.maxHeight = avail + 'px';
|
||||||
|
};
|
||||||
langBtn.addEventListener('click', e => {
|
langBtn.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only open popup when there are multiple language options
|
// Only open popup when there are multiple language options
|
||||||
if (langPopup.querySelectorAll('.ytp-lang-option').length < 2) return;
|
if (langPopup.querySelectorAll('.ytp-lang-option').length < 2) return;
|
||||||
settingsPanel.classList.remove('open');
|
settingsPanel.classList.remove('open');
|
||||||
|
const willOpen = !langPopup.classList.contains('open');
|
||||||
|
if (willOpen) { sizeLangPopup(); clearTimeout(hideTimer); }
|
||||||
langPopup.classList.toggle('open');
|
langPopup.classList.toggle('open');
|
||||||
|
if (!willOpen) showControls(); // menu closed → resume normal auto-hide
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
if (!langPopup.classList.contains('open')) return;
|
||||||
|
langPopup.classList.remove('open');
|
||||||
|
showControls(); // menu closed → resume normal auto-hide
|
||||||
});
|
});
|
||||||
document.addEventListener('click', () => langPopup.classList.remove('open'));
|
|
||||||
langPopup.addEventListener('click', e => e.stopPropagation());
|
langPopup.addEventListener('click', e => e.stopPropagation());
|
||||||
|
|
||||||
langOpts.forEach(opt => {
|
langOpts.forEach(opt => {
|
||||||
@ -830,6 +863,7 @@ if (langBtn && langPopup) {
|
|||||||
? `<span class="fi fi-${flag}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>`
|
? `<span class="fi fi-${flag}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>`
|
||||||
: langBtn.innerHTML;
|
: langBtn.innerHTML;
|
||||||
langPopup.classList.remove('open');
|
langPopup.classList.remove('open');
|
||||||
|
showControls(); // menu closed → resume normal auto-hide
|
||||||
|
|
||||||
// Update title flag
|
// Update title flag
|
||||||
const titleFlagEl = document.getElementById('videoTitleFlag');
|
const titleFlagEl = document.getElementById('videoTitleFlag');
|
||||||
@ -1401,6 +1435,7 @@ window._audioPlayerUpdate = function(d) {
|
|||||||
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
|
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
|
||||||
opt.classList.add('active');
|
opt.classList.add('active');
|
||||||
langPopup.classList.remove('open');
|
langPopup.classList.remove('open');
|
||||||
|
showControls(); // menu closed → resume normal auto-hide
|
||||||
var _vol = audio.volume, _muted = audio.muted;
|
var _vol = audio.volume, _muted = audio.muted;
|
||||||
audio.src = url; audio.load();
|
audio.src = url; audio.load();
|
||||||
audio.volume = _vol; audio.muted = _muted;
|
audio.volume = _vol; audio.muted = _muted;
|
||||||
|
|||||||
@ -95,6 +95,9 @@ function switchVdbTab(panelId, btn) {
|
|||||||
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
document.getElementById(panelId).classList.add('active');
|
document.getElementById(panelId).classList.add('active');
|
||||||
|
// About just became visible — re-measure now that its panel is laid out, since
|
||||||
|
// a measurement taken while it was display:none would have read 0.
|
||||||
|
if (panelId === 'vdb-about' && window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
||||||
if (panelId === 'vdb-insights') {
|
if (panelId === 'vdb-insights') {
|
||||||
const panel = document.getElementById('vdb-insights');
|
const panel = document.getElementById('vdb-insights');
|
||||||
const currentUrl = panel && panel.dataset.insightsBase;
|
const currentUrl = panel && panel.dataset.insightsBase;
|
||||||
@ -128,7 +131,13 @@ function _vdbApplyActiveTab() {
|
|||||||
(function _vdbWatchSwaps() {
|
(function _vdbWatchSwaps() {
|
||||||
const wrap = document.getElementById('vdbWrap');
|
const wrap = document.getElementById('vdbWrap');
|
||||||
if (!wrap || wrap._vdbTabObserver) return;
|
if (!wrap || wrap._vdbTabObserver) return;
|
||||||
const obs = new MutationObserver(() => _vdbApplyActiveTab());
|
const obs = new MutationObserver(() => {
|
||||||
|
_vdbApplyActiveTab();
|
||||||
|
// The swap replaced the description markup — re-evaluate the "Show more"
|
||||||
|
// button once layout/fonts/images settle. Central here so every SPA swap
|
||||||
|
// path is covered, not just the ones that remember to call it themselves.
|
||||||
|
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
||||||
|
});
|
||||||
obs.observe(wrap, { childList: true, subtree: false });
|
obs.observe(wrap, { childList: true, subtree: false });
|
||||||
wrap._vdbTabObserver = obs;
|
wrap._vdbTabObserver = obs;
|
||||||
})();
|
})();
|
||||||
@ -147,9 +156,25 @@ function _vdbCheckOverflow() {
|
|||||||
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
|
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
|
||||||
if (!d || !b) return;
|
if (!d || !b) return;
|
||||||
if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; }
|
if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; }
|
||||||
|
// If the About panel isn't laid out yet (e.g. Insights tab active, or mid-swap),
|
||||||
|
// scrollHeight reads 0 — don't hide the button on a bogus zero measurement.
|
||||||
|
if (d.scrollHeight === 0) return;
|
||||||
b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none';
|
b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', _vdbCheckOverflow);
|
// A single measurement right after an SPA swap is unreliable: web-fonts, images
|
||||||
window.addEventListener('load', _vdbCheckOverflow);
|
// inside the description, and panel layout can all change the height a beat later,
|
||||||
|
// each of which flips whether the clamp overflows. Re-check at every settling point.
|
||||||
|
window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function () {
|
||||||
|
requestAnimationFrame(_vdbCheckOverflow);
|
||||||
|
setTimeout(_vdbCheckOverflow, 120);
|
||||||
|
setTimeout(_vdbCheckOverflow, 400);
|
||||||
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(_vdbCheckOverflow);
|
||||||
|
const d = document.getElementById('vdbDescShort');
|
||||||
|
if (d) d.querySelectorAll('img').forEach(function (img) {
|
||||||
|
if (!img.complete) img.addEventListener('load', _vdbCheckOverflow, { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
|
||||||
|
window.addEventListener('load', window._vdbScheduleOverflowCheck);
|
||||||
</script>
|
</script>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -552,9 +552,9 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Save to Playlist Button -->
|
<!-- Save to Playlist Button -->
|
||||||
<button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})">
|
<x-save-to-playlist-button :video="$video" class="yt-action-btn">
|
||||||
<i class="bi bi-collection-plus"></i> Save
|
<i class="bi bi-collection-plus"></i> Save
|
||||||
</button>
|
</x-save-to-playlist-button>
|
||||||
@auth
|
@auth
|
||||||
<!-- Quick Watch Later Button -->
|
<!-- Quick Watch Later Button -->
|
||||||
<form method="POST" action="{{ route('videos.watchLater', $video) }}" class="d-inline"
|
<form method="POST" action="{{ route('videos.watchLater', $video) }}" class="d-inline"
|
||||||
@ -905,9 +905,9 @@
|
|||||||
</x-share-button>
|
</x-share-button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})" style="flex:1;">
|
<x-save-to-playlist-button :video="$video" class="yt-action-btn" style="flex:1;">
|
||||||
<i class="bi bi-collection-plus"></i><span>Save</span>
|
<i class="bi bi-collection-plus"></i><span>Save</span>
|
||||||
</button>
|
</x-save-to-playlist-button>
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
@if (Auth::id() === $video->user_id)
|
@if (Auth::id() === $video->user_id)
|
||||||
|
|||||||
@ -2207,39 +2207,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Match Highlights Toggle with localStorage persistence
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const toggleBtn = document.getElementById('matchHighlightsToggle');
|
|
||||||
const sidebar = document.querySelector('.events-sidebar');
|
|
||||||
const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }};
|
|
||||||
|
|
||||||
if (toggleBtn && sidebar) {
|
|
||||||
// Load saved state on page load
|
|
||||||
const savedState = localStorage.getItem(`highlights_${videoId}`);
|
|
||||||
if (savedState === 'open') {
|
|
||||||
sidebar.classList.add('show');
|
|
||||||
toggleBtn.classList.add('expanded');
|
|
||||||
toggleBtn.textContent = 'Highlights';
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBtn.addEventListener('click', function() {
|
|
||||||
const isOpen = sidebar.classList.toggle('show');
|
|
||||||
toggleBtn.classList.toggle('expanded');
|
|
||||||
|
|
||||||
// Save state to localStorage
|
|
||||||
if (isOpen) {
|
|
||||||
localStorage.setItem(`highlights_${videoId}`, 'open');
|
|
||||||
toggleBtn.textContent = 'Highlights';
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(`highlights_${videoId}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Match Highlights toggle handler. MUST live OUTSIDE the disabled
|
||||||
|
@if(false ...) legacy panel above — otherwise the button never gets
|
||||||
|
its click listener and the Highlights pane can't be opened. --}}
|
||||||
|
<script>
|
||||||
|
// Match Highlights Toggle with localStorage persistence
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const toggleBtn = document.getElementById('matchHighlightsToggle');
|
||||||
|
const sidebar = document.querySelector('.events-sidebar');
|
||||||
|
// NOTE: emit videoId with the json Blade directive (raw), never with
|
||||||
|
// escaped echo braces — those HTML-escape the quotes and crash the script.
|
||||||
|
const videoId = @json(isset($video) ? $video->getRouteKey() : '');
|
||||||
|
|
||||||
|
if (toggleBtn && sidebar) {
|
||||||
|
// Load saved state on page load
|
||||||
|
const savedState = localStorage.getItem(`highlights_${videoId}`);
|
||||||
|
if (savedState === 'open') {
|
||||||
|
sidebar.classList.add('show');
|
||||||
|
toggleBtn.classList.add('expanded');
|
||||||
|
toggleBtn.textContent = 'Highlights';
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', function() {
|
||||||
|
const isOpen = sidebar.classList.toggle('show');
|
||||||
|
toggleBtn.classList.toggle('expanded');
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
if (isOpen) {
|
||||||
|
localStorage.setItem(`highlights_${videoId}`, 'open');
|
||||||
|
toggleBtn.textContent = 'Highlights';
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(`highlights_${videoId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<x-video-comments :video="$video" />
|
<x-video-comments :video="$video" />
|
||||||
|
|
||||||
@ -3203,8 +3209,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }};
|
// NOTE: emit these with the json Blade directive (raw output). Escaped echo
|
||||||
const isOwner = {{ json_encode(Auth::check() && isset($video) && Auth::id() === $video->user_id) }};
|
// braces HTML-escape the quotes (videoId becomes an entity-encoded string) and
|
||||||
|
// crash this whole block — which loads match data, switches tabs, drives CRUD.
|
||||||
|
const videoId = @json(isset($video) ? $video->getRouteKey() : '');
|
||||||
|
const isOwner = @json(Auth::check() && isset($video) && Auth::id() === $video->user_id);
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadMatchData();
|
loadMatchData();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user