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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

201 lines
7.2 KiB
PHP

@props(['playlist'])
@php
$pl = $playlist;
$firstVid = $pl->videos->first();
$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
<style>
.pl-count-badge {
position: absolute;
inset: 0 0 0 auto;
width: 72px;
background: rgba(0,0,0,.78);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: #fff;
pointer-events: none;
z-index: 3;
}
.pl-count-badge i { font-size: 20px; }
.pl-visibility-badge {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0,0,0,.75);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
letter-spacing: .4px;
pointer-events: none;
z-index: 3;
}
.pl-type-badge {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,.82);
color: #a78bfa;
font-size: 11px;
font-weight: 700;
padding: 3px 7px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
letter-spacing: .4px;
pointer-events: none;
white-space: nowrap;
z-index: 3;
}
</style>
@endonce
<div class="yt-video-card">
<a href="{{ $plUrl }}">
<div class="yt-video-thumb">
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
<div class="pl-count-badge">
<i class="bi bi-collection-play-fill"></i>
{{ $pl->videos_count }}
</div>
<span class="pl-type-badge">
<i class="bi bi-collection-play-fill"></i> Playlist
</span>
@if($pl->visibility === 'private')
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
@elseif($pl->visibility === 'unlisted')
<span class="pl-visibility-badge"><i class="bi bi-link-45deg"></i> Unlisted</span>
@endif
</div>
</a>
<div class="yt-video-info">
<div class="yt-channel-icon"
style="cursor:pointer;"
onclick="window.location.href='{{ $pl->user ? route('channel', $pl->user->channel) : '#' }}'">
@if($pl->user?->avatar_url)
<img src="{{ $pl->user->avatar_url }}" alt="{{ $pl->user->name }}"
style="width:100%;height:100%;object-fit:cover;border-radius:50%;">
@elseif($pl->user)
<span style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#fff;">
{{ mb_strtoupper(mb_substr($pl->user->name, 0, 1)) }}
</span>
@endif
</div>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ $plUrl }}">{{ $pl->name }}</a>
</h3>
@if($pl->user)
<a href="{{ route('channel', $pl->user->channel) }}" class="yt-channel-name"
onclick="event.stopPropagation()">{{ $pl->user->name }}</a>
@endif
<div class="yt-video-meta">
<span class="yt-type-label" style="color:#a78bfa;">
<i class="bi bi-collection-play-fill"></i>
Playlist
</span>
&nbsp;·&nbsp;
{{ 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