ghassan a4384113c2 Audio songs: one-folder storage, version-aware download/share, GPU-checked renders
Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:43 +03:00

826 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', 'Edit ' . $video->title . ' | ' . config('app.name'))
@section('main_class', 'edit-page-only edit-page-responsive')
@section('extra_styles')
<style>
@media (max-width: 991px) {
.edit-page-only .yt-header, .edit-page-only .yt-sidebar { display: block !important; }
.edit-page-only .yt-main { display: block !important; margin-left: 0; padding-bottom: 80px; }
.edit-page-only.edit-page-responsive { display: block; background: transparent; padding: 0; }
.edit-page-only.edit-page-responsive .edit-page-standalone { max-width: 100%; border-radius: 0; box-shadow: none; background: var(--bg-primary); border: none; padding: 16px; }
.edit-page-only.edit-page-responsive .edit-page-body { padding: 0; background: transparent; }
.edit-page-only.edit-page-responsive .form-input,
.edit-page-only.edit-page-responsive .form-textarea { background: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); }
.edit-page-only.edit-page-responsive .form-label { color: var(--text-primary); }
#edit-thumbnail-dropzone { padding: 20px 16px; }
.options-grid { flex-direction: column; }
.option-item { min-width: 100%; }
}
@media (min-width: 992px) {
.edit-page-only { min-height: auto; display: block; padding: 20px; background: #121212; }
}
.edit-page-standalone {
background: #1a1a1a;
border: none;
border-radius: 0;
box-shadow: none;
overflow: hidden;
width: 100%;
max-width: 700px;
margin: 0 auto;
padding: 20px;
}
.edit-page-body { padding: 0; background: transparent; }
/* Form Groups */
.form-group { margin-bottom: 20px; }
.form-label { display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #e5e5e5; }
.form-input, .form-textarea { width: 100%; background: #121212; border: 1px solid #333; border-radius: 8px; padding: 12px 16px; color: var(--text-primary); font-size: 14px; }
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-red); }
.form-textarea { resize: vertical; min-height: 100px; }
/* Current thumbnail */
#current-thumb-wrap {
width: 100%; height: 140px; border-radius: 10px; overflow: hidden;
background: #151515; border: 2px dashed #404040; position: relative; margin-bottom: 8px;
}
#current-thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: none; }
#current-thumb-wrap.has-thumb img { display: block; }
#current-thumb-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #666; gap: 8px;
}
#current-thumb-placeholder i { font-size: 32px; }
#current-thumb-placeholder span { font-size: 13px; }
#current-thumb-wrap.has-thumb #current-thumb-placeholder { display: none; }
/* Thumbnail Dropzone */
#edit-thumbnail-dropzone {
border: 2px dashed #404040;
border-radius: 10px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #151515;
position: relative;
}
#edit-thumbnail-dropzone:hover { border-color: var(--brand-red); }
#edit-thumbnail-dropzone.dragover { border-color: var(--brand-red); background: rgba(230,30,30,.05); }
#edit-thumbnail { display: none; }
#edit-thumbnail-default i { font-size: 28px; color: #666; margin-bottom: 6px; }
#edit-thumbnail-default p { color: #888; font-size: 13px; margin: 0; }
#edit-thumbnail-info { display: none; }
#edit-thumbnail-info.active { display: block; }
#edit-thumbnail-info img { max-width: 100%; max-height: 120px; border-radius: 8px; }
/* Options Grid */
.options-section { margin-bottom: 24px; }
.options-label { font-size: 14px; font-weight: 500; color: #e5e5e5; margin-bottom: 12px; display: block; }
.options-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.option-item { flex: 1; min-width: 100px; cursor: pointer; }
.option-item input { display: none; }
.option-content { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #1a1a1a; border: 2px solid #333; border-radius: 10px; transition: all 0.2s; }
.option-item:hover .option-content { border-color: #555; background: #1f1f1f; }
.option-item.active .option-content { border-color: var(--brand-red); background: rgba(230,30,30,.1); }
.option-content i { font-size: 18px; color: #666; width: 20px; text-align: center; }
.option-item.active .option-content i { color: var(--brand-red); }
.option-content span { font-size: 13px; color: #aaa; }
.option-item.active .option-content span { color: #fff; }
/* File info row (for thumbnail) */
.file-info-row {
display: flex; align-items: center; gap: 12px; padding: 12px;
background: #1f1f1f; border-radius: 8px; border: 1px solid #333;
}
.file-info-row .thumb-preview { width: 80px; height: 48px; overflow: hidden; border-radius: 6px; flex-shrink: 0; }
.file-info-row .thumb-preview img { width: 100%; height: 100%; object-fit: cover; }
.file-info-row .file-meta { flex: 1; text-align: left; min-width: 0; }
.file-info-row .file-meta .fn { color: #e5e5e5; font-weight: 500; font-size: 13px; margin: 0 0 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-info-row .file-meta .fs { color: #888; font-size: 12px; margin: 0; }
.btn-remove { width: 32px; height: 32px; border-radius: 50%; background: #333; border: none; color: #888; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.btn-remove:hover { background: var(--brand-red); color: white; }
/* Slides manager */
.ep-slides-strip {
display: flex; flex-wrap: wrap; gap: 8px;
min-height: 72px; padding: 10px;
background: rgba(255,255,255,.03);
border: 2px dashed rgba(255,255,255,.1);
border-radius: 10px; margin-bottom: 8px;
}
.ep-slides-empty {
display: flex; align-items: center; justify-content: center;
width: 100%; color: var(--text-secondary); font-size: 13px;
}
.ep-item {
position: relative; width: 72px; height: 72px;
border-radius: 8px; overflow: hidden;
border: 2px solid rgba(255,255,255,.12);
cursor: grab; flex-shrink: 0;
transition: border-color .2s;
}
.ep-item:hover { border-color: var(--brand-red); }
.ep-item img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.ep-drag-handle {
position: absolute; top: 2px; left: 2px;
width: 20px; height: 20px;
background: rgba(0,0,0,.55); border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #ccc; cursor: grab;
}
.ep-remove {
position: absolute; top: 2px; right: 2px;
width: 20px; height: 20px;
background: rgba(0,0,0,.55); border: none; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #ccc; cursor: pointer; padding: 0;
transition: background .15s, color .15s;
}
.ep-remove:hover { background: var(--brand-red); color: #fff; }
.ep-item.ep-dragging { opacity: .35; outline: 2px dashed var(--brand-red); }
/* Status */
#edit-status { display: none; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
#edit-status.success { display: block; background: rgba(34,197,94,.2); color: #4ade80; border: 1px solid rgba(34,197,94,.3); }
#edit-status.error { display: block; background: rgba(239,68,68,.2); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
/* Submit Button */
.btn-submit { width: 100%; justify-content: center; font-size: 15px; font-weight: 600; }
.btn-submit:disabled { background: #555 !important; border-color: #555 !important; cursor: not-allowed; opacity: 1; }
/* Danger zone */
.danger-zone { margin-top: 32px; padding-top: 20px; border-top: 1px solid #333; }
.danger-zone p { font-size: 13px; color: #888; margin-bottom: 12px; }
.danger-zone .action-btn { width: 100%; justify-content: center; }
@media (max-width: 600px) {
.options-grid { flex-direction: column; }
.option-item { min-width: 100%; }
}
</style>
@endsection
@section('content')
<div class="edit-page-standalone">
<div class="edit-page-body">
<form id="edit-form" enctype="multipart/form-data">
@csrf
@method('PUT')
@if(!$video->isAudioOnly())
{{-- Current Thumbnail (non-audio only) --}}
<div class="form-group">
<label class="form-label"><i class="bi bi-image"></i> Current Thumbnail</label>
<div id="current-thumb-wrap" class="{{ $video->thumbnail ? 'has-thumb' : '' }}">
<img src="{{ $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : '' }}" alt="Thumbnail">
<div id="current-thumb-placeholder">
<i class="bi bi-card-image"></i>
<span>No thumbnail set</span>
</div>
</div>
</div>
@endif
{{-- Title --}}
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" name="title" id="edit-title" required class="form-input"
value="{{ $video->title }}" placeholder="Enter video title">
</div>
{{-- Description --}}
<div class="form-group">
<label class="form-label">Description</label>
<x-rich-text-editor name="description" id="edit-description" :value="$video->description" placeholder="Tell viewers about your video…" />
</div>
@if(!$video->isAudioOnly())
{{-- Thumbnail dropzone (non-audio) --}}
<div class="form-group">
<label class="form-label"><i class="bi bi-camera"></i> Change Thumbnail</label>
<div id="edit-thumbnail-dropzone">
<input type="file" name="thumbnail" id="edit-thumbnail" accept="image/*">
<div id="edit-thumbnail-default">
<i class="bi bi-card-image"></i>
<p>Click to select new thumbnail (optional, max 20MB)</p>
</div>
<div id="edit-thumbnail-info">
<div class="file-info-row">
<div class="thumb-preview"><img id="edit-thumb-preview-img" src="" alt=""></div>
<div class="file-meta">
<p class="fn" id="edit-thumb-filename"></p>
<p class="fs" id="edit-thumb-filesize"></p>
</div>
<button type="button" class="btn-remove" onclick="removeEditThumb(event)"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
@else
{{-- Slides manager (audio tracks only) --}}
<div class="form-group">
<label class="form-label">
<i class="bi bi-images"></i> Cover Slides
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:6px;">Drag to reorder · click × to remove</span>
</label>
<input type="hidden" name="slides_order" id="ep-slides-order" value="[]">
<input type="file" name="slides_add[]" id="ep-slides-add-input" accept="image/*" multiple style="display:none;">
<div class="ep-slides-strip" id="ep-slides-strip">
<div class="ep-slides-empty" id="ep-slides-empty">No slides yet</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" class="action-btn" onclick="document.getElementById('ep-slides-add-input').click()">
<i class="bi bi-plus-lg"></i> <span>Add Images</span>
</button>
<span id="ep-slides-hint" style="font-size:12px;color:var(--text-secondary);"></span>
</div>
</div>
{{-- Primary Language (audio only) --}}
<div class="form-group">
<x-language-select
name="primary_language"
id="ep_primary_language"
label="Track Language"
placeholder="Select language"
value="{{ $video->language ?? '' }}"
/>
</div>
{{-- Language Tracks Manager (audio only) --}}
<div class="form-group">
<label class="form-label"><i class="bi bi-translate"></i> Language Tracks</label>
{{-- Existing tracks --}}
<div id="ep-tracks-existing" style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px;">
@forelse($video->audioTracks as $track)
<div style="display:flex;flex-direction:column;gap:6px;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 12px;" data-track-id="{{ $track->id }}" id="ep-track-{{ $track->id }}">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:11px;font-weight:700;background:rgba(255,255,255,.1);padding:2px 6px;border-radius:4px;font-family:monospace;">{{ strtoupper($track->language) }}</span>
<span style="flex:1;font-size:13px;color:#999;">{{ $track->label }}</span>
<a href="{{ route('videos.audio-track', ['video' => $video, 'track' => $track->id]) }}" target="_blank" style="font-size:12px;color:#888;" title="Preview"><i class="bi bi-play-circle"></i></a>
<input type="hidden" name="delete_track_ids[]" id="ep-del-{{ $track->id }}" value="" disabled>
<button type="button" onclick="_epMarkDelTrack({{ $track->id }}, this)" style="background:none;border:none;color:#888;cursor:pointer;padding:2px 4px;font-size:14px;" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
<input type="text" name="track_title_updates[{{ $track->id }}]"
value="{{ $track->title ?? '' }}"
placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;">
<x-rich-text-editor :name="'track_description_updates[' . $track->id . ']'"
:id="'ep-track-desc-' . $track->id"
:value="$track->description ?? ''"
min-height="72px"
placeholder="Description in this language…" />
<label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;"
onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i>
<span style="font-size:12px;color:#888;flex:1;" id="ep-track-fname-{{ $track->id }}">Keep existing / choose new file…</span>
<input type="file" name="track_file_updates[{{ $track->id }}]"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('ep-track-fname-{{ $track->id }}').textContent=this.files[0]?.name||'Keep existing / choose new file…'">
</label>
</div>
@empty
<p id="ep-tracks-empty-msg" style="font-size:12px;color:#888;margin:0 0 4px;">No extra language tracks yet.</p>
@endforelse
</div>
{{-- New tracks to add --}}
<div id="ep-tracks-new-list" style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px;"></div>
<button type="button" class="action-btn" onclick="addEpTrackRow(event)" style="font-size:13px;">
<i class="bi bi-plus-circle"></i> <span>Add Language Track</span>
</button>
</div>
@endif
{{-- Video Type --}}
<div class="options-section">
<span class="options-label">Video Type</span>
<div class="options-grid" id="edit-type-options">
@foreach([['generic','bi-film','Generic'],['music','bi-music-note','Music'],['match','bi-trophy','Match']] as [$val,$icon,$label])
<label class="option-item {{ ($video->type ?? 'generic') === $val ? 'active' : '' }}" data-type="{{ $val }}">
<input type="radio" name="type" value="{{ $val }}" {{ ($video->type ?? 'generic') === $val ? 'checked' : '' }}>
<div class="option-content">
<i class="bi {{ $icon }}"></i>
<span>{{ $label }}</span>
</div>
</label>
@endforeach
</div>
</div>
{{-- Privacy --}}
<div class="options-section">
<span class="options-label">Privacy</span>
<div class="options-grid" id="edit-visibility-options">
@foreach([['public','bi-globe','Public'],['unlisted','bi-link-45deg','Unlisted'],['private','bi-lock','Private']] as [$val,$icon,$label])
<label class="option-item {{ ($video->visibility ?? 'public') === $val ? 'active' : '' }}" data-privacy="{{ $val }}">
<input type="radio" name="visibility" value="{{ $val }}" {{ ($video->visibility ?? 'public') === $val ? 'checked' : '' }}>
<div class="option-content">
<i class="bi {{ $icon }}"></i>
<span>{{ $label }}</span>
</div>
</label>
@endforeach
</div>
</div>
{{-- Download Access --}}
<div class="options-section">
<span class="options-label"><i class="bi bi-download" style="color:var(--brand-red);margin-right:6px;"></i>Who can download?</span>
<div class="options-grid" id="edit-download-options">
@foreach([['disabled','bi-slash-circle','Disabled'],['everyone','bi-globe','Everyone'],['registered','bi-person-check','Members'],['subscribers','bi-star','Subscribers']] as [$val,$icon,$label])
<label class="option-item {{ ($video->download_access ?? 'disabled') === $val ? 'active' : '' }}" data-download="{{ $val }}">
<input type="radio" name="download_access" value="{{ $val }}" {{ ($video->download_access ?? 'disabled') === $val ? 'checked' : '' }}>
<div class="option-content">
<i class="bi {{ $icon }}"></i>
<span>{{ $label }}</span>
</div>
</label>
@endforeach
</div>
</div>
{{-- Status --}}
<div id="edit-status"></div>
{{-- Submit --}}
<button type="submit" id="edit-submit-btn" class="action-btn action-btn-primary btn-submit">
<i class="bi bi-check-lg"></i> <span>Save Changes</span>
</button>
</form>
{{-- Replace File --}}
<div class="danger-zone" style="margin-top:20px;">
<p style="color:#f87171;"><i class="bi bi-arrow-repeat" style="margin-right:6px;"></i>Replace Media File</p>
<p style="font-size:12px;color:#888;margin:-8px 0 14px;line-height:1.5;">
Fix a corrupted or missing file without losing any views, likes, or comments.
</p>
<div id="ep-rfl-dropzone" onclick="document.getElementById('ep-rfl-input').click()"
style="border:2px dashed #444;border-radius:10px;padding:18px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:10px;"
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
onmouseleave="this.style.borderColor='#444';this.style.background='transparent'">
<i class="bi bi-cloud-upload" style="font-size:26px;color:#666;display:block;margin-bottom:6px;"></i>
<span id="ep-rfl-label" style="font-size:13px;color:#888;">Tap to choose replacement file</span>
<input type="file" id="ep-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="epRflSelected(this)">
</div>
<div id="ep-rfl-info" style="display:none;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 14px;align-items:center;gap:10px;margin-bottom:10px;">
<i class="bi bi-file-earmark-play" style="font-size:20px;color:#ef4444;flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div id="ep-rfl-name" style="font-size:13px;font-weight:600;color:#e5e5e5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="ep-rfl-size" style="font-size:11px;color:#888;margin-top:2px;"></div>
</div>
<button type="button" onclick="epRflClear()" style="background:none;border:none;color:#888;cursor:pointer;font-size:16px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
</div>
<div id="ep-rfl-status" style="display:none;margin-bottom:10px;font-size:12px;padding:8px 12px;border-radius:6px;"></div>
<button type="button" id="ep-rfl-btn" onclick="epRflSubmit()" disabled
class="action-btn action-btn-danger" style="width:100%;justify-content:center;">
<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>
</button>
</div>
{{-- Danger zone --}}
<div class="danger-zone" style="margin-top:12px;">
<p>Danger Zone</p>
<form action="{{ route('videos.destroy', $video) }}" method="POST" id="delete-edit-form">
@csrf
@method('DELETE')
<button type="button" class="action-btn action-btn-danger"
onclick="showConfirm('Delete this video? This cannot be undone.', function(){ document.getElementById('delete-edit-form').submit(); }, 'Delete')">
<i class="bi bi-trash"></i> <span>Delete Video</span>
</button>
</form>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024, sizes = ['Bytes','KB','MB','GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
@if(!$video->isAudioOnly())
// ── Thumbnail dropzone (non-audio) ────────────────────────────
const editThumbInput = document.getElementById('edit-thumbnail');
const editThumbDropzone = document.getElementById('edit-thumbnail-dropzone');
editThumbDropzone.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove')) return;
if (typeof window.openCropperModal_thumb_edit_mobile === 'function') {
window.openCropperModal_thumb_edit_mobile();
const internal = document.getElementById('tcInput_thumb_edit_mobile');
if (internal) internal.click();
} else {
editThumbInput.click();
}
});
editThumbDropzone.addEventListener('dragover', e => { e.preventDefault(); editThumbDropzone.classList.add('dragover'); });
editThumbDropzone.addEventListener('dragleave', () => editThumbDropzone.classList.remove('dragover'));
editThumbDropzone.addEventListener('drop', e => {
e.preventDefault(); editThumbDropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_edit_mobile === 'function') {
window.tcPreload_thumb_edit_mobile(droppedFile);
window.openCropperModal_thumb_edit_mobile();
} else {
editThumbInput.files = e.dataTransfer.files;
handleEditThumbSelect();
}
}
});
editThumbInput.addEventListener('change', handleEditThumbSelect);
function handleEditThumbSelect() {
if (!editThumbInput.files || !editThumbInput.files[0]) return;
const file = editThumbInput.files[0];
document.getElementById('edit-thumb-filename').textContent = file.name;
document.getElementById('edit-thumb-filesize').textContent = formatFileSize(file.size);
const reader = new FileReader();
reader.onload = e => { document.getElementById('edit-thumb-preview-img').src = e.target.result; };
reader.readAsDataURL(file);
document.getElementById('edit-thumbnail-default').style.display = 'none';
document.getElementById('edit-thumbnail-info').classList.add('active');
}
function removeEditThumb(e) {
e.preventDefault(); e.stopPropagation();
editThumbInput.value = '';
document.getElementById('edit-thumbnail-default').style.display = 'block';
document.getElementById('edit-thumbnail-info').classList.remove('active');
}
@else
// ── Slides manager (audio tracks only) ────────────────────────
let epSlidesData = @json($video->slides->map(fn($s) => ['id' => $s->id, 'url' => route('media.thumbnail', $s->filename)])->values());
let epDragSrc = null;
function _epSyncOrder() {
const keptIds = epSlidesData.filter(s => s.id).map(s => s.id);
document.getElementById('ep-slides-order').value = JSON.stringify(keptIds);
const count = epSlidesData.length;
document.getElementById('ep-slides-hint').textContent =
count === 1 ? '1 image — static cover' : count + ' images — will crossfade';
}
function epSlidesRefresh() {
const strip = document.getElementById('ep-slides-strip');
const empty = document.getElementById('ep-slides-empty');
strip.querySelectorAll('.ep-item').forEach(el => el.remove());
empty.style.display = epSlidesData.length === 0 ? 'flex' : 'none';
epSlidesData.forEach((item) => {
const div = document.createElement('div');
div.className = 'ep-item';
div.draggable = true;
div._epData = item; // reference on element — avoids stale-index issues during drag
div.innerHTML = `
<div class="ep-drag-handle" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></div>
<img src="${item.url}" alt="">
<button type="button" class="ep-remove" title="Remove"><i class="bi bi-x-lg"></i></button>
`;
div.querySelector('.ep-remove').addEventListener('click', () => {
epSlidesData.splice(epSlidesData.indexOf(div._epData), 1);
epSlidesRefresh();
});
div.addEventListener('dragstart', e => {
epDragSrc = div;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => div.classList.add('ep-dragging'), 0);
});
// On dragend: sync epSlidesData from current DOM order, then update inputs
div.addEventListener('dragend', () => {
div.classList.remove('ep-dragging');
epDragSrc = null;
const newData = [];
strip.querySelectorAll('.ep-item').forEach(el => newData.push(el._epData));
epSlidesData = newData;
_epSyncOrder();
});
// On dragover: only move element in DOM — never re-render
div.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!epDragSrc || epDragSrc === div) return;
const items = [...strip.querySelectorAll('.ep-item')];
const srcPos = items.indexOf(epDragSrc);
const tgtPos = items.indexOf(div);
if (srcPos < tgtPos) div.after(epDragSrc);
else div.before(epDragSrc);
});
strip.appendChild(div);
});
_epSyncOrder();
}
document.getElementById('ep-slides-add-input').addEventListener('change', function() {
epSlidesCropStart(this.files);
this.value = '';
});
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _epSlidesCropQueue = [];
function epSlidesCropStart(fileList) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_epSlidesCropQueue = imgs;
_epSlidesCropLoadNext();
}
function _epSlidesCropLoadNext() {
if (!_epSlidesCropQueue.length) { window.closeCropperModal('slides_edit_mobile'); return; }
const f = _epSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit_mobile === 'function') {
window.tcPreload_slides_edit_mobile(f);
window.openCropperModal_slides_edit_mobile();
}
}
window.epSlidesCropDone = function(file) {
if (file) {
const reader = new FileReader();
reader.onload = e => {
epSlidesData.push({ file, url: e.target.result });
epSlidesRefresh();
_epSlidesCropLoadNext();
};
reader.readAsDataURL(file);
return;
}
_epSlidesCropLoadNext();
};
// Initialise strip with existing slides
epSlidesRefresh();
@endif
// ── Option selectors ──────────────────────────────────────────
document.querySelectorAll('#edit-type-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#edit-type-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
document.querySelectorAll('#edit-visibility-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#edit-visibility-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
document.querySelectorAll('#edit-download-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#edit-download-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// ── Form submission ───────────────────────────────────────────
document.getElementById('edit-form').addEventListener('submit', function(e) {
e.preventDefault();
const title = document.getElementById('edit-title').value.trim();
if (!title) { showEditError('Please enter a title.'); return; }
const btn = document.getElementById('edit-submit-btn');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Saving...';
document.getElementById('edit-status').className = '';
let formData = new FormData(this);
@if($video->isAudioOnly())
// Append newly added slide files
epSlidesData.filter(s => s.file).forEach(s => formData.append('slides_add[]', s.file, s.file.name));
// Append new extra track files (already have name="extra_track_files[]" in DOM)
// delete_track_ids are already wired via enabled hidden inputs
@endif
fetch('{{ route("videos.update", $video) }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
.then(r => r.json())
.then(data => {
if (data.success) {
const status = document.getElementById('edit-status');
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
status.className = 'success';
setTimeout(() => window.location.href = '{{ route("videos.show", $video) }}', 1000);
} else {
showEditError(data.message || 'Update failed.');
}
})
.catch(() => showEditError('Something went wrong. Please try again.'));
});
@if($video->isAudioOnly())
// ── Language track management ─────────────────────────────────────────────
const EP_LANG_OPTIONS = @json(\App\Data\Languages::forLanguage());
let _epTrackCounter = 0;
function _epMarkDelTrack(trackId, btn) {
const hiddenInput = document.getElementById('ep-del-' + trackId);
if (hiddenInput) {
hiddenInput.value = trackId;
hiddenInput.disabled = false;
}
btn.closest('[data-track-id]').remove();
const existing = document.getElementById('ep-tracks-existing');
if (existing && existing.querySelectorAll('[data-track-id]').length === 0) {
let msg = document.getElementById('ep-tracks-empty-msg');
if (!msg) {
msg = document.createElement('p');
msg.id = 'ep-tracks-empty-msg';
msg.style.cssText = 'font-size:12px;color:#888;margin:0 0 4px;';
msg.textContent = 'No extra language tracks yet.';
existing.appendChild(msg);
}
}
}
function buildLangSelectHtmlEp(uid) {
const opts = EP_LANG_OPTIONS.map(o =>
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
<span class="csd-opt-main">${o.label}</span>
<span class="csd-opt-sub">${o.native}</span>
</li>`
).join('');
return `<div class="csd-wrap" id="csd_${uid}">
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
<span class="csd-val ph">Select language</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch"><i class="bi bi-search"></i><input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false"></div>
<ul class="csd-list">${opts}</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
</div>`;
}
function addEpTrackRow(e) {
if (e) e.preventDefault();
const uid = 'epet_' + (++_epTrackCounter);
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-direction:column;gap:8px;background:#151515;border:1px solid #333;border-radius:8px;padding:10px 12px;';
row.innerHTML = `
<div style="display:flex;gap:8px;align-items:center;">
<div style="flex:1;min-width:0;">${buildLangSelectHtmlEp(uid)}</div>
<button type="button" onclick="this.closest('[style*=flex-direction]').remove();" style="background:none;border:none;color:#888;cursor:pointer;padding:4px;font-size:16px;flex-shrink:0;" title="Remove">
<i class="bi bi-x-lg"></i>
</button>
</div>
<input type="text" name="extra_track_titles[]"
placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;">
<label style="display:flex;align-items:center;gap:8px;background:#121212;border:1px solid #333;border-radius:6px;padding:8px 10px;cursor:pointer;">
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;"></i>
<span class="et-filename-${uid}" style="font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;">Choose audio file</span>
<input type="file" name="extra_track_files[]" accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="this.parentNode.querySelector('.et-filename-${uid}').textContent=this.files[0]?.name||'Choose audio file';">
</label>`;
document.getElementById('ep-tracks-new-list').appendChild(row);
if (window.CSD) new CSD('csd_' + uid);
}
@endif
function showEditError(message) {
const status = document.getElementById('edit-status');
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
status.className = 'error';
const btn = document.getElementById('edit-submit-btn');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-lg"></i> <span>Save Changes</span>';
}
// ── Replace File ──────────────────────────────────────────────────────
let _epRflFile = null;
const _epRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
function epRflSelected(input) {
_epRflFile = input.files[0] || null;
const info = document.getElementById('ep-rfl-info');
if (_epRflFile) {
document.getElementById('ep-rfl-name').textContent = _epRflFile.name;
document.getElementById('ep-rfl-size').textContent = _epRflFmtSize(_epRflFile.size);
document.getElementById('ep-rfl-label').textContent = _epRflFile.name;
info.style.display = 'flex';
} else {
info.style.display = 'none';
document.getElementById('ep-rfl-label').textContent = 'Tap to choose replacement file';
}
document.getElementById('ep-rfl-btn').disabled = !_epRflFile;
document.getElementById('ep-rfl-status').style.display = 'none';
}
function epRflClear() {
_epRflFile = null;
document.getElementById('ep-rfl-input').value = '';
document.getElementById('ep-rfl-info').style.display = 'none';
document.getElementById('ep-rfl-label').textContent = 'Tap to choose replacement file';
document.getElementById('ep-rfl-btn').disabled = true;
document.getElementById('ep-rfl-status').style.display = 'none';
}
function epRflSubmit() {
if (!_epRflFile) return;
const btn = document.getElementById('ep-rfl-btn');
const status = document.getElementById('ep-rfl-status');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
status.style.display = 'none';
const fd = new FormData();
fd.append('_token', '{{ csrf_token() }}');
fd.append('replacement_file', _epRflFile);
fetch('{{ route("videos.replaceFile", $video) }}', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.cssText = 'display:block;background:rgba(74,222,128,.1);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
epRflClear();
setTimeout(() => location.reload(), 2000);
} else {
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = data.message || 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>';
}
})
.catch(() => {
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>';
});
}
</script>
<x-image-cropper
id="thumb_edit_mobile"
:width="448"
:height="252"
shape="square"
target-input="edit-thumbnail"
preview-img="edit-thumbnail-preview-img"
output-width="1280"
title="Crop Thumbnail"
/>
<x-image-cropper
id="slides_edit_mobile"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="epSlidesCropDone"
/>
@endsection