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:
ghassanandClaude Opus 4.7 committed 2026-07-31 02:42:25 +03:00
1 parent af276f2cd1
commit ba8ab11ce4
12 files changed
+547 -49

No files matched your search

@@ -237,9 +237,22 @@
.then(data => {
if (data.success && data.playlists && data.playlists.length > 0) {
let html = '';
// Language-track the modal is scoped to (0 = primary).
// Read once here so both the "already in?" check and the
// toggle call use the same value.
const currentTrackId = parseInt(window._ytpTrackId || 0);
data.playlists.forEach(function(playlist) {
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(
videoId));
// Prefer entries[] (per-track granularity) when present.
// Fall back to video_ids for older API responses.
let isInPlaylist = false;
if (Array.isArray(playlist.entries)) {
isInPlaylist = playlist.entries.some(function (e) {
return e.video_id === parseInt(videoId)
&& (e.audio_track_id || 0) === currentTrackId;
});
} else if (playlist.video_ids) {
isInPlaylist = playlist.video_ids.includes(parseInt(videoId));
}
const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
const durationText = playlist.formatted_duration || '0m';
html += `
@@ -328,7 +341,11 @@
// of the encoded route key used by GET /videos/{video}.
var url = `/playlists/${playlistId}/videos`;
var method = currentlyIn ? 'DELETE' : 'POST';
var body = JSON.stringify({ video_id: videoId });
// Pin the row to the currently-active audio track (0 = primary/null).
var trackId = parseInt(window._ytpTrackId || 0);
var payload = { video_id: videoId };
if (trackId > 0) payload.audio_track_id = trackId;
var body = JSON.stringify(payload);
fetch(url, {
method,
@@ -980,6 +980,14 @@ document.getElementById('edit-form').addEventListener('submit', function(e) {
// Append delete IDs
_editDeleteTrackIds.forEach(id => formData.append('delete_track_ids[]', id));
// Ordered list of track IDs as displayed. Index 0 is the primary slot;
// "primary" is the sentinel for the original primary track row, since
// the primary isn't a video_audio_tracks row of its own.
Array.from(document.querySelectorAll('#edit-tc-list > .um-track-card')).forEach(card => {
const val = (card.id === 'edit-tc-t1-card') ? 'primary' : (card.dataset.trackId || '');
if (val) formData.append('track_order[]', val);
});
// ── Log tracker: dump the exact track-related payload being submitted ──────
(function () {
const promoteId = document.getElementById('edit-promote-track-id')?.value || '(none)';
@@ -1626,6 +1626,42 @@ function removeExtraTrackModal(n) {
}
}());
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
async function _chunkedUploadVideo(file, onProgress) {
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit
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-modal').addEventListener('submit', function(e) {
e.preventDefault();
@@ -1685,11 +1721,39 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
document.getElementById('submit-btn-modal').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
document.getElementById('status-message-modal').className = 'status-message-modal';
// The video file is the big one — upload it in 8 MB chunks first, then
// submit the small form (thumbnails/slides/text) as the final request.
const _videoFile = videoInput.files[0];
formData.delete('video');
const _pbar = document.getElementById('progress-bar-modal');
const _ptext = document.getElementById('progress-text-modal');
_pbar.style.width = '0%';
_ptext.textContent = 'Uploading… 0%';
_chunkedUploadVideo(_videoFile, function(p) {
// Chunk upload accounts for 0–95% of the visible progress.
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) {
_uploadInProgress = false;
_showUploadError(err && err.message ? err.message : 'Chunked upload failed');
});
xhr.upload.addEventListener('progress', function(ev) {
if (ev.lengthComputable) {
const pct = Math.round((ev.loaded / ev.total) * 100);
document.getElementById('progress-bar-modal').style.width = pct + '%';
document.getElementById('progress-text-modal').textContent = 'Uploading… ' + pct + '%';
if (ev.lengthComputable && ev.total > 0) {
const pct = 96 + Math.round((ev.loaded / ev.total) * 3);
_pbar.style.width = pct + '%';
}
});
@@ -1730,10 +1794,6 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
xhr.addEventListener('error', () => { _uploadInProgress = false; _showUploadError('Upload failed. Please check your connection.'); });
xhr.addEventListener('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); });
xhr.addEventListener('abort', () => { _uploadInProgress = false; _showUploadError('Upload was cancelled.'); });
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
});
function _showUploadError(message) {