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>
826 lines
42 KiB
PHP
826 lines
42 KiB
PHP
@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
|