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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -576,10 +576,14 @@
|
||||
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
||||
|
||||
// swap audio src (keeps browser autoplay permission)
|
||||
// Playlist rows may pin a specific language track — prefer
|
||||
// active_stream_url when present so we play that one instead
|
||||
// of the primary.
|
||||
var audio = document.getElementById('audioEl');
|
||||
var srcToPlay = d.active_stream_url || d.stream_url;
|
||||
if (audio) {
|
||||
var _savedVol = audio.volume; var _savedMuted = audio.muted;
|
||||
audio.src = d.stream_url; audio.load();
|
||||
audio.src = srcToPlay; audio.load();
|
||||
audio.volume = _savedVol; audio.muted = _savedMuted;
|
||||
}
|
||||
|
||||
@@ -587,6 +591,23 @@
|
||||
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
|
||||
catch(e) { console.warn('_audioPlayerUpdate', e); }
|
||||
|
||||
// After the popup is rebuilt, mark the pinned language
|
||||
// option as active so the flag/lyrics/share reflect it.
|
||||
if (d.active_track_id) {
|
||||
try {
|
||||
var pop = document.getElementById('ytpLangPopup');
|
||||
var opt = pop && pop.querySelector('.ytp-lang-option[data-lang-id="' + d.active_track_id + '"]');
|
||||
if (opt) {
|
||||
pop.querySelectorAll('.ytp-lang-option').forEach(function(o){ o.classList.remove('active'); });
|
||||
opt.classList.add('active');
|
||||
window._ytpTrackId = parseInt(d.active_track_id, 10) || 0;
|
||||
var lb = document.getElementById('ytpLangBtn') || document.querySelector('.ytp-lang-btn');
|
||||
var flag = opt.dataset.langFlag;
|
||||
if (lb && flag) lb.innerHTML = '<span class="fi fi-' + flag + '" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>';
|
||||
}
|
||||
} catch(e) { console.warn('active_track_id apply', e); }
|
||||
}
|
||||
|
||||
// update state
|
||||
PL_CURRENT = d.id;
|
||||
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
|
||||
@@ -699,6 +720,23 @@
|
||||
if(a&&plLoop==='one') a.loop=true;
|
||||
plRender();
|
||||
plHighlight(PL_CURRENT, false);
|
||||
|
||||
// Honour ?track=ID on initial load — same effect as the
|
||||
// pin logic in plTransitionTo, but applied to the server-
|
||||
// rendered primary track on first paint.
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var initTrack = parseInt(params.get('track') || '0', 10);
|
||||
if (initTrack > 0) {
|
||||
var applyPin = function() {
|
||||
var pop = document.getElementById('ytpLangPopup');
|
||||
var opt = pop && pop.querySelector('.ytp-lang-option[data-lang-id="' + initTrack + '"]');
|
||||
if (opt) { opt.click(); return true; }
|
||||
return false;
|
||||
};
|
||||
if (!applyPin()) setTimeout(applyPin, 300);
|
||||
}
|
||||
} catch(e) { console.warn('initial track pin', e); }
|
||||
}
|
||||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit();
|
||||
})();
|
||||
@@ -707,9 +745,14 @@
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||
@php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp
|
||||
@php
|
||||
$pinnedTrackId = $playlistVideo->pivot->audio_track_id ?? null;
|
||||
$trackQs = $pinnedTrackId ? '&track=' . $pinnedTrackId : '';
|
||||
@endphp
|
||||
<div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}"
|
||||
data-pl-id="{{ $playlistVideo->id }}"
|
||||
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}')"
|
||||
data-pl-track-id="{{ $pinnedTrackId ?? '' }}"
|
||||
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}{{ $trackQs }}')"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
|
||||
Reference in new issue
Block a user