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>
2621 lines
111 KiB
PHP
2621 lines
111 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>@yield('title', config('app.name'))</title>
|
|
|
|
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
|
|
@stack('head')
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
|
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
|
|
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
<link rel="stylesheet" href="{{ 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;
|
|
--bg-dark: #0f0f0f;
|
|
--bg-secondary: #1e1e1e;
|
|
--border-color: #303030;
|
|
--text-primary: #f1f1f1;
|
|
--text-secondary: #aaaaaa;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html {
|
|
overflow-x: hidden;
|
|
max-width: 100%;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-dark);
|
|
color: var(--text-primary);
|
|
font-family: "Roboto", "Arial", sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow-x: hidden;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Header */
|
|
.yt-header {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 56px;
|
|
background: var(--bg-dark);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 16px;
|
|
z-index: 1000;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.yt-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.yt-menu-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.yt-menu-btn:hover { background: var(--border-color); }
|
|
|
|
.yt-logo {
|
|
display: flex;
|
|
align-items: center;
|
|
text-decoration: none;
|
|
gap: 4px;
|
|
}
|
|
|
|
.yt-logo-text {
|
|
font-size: 1.4rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
letter-spacing: -1px;
|
|
}
|
|
|
|
/* Search */
|
|
.yt-header-center {
|
|
flex: 1;
|
|
max-width: 640px;
|
|
margin: 0 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.yt-search {
|
|
flex: 1;
|
|
display: flex;
|
|
height: 40px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.yt-search-form {
|
|
flex: 1;
|
|
display: flex;
|
|
height: 40px;
|
|
}
|
|
|
|
.yt-search-input {
|
|
flex: 1;
|
|
background: #121212;
|
|
border: 1px solid var(--border-color);
|
|
border-right: none;
|
|
border-radius: 20px 0 0 20px;
|
|
padding: 0 16px;
|
|
color: var(--text-primary);
|
|
font-size: 16px;
|
|
}
|
|
|
|
.yt-search-input:focus {
|
|
outline: none;
|
|
border-color: #1c62b9;
|
|
}
|
|
|
|
.yt-search-btn {
|
|
width: 64px;
|
|
background: #222;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0 20px 20px 0;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.yt-search-btn:hover { background: #303030; }
|
|
|
|
.yt-search-mic {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: #3f3f3f;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.1rem;
|
|
flex-shrink: 0;
|
|
transition: background 0.2s;
|
|
}
|
|
.yt-search-mic:hover { background: #555; }
|
|
|
|
/* Header Right */
|
|
.yt-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.yt-icon-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.yt-icon-btn:hover { background: var(--border-color); }
|
|
|
|
.yt-user-avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: #555;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.yt-sidebar {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 240px;
|
|
background: var(--bg-dark);
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 8px 0;
|
|
transition: transform 0.25s ease, width 0.25s ease;
|
|
z-index: 999;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border-color) transparent;
|
|
}
|
|
|
|
.yt-sidebar-section {
|
|
padding: 0 8px 4px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
margin-bottom: 4px;
|
|
}
|
|
.yt-sidebar-section:last-child { border-bottom: none; }
|
|
|
|
.yt-sidebar-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 18px;
|
|
padding: 0 12px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.yt-sidebar-link i {
|
|
font-size: 18px;
|
|
flex-shrink: 0;
|
|
width: 22px;
|
|
text-align: center;
|
|
}
|
|
|
|
.yt-sidebar-link:hover {
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.yt-sidebar-link.active {
|
|
background: rgba(230, 30, 30, 0.12);
|
|
color: var(--brand-red);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.yt-sidebar-link.active i { color: var(--brand-red); }
|
|
|
|
/* Sidebar section label */
|
|
.yt-sidebar-section-header {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
padding: 12px 12px 4px;
|
|
}
|
|
|
|
/* ══════════════════════════════════════════
|
|
GLOBAL BUTTON SYSTEM
|
|
All UI buttons must use one of these classes.
|
|
border-radius: 8px, padding: 8px 14px, flex icon+text.
|
|
══════════════════════════════════════════ */
|
|
.action-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
transition: background .15s, border-color .15s, color .15s;
|
|
font-family: inherit;
|
|
line-height: 1;
|
|
}
|
|
.action-btn:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
.action-btn:focus { outline: none; box-shadow: 0 0 0 2px rgba(255,255,255,.15); }
|
|
.action-btn:disabled, .action-btn[disabled] { opacity: .5; cursor: not-allowed; }
|
|
|
|
/* Primary variant — brand red */
|
|
.action-btn-primary, .action-btn.primary {
|
|
background: var(--brand-red);
|
|
border-color: var(--brand-red);
|
|
color: #fff;
|
|
}
|
|
.action-btn-primary:hover, .action-btn.primary:hover {
|
|
background: #cc1a1a;
|
|
border-color: #cc1a1a;
|
|
color: #fff;
|
|
}
|
|
|
|
/* Danger variant — destructive actions */
|
|
.action-btn-danger, .action-btn.danger {
|
|
border-color: #c53030;
|
|
color: #fc8181;
|
|
background: transparent;
|
|
}
|
|
.action-btn-danger:hover, .action-btn.danger:hover {
|
|
background: rgba(197,48,48,.15);
|
|
color: #fc8181;
|
|
}
|
|
|
|
/* Icon-only variant (no text label) */
|
|
.action-btn.icon-only { padding: 8px; }
|
|
|
|
/* Filter chip bar */
|
|
.yt-filter-bar {
|
|
display: flex;
|
|
gap: 12px;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
padding: 12px 24px;
|
|
margin: -24px -24px 20px;
|
|
background: var(--bg-dark);
|
|
position: sticky;
|
|
top: 56px;
|
|
z-index: 90;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.yt-filter-bar::-webkit-scrollbar { display: none; }
|
|
|
|
.yt-chip {
|
|
flex-shrink: 0;
|
|
background: #3f3f3f;
|
|
color: var(--text-primary);
|
|
border: none;
|
|
padding: 6px 12px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
transition: background 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.yt-chip:hover {
|
|
background: #555;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
.yt-chip.active {
|
|
background: var(--text-primary);
|
|
color: #0f0f0f;
|
|
}
|
|
|
|
/* Mobile sidebar overlay */
|
|
.yt-sidebar-overlay {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 998;
|
|
display: none;
|
|
}
|
|
|
|
.yt-sidebar-overlay.show { display: block; }
|
|
|
|
/* Impersonation banner */
|
|
.impersonate-bar {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 999;
|
|
height: 40px;
|
|
background: #7a4f00;
|
|
border-bottom: 1px solid #c47f00;
|
|
color: #ffd166;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
padding: 0 16px;
|
|
}
|
|
.impersonate-bar i { font-size: 15px; }
|
|
.impersonate-exit-btn {
|
|
margin-left: 16px;
|
|
background: rgba(255,255,255,.12);
|
|
border: 1px solid rgba(255,209,102,.5);
|
|
color: #ffd166;
|
|
border-radius: 6px;
|
|
padding: 4px 12px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
transition: background .15s;
|
|
}
|
|
.impersonate-exit-btn:hover { background: rgba(255,255,255,.2); }
|
|
body.has-impersonate-bar .yt-main { margin-top: calc(56px + 40px); }
|
|
|
|
/* Main Content */
|
|
.yt-main {
|
|
margin-top: 56px;
|
|
margin-left: 240px;
|
|
padding: 24px;
|
|
min-height: calc(100vh - 56px);
|
|
transition: margin-left 0.3s;
|
|
}
|
|
|
|
/* Upload Button */
|
|
.yt-upload-btn {
|
|
background: var(--brand-red);
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.yt-upload-btn:hover { background: #cc1a1a; }
|
|
|
|
/* ── Header user dropdown (custom, no Bootstrap) ───────────────── */
|
|
/* ── Notification bell ── */
|
|
@keyframes bell-ring {
|
|
0% { transform: rotate(0) scale(1); }
|
|
8% { transform: rotate(-22deg) scale(1.15); }
|
|
20% { transform: rotate(20deg) scale(1.15); }
|
|
32% { transform: rotate(-16deg) scale(1.1); }
|
|
44% { transform: rotate(12deg) scale(1.05); }
|
|
56% { transform: rotate(-8deg) scale(1); }
|
|
68% { transform: rotate(5deg) scale(1); }
|
|
80% { transform: rotate(-3deg) scale(1); }
|
|
100% { transform: rotate(0) scale(1); }
|
|
}
|
|
@keyframes bell-sway {
|
|
0%, 100% { transform: rotate(0); }
|
|
30% { transform: rotate(-10deg); }
|
|
70% { transform: rotate(10deg); }
|
|
}
|
|
#notifBtn {
|
|
transform-origin: center top;
|
|
transition: transform 0.1s;
|
|
}
|
|
#notifBtn.bell-ring {
|
|
animation: bell-ring 0.85s cubic-bezier(.36,.07,.19,.97) both;
|
|
}
|
|
#notifBtn.bell-has-unread:not(.bell-ring) {
|
|
animation: bell-sway 2s ease-in-out infinite;
|
|
}
|
|
.yt-notif-wrap { position: relative; }
|
|
.yt-notif-badge {
|
|
position: absolute; top: 4px; right: 4px;
|
|
min-width: 16px; height: 16px; border-radius: 8px;
|
|
background: var(--brand-red); color: #fff;
|
|
font-size: 10px; font-weight: 700; line-height: 16px;
|
|
text-align: center; padding: 0 3px;
|
|
pointer-events: none;
|
|
}
|
|
.yt-notif-panel {
|
|
position: fixed;
|
|
width: 420px;
|
|
max-height: 600px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 14px;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,.6);
|
|
z-index: 10001;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.yt-notif-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px 18px 14px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
.yt-notif-title { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
|
.yt-notif-mark-all {
|
|
font-size: 12px; color: var(--brand-red); background: none;
|
|
border: none; cursor: pointer; padding: 0; font-weight: 500;
|
|
}
|
|
.yt-notif-mark-all:hover { text-decoration: underline; }
|
|
.yt-notif-list { overflow-y: auto; flex: 1; min-height: 0; }
|
|
.yt-notif-item {
|
|
display: flex; align-items: flex-start; gap: 12px;
|
|
padding: 12px 16px; cursor: pointer; text-decoration: none;
|
|
border-bottom: 1px solid rgba(255,255,255,.04);
|
|
transition: background .12s;
|
|
}
|
|
.yt-notif-item:hover { background: rgba(255,255,255,.05); }
|
|
.yt-notif-item.unread { background: rgba(230,30,30,.06); }
|
|
.yt-notif-item.unread:hover { background: rgba(230,30,30,.1); }
|
|
.yt-notif-thumb {
|
|
width: 96px; height: 54px; border-radius: 6px; object-fit: cover;
|
|
flex-shrink: 0; background: var(--bg-dark); display: block;
|
|
}
|
|
.yt-notif-thumb-placeholder {
|
|
width: 96px; height: 54px; border-radius: 6px; flex-shrink: 0;
|
|
background: var(--border-color); display: flex; align-items: center;
|
|
justify-content: center; color: var(--text-secondary); font-size: 20px;
|
|
}
|
|
.yt-notif-body { flex: 1; min-width: 0; }
|
|
.yt-notif-text {
|
|
font-size: 13px; color: var(--text-primary); line-height: 1.45;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.yt-notif-text strong { font-weight: 600; }
|
|
.yt-notif-preview { font-style: italic; opacity: .75; }
|
|
.yt-notif-time { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
|
|
.yt-notif-dot {
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
background: var(--brand-red); flex-shrink: 0; margin-top: 8px;
|
|
}
|
|
.yt-notif-empty {
|
|
padding: 48px 16px; text-align: center;
|
|
font-size: 13px; color: var(--text-secondary);
|
|
}
|
|
@media (max-width: 480px) {
|
|
.yt-notif-panel { width: calc(100vw - 16px); }
|
|
}
|
|
|
|
.yt-user-dropdown { position: relative; }
|
|
|
|
.yt-user-panel {
|
|
display: none;
|
|
position: fixed;
|
|
width: 224px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,.55);
|
|
z-index: 10001;
|
|
overflow: hidden;
|
|
}
|
|
.yt-user-panel.open { display: block; }
|
|
|
|
.yt-panel-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.yt-panel-info img {
|
|
width: 36px; height: 36px;
|
|
border-radius: 50%; flex-shrink: 0;
|
|
object-fit: cover;
|
|
}
|
|
.yt-panel-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
|
.yt-panel-email {
|
|
font-size: 11px; color: var(--text-secondary); margin-top: 1px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 145px;
|
|
}
|
|
|
|
.yt-panel-links { padding: 6px 0; }
|
|
|
|
.yt-drop-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 9px 16px;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
width: 100%;
|
|
background: transparent;
|
|
border: none;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
line-height: 1;
|
|
}
|
|
.yt-drop-item:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
.yt-drop-item i { font-size: 15px; width: 18px; text-align: center; flex-shrink: 0; }
|
|
.yt-drop-item.danger { color: #fc8181; }
|
|
.yt-drop-item.danger:hover { background: rgba(197,48,48,.15); color: #fc8181; }
|
|
|
|
.yt-panel-divider { height: 1px; background: var(--border-color); margin: 4px 0; }
|
|
|
|
/* Mobile circle button */
|
|
@media (max-width: 576px) {
|
|
.yt-upload-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
justify-content: center;
|
|
}
|
|
.yt-upload-btn span {
|
|
display: none;
|
|
}
|
|
|
|
/* Show text on larger mobile */
|
|
@media (min-width: 400px) {
|
|
.yt-upload-btn {
|
|
width: auto;
|
|
border-radius: 20px;
|
|
padding: 8px 16px;
|
|
}
|
|
.yt-upload-btn span {
|
|
display: inline;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Responsive */
|
|
/* ─── Desktop: mini-guide (collapsed = 72 px stacked icon+label) ─── */
|
|
@media (min-width: 992px) {
|
|
.yt-sidebar.collapsed {
|
|
width: 72px;
|
|
padding: 8px 0;
|
|
overflow: hidden;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-link {
|
|
flex-direction: column;
|
|
height: auto;
|
|
padding: 10px 4px;
|
|
gap: 4px;
|
|
border-radius: 12px;
|
|
align-items: center;
|
|
text-align: center;
|
|
margin: 0 4px;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-link i {
|
|
font-size: 20px;
|
|
width: auto;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-link span {
|
|
font-size: 10px;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-section {
|
|
border-bottom: none;
|
|
padding-bottom: 4px;
|
|
margin-bottom: 0;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-section-header { display: none; }
|
|
.yt-main.collapsed { margin-left: 72px; }
|
|
}
|
|
|
|
@media (max-width: 991px) {
|
|
.yt-sidebar {
|
|
transform: translateX(-100%);
|
|
}
|
|
|
|
.yt-sidebar.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.yt-main {
|
|
margin-left: 0;
|
|
}
|
|
|
|
.yt-search-mic { display: none; }
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.yt-header-center { display: none; }
|
|
.yt-main { padding: 16px; }
|
|
.yt-main.video-view-page { padding: 0 !important; }
|
|
.video-view-page .yt-sidebar-container { padding: 0 16px; }
|
|
}
|
|
|
|
/* Mobile Search Toggle Button */
|
|
.yt-mobile-search-toggle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.yt-mobile-search-toggle:hover {
|
|
background: var(--border-color);
|
|
}
|
|
|
|
/* Mobile Search Overlay */
|
|
.mobile-search-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: var(--bg-dark);
|
|
z-index: 1001;
|
|
padding: 60px 16px 16px;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.mobile-search-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.mobile-search-form {
|
|
display: flex;
|
|
width: 100%;
|
|
max-width: 600px;
|
|
height: 44px;
|
|
}
|
|
|
|
.mobile-search-input {
|
|
flex: 1;
|
|
background: #121212;
|
|
border: 1px solid var(--border-color);
|
|
border-right: none;
|
|
border-radius: 20px 0 0 20px;
|
|
padding: 0 16px;
|
|
color: var(--text-primary);
|
|
font-size: 16px;
|
|
}
|
|
|
|
.mobile-search-input:focus {
|
|
outline: none;
|
|
border-color: #1c62b9;
|
|
}
|
|
|
|
.mobile-search-submit {
|
|
width: 50px;
|
|
background: #222;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0 20px 20px 0;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.mobile-search-submit:hover {
|
|
background: #303030;
|
|
}
|
|
|
|
/* Dropdown */
|
|
.dropdown-menu-dark {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.dropdown-item {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Modal input focus */
|
|
#deleteVideoInput:focus {
|
|
outline: none;
|
|
border-color: #ef4444 !important;
|
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.delete-otp-input {
|
|
width: 100%;
|
|
background: #1a1a1a;
|
|
border: 1px solid rgba(239, 68, 68, 0.5);
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
padding: 12px 16px;
|
|
font-size: 22px;
|
|
letter-spacing: 0.4em;
|
|
text-align: center;
|
|
text-indent: 0.4em;
|
|
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
|
|
box-sizing: border-box;
|
|
transition: border-color .15s ease, box-shadow .15s ease;
|
|
}
|
|
.delete-otp-input::placeholder {
|
|
color: rgba(255, 255, 255, 0.35);
|
|
letter-spacing: 0.4em;
|
|
}
|
|
.delete-otp-input:focus {
|
|
outline: none;
|
|
border-color: #ef4444;
|
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
|
|
}
|
|
|
|
/* ── Playlist controls bar (sidebar, all video types) ── */
|
|
.pl-controls-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: 10px;
|
|
padding: 6px 8px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
.pl-ctrl-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 5px 8px;
|
|
border-radius: 6px;
|
|
font-size: 15px;
|
|
transition: color .15s, background .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.pl-ctrl-btn:hover:not(:disabled) { color: var(--text-primary); background: rgba(255,255,255,.07); }
|
|
.pl-ctrl-btn:disabled { opacity: .35; cursor: default; }
|
|
.pl-ctrl-btn.pl-ctrl-active { color: var(--brand-red); }
|
|
.pl-ctrl-divider { width: 1px; height: 18px; background: var(--border-color); margin: 0 2px; flex-shrink: 0; }
|
|
.pl-ctrl-autoplay { margin-left: auto; }
|
|
.pl-autoplay-label { font-size: 12px; font-weight: 500; }
|
|
|
|
/* ── Mini-player ─────────────────────────────────────── */
|
|
#ytpMini {
|
|
position: fixed;
|
|
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
|
right: 12px;
|
|
width: 300px;
|
|
background: #1a1a1a;
|
|
border: 1px solid #333;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
z-index: 1999;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
|
}
|
|
#ytpMiniVideo {
|
|
position: relative;
|
|
width: 100%;
|
|
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,
|
|
#ytpMiniVideo .ytp-large-play-btn,
|
|
#ytpMiniVideo .ytp-spinner,
|
|
#ytpMiniVideo .ytp-dbl-left,
|
|
#ytpMiniVideo .ytp-dbl-right { display:none !important; }
|
|
#ytpMiniBar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 8px;
|
|
gap: 6px;
|
|
background: #1a1a1a;
|
|
}
|
|
#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;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: block;
|
|
}
|
|
#ytpMiniControls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
#ytpMiniControls button,
|
|
#ytpMiniControls a {
|
|
background: none;
|
|
border: none;
|
|
color: #ccc;
|
|
cursor: pointer;
|
|
padding: 4px 6px;
|
|
font-size: 16px;
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
line-height: 1;
|
|
}
|
|
#ytpMiniControls button:hover,
|
|
#ytpMiniControls a:hover { color:#fff; background:rgba(255,255,255,.1); }
|
|
@media (max-width: 480px) {
|
|
#ytpMini { width: calc(100vw - 24px); right: 12px; }
|
|
}
|
|
|
|
/* ── Mobile responsive ───────────────────────────────── */
|
|
@media (max-width: 480px) {
|
|
.yt-menu-btn, .yt-icon-btn, .yt-mobile-search-toggle {
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
}
|
|
.yt-main { padding: 12px 8px !important; }
|
|
.yt-video-title, .video-title { font-size: 14px !important; }
|
|
.channel-info { gap: 8px !important; }
|
|
.channel-avatar { width: 32px !important; height: 32px !important; }
|
|
.channel-name { font-size: 14px !important; }
|
|
.channel-subs { font-size: 12px !important; }
|
|
.yt-channel-name, .yt-video-meta { font-size: 12px !important; }
|
|
.video-actions {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding-bottom: 8px;
|
|
width: 100%;
|
|
}
|
|
.yt-action-btn { flex-shrink: 0; padding: 8px 12px; font-size: 13px; }
|
|
.comment-item { flex-direction: column; }
|
|
.comment-item > img { width: 32px !important; height: 32px !important; }
|
|
.yt-search-input { font-size: 14px; padding: 0 12px; }
|
|
.yt-header { padding: 0 8px; }
|
|
.dropdown-menu { width: 100%; min-width: 200px; }
|
|
}
|
|
@media (max-width: 360px) {
|
|
.yt-main { padding: 8px 4px !important; }
|
|
.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); }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.video-container {
|
|
border-radius: 0 !important;
|
|
margin: 0 !important;
|
|
max-width: 100% !important;
|
|
width: 100% !important;
|
|
}
|
|
}
|
|
@media (hover: none) {
|
|
.yt-sidebar-link { padding: 0 16px; }
|
|
.yt-sidebar-link:hover { background: transparent; }
|
|
.yt-sidebar-link:active { background: var(--border-color); }
|
|
}
|
|
|
|
/* ── Bottom nav + mobile scroll model ───────────────── */
|
|
.yt-bottom-nav {
|
|
display: none;
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: calc(56px + env(safe-area-inset-bottom, 0px));
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
background: var(--bg-dark);
|
|
border-top: 1px solid var(--border-color);
|
|
z-index: 1000;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
-webkit-tap-highlight-color: transparent;
|
|
will-change: transform;
|
|
}
|
|
.yt-bottom-nav-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
height: 100%;
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
font-size: 12px;
|
|
gap: 4px;
|
|
transition: color 0.2s;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
border: none;
|
|
min-width: 56px;
|
|
}
|
|
.yt-bottom-nav-item:hover { color: var(--text-primary); }
|
|
.yt-bottom-nav-item.active { color: var(--text-primary); }
|
|
.yt-bottom-nav-item i { font-size: 24px; }
|
|
.yt-bottom-nav-item span { font-size: 10px; font-weight: 500; }
|
|
@media (max-width: 768px) {
|
|
html { overflow: hidden; height: 100%; max-width: 100%; }
|
|
body {
|
|
overflow: hidden;
|
|
height: 100%;
|
|
max-width: 100%;
|
|
position: fixed;
|
|
width: 100%;
|
|
}
|
|
.yt-bottom-nav { display: flex; transform: none !important; }
|
|
.yt-main {
|
|
position: fixed !important;
|
|
top: 56px !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: calc(56px + env(safe-area-inset-bottom, 0px)) !important;
|
|
margin: 0 !important;
|
|
padding: 16px !important;
|
|
padding-bottom: 16px !important;
|
|
min-height: unset !important;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
overscroll-behavior-y: contain;
|
|
}
|
|
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
|
.yt-main.video-view-page { padding: 0 !important; }
|
|
.yt-filter-bar {
|
|
position: relative !important;
|
|
top: auto !important;
|
|
z-index: auto !important;
|
|
margin: -16px -16px 16px !important;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
@yield('extra_styles')
|
|
|
|
{{-- Device fingerprint: computes a stable per-device hash on first visit,
|
|
caches it in localStorage + the `_fp` cookie so the server can dedupe guests
|
|
across IP/VPN/country changes. Loaded async — never blocks paint. --}}
|
|
<script src="{{ asset('fp.js') }}" async></script>
|
|
</head>
|
|
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
|
|
<!-- Header -->
|
|
@include('layouts.partials.header')
|
|
|
|
<!-- Impersonation Banner -->
|
|
@if(session('impersonator_id'))
|
|
<div class="impersonate-bar">
|
|
<i class="bi bi-person-fill-gear"></i>
|
|
<span>Impersonating <strong>{{ Auth::user()->name }}</strong> — you are acting as this user</span>
|
|
<form method="POST" action="{{ route('impersonate.exit') }}" style="margin:0;">
|
|
@csrf
|
|
<button type="submit" class="impersonate-exit-btn">
|
|
<i class="bi bi-box-arrow-right"></i> Exit Impersonation
|
|
</button>
|
|
</form>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Mobile Search Overlay -->
|
|
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
|
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
|
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
|
<button type="submit" class="mobile-search-submit">
|
|
<i class="bi bi-search"></i>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Sidebar Overlay (Mobile) -->
|
|
<div class="yt-sidebar-overlay" onclick="toggleSidebar()"></div>
|
|
|
|
<!-- Sidebar -->
|
|
@include('layouts.partials.sidebar')
|
|
|
|
<!-- Main Content -->
|
|
<main class="yt-main @yield('main_class')" id="main">
|
|
@yield('content')
|
|
</main>
|
|
|
|
<!-- Upload Modal -->
|
|
@auth
|
|
@include('layouts.partials.upload-modal')
|
|
@include('layouts.partials.edit-video-modal')
|
|
@include('layouts.partials.sports-match-modal')
|
|
@endauth
|
|
|
|
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
|
@include('layouts.partials.add-to-playlist-modal')
|
|
|
|
<!-- Share Modal - Available on all pages -->
|
|
<x-share-modal />
|
|
|
|
<!-- Delete Video Modal -->
|
|
@auth
|
|
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
|
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
|
|
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600; display: flex; align-items: center; gap: 10px;">
|
|
<i class="bi bi-exclamation-triangle-fill" style="color: #ef4444;"></i>
|
|
Delete Video
|
|
</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 24px;">
|
|
<div style="background: #282828; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
|
|
<p style="color: #fff; margin: 0; font-size: 14px; line-height: 1.6;">
|
|
<strong style="color: #ef4444;">Warning:</strong> This action is permanent and cannot be undone.
|
|
All data associated with this video will be lost, including:
|
|
</p>
|
|
<ul style="color: #aaa; margin: 12px 0 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
|
|
<li>View count</li>
|
|
<li>Comments</li>
|
|
<li>Likes</li>
|
|
<li>Thumbnail</li>
|
|
<li>Video file</li>
|
|
</ul>
|
|
</div>
|
|
<div style="margin-bottom: 16px;">
|
|
<label for="deleteVideoInput" style="color: #aaa; font-size: 14px; margin-bottom: 8px; display: block;">
|
|
To confirm deletion, type <strong style="color: #fff;">"<span id="deleteVideoName"></span>"</strong> below:
|
|
</label>
|
|
<input type="text"
|
|
id="deleteVideoInput"
|
|
class="form-control"
|
|
style="background: #282828; border: 1px solid #3f3f3f; color: #fff; padding: 12px 16px; border-radius: 8px; font-size: 14px;"
|
|
placeholder="Enter video name">
|
|
</div>
|
|
@auth
|
|
@if(Auth::user()->two_factor_enabled)
|
|
<div id="deleteOtpWrap" style="margin-top: 4px;">
|
|
<label for="deleteOtpInput" style="color: #aaa; font-size: 14px; margin-bottom: 8px; display: flex; align-items: center; gap: 7px;">
|
|
<i class="bi bi-shield-lock-fill" style="color: #a78bfa;"></i>
|
|
<span>Enter your <strong style="color:#fff;">2FA code</strong> to confirm:</span>
|
|
</label>
|
|
<input type="text" id="deleteOtpInput" inputmode="numeric" pattern="[0-9]*"
|
|
maxlength="6" autocomplete="one-time-code"
|
|
class="delete-otp-input"
|
|
placeholder="000000">
|
|
</div>
|
|
@endif
|
|
@endauth
|
|
</div>
|
|
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px; gap: 12px;">
|
|
<button type="button" class="btn" style="background: #3f3f3f; color: #fff; padding: 10px 20px; border-radius: 8px; font-weight: 500; border: none;" data-bs-dismiss="modal">
|
|
Cancel
|
|
</button>
|
|
<button type="button" id="confirmDeleteBtn" class="btn" style="background: #ef4444; color: #fff; padding: 10px 20px; border-radius: 8px; font-weight: 500; border: none; opacity: 0.5; cursor: not-allowed;" disabled onclick="confirmDeleteVideo()">
|
|
Delete Video
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endauth
|
|
|
|
<!-- Toast Container -->
|
|
<div id="toast-container" style="position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;"></div>
|
|
|
|
<!-- Generic Confirm Modal -->
|
|
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
|
<div class="modal-content" style="background:#1a1a1a;border:1px solid #3f3f3f;border-radius:12px;">
|
|
<div class="modal-body" style="padding:24px;">
|
|
<p id="confirmModalMessage" style="color:#fff;margin:0 0 20px;font-size:15px;line-height:1.5;"></p>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
<button type="button" class="btn" data-bs-dismiss="modal" style="background:#3f3f3f;color:#fff;padding:8px 18px;border-radius:8px;border:none;font-size:14px;">Cancel</button>
|
|
<button type="button" id="confirmModalOkBtn" class="btn" style="background:#ef4444;color:#fff;padding:8px 18px;border-radius:8px;border:none;font-size:14px;">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- YouTube-style Bottom Navigation Bar (Mobile) -->
|
|
<nav class="yt-bottom-nav">
|
|
<a href="{{ route('videos.index') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.index') ? 'active' : '' }}">
|
|
<i class="bi bi-house-door-fill"></i>
|
|
<span>Home</span>
|
|
</a>
|
|
<a href="{{ route('videos.trending') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.trending') ? 'active' : '' }}">
|
|
<i class="bi bi-fire"></i>
|
|
<span>Trending</span>
|
|
</a>
|
|
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
|
<i class="bi bi-plus-circle-fill"></i>
|
|
<span>Upload</span>
|
|
</a>
|
|
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
|
<i class="bi bi-collection-play-fill"></i>
|
|
<span>History</span>
|
|
</a>
|
|
<a href="{{ auth()->check() ? route('channel', auth()->user()->channel) : route('login') }}" class="yt-bottom-nav-item">
|
|
<i class="bi bi-person-fill"></i>
|
|
<span>Profile</span>
|
|
</a>
|
|
</nav>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
// Global toast notification — replaces alert()
|
|
function showToast(message, type) {
|
|
type = type || 'info';
|
|
const colors = { success: '#22c55e', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' };
|
|
const icons = { success: 'bi-check-circle-fill', error: 'bi-x-circle-fill', warning: 'bi-exclamation-triangle-fill', info: 'bi-info-circle-fill' };
|
|
const color = colors[type] || colors.info;
|
|
const icon = icons[type] || icons.info;
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.style.cssText = 'background:#1a1a1a;border:1px solid #3f3f3f;border-left:4px solid ' + color + ';color:#fff;padding:14px 16px;border-radius:8px;font-size:14px;display:flex;align-items:center;gap:10px;min-width:260px;max-width:380px;box-shadow:0 4px 20px rgba(0,0,0,.6);pointer-events:all;opacity:0;transition:opacity .25s ease;';
|
|
toast.innerHTML = '<i class="bi ' + icon + '" style="color:' + color + ';font-size:16px;flex-shrink:0;"></i><span style="flex:1;">' + message + '</span><button onclick="this.parentElement.remove()" style="background:none;border:none;color:#888;cursor:pointer;padding:0;font-size:18px;line-height:1;">×</button>';
|
|
container.appendChild(toast);
|
|
requestAnimationFrame(function() { toast.style.opacity = '1'; });
|
|
setTimeout(function() {
|
|
toast.style.opacity = '0';
|
|
setTimeout(function() { toast.remove(); }, 280);
|
|
}, 4000);
|
|
}
|
|
|
|
// Flash session toasts
|
|
@if(session('toast_error'))
|
|
showToast(@json(session('toast_error')), 'error');
|
|
@endif
|
|
@if(session('toast_success'))
|
|
showToast(@json(session('toast_success')), 'success');
|
|
@endif
|
|
@if(session('toast_warning'))
|
|
showToast(@json(session('toast_warning')), 'warning');
|
|
@endif
|
|
@if(session('toast_info'))
|
|
showToast(@json(session('toast_info')), 'info');
|
|
@endif
|
|
|
|
// Global confirm modal — replaces confirm()
|
|
function showConfirm(message, onConfirm, confirmLabel) {
|
|
document.getElementById('confirmModalMessage').textContent = message;
|
|
const okBtn = document.getElementById('confirmModalOkBtn');
|
|
okBtn.textContent = confirmLabel || 'Confirm';
|
|
const modalEl = document.getElementById('confirmModal');
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
const handler = function() {
|
|
okBtn.removeEventListener('click', handler);
|
|
modal.hide();
|
|
onConfirm();
|
|
};
|
|
okBtn.addEventListener('click', handler);
|
|
modalEl.addEventListener('hidden.bs.modal', function cleanup() {
|
|
okBtn.removeEventListener('click', handler);
|
|
modalEl.removeEventListener('hidden.bs.modal', cleanup);
|
|
});
|
|
modal.show();
|
|
}
|
|
|
|
// Mobile search toggle function
|
|
function toggleMobileSearch() {
|
|
const overlay = document.getElementById('mobileSearchOverlay');
|
|
overlay.classList.toggle('active');
|
|
|
|
// Focus input when overlay opens
|
|
if (overlay.classList.contains('active')) {
|
|
setTimeout(function() {
|
|
document.querySelector('.mobile-search-input').focus();
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
// Close mobile search on escape key
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
const overlay = document.getElementById('mobileSearchOverlay');
|
|
if (overlay.classList.contains('active')) {
|
|
overlay.classList.remove('active');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sidebar toggle function
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const overlay = document.querySelector('.yt-sidebar-overlay');
|
|
const main = document.getElementById('main');
|
|
|
|
// Check if we're on mobile or desktop
|
|
const isMobile = window.innerWidth < 992;
|
|
|
|
if (isMobile) {
|
|
// Mobile behavior - use 'open' class
|
|
sidebar.classList.toggle('open');
|
|
overlay.classList.toggle('show');
|
|
} else {
|
|
// Desktop behavior - use 'collapsed' class
|
|
sidebar.classList.toggle('collapsed');
|
|
main.classList.toggle('collapsed');
|
|
|
|
// Save state to localStorage
|
|
const isCollapsed = sidebar.classList.contains('collapsed');
|
|
localStorage.setItem('sidebarCollapsed', isCollapsed);
|
|
}
|
|
}
|
|
|
|
// Header user dropdown (custom, replaces Bootstrap)
|
|
function toggleHeaderDropdown() {
|
|
const btn = document.getElementById('headerUserBtn');
|
|
const panel = document.getElementById('headerUserPanel');
|
|
const open = panel.classList.contains('open');
|
|
closeHeaderDropdown();
|
|
if (!open) {
|
|
const r = btn.getBoundingClientRect();
|
|
panel.style.top = (r.bottom + 6) + 'px';
|
|
panel.style.right = Math.max(0, window.innerWidth - r.right) + 'px';
|
|
panel.style.left = '';
|
|
panel.classList.add('open');
|
|
}
|
|
}
|
|
function closeHeaderDropdown() {
|
|
const panel = document.getElementById('headerUserPanel');
|
|
if (panel) panel.classList.remove('open');
|
|
}
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.yt-user-dropdown')) closeHeaderDropdown();
|
|
if (!e.target.closest('.yt-notif-wrap')) closeNotifPanel();
|
|
});
|
|
|
|
/* ── Notification panel ── */
|
|
@auth
|
|
(function () {
|
|
var fetchUrl = '{{ route("notifications.fetch") }}';
|
|
var countUrl = '{{ route("notifications.count") }}';
|
|
var readAllUrl = '{{ route("notifications.read-all") }}';
|
|
var csrf = '{{ csrf_token() }}';
|
|
var loaded = false;
|
|
var prevUnread = 0;
|
|
var panel = document.getElementById('notifPanel');
|
|
var badge = document.getElementById('notifBadge');
|
|
var markAllBtn = document.getElementById('notifMarkAll');
|
|
var list = document.getElementById('notifList');
|
|
|
|
// ── Chime (Web Audio API — no file, no autoplay warm-up needed) ──
|
|
var _actx = null;
|
|
var _chimeQueued = false;
|
|
|
|
function _ensureCtx() {
|
|
if (!_actx) {
|
|
try { _actx = new (window.AudioContext || window.webkitAudioContext)(); }
|
|
catch(e) {}
|
|
}
|
|
return _actx;
|
|
}
|
|
|
|
function _doChimeNow() {
|
|
var ctx = _actx;
|
|
if (!ctx) return;
|
|
try {
|
|
var t = ctx.currentTime;
|
|
[[880,0],[1108,0.13],[1320,0.26]].forEach(function(n) {
|
|
var osc = ctx.createOscillator();
|
|
var g = ctx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = n[0];
|
|
g.gain.setValueAtTime(0, t + n[1]);
|
|
g.gain.linearRampToValueAtTime(0.20, t + n[1] + 0.015);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + n[1] + 0.75);
|
|
osc.connect(g);
|
|
g.connect(ctx.destination);
|
|
osc.start(t + n[1]);
|
|
osc.stop(t + n[1] + 0.8);
|
|
});
|
|
} catch(e) {}
|
|
}
|
|
|
|
// On every user gesture: resume context and flush any queued chime
|
|
function _onGesture() {
|
|
var ctx = _ensureCtx();
|
|
if (!ctx) return;
|
|
if (ctx.state === 'suspended') {
|
|
ctx.resume().then(function() {
|
|
if (_chimeQueued) { _chimeQueued = false; _doChimeNow(); }
|
|
}).catch(function(){});
|
|
} else if (_chimeQueued) {
|
|
_chimeQueued = false;
|
|
_doChimeNow();
|
|
}
|
|
}
|
|
['click','keydown','touchstart','scroll'].forEach(function(ev) {
|
|
document.addEventListener(ev, _onGesture, { passive: true });
|
|
});
|
|
|
|
function playChime() {
|
|
_ensureCtx();
|
|
if (!_actx) return;
|
|
if (_actx.state === 'running') {
|
|
_doChimeNow();
|
|
} else {
|
|
// Context suspended — queue it, also try resume immediately
|
|
_chimeQueued = true;
|
|
_actx.resume().then(function() {
|
|
if (_chimeQueued) { _chimeQueued = false; _doChimeNow(); }
|
|
}).catch(function(){});
|
|
}
|
|
}
|
|
|
|
// ── Bell ring animation ────────────────────────────────
|
|
var _bellTimer = null;
|
|
function ringBell() {
|
|
var btn = document.getElementById('notifBtn');
|
|
if (!btn) return;
|
|
btn.classList.remove('bell-ring');
|
|
// Force reflow so removing+adding the class restarts the animation
|
|
void btn.getBoundingClientRect();
|
|
btn.classList.add('bell-ring');
|
|
clearTimeout(_bellTimer);
|
|
_bellTimer = setTimeout(function () { btn.classList.remove('bell-ring'); }, 900);
|
|
}
|
|
|
|
function setBellUnread(hasUnread) {
|
|
var btn = document.getElementById('notifBtn');
|
|
if (!btn) return;
|
|
btn.classList.toggle('bell-has-unread', !!hasUnread);
|
|
if (!hasUnread) btn.classList.remove('bell-ring');
|
|
}
|
|
|
|
function post(url) {
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
});
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function notifText(d) {
|
|
var actor = '<strong>' + escHtml(d.actor_name || d.uploader_name || d.user_name || '') + '</strong>';
|
|
var title = '"' + escHtml(d.video_title || '') + '"';
|
|
var preview = d.comment_preview ? ' <em class="yt-notif-preview">"' + escHtml(d.comment_preview) + '"</em>' : '';
|
|
switch (d.type) {
|
|
case 'new_comment': return actor + ' commented on your video ' + title + ':' + preview;
|
|
case 'new_reply': return actor + ' replied to your comment:' + preview;
|
|
case 'comment_like':return actor + ' liked your comment on ' + title;
|
|
case 'new_user': return '🎉 ' + actor + ' just joined TAKEONE!';
|
|
case 'new_subscriber': return '🔔 ' + actor + ' subscribed to your channel';
|
|
case 'video_like': return '❤️ ' + actor + ' liked your video ' + title;
|
|
case 'video_shared': return '📤 ' + actor + ' shared a video with you: ' + title + (d.message ? ' <em class="yt-notif-preview">"' + escHtml(d.message) + '"</em>' : '');
|
|
case 'new_post': return '📝 ' + actor + ' posted something new';
|
|
default: return actor + ' uploaded a new video: ' + title;
|
|
}
|
|
}
|
|
|
|
function notifHref(d) {
|
|
if (d.type === 'new_user') return '/channel/' + encodeURIComponent(d.user_channel || '');
|
|
if (d.type === 'new_subscriber') return '/channel/' + encodeURIComponent(d.actor_channel || '');
|
|
if (d.type === 'new_post') return '/channel/' + encodeURIComponent(d.author_channel || '');
|
|
return '/videos/' + escHtml(d.video_route_key || '');
|
|
}
|
|
|
|
function notifThumb(d) {
|
|
var avatarTypes = ['new_user', 'new_subscriber', 'new_post'];
|
|
if (avatarTypes.indexOf(d.type) !== -1) {
|
|
var av = d.user_avatar || d.actor_avatar || d.author_avatar || '';
|
|
return av
|
|
? '<img class="yt-notif-thumb" src="' + escHtml(av) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)" style="border-radius:50%">'
|
|
: '<div class="yt-notif-thumb-placeholder"><i class="bi bi-person-circle"></i></div>';
|
|
}
|
|
return d.video_thumbnail
|
|
? '<img class="yt-notif-thumb" src="/media/thumbnails/' + escHtml(d.video_thumbnail) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)">'
|
|
: '<div class="yt-notif-thumb-placeholder"><i class="bi bi-play-circle"></i></div>';
|
|
}
|
|
|
|
function renderNotifications(data) {
|
|
var unread = data.unread_count;
|
|
if (unread > 0) {
|
|
badge.textContent = unread > 99 ? '99+' : unread;
|
|
badge.style.display = '';
|
|
if (markAllBtn) markAllBtn.style.display = '';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
}
|
|
|
|
if (!data.notifications || data.notifications.length === 0) {
|
|
list.innerHTML = '<div class="yt-notif-empty">You\'re all caught up!</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.notifications.map(function (n) {
|
|
var d = n.data;
|
|
var dot = !n.read ? '<div class="yt-notif-dot"></div>' : '';
|
|
return '<a class="yt-notif-item' + (!n.read ? ' unread' : '') + '" '
|
|
+ 'href="' + notifHref(d) + '" '
|
|
+ 'data-notif-id="' + escHtml(n.id) + '" '
|
|
+ 'onclick="handleNotifClick(event, this)">'
|
|
+ notifThumb(d)
|
|
+ '<div class="yt-notif-body">'
|
|
+ '<div class="yt-notif-text">' + notifText(d) + '</div>'
|
|
+ '<div class="yt-notif-time">' + escHtml(n.time) + '</div>'
|
|
+ '</div>'
|
|
+ dot
|
|
+ '</a>';
|
|
}).join('');
|
|
}
|
|
|
|
window.notifThumbFallback = function (img) {
|
|
var ph = document.createElement('div');
|
|
ph.className = 'yt-notif-thumb-placeholder';
|
|
ph.innerHTML = '<i class="bi bi-play-circle"></i>';
|
|
img.parentNode.replaceChild(ph, img);
|
|
};
|
|
|
|
function loadNotifications() {
|
|
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) { loaded = true; renderNotifications(data); })
|
|
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; });
|
|
}
|
|
|
|
window.toggleNotifPanel = function () {
|
|
var btn = document.getElementById('notifBtn');
|
|
if (panel.style.display === 'none' || !panel.style.display) {
|
|
closeHeaderDropdown();
|
|
var r = btn.getBoundingClientRect();
|
|
var maxH = Math.min(600, window.innerHeight - r.bottom - 16);
|
|
panel.style.top = (r.bottom + 6) + 'px';
|
|
panel.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
|
|
panel.style.left = '';
|
|
panel.style.maxHeight = maxH + 'px';
|
|
panel.style.display = 'flex';
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
if (!loaded) loadNotifications();
|
|
} else {
|
|
closeNotifPanel();
|
|
}
|
|
};
|
|
|
|
window.closeNotifPanel = function () {
|
|
if (panel) { panel.style.display = 'none'; }
|
|
var btn = document.getElementById('notifBtn');
|
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
};
|
|
|
|
window.handleNotifClick = function (e, el) {
|
|
var id = el.dataset.notifId;
|
|
el.classList.remove('unread');
|
|
el.querySelector('.yt-notif-dot')?.remove();
|
|
post('{{ url("/notifications") }}/' + id + '/read')
|
|
.then(function () {
|
|
var remaining = list.querySelectorAll('.yt-notif-item.unread').length;
|
|
if (remaining === 0) {
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
setBellUnread(false);
|
|
} else {
|
|
badge.textContent = remaining > 99 ? '99+' : remaining;
|
|
}
|
|
prevUnread = remaining;
|
|
});
|
|
closeNotifPanel();
|
|
};
|
|
|
|
window.markAllRead = function () {
|
|
list.querySelectorAll('.yt-notif-item.unread').forEach(function (el) {
|
|
el.classList.remove('unread');
|
|
el.querySelector('.yt-notif-dot')?.remove();
|
|
});
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
prevUnread = 0;
|
|
setBellUnread(false);
|
|
post(readAllUrl);
|
|
};
|
|
|
|
// ── Poll every 60 s — count-only (1 lightweight DB query) ──
|
|
function poll() {
|
|
fetch(countUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var newCount = data.unread_count || 0;
|
|
if (newCount > prevUnread) {
|
|
playChime();
|
|
ringBell();
|
|
badge.textContent = newCount > 99 ? '99+' : newCount;
|
|
badge.style.display = '';
|
|
if (markAllBtn) markAllBtn.style.display = '';
|
|
setBellUnread(true);
|
|
loaded = false; // force re-fetch when panel opens
|
|
} else if (newCount === 0 && prevUnread > 0) {
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
setBellUnread(false);
|
|
}
|
|
prevUnread = newCount;
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
setInterval(poll, 60000);
|
|
|
|
// ── Initial badge load — deferred so it doesn't block page render ──
|
|
(window.requestIdleCallback || function(cb){ setTimeout(cb, 500); })(function () {
|
|
fetch(countUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
prevUnread = data.unread_count || 0;
|
|
if (prevUnread > 0) {
|
|
badge.textContent = prevUnread > 99 ? '99+' : prevUnread;
|
|
badge.style.display = '';
|
|
if (markAllBtn) markAllBtn.style.display = '';
|
|
setBellUnread(true);
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
});
|
|
})();
|
|
@endauth
|
|
|
|
// Restore sidebar state from localStorage on page load
|
|
function restoreSidebarState() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('main');
|
|
const isMobile = window.innerWidth < 992;
|
|
|
|
if (!isMobile) {
|
|
const savedState = localStorage.getItem('sidebarCollapsed');
|
|
// Default to mini (collapsed) if no preference saved — matches YouTube
|
|
if (savedState !== 'false') {
|
|
sidebar.classList.add('collapsed');
|
|
main.classList.add('collapsed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set active sidebar link based on current route
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Restore sidebar state
|
|
restoreSidebarState();
|
|
|
|
const currentPath = window.location.pathname;
|
|
const sidebarLinks = document.querySelectorAll('.yt-sidebar-link');
|
|
|
|
sidebarLinks.forEach(link => {
|
|
const href = link.getAttribute('href');
|
|
if (href === currentPath || (currentPath.startsWith(href) && href !== '/')) {
|
|
link.classList.add('active');
|
|
} else if (href === '/' && currentPath === '/') {
|
|
link.classList.add('active');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle window resize to reset state for mobile
|
|
window.addEventListener('resize', function() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('main');
|
|
const isMobile = window.innerWidth < 992;
|
|
|
|
if (isMobile) {
|
|
// On mobile, remove collapsed state
|
|
sidebar.classList.remove('collapsed');
|
|
main.classList.remove('collapsed');
|
|
} else {
|
|
// On desktop, restore from localStorage
|
|
restoreSidebarState();
|
|
}
|
|
});
|
|
|
|
// Delete video modal functions
|
|
let currentDeleteVideoId = null;
|
|
let currentDeleteVideoTitle = '';
|
|
|
|
const _2faEnabled = {{ Auth::check() && Auth::user()->two_factor_enabled ? 'true' : 'false' }};
|
|
|
|
function showDeleteModal(videoId, videoTitle) {
|
|
currentDeleteVideoId = videoId;
|
|
currentDeleteVideoTitle = videoTitle.replace(/\s+/g, ' ').trim();
|
|
|
|
document.getElementById('deleteVideoName').textContent = currentDeleteVideoTitle;
|
|
document.getElementById('deleteVideoInput').value = '';
|
|
const otpInput = document.getElementById('deleteOtpInput');
|
|
if (otpInput) otpInput.value = '';
|
|
|
|
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
|
deleteBtn.disabled = true;
|
|
deleteBtn.style.opacity = '0.5';
|
|
deleteBtn.style.cursor = 'not-allowed';
|
|
|
|
const dropdown = document.querySelector('.dropdown-menu.show');
|
|
if (dropdown) dropdown.classList.remove('show');
|
|
|
|
const modalElement = document.getElementById('deleteVideoModal');
|
|
const modal = new bootstrap.Modal(modalElement);
|
|
modal.show();
|
|
}
|
|
|
|
function _updateDeleteBtn() {
|
|
const titleOk = document.getElementById('deleteVideoInput').value.replace(/\s+/g, ' ').trim() === currentDeleteVideoTitle;
|
|
const otpInput = document.getElementById('deleteOtpInput');
|
|
const otpOk = !_2faEnabled || (otpInput && otpInput.value.replace(/\s/g,'').length === 6);
|
|
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
|
const ok = titleOk && otpOk;
|
|
deleteBtn.disabled = !ok;
|
|
deleteBtn.style.opacity = ok ? '1' : '0.5';
|
|
deleteBtn.style.cursor = ok ? 'pointer' : 'not-allowed';
|
|
}
|
|
|
|
document.getElementById('deleteVideoInput').addEventListener('input', _updateDeleteBtn);
|
|
const _otpInput = document.getElementById('deleteOtpInput');
|
|
if (_otpInput) _otpInput.addEventListener('input', _updateDeleteBtn);
|
|
|
|
function confirmDeleteVideo() {
|
|
if (!currentDeleteVideoId || !currentDeleteVideoTitle) return;
|
|
|
|
const inputValue = document.getElementById('deleteVideoInput').value;
|
|
if (inputValue.replace(/\s+/g,' ').trim() !== currentDeleteVideoTitle) {
|
|
showToast('Video name does not match. Please try again.', 'error');
|
|
return;
|
|
}
|
|
|
|
const otpInput = document.getElementById('deleteOtpInput');
|
|
const otpCode = otpInput ? otpInput.value.replace(/\s/g, '') : '';
|
|
if (_2faEnabled && otpCode.length !== 6) {
|
|
showToast('Please enter your 6-digit 2FA code.', 'error');
|
|
if (otpInput) otpInput.focus();
|
|
return;
|
|
}
|
|
|
|
const headers = {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
'Accept': 'application/json',
|
|
};
|
|
if (_2faEnabled) headers['X-2FA-Code'] = otpCode;
|
|
|
|
fetch(`/videos/${currentDeleteVideoId}`, { method: 'DELETE', headers })
|
|
.then(response => {
|
|
if (response.status === 200 || response.status === 302 || response.redirected) {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteVideoModal'));
|
|
if (modal) modal.hide();
|
|
window.location.href = "{{ route('videos.index') }}";
|
|
} else if (response.status === 403) {
|
|
showToast('You do not have permission to delete this video.', 'error');
|
|
} else if (response.status === 404) {
|
|
showToast('Video not found.', 'error');
|
|
} else if (response.status === 422) {
|
|
response.json().then(data => {
|
|
showToast(data.message || 'Invalid 2FA code. Please try again.', 'error');
|
|
if (otpInput) { otpInput.value = ''; otpInput.focus(); }
|
|
_updateDeleteBtn();
|
|
});
|
|
} else {
|
|
response.json().then(data => {
|
|
showToast(data.message || 'Failed to delete video. Please try again.', 'error');
|
|
}).catch(() => {
|
|
showToast('Failed to delete video. Please try again.', 'error');
|
|
});
|
|
}
|
|
})
|
|
.catch(() => showToast('An error occurred while deleting the video.', 'error'));
|
|
}
|
|
</script>
|
|
|
|
{{-- Page-specific scripts come from each view's @section('scripts').
|
|
Wrapped in a marker element so the SPA navigator can swap & re-run
|
|
them — without this, SPA nav into a page whose JS defines functions
|
|
like switchTab() leaves those functions undefined, and any onclick
|
|
handler that calls them silently fails. --}}
|
|
<div id="page-scripts">
|
|
@yield('scripts')
|
|
</div>
|
|
|
|
{{-- ═══════════════════════════════════════════════════════════
|
|
ADMIN ERROR CATCHER — only rendered for super_admins
|
|
═══════════════════════════════════════════════════════════ --}}
|
|
@auth
|
|
@if(Auth::user()->isSuperAdmin())
|
|
<div id="errCatcher" style="position:fixed;bottom:80px;right:16px;z-index:99999;font-family:monospace;">
|
|
<!-- Badge -->
|
|
<button id="errBadge" onclick="toggleErrPanel()"
|
|
style="display:none;background:#e61e1e;color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:14px;font-weight:700;cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,.5);position:relative;">
|
|
<span id="errCount">0</span>
|
|
<span style="position:absolute;top:-2px;right:-2px;width:10px;height:10px;background:#ff0;border-radius:50%;"></span>
|
|
</button>
|
|
<!-- Panel -->
|
|
<div id="errPanel" style="display:none;width:min(520px,90vw);max-height:70vh;overflow-y:auto;background:#111;border:1px solid #e61e1e;border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.7);">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #333;background:#1a1a1a;border-radius:10px 10px 0 0;">
|
|
<span style="color:#e61e1e;font-weight:700;font-size:13px;"><i class="bi bi-bug-fill"></i> Error Catcher</span>
|
|
<div style="display:flex;gap:8px;">
|
|
<button onclick="clearErrors()" style="background:#333;border:none;color:#aaa;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;">Clear</button>
|
|
<button onclick="toggleErrPanel()" style="background:#333;border:none;color:#aaa;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;">Close</button>
|
|
</div>
|
|
</div>
|
|
<div id="errList" style="padding:10px 14px;display:flex;flex-direction:column;gap:10px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
let errors = [];
|
|
|
|
function renderErrors() {
|
|
const list = document.getElementById('errList');
|
|
const badge = document.getElementById('errBadge');
|
|
const count = document.getElementById('errCount');
|
|
count.textContent = errors.length;
|
|
badge.style.display = errors.length ? 'block' : 'none';
|
|
list.innerHTML = errors.map((e, i) => `
|
|
<div style="background:#1a1a1a;border:1px solid #333;border-radius:6px;padding:10px;font-size:11px;">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
|
|
<span style="color:#e61e1e;font-weight:700;">${e.status} ${e.method} ${e.url}</span>
|
|
<span style="color:#666;">${e.time}</span>
|
|
</div>
|
|
<pre style="color:#f8f8f2;white-space:pre-wrap;word-break:break-all;margin:0;max-height:200px;overflow-y:auto;background:#0a0a0a;padding:8px;border-radius:4px;">${escHtml(e.body)}</pre>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function addError(status, method, url, body) {
|
|
errors.unshift({ status, method, url, body: tryFormat(body), time: new Date().toLocaleTimeString() });
|
|
if (errors.length > 20) errors.pop();
|
|
renderErrors();
|
|
// Auto-open panel on first error
|
|
if (errors.length === 1) document.getElementById('errPanel').style.display = 'block';
|
|
}
|
|
|
|
function tryFormat(raw) {
|
|
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw || '(empty)'; }
|
|
}
|
|
|
|
window.toggleErrPanel = function () {
|
|
const p = document.getElementById('errPanel');
|
|
p.style.display = p.style.display === 'none' ? 'block' : 'none';
|
|
};
|
|
window.clearErrors = function () {
|
|
errors = [];
|
|
renderErrors();
|
|
document.getElementById('errPanel').style.display = 'none';
|
|
};
|
|
|
|
// ── Intercept XHR ──────────────────────────────────────────
|
|
const OrigXHR = window.XMLHttpRequest;
|
|
function PatchedXHR() {
|
|
const xhr = new OrigXHR();
|
|
let _method, _url;
|
|
const origOpen = xhr.open.bind(xhr);
|
|
xhr.open = function (method, url, ...rest) {
|
|
_method = method; _url = url;
|
|
return origOpen(method, url, ...rest);
|
|
};
|
|
xhr.addEventListener('loadend', function () {
|
|
if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
|
|
addError(xhr.status, _method, _url, xhr.responseText);
|
|
}
|
|
});
|
|
return xhr;
|
|
}
|
|
PatchedXHR.prototype = OrigXHR.prototype;
|
|
window.XMLHttpRequest = PatchedXHR;
|
|
|
|
// ── Intercept fetch ────────────────────────────────────────
|
|
const origFetch = window.fetch;
|
|
window.fetch = async function (...args) {
|
|
const res = await origFetch(...args);
|
|
if (!res.ok) {
|
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '?';
|
|
const meth = args[1]?.method ?? 'GET';
|
|
res.clone().text().then(body => addError(res.status, meth, url, body));
|
|
}
|
|
return res;
|
|
};
|
|
})();
|
|
</script>
|
|
@endif
|
|
@endauth
|
|
|
|
<!-- ── Persistent Mini-Player ──────────────────────────────────────────
|
|
#videoPlayer is teleported into #ytpMiniVideo via DOM adoption so
|
|
HLS.js keeps streaming without any reconnection or discontinuity.
|
|
Activated by scroll (video leaves viewport) or by SPA navigation.
|
|
──────────────────────────────────────────────────────────────────── -->
|
|
<div id="ytpMini" style="display:none;" aria-label="Mini player">
|
|
<div id="ytpMiniVideo"></div>
|
|
<div id="ytpMiniBar">
|
|
<div id="ytpMiniInfo">
|
|
<span id="ytpMiniTitle"></span>
|
|
</div>
|
|
<div id="ytpMiniControls">
|
|
<button id="ytpMiniPlay" title="Play / Pause"><i class="bi bi-play-fill"></i></button>
|
|
<a id="ytpMiniExpand" title="Back to video" href="#"><i class="bi bi-box-arrow-up-right"></i></a>
|
|
<button id="ytpMiniClose" title="Close"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
/* ── Mini-player controller (teleportation-based) ─────────────────────
|
|
Moves the actual #videoPlayer element into the mini slot so HLS.js
|
|
never drops the stream. Two modes:
|
|
'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');
|
|
var titleEl = document.getElementById('ytpMiniTitle');
|
|
var playBtn = document.getElementById('ytpMiniPlay');
|
|
var expandBtn = document.getElementById('ytpMiniExpand');
|
|
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 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 m = getMedia();
|
|
if (!playBtn) return;
|
|
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) {
|
|
/* 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>';
|
|
|
|
/* Teleport the audio element to the mini wrap. <audio> is
|
|
invisible, so visual layout is unaffected. */
|
|
_origParent = a.parentNode;
|
|
_origNext = a.nextSibling;
|
|
wrap.appendChild(a);
|
|
}
|
|
|
|
titleEl.textContent = title || 'Now playing';
|
|
expandBtn.href = url || '#';
|
|
_mode = mode;
|
|
wrap.style.display = 'block';
|
|
syncBtn();
|
|
var m = getMedia();
|
|
if (m) {
|
|
m.addEventListener('play', syncBtn);
|
|
m.addEventListener('pause', syncBtn);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function restore() {
|
|
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() {
|
|
restore();
|
|
wrap.style.display = 'none';
|
|
_mode = null;
|
|
}
|
|
|
|
if (playBtn) {
|
|
playBtn.addEventListener('click', function () {
|
|
var m = getMedia();
|
|
if (!m) return;
|
|
if (m.paused) m.play().catch(function(){});
|
|
else m.pause();
|
|
});
|
|
}
|
|
|
|
if (expandBtn) {
|
|
expandBtn.addEventListener('click', function (e) {
|
|
if (_mode === 'scroll') {
|
|
e.preventDefault();
|
|
/* Scroll back to the player and restore it */
|
|
var main = document.getElementById('main');
|
|
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
|
|
deactivate();
|
|
return;
|
|
}
|
|
/* 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 () {
|
|
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 _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; },
|
|
isScrollMode: function () { return _mode === 'scroll'; },
|
|
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
|
|
};
|
|
})();
|
|
|
|
/* ── SPA navigation — swaps <main> content so the mini player video
|
|
element lives on across page changes without any HLS reconnection ── */
|
|
(function () {
|
|
|
|
function isVideoShowPage(url) {
|
|
/* /videos/{key} — two path segments, second not a static word */
|
|
var parts = new URL(url, location.href).pathname
|
|
.replace(/\/$/, '').split('/').filter(Boolean);
|
|
if (parts.length !== 2 || parts[0] !== 'videos') return false;
|
|
var STATIC = { search:1, trending:1, create:1, shorts:1 };
|
|
return !STATIC[parts[1]];
|
|
}
|
|
|
|
function isInternal(href) {
|
|
try { return new URL(href, location.href).origin === location.origin; }
|
|
catch(e) { return false; }
|
|
}
|
|
|
|
function reExecScripts(container) {
|
|
Array.from(container.querySelectorAll('script')).forEach(function (old) {
|
|
var n = document.createElement('script');
|
|
Array.from(old.attributes).forEach(function (a) {
|
|
n.setAttribute(a.name, a.value);
|
|
});
|
|
n.textContent = old.textContent;
|
|
old.parentNode.replaceChild(n, old);
|
|
});
|
|
}
|
|
|
|
function updateNavStates(url) {
|
|
var path = new URL(url, location.href).pathname;
|
|
document.querySelectorAll('.yt-bottom-nav-item, .yt-sidebar-item[href]')
|
|
.forEach(function (el) {
|
|
try {
|
|
var ep = new URL(el.getAttribute('href') || '', location.href).pathname;
|
|
el.classList.toggle('active', ep !== '/' && path.startsWith(ep) || ep === path);
|
|
} catch(e) {}
|
|
});
|
|
}
|
|
|
|
/* 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) {
|
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
|
var newM = doc.getElementById('main');
|
|
var curM = document.getElementById('main');
|
|
if (!newM || !curM) { location.href = url; return; }
|
|
|
|
/* Safety: if destination turned out to be a video page, hard-navigate */
|
|
if (doc.getElementById('ytpWrap') || doc.getElementById('videoPlayer')) {
|
|
location.href = url;
|
|
return;
|
|
}
|
|
|
|
document.title = doc.title;
|
|
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;
|
|
/* 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 */
|
|
}
|
|
|
|
/* ── Intercept link clicks ── */
|
|
document.addEventListener('click', function (e) {
|
|
var a = e.target.closest('a[href]');
|
|
if (!a) return;
|
|
var href = a.getAttribute('href');
|
|
if (!href || href === '#' || /^(javascript:|mailto:|tel:)/.test(href)) return;
|
|
if (a.target === '_blank') return;
|
|
if (!isInternal(href)) return;
|
|
/* SPA-managed video cards handle their own transitions */
|
|
if (a.hasAttribute('data-rec-url') || a.hasAttribute('data-pl-id')) return;
|
|
|
|
var destUrl = new URL(href, location.href).href;
|
|
var v = document.getElementById('videoPlayer');
|
|
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);
|
|
}, true);
|
|
|
|
/* ── Back / forward button ── */
|
|
window.addEventListener('popstate', function (e) {
|
|
var url = location.href;
|
|
var miniOn = window._miniPlayer && window._miniPlayer.isActive();
|
|
|
|
if (isVideoShowPage(url)) {
|
|
if (miniOn) stopMiniAndNavigate(url);
|
|
location.reload();
|
|
return;
|
|
}
|
|
|
|
if (miniOn || (e.state && e.state.spa)) {
|
|
spaGo(url);
|
|
}
|
|
});
|
|
|
|
})();
|
|
</script>
|
|
|
|
{{-- Profile-visit tracking: any link with data-profile-visit-url fires a beacon on click --}}
|
|
<script>
|
|
(function () {
|
|
var _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
document.addEventListener('click', function (e) {
|
|
var a = e.target.closest('[data-profile-visit-url]');
|
|
if (!a) return;
|
|
var url = a.getAttribute('data-profile-visit-url');
|
|
var src = a.getAttribute('data-source-video-id') || '';
|
|
if (!url) return;
|
|
try {
|
|
var body = new URLSearchParams({ source_video_id: src }).toString();
|
|
if (navigator.sendBeacon) {
|
|
var blob = new Blob([body + '&_token=' + encodeURIComponent(_csrf)], { type: 'application/x-www-form-urlencoded' });
|
|
navigator.sendBeacon(url, blob);
|
|
} else {
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
|
body: body,
|
|
credentials: 'same-origin',
|
|
keepalive: true,
|
|
}).catch(function(){});
|
|
}
|
|
} catch (err) {}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|