Chunked video upload, persistent track order, per-track playlist entries
Chunked uploads: bypass Cloudflare's 100 MB request cap by slicing the video/audio into 8 MB chunks and reassembling server-side before the normal store flow runs. Track reorder: audio tracks gain a `position` column so a user's edit- modal ordering survives reload. `Video::audioTracks()` now orders by position, `promote_track_id` remembers the swapped row, and freshly uploaded tracks append to the end. Playlist per-track pinning: `playlist_videos.audio_track_id` remembers which language the user pinned when adding a song. Same song with a different track becomes a separate row (Spotify-style duplicates). Player-data exposes `active_stream_url` + `active_track_id`, sidebar cards render `?track=` from the pivot, and both `plTransitionTo` and initial-load code activate the corresponding language option. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
af276f2cd1
commit
ba8ab11ce4
12 files changed
+547
-49
No files matched your search
@@ -798,6 +798,42 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB cap) ─
|
||||
async function _cChunkedUploadVideo(file, onProgress) {
|
||||
const CHUNK = 8 * 1024 * 1024;
|
||||
const total = Math.max(1, Math.ceil(file.size / CHUNK));
|
||||
const uploadId = (window.crypto && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const start = i * CHUNK;
|
||||
const end = Math.min(start + CHUNK, file.size);
|
||||
const blob = file.slice(start, end);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('upload_id', uploadId);
|
||||
fd.append('chunk_index', i);
|
||||
fd.append('total_chunks', total);
|
||||
fd.append('filename', file.name);
|
||||
fd.append('chunk', blob, 'chunk');
|
||||
|
||||
const res = await fetch('{{ route("videos.uploadChunk") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = 'Chunk ' + (i + 1) + '/' + total + ' failed (' + res.status + ')';
|
||||
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (onProgress) onProgress({ loaded: end, total: file.size });
|
||||
}
|
||||
return { uploadId: uploadId, filename: file.name };
|
||||
}
|
||||
|
||||
// ── Form submission ───────────────────────────────────────
|
||||
document.getElementById('upload-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -845,11 +881,34 @@
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
||||
document.getElementById('status-message').className = '';
|
||||
|
||||
// Chunk-upload the (potentially huge) video/audio file first, then
|
||||
// send the small form + upload_id. Keeps every request <100 MB.
|
||||
formData.delete('video');
|
||||
const _pbar = document.getElementById('progress-bar');
|
||||
const _ptext = document.getElementById('progress-text');
|
||||
_pbar.style.width = '0%';
|
||||
_ptext.textContent = 'Uploading... 0%';
|
||||
|
||||
_cChunkedUploadVideo(currentFile, function(p) {
|
||||
const pct = Math.round((p.loaded / p.total) * 95);
|
||||
_pbar.style.width = pct + '%';
|
||||
_ptext.textContent = 'Uploading... ' + pct + '%';
|
||||
}).then(function(res) {
|
||||
formData.append('video_upload_id', res.uploadId);
|
||||
formData.append('video_filename', res.filename);
|
||||
_pbar.style.width = '96%';
|
||||
_ptext.textContent = 'Finalising...';
|
||||
xhr.open('POST', '{{ route("videos.store") }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
xhr.send(formData);
|
||||
}).catch(function(err) {
|
||||
showError(err && err.message ? err.message : 'Chunked upload failed');
|
||||
});
|
||||
|
||||
xhr.upload.addEventListener('progress', function(e) {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
document.getElementById('progress-bar').style.width = pct + '%';
|
||||
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
|
||||
if (e.lengthComputable && e.total > 0) {
|
||||
const pct = 96 + Math.round((e.loaded / e.total) * 3);
|
||||
_pbar.style.width = pct + '%';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -886,10 +945,6 @@
|
||||
|
||||
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
|
||||
xhr.addEventListener('timeout', () => showError('Upload timed out.'));
|
||||
|
||||
xhr.open('POST', '{{ route("videos.store") }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
|
||||
Reference in new issue
Block a user