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

+46 -7
View File
@@ -34,7 +34,7 @@ class Playlist extends Model
public function videos()
{
return $this->belongsToMany(Video::class, 'playlist_videos')
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
->withPivot('position', 'audio_track_id', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
->orderBy('position');
}
@@ -296,24 +296,48 @@ class Playlist extends Model
}
}
// Add video to playlist
public function addVideo(Video $video)
// Add video to playlist. When $audioTrackId is null the primary track
// plays; otherwise the specified language track is pinned to this row.
// Same video with a *different* track becomes a separate row.
public function addVideo(Video $video, ?int $audioTrackId = null)
{
if ($this->hasVideo($video)) {
if ($this->hasVideoWithTrack($video, $audioTrackId)) {
return false;
}
$maxPosition = $this->videos()->max('position') ?? -1;
$this->videos()->attach($video->id, [
'position' => $maxPosition + 1,
'added_at' => now(),
'position' => $maxPosition + 1,
'audio_track_id' => $audioTrackId,
'added_at' => now(),
]);
return true;
}
// Remove video from playlist
// Remove a specific (video, track) row from the playlist. When
// $audioTrackId is null, only the "primary track" row is removed —
// language-pinned rows for the same video are left in place.
public function removeVideoWithTrack(Video $video, ?int $audioTrackId = null)
{
$query = \DB::table('playlist_videos')
->where('playlist_id', $this->id)
->where('video_id', $video->id);
if ($audioTrackId === null) {
$query->whereNull('audio_track_id');
} else {
$query->where('audio_track_id', $audioTrackId);
}
$deleted = $query->delete();
if ($deleted) $this->reorderPositions();
return (bool) $deleted;
}
// Remove *all* rows for a given video (any track). Preserved for
// callers that don't care about the track distinction.
public function removeVideo(Video $video)
{
if (! $this->hasVideo($video)) {
@@ -328,6 +352,21 @@ class Playlist extends Model
return true;
}
// Does this playlist contain (video, track)? track=null matches the
// "primary track" row specifically, not any row for the video.
public function hasVideoWithTrack(Video $video, ?int $audioTrackId = null): bool
{
$query = \DB::table('playlist_videos')
->where('playlist_id', $this->id)
->where('video_id', $video->id);
if ($audioTrackId === null) {
$query->whereNull('audio_track_id');
} else {
$query->where('audio_track_id', $audioTrackId);
}
return $query->exists();
}
// Reorder positions after removal
protected function reorderPositions()
{