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) <noreply@anthropic.com>
This commit is contained in:
parent
af276f2cd1
commit
ba8ab11ce4
@ -323,7 +323,8 @@ class PlaylistController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'video_id' => 'required|exists:videos,id',
|
'video_id' => 'required|exists:videos,id',
|
||||||
|
'audio_track_id' => 'nullable|integer|exists:video_audio_tracks,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$video = Video::findOrFail($request->video_id);
|
$video = Video::findOrFail($request->video_id);
|
||||||
@ -333,7 +334,13 @@ class PlaylistController extends Controller
|
|||||||
abort(403, 'You cannot add this video to your playlist.');
|
abort(403, 'You cannot add this video to your playlist.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$added = $playlist->addVideo($video);
|
// Track must actually belong to this video (avoid arbitrary FK pinning).
|
||||||
|
$audioTrackId = $request->input('audio_track_id');
|
||||||
|
if ($audioTrackId && ! $video->audioTracks()->whereKey($audioTrackId)->exists()) {
|
||||||
|
$audioTrackId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = $playlist->addVideo($video, $audioTrackId ? (int) $audioTrackId : null);
|
||||||
|
|
||||||
if ($request->expectsJson() || $request->ajax()) {
|
if ($request->expectsJson() || $request->ajax()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -353,9 +360,30 @@ class PlaylistController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function removeVideoByBody(Request $request, Playlist $playlist)
|
public function removeVideoByBody(Request $request, Playlist $playlist)
|
||||||
{
|
{
|
||||||
$request->validate(['video_id' => 'required|exists:videos,id']);
|
$request->validate([
|
||||||
|
'video_id' => 'required|exists:videos,id',
|
||||||
|
'audio_track_id' => 'nullable|integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $playlist->canEdit(Auth::user())) {
|
||||||
|
abort(403, 'You do not have permission to edit this playlist.');
|
||||||
|
}
|
||||||
|
|
||||||
$video = Video::findOrFail($request->video_id);
|
$video = Video::findOrFail($request->video_id);
|
||||||
return $this->removeVideo($request, $playlist, $video);
|
$trackId = $request->filled('audio_track_id')
|
||||||
|
? (int) $request->input('audio_track_id')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$removed = $playlist->removeVideoWithTrack($video, $trackId);
|
||||||
|
|
||||||
|
if ($request->expectsJson() || $request->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $removed ? 'Video removed from playlist.' : 'Video was not in the playlist.',
|
||||||
|
'video_count' => $playlist->video_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return back()->with('success', 'Video removed from playlist.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove video from playlist
|
// Remove video from playlist
|
||||||
@ -431,6 +459,17 @@ class PlaylistController extends Controller
|
|||||||
'visibility' => $p->visibility,
|
'visibility' => $p->visibility,
|
||||||
'thumbnail_url' => $p->thumbnail_url,
|
'thumbnail_url' => $p->thumbnail_url,
|
||||||
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
|
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
|
||||||
|
// Track-aware entries: each row = {video_id, audio_track_id or null}.
|
||||||
|
// Consumers that need per-track granularity use this; older callers
|
||||||
|
// can still rely on video_ids above.
|
||||||
|
'entries' => \DB::table('playlist_videos')
|
||||||
|
->where('playlist_id', $p->id)
|
||||||
|
->select('video_id', 'audio_track_id')
|
||||||
|
->get()
|
||||||
|
->map(fn ($r) => [
|
||||||
|
'video_id' => (int) $r->video_id,
|
||||||
|
'audio_track_id' => $r->audio_track_id ? (int) $r->audio_track_id : null,
|
||||||
|
])->all(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -131,9 +131,92 @@ class VideoController extends Controller
|
|||||||
return view('videos.create');
|
return view('videos.create');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive one chunk of a large upload. Client posts chunks of ~8 MB
|
||||||
|
* (well under Cloudflare's 100 MB per-request cap) sequentially; server
|
||||||
|
* appends each to a per-upload buffer under data/app/tmp/uploads/{uid}/.
|
||||||
|
* When the final `store` call is made with `video_upload_id`, the
|
||||||
|
* assembled buffer is promoted to the real UploadedFile.
|
||||||
|
*/
|
||||||
|
public function uploadChunk(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'upload_id' => ['required', 'string', 'regex:/^[A-Za-z0-9\-]{8,64}$/'],
|
||||||
|
'chunk_index' => 'required|integer|min:0',
|
||||||
|
'total_chunks' => 'required|integer|min:1|max:100000',
|
||||||
|
'filename' => 'required|string|max:255',
|
||||||
|
'chunk' => 'required|file',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$uid = Auth::id();
|
||||||
|
$uploadId = $request->input('upload_id');
|
||||||
|
$idx = (int) $request->input('chunk_index');
|
||||||
|
$total = (int) $request->input('total_chunks');
|
||||||
|
$filename = basename($request->input('filename'));
|
||||||
|
|
||||||
|
$dir = storage_path("app/tmp/uploads/{$uid}");
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Cannot create upload buffer'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$partPath = "{$dir}/{$uploadId}.part";
|
||||||
|
$mode = $idx === 0 ? 'wb' : 'ab';
|
||||||
|
|
||||||
|
$out = @fopen($partPath, $mode);
|
||||||
|
if (!$out) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Cannot open upload buffer'], 500);
|
||||||
|
}
|
||||||
|
$in = fopen($request->file('chunk')->getRealPath(), 'rb');
|
||||||
|
while (!feof($in)) fwrite($out, fread($in, 1 << 20));
|
||||||
|
fclose($in);
|
||||||
|
fclose($out);
|
||||||
|
|
||||||
|
@file_put_contents("{$dir}/{$uploadId}.meta", json_encode([
|
||||||
|
'filename' => $filename,
|
||||||
|
'total_chunks' => $total,
|
||||||
|
'received' => $idx + 1,
|
||||||
|
'updated_at' => time(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'chunk_index' => $idx,
|
||||||
|
'received' => $idx + 1,
|
||||||
|
'total_chunks' => $total,
|
||||||
|
'complete' => ($idx + 1) === $total,
|
||||||
|
'size' => filesize($partPath),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Reassemble chunked upload (see uploadChunk). Must run BEFORE any
|
||||||
|
// call to $request->file()/hasFile() — Laravel caches convertedFiles
|
||||||
|
// on first access, and a stale cache would ignore our injected file.
|
||||||
|
// Using $request->files->has() (Symfony FileBag) does not trigger
|
||||||
|
// that cache, so it's safe to check here.
|
||||||
|
if ($request->filled('video_upload_id') && !$request->files->has('video')) {
|
||||||
|
$uid = Auth::id();
|
||||||
|
$uploadId = preg_replace('/[^A-Za-z0-9\-]/', '', (string) $request->input('video_upload_id'));
|
||||||
|
$filename = basename((string) $request->input('video_filename', 'upload.mp4'));
|
||||||
|
$partPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.part");
|
||||||
|
$metaPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.meta");
|
||||||
|
|
||||||
|
if (!$uploadId || !is_file($partPath)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Upload buffer not found — please retry.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = @mime_content_type($partPath) ?: 'application/octet-stream';
|
||||||
|
$request->files->set('video', new \Illuminate\Http\UploadedFile(
|
||||||
|
$partPath, $filename, $mime, null, true
|
||||||
|
));
|
||||||
|
@unlink($metaPath);
|
||||||
|
}
|
||||||
|
|
||||||
$audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
|
$audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
|
||||||
$uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? '');
|
$uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? '');
|
||||||
$isAudioUpload = in_array($uploadedExt, $audioExtensions);
|
$isAudioUpload = in_array($uploadedExt, $audioExtensions);
|
||||||
@ -614,6 +697,13 @@ class VideoController extends Controller
|
|||||||
? route('media.thumbnail', $video->thumbnail)
|
? route('media.thumbnail', $video->thumbnail)
|
||||||
: asset('storage/images/logo.png');
|
: asset('storage/images/logo.png');
|
||||||
|
|
||||||
|
// Requested language track (0/absent = primary). Only honoured when
|
||||||
|
// the id actually belongs to this video.
|
||||||
|
$requestedTrackId = (int) $request->query('track', 0);
|
||||||
|
$requestedTrack = $requestedTrackId
|
||||||
|
? $video->audioTracks->firstWhere('id', $requestedTrackId)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Per-track slide map (key "0" = primary). Each entry already has the
|
// Per-track slide map (key "0" = primary). Each entry already has the
|
||||||
// sharing fallback applied by Video::slidesForTrack — a track without its
|
// sharing fallback applied by Video::slidesForTrack — a track without its
|
||||||
// own slides borrows the primary's (or a sibling's) automatically.
|
// own slides borrows the primary's (or a sibling's) automatically.
|
||||||
@ -623,7 +713,9 @@ class VideoController extends Controller
|
|||||||
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
|
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
|
||||||
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
|
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
|
||||||
}
|
}
|
||||||
$slides = $slideMap['0'];
|
$slides = $requestedTrack
|
||||||
|
? ($slideMap[(string) $requestedTrack->id] ?: $slideMap['0'])
|
||||||
|
: $slideMap['0'];
|
||||||
|
|
||||||
$allLangData = \App\Data\Languages::all();
|
$allLangData = \App\Data\Languages::all();
|
||||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||||
@ -652,6 +744,15 @@ class VideoController extends Controller
|
|||||||
'has_hls' => (bool) $video->has_hls,
|
'has_hls' => (bool) $video->has_hls,
|
||||||
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
|
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
|
||||||
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
|
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
|
||||||
|
// When a specific language track is requested (playlist row pin,
|
||||||
|
// shared link with ?track=), expose it separately. The player uses
|
||||||
|
// this for the actual <audio> src while stream_url stays as the
|
||||||
|
// primary, so the language popup still lists the true primary.
|
||||||
|
'active_track_id' => $requestedTrack->id ?? null,
|
||||||
|
'active_stream_url' => $requestedTrack
|
||||||
|
? route('videos.audio-track', ['video' => $video, 'track' => $requestedTrack->id])
|
||||||
|
. '?v=' . $requestedTrack->updated_at->timestamp
|
||||||
|
: null,
|
||||||
'cover_url' => $coverUrl,
|
'cover_url' => $coverUrl,
|
||||||
'slides' => $slides,
|
'slides' => $slides,
|
||||||
'slide_map' => $slideMap,
|
'slide_map' => $slideMap,
|
||||||
@ -959,6 +1060,12 @@ class VideoController extends Controller
|
|||||||
'promote_track_id' => 'nullable|integer',
|
'promote_track_id' => 'nullable|integer',
|
||||||
'delete_track_ids' => 'nullable|array',
|
'delete_track_ids' => 'nullable|array',
|
||||||
'delete_track_ids.*' => 'integer',
|
'delete_track_ids.*' => 'integer',
|
||||||
|
// Ordered list of track IDs as they appear in the edit UI. Index
|
||||||
|
// 0 is the primary; the value at each index is either a numeric
|
||||||
|
// track id (existing secondary) or the literal string "primary"
|
||||||
|
// for the original primary. Missing = no reorder was performed.
|
||||||
|
'track_order' => 'nullable|array',
|
||||||
|
'track_order.*' => 'nullable|string|max:32',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$oldTitle = $video->title;
|
$oldTitle = $video->title;
|
||||||
@ -1212,7 +1319,9 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Promote a secondary track to primary (swap metadata)
|
// Promote a secondary track to primary (swap metadata)
|
||||||
if ($promoteId = (int) $request->input('promote_track_id')) {
|
$promoteId = (int) $request->input('promote_track_id');
|
||||||
|
$newSecondaryId = null; // set below if a promote happens
|
||||||
|
if ($promoteId) {
|
||||||
$promoteTrack = $video->audioTracks()->find($promoteId);
|
$promoteTrack = $video->audioTracks()->find($promoteId);
|
||||||
if ($promoteTrack) {
|
if ($promoteTrack) {
|
||||||
\Log::info('Track promote: swapping primary ↔ secondary', [
|
\Log::info('Track promote: swapping primary ↔ secondary', [
|
||||||
@ -1221,7 +1330,7 @@ class VideoController extends Controller
|
|||||||
'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
|
'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
|
||||||
]);
|
]);
|
||||||
// Create new secondary track from current primary metadata
|
// Create new secondary track from current primary metadata
|
||||||
VideoAudioTrack::create([
|
$newSecondary = VideoAudioTrack::create([
|
||||||
'video_id' => $video->id,
|
'video_id' => $video->id,
|
||||||
'language' => $video->language ?? 'en',
|
'language' => $video->language ?? 'en',
|
||||||
'label' => strtoupper($video->language ?? 'en'),
|
'label' => strtoupper($video->language ?? 'en'),
|
||||||
@ -1230,6 +1339,7 @@ class VideoController extends Controller
|
|||||||
'path' => $video->path,
|
'path' => $video->path,
|
||||||
'filename' => $video->filename,
|
'filename' => $video->filename,
|
||||||
]);
|
]);
|
||||||
|
$newSecondaryId = $newSecondary->id;
|
||||||
// Override $data with the promoted track's values
|
// Override $data with the promoted track's values
|
||||||
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
|
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
|
||||||
$data['language'] = $promoteTrack->language;
|
$data['language'] = $promoteTrack->language;
|
||||||
@ -1242,6 +1352,40 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the user's reordered track list. `track_order` is the
|
||||||
|
// ordered list of items in the edit UI, index 0 being the primary.
|
||||||
|
// Each entry is either the literal string "primary" (the original
|
||||||
|
// primary track row placeholder) or a numeric secondary-track id.
|
||||||
|
$trackOrder = $request->input('track_order', []);
|
||||||
|
if (is_array($trackOrder) && count($trackOrder) > 0) {
|
||||||
|
foreach ($trackOrder as $i => $val) {
|
||||||
|
if ($i === 0) continue; // slot 0 is the primary, not a track row
|
||||||
|
if ($val === 'primary') {
|
||||||
|
// The old primary was demoted to secondary in the promote
|
||||||
|
// block above; write its new position.
|
||||||
|
if ($newSecondaryId) {
|
||||||
|
VideoAudioTrack::where('id', $newSecondaryId)->update(['position' => $i]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tid = (int) $val;
|
||||||
|
if ($tid <= 0 || $tid === $promoteId) continue; // promoted id is now the primary
|
||||||
|
VideoAudioTrack::where('id', $tid)
|
||||||
|
->where('video_id', $video->id)
|
||||||
|
->update(['position' => $i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any tracks that were freshly created in this same request (e.g.
|
||||||
|
// brand-new secondaries uploaded via extra_track_files) still sit at
|
||||||
|
// position=0 because the client couldn't know their DB id in advance.
|
||||||
|
// Append them to the end of the list so the ordering stays stable.
|
||||||
|
$maxPos = (int) $video->audioTracks()->max('position');
|
||||||
|
foreach ($video->audioTracks()->where('position', 0)->orderBy('id')->get() as $t) {
|
||||||
|
$maxPos++;
|
||||||
|
$t->update(['position' => $maxPos]);
|
||||||
|
}
|
||||||
|
|
||||||
$video->update($data);
|
$video->update($data);
|
||||||
|
|
||||||
// If the title changed, rename the NAS folder and update meta.json.
|
// If the title changed, rename the NAS folder and update meta.json.
|
||||||
@ -2989,7 +3133,7 @@ class VideoController extends Controller
|
|||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar
|
'avatar' => $u->avatar
|
||||||
? route('media.avatar', $u->avatar)
|
? route('media.avatar', $u->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
: asset('images/default-avatar.svg'),
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
'device' => $ua['device'],
|
'device' => $ua['device'],
|
||||||
@ -3044,7 +3188,7 @@ class VideoController extends Controller
|
|||||||
'user_avatar' => $r->user_id
|
'user_avatar' => $r->user_id
|
||||||
? ($r->user_avatar
|
? ($r->user_avatar
|
||||||
? route('media.avatar', $r->user_avatar)
|
? route('media.avatar', $r->user_avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
: asset('images/default-avatar.svg'))
|
||||||
: null,
|
: null,
|
||||||
'country' => $r->country,
|
'country' => $r->country,
|
||||||
'device' => $ua['device'],
|
'device' => $ua['device'],
|
||||||
@ -3099,7 +3243,7 @@ class VideoController extends Controller
|
|||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar
|
'avatar' => $u->avatar
|
||||||
? route('media.avatar', $u->avatar)
|
? route('media.avatar', $u->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
: asset('images/default-avatar.svg'),
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
'device' => $ua['device'],
|
'device' => $ua['device'],
|
||||||
@ -3145,7 +3289,7 @@ class VideoController extends Controller
|
|||||||
'user_avatar'=> $r->user_id
|
'user_avatar'=> $r->user_id
|
||||||
? ($r->user_avatar
|
? ($r->user_avatar
|
||||||
? route('media.avatar', $r->user_avatar)
|
? route('media.avatar', $r->user_avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
: asset('images/default-avatar.svg'))
|
||||||
: null,
|
: null,
|
||||||
'country' => $r->country,
|
'country' => $r->country,
|
||||||
'type' => $r->type,
|
'type' => $r->type,
|
||||||
@ -3238,7 +3382,7 @@ class VideoController extends Controller
|
|||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar
|
'avatar' => $u->avatar
|
||||||
? route('media.avatar', $u->avatar)
|
? route('media.avatar', $u->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
: asset('images/default-avatar.svg'),
|
||||||
'liked_at' => $u->liked_at,
|
'liked_at' => $u->liked_at,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -3276,7 +3420,7 @@ class VideoController extends Controller
|
|||||||
'avatar' => $user
|
'avatar' => $user
|
||||||
? ($user->avatar
|
? ($user->avatar
|
||||||
? route('media.avatar', $user->avatar)
|
? route('media.avatar', $user->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $user->id)
|
: asset('images/default-avatar.svg'))
|
||||||
: null,
|
: null,
|
||||||
'country' => $topCountry ? $topCountry->country : null,
|
'country' => $topCountry ? $topCountry->country : null,
|
||||||
'country_name' => $topCountry ? $topCountry->country_name : null,
|
'country_name' => $topCountry ? $topCountry->country_name : null,
|
||||||
@ -3401,7 +3545,7 @@ class VideoController extends Controller
|
|||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'channel' => $u->username,
|
'channel' => $u->username,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : asset('images/default-avatar.svg'),
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
]);
|
]);
|
||||||
@ -3465,7 +3609,7 @@ class VideoController extends Controller
|
|||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'channel' => $u->username,
|
'channel' => $u->username,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : asset('images/default-avatar.svg'),
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
]);
|
]);
|
||||||
@ -3536,7 +3680,7 @@ class VideoController extends Controller
|
|||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : asset('images/default-avatar.svg'),
|
||||||
],
|
],
|
||||||
'total' => $records->count(),
|
'total' => $records->count(),
|
||||||
'records' => $records,
|
'records' => $records,
|
||||||
@ -3640,7 +3784,7 @@ class VideoController extends Controller
|
|||||||
'channel' => $u->username,
|
'channel' => $u->username,
|
||||||
'avatar' => $u->avatar
|
'avatar' => $u->avatar
|
||||||
? route('media.avatar', $u->avatar)
|
? route('media.avatar', $u->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
: asset('images/default-avatar.svg'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3743,12 +3887,12 @@ class VideoController extends Controller
|
|||||||
'channel' => $user->username,
|
'channel' => $user->username,
|
||||||
'avatar' => $user->avatar
|
'avatar' => $user->avatar
|
||||||
? route('media.avatar', $user->avatar)
|
? route('media.avatar', $user->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $user->id,
|
: asset('images/default-avatar.svg'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$identity['name'] = 'Guest (' . $key . ')';
|
$identity['name'] = 'Guest (' . $key . ')';
|
||||||
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
|
$identity['avatar'] = asset('images/default-avatar.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Country aggregation
|
// Country aggregation
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class Playlist extends Model
|
|||||||
public function videos()
|
public function videos()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Video::class, 'playlist_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');
|
->orderBy('position');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,24 +296,48 @@ class Playlist extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add video to playlist
|
// Add video to playlist. When $audioTrackId is null the primary track
|
||||||
public function addVideo(Video $video)
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxPosition = $this->videos()->max('position') ?? -1;
|
$maxPosition = $this->videos()->max('position') ?? -1;
|
||||||
|
|
||||||
$this->videos()->attach($video->id, [
|
$this->videos()->attach($video->id, [
|
||||||
'position' => $maxPosition + 1,
|
'position' => $maxPosition + 1,
|
||||||
'added_at' => now(),
|
'audio_track_id' => $audioTrackId,
|
||||||
|
'added_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
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)
|
public function removeVideo(Video $video)
|
||||||
{
|
{
|
||||||
if (! $this->hasVideo($video)) {
|
if (! $this->hasVideo($video)) {
|
||||||
@ -328,6 +352,21 @@ class Playlist extends Model
|
|||||||
return true;
|
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
|
// Reorder positions after removal
|
||||||
protected function reorderPositions()
|
protected function reorderPositions()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -67,7 +67,9 @@ class Video extends Model
|
|||||||
|
|
||||||
public function audioTracks()
|
public function audioTracks()
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id');
|
return $this->hasMany(\App\Models\VideoAudioTrack::class)
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasSlideshow(): bool
|
public function hasSlideshow(): bool
|
||||||
@ -252,7 +254,29 @@ class Video extends Model
|
|||||||
if ($realId === null || $realId <= 0) {
|
if ($realId === null || $realId <= 0) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
return $this->where('id', $realId)->firstOrFail();
|
$video = $this->where('id', $realId)->first();
|
||||||
|
if ($video) {
|
||||||
|
return $video;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video not found — check the merge-redirect table so old links still work.
|
||||||
|
$redirect = \DB::table('video_redirects')->where('source_id', $realId)->first();
|
||||||
|
if ($redirect) {
|
||||||
|
$target = static::find($redirect->target_id);
|
||||||
|
if ($target) {
|
||||||
|
$newValue = self::encodeId($target->id);
|
||||||
|
$newUrl = preg_replace(
|
||||||
|
'#/'.preg_quote((string) $value, '#').'(?=/|\?|$)#',
|
||||||
|
'/'.$newValue,
|
||||||
|
request()->fullUrl(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
throw new \Illuminate\Http\Exceptions\HttpResponseException(
|
||||||
|
redirect()->to($newUrl, 302)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All share URLs use the unguessable token route
|
// All share URLs use the unguessable token route
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('position')->default(0)->after('label');
|
||||||
|
$table->index(['video_id', 'position']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backfill existing rows so their current id-order becomes the
|
||||||
|
// canonical position. New rows start at 0 and get placed at the
|
||||||
|
// end by the update handler.
|
||||||
|
DB::statement('UPDATE video_audio_tracks SET position = id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['video_id', 'position']);
|
||||||
|
$table->dropColumn('position');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('playlist_videos', function (Blueprint $table) {
|
||||||
|
// Nullable — null means "play the primary track".
|
||||||
|
$table->unsignedBigInteger('audio_track_id')
|
||||||
|
->nullable()
|
||||||
|
->after('video_id');
|
||||||
|
|
||||||
|
// Drop the old (playlist_id, video_id) uniqueness so the same
|
||||||
|
// video can appear multiple times with different tracks.
|
||||||
|
$table->dropUnique(['playlist_id', 'video_id']);
|
||||||
|
|
||||||
|
$table->index(['playlist_id', 'video_id', 'audio_track_id'], 'playlist_video_track_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('playlist_videos', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('playlist_video_track_idx');
|
||||||
|
$table->unique(['playlist_id', 'video_id']);
|
||||||
|
$table->dropColumn('audio_track_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -237,9 +237,22 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.playlists && data.playlists.length > 0) {
|
if (data.success && data.playlists && data.playlists.length > 0) {
|
||||||
let html = '';
|
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) {
|
data.playlists.forEach(function(playlist) {
|
||||||
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(
|
// Prefer entries[] (per-track granularity) when present.
|
||||||
videoId));
|
// 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 visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
|
||||||
const durationText = playlist.formatted_duration || '0m';
|
const durationText = playlist.formatted_duration || '0m';
|
||||||
html += `
|
html += `
|
||||||
@ -328,7 +341,11 @@
|
|||||||
// of the encoded route key used by GET /videos/{video}.
|
// of the encoded route key used by GET /videos/{video}.
|
||||||
var url = `/playlists/${playlistId}/videos`;
|
var url = `/playlists/${playlistId}/videos`;
|
||||||
var method = currentlyIn ? 'DELETE' : 'POST';
|
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, {
|
fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@ -980,6 +980,14 @@ document.getElementById('edit-form').addEventListener('submit', function(e) {
|
|||||||
// Append delete IDs
|
// Append delete IDs
|
||||||
_editDeleteTrackIds.forEach(id => formData.append('delete_track_ids[]', id));
|
_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 ──────
|
// ── Log tracker: dump the exact track-related payload being submitted ──────
|
||||||
(function () {
|
(function () {
|
||||||
const promoteId = document.getElementById('edit-promote-track-id')?.value || '(none)';
|
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 ───────────────────────────────────────────
|
// ── Form submission ───────────────────────────────────────────
|
||||||
document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
|
document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
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('submit-btn-modal').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
|
||||||
document.getElementById('status-message-modal').className = 'status-message-modal';
|
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) {
|
xhr.upload.addEventListener('progress', function(ev) {
|
||||||
if (ev.lengthComputable) {
|
if (ev.lengthComputable && ev.total > 0) {
|
||||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
const pct = 96 + Math.round((ev.loaded / ev.total) * 3);
|
||||||
document.getElementById('progress-bar-modal').style.width = pct + '%';
|
_pbar.style.width = pct + '%';
|
||||||
document.getElementById('progress-text-modal').textContent = 'Uploading… ' + 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('error', () => { _uploadInProgress = false; _showUploadError('Upload failed. Please check your connection.'); });
|
||||||
xhr.addEventListener('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); });
|
xhr.addEventListener('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); });
|
||||||
xhr.addEventListener('abort', () => { _uploadInProgress = false; _showUploadError('Upload was cancelled.'); });
|
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) {
|
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 ───────────────────────────────────────
|
// ── Form submission ───────────────────────────────────────
|
||||||
document.getElementById('upload-form').addEventListener('submit', function(e) {
|
document.getElementById('upload-form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -845,11 +881,34 @@
|
|||||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
||||||
document.getElementById('status-message').className = '';
|
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) {
|
xhr.upload.addEventListener('progress', function(e) {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable && e.total > 0) {
|
||||||
const pct = Math.round((e.loaded / e.total) * 100);
|
const pct = 96 + Math.round((e.loaded / e.total) * 3);
|
||||||
document.getElementById('progress-bar').style.width = pct + '%';
|
_pbar.style.width = pct + '%';
|
||||||
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -886,10 +945,6 @@
|
|||||||
|
|
||||||
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
|
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
|
||||||
xhr.addEventListener('timeout', () => showError('Upload timed out.'));
|
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) {
|
function showError(message) {
|
||||||
|
|||||||
@ -576,10 +576,14 @@
|
|||||||
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
||||||
|
|
||||||
// swap audio src (keeps browser autoplay permission)
|
// 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 audio = document.getElementById('audioEl');
|
||||||
|
var srcToPlay = d.active_stream_url || d.stream_url;
|
||||||
if (audio) {
|
if (audio) {
|
||||||
var _savedVol = audio.volume; var _savedMuted = audio.muted;
|
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;
|
audio.volume = _savedVol; audio.muted = _savedMuted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -587,6 +591,23 @@
|
|||||||
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
|
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
|
||||||
catch(e) { console.warn('_audioPlayerUpdate', e); }
|
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
|
// update state
|
||||||
PL_CURRENT = d.id;
|
PL_CURRENT = d.id;
|
||||||
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
|
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
|
||||||
@ -699,6 +720,23 @@
|
|||||||
if(a&&plLoop==='one') a.loop=true;
|
if(a&&plLoop==='one') a.loop=true;
|
||||||
plRender();
|
plRender();
|
||||||
plHighlight(PL_CURRENT, false);
|
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();
|
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit();
|
||||||
})();
|
})();
|
||||||
@ -707,9 +745,14 @@
|
|||||||
<div class="recommended-videos-list">
|
<div class="recommended-videos-list">
|
||||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||||
@php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp
|
@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' : '' }}"
|
<div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}"
|
||||||
data-pl-id="{{ $playlistVideo->id }}"
|
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;">
|
style="cursor:pointer;">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($playlistVideo->thumbnail)
|
@if ($playlistVideo->thumbnail)
|
||||||
|
|||||||
@ -70,12 +70,18 @@ Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->n
|
|||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||||
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
|
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
|
||||||
|
Route::post('/videos/upload-chunk', [VideoController::class, 'uploadChunk'])->name('videos.uploadChunk');
|
||||||
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
||||||
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
||||||
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
|
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
|
||||||
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
||||||
Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete');
|
Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete');
|
||||||
|
|
||||||
|
// Music-video merge — fold {source} into {target}. Owner-only.
|
||||||
|
Route::get('/videos/{target}/merge/candidates', [\App\Http\Controllers\VideoMergeController::class, 'candidates'])->name('videos.merge.candidates');
|
||||||
|
Route::get('/videos/{target}/merge/{source}/preview',[\App\Http\Controllers\VideoMergeController::class, 'preview'])->name('videos.merge.preview');
|
||||||
|
Route::post('/videos/{target}/merge/{source}', [\App\Http\Controllers\VideoMergeController::class, 'merge'])->name('videos.merge');
|
||||||
|
|
||||||
// Like/unlike routes
|
// Like/unlike routes
|
||||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user