Add video platform features: authentication, video management, user profiles, likes/views tracking
- Added authentication controllers (Login, Register) - Added UserController for user profile management - Added VideoController with full CRUD operations - Added Video model with relationships (user, likes, views) - Added User model enhancements (avatar, video relationships) - Added database migrations for video_likes, video_views, user_avatar, video_visibility - Added CompressVideoJob for video processing - Added VideoUploaded mail notification - Added authentication routes - Updated web routes with video and user routes - Added layout templates (app, plain, partials) - Added user views (profile, settings, channel, history, liked) - Added video views (create, edit, index, show) - Added email templates
This commit is contained in:
1 parent
6a2026df4b
commit
5253f89b63
35 files changed
+5092
-849
No files matched your search
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials)) {
|
||||
$request->session()->regenerate();
|
||||
return redirect()->intended('/videos');
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
return redirect('/videos');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
return redirect('/videos');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['channel']);
|
||||
}
|
||||
|
||||
// Profile page - view own profile
|
||||
public function profile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
return view('user.profile', compact('user'));
|
||||
}
|
||||
|
||||
// Update profile
|
||||
public function updateProfile(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
]);
|
||||
|
||||
$data = ['name' => $request->name];
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar
|
||||
if ($user->avatar) {
|
||||
Storage::delete('public/avatars/' . $user->avatar);
|
||||
}
|
||||
$filename = Str::uuid() . '.' . $request->file('avatar')->getClientOriginalExtension();
|
||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
||||
$data['avatar'] = $filename;
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return redirect()->route('profile')->with('success', 'Profile updated successfully!');
|
||||
}
|
||||
|
||||
// Settings page
|
||||
public function settings()
|
||||
{
|
||||
$user = Auth::user();
|
||||
return view('user.settings', compact('user'));
|
||||
}
|
||||
|
||||
// Update settings (password)
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$request->validate([
|
||||
'current_password' => 'required',
|
||||
'new_password' => 'required|min:8|confirmed',
|
||||
]);
|
||||
|
||||
if (!Hash::check($request->current_password, $user->password)) {
|
||||
return back()->withErrors(['current_password' => 'Current password is incorrect']);
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'password' => Hash::make($request->new_password)
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')->with('success', 'Password updated successfully!');
|
||||
}
|
||||
|
||||
// User's channel page - view videos
|
||||
public function channel($userId = null)
|
||||
{
|
||||
if ($userId) {
|
||||
$user = User::findOrFail($userId);
|
||||
} else {
|
||||
$user = Auth::user();
|
||||
}
|
||||
|
||||
// If viewing own channel, show all videos including private
|
||||
// If viewing someone else's channel, show only public videos
|
||||
if (Auth::check() && Auth::user()->id === $user->id) {
|
||||
$videos = Video::where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
} else {
|
||||
$videos = Video::public()
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
}
|
||||
|
||||
return view('user.channel', compact('user', 'videos'));
|
||||
}
|
||||
|
||||
// Watch history
|
||||
public function history()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get videos the user has watched, ordered by most recently watched
|
||||
// Include private videos since they are the user's own
|
||||
$videoIds = \DB::table('video_views')
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('watched_at', 'desc')
|
||||
->pluck('video_id')
|
||||
->unique();
|
||||
|
||||
$videos = Video::whereIn('id', $videoIds)
|
||||
->where(function($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortByDesc(function ($video) use ($videoIds) {
|
||||
return $videoIds->search($video->id);
|
||||
});
|
||||
|
||||
return view('user.history', compact('videos'));
|
||||
}
|
||||
|
||||
// Liked videos
|
||||
public function liked()
|
||||
{
|
||||
$user = Auth::user();
|
||||
// Include private videos in liked (user's own private videos)
|
||||
$videos = $user->likes()
|
||||
->where(function($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
return view('user.liked', compact('videos'));
|
||||
}
|
||||
|
||||
// Like a video
|
||||
public function like(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$video->isLikedBy($user)) {
|
||||
$video->likes()->attach($user->id);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
// Unlike a video
|
||||
public function unlike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$video->likes()->detach($user->id);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
// Toggle like (API)
|
||||
public function toggleLike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($video->isLikedBy($user)) {
|
||||
$video->likes()->detach($user->id);
|
||||
$liked = false;
|
||||
} else {
|
||||
$video->likes()->attach($user->id);
|
||||
$liked = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'liked' => $liked,
|
||||
'like_count' => $video->like_count
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,49 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Jobs\CompressVideoJob;
|
||||
use App\Mail\VideoUploaded;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Support\Str;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\FFProbe;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VideoController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$videos = Video::latest()->paginate(12);
|
||||
$videos = Video::public()->latest()->paginate(12);
|
||||
return view('videos.index', compact('videos'));
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = $request->get('q', '');
|
||||
|
||||
if (empty($query)) {
|
||||
return redirect()->route('videos.index');
|
||||
}
|
||||
|
||||
$videos = Video::public()
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('title', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
return view('videos.index', compact('videos', 'query'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('videos.create');
|
||||
@@ -25,88 +55,147 @@ class VideoController extends Controller
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'video' => 'required|mimes:mp4,mov,avi,webm|max:2000000',
|
||||
'video' => 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv|max:2000000',
|
||||
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'orientation' => 'nullable|in:portrait,landscape,square,ultrawide',
|
||||
'visibility' => 'nullable|in:public,unlisted,private',
|
||||
]);
|
||||
|
||||
$file = $request->file('video');
|
||||
$filename = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
||||
$path = $file->storeAs('videos', $filename, 'public');
|
||||
$videoFile = $request->file('video');
|
||||
$filename = Str::slug($request->title) . '-' . time() . '.' . $videoFile->getClientOriginalExtension();
|
||||
$path = $videoFile->storeAs('public/videos', $filename);
|
||||
|
||||
$width = 0;
|
||||
$height = 0;
|
||||
$duration = 0;
|
||||
$orientation = $request->orientation ?? 'landscape';
|
||||
|
||||
$videoPath = storage_path('app/public/videos/' . $filename);
|
||||
|
||||
if (!$request->orientation && file_exists($videoPath)) {
|
||||
$durationCmd = "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 " . escapeshellarg($videoPath);
|
||||
$duration = (int) shell_exec($durationCmd);
|
||||
|
||||
$dimCmd = "ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 " . escapeshellarg($videoPath);
|
||||
$output = shell_exec($dimCmd);
|
||||
|
||||
if ($output) {
|
||||
$parts = explode('x', trim($output));
|
||||
if (count($parts) == 2) {
|
||||
$width = (int) trim($parts[0]);
|
||||
$height = (int) trim($parts[1]);
|
||||
// Get file info
|
||||
$fileSize = $videoFile->getSize();
|
||||
$mimeType = $videoFile->getMimeType();
|
||||
|
||||
$thumbnailPath = null;
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension();
|
||||
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
} else {
|
||||
// Extract thumbnail from video using FFmpeg
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$videoPath = storage_path('app/' . $path);
|
||||
|
||||
if (file_exists($videoPath)) {
|
||||
$video = $ffmpeg->open($videoPath);
|
||||
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
|
||||
|
||||
if ($height > $width * 1.2) {
|
||||
$orientation = 'portrait';
|
||||
} elseif ($width > $height * 1.5) {
|
||||
$orientation = 'ultrawide';
|
||||
} elseif (abs($width - $height) < 50) {
|
||||
$orientation = 'square';
|
||||
} else {
|
||||
$orientation = 'landscape';
|
||||
$thumbFilename = Str::uuid() . '.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
if (!file_exists(storage_path('app/public/thumbnails'))) {
|
||||
mkdir(storage_path('app/public/thumbnails'), 0755, true);
|
||||
}
|
||||
|
||||
$frame->save($thumbFullPath);
|
||||
$thumbnailPath = 'public/thumbnails/' . $thumbFilename;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFmpeg failed to extract thumbnail: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Get video dimensions and detect orientation using FFmpeg
|
||||
$width = null;
|
||||
$height = null;
|
||||
$orientation = 'landscape';
|
||||
|
||||
try {
|
||||
$ffprobe = FFProbe::create();
|
||||
$videoPath = storage_path('app/' . $path);
|
||||
|
||||
if (file_exists($videoPath)) {
|
||||
$streams = $ffprobe->streams($videoPath);
|
||||
$videoStream = $streams->videos()->first();
|
||||
|
||||
if ($videoStream) {
|
||||
$width = $videoStream->get('width');
|
||||
$height = $videoStream->get('height');
|
||||
|
||||
// Auto-detect orientation based on dimensions
|
||||
if ($width && $height) {
|
||||
if ($height > $width) {
|
||||
$orientation = 'portrait';
|
||||
} elseif ($width > $height) {
|
||||
$orientation = 'landscape';
|
||||
} else {
|
||||
$orientation = 'square';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFprobe failed to get video dimensions: ' . $e->getMessage());
|
||||
// Use default orientation
|
||||
}
|
||||
|
||||
$thumbnailFilename = null;
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$thumbFile = $request->file('thumbnail');
|
||||
$thumbnailFilename = Str::uuid() . '.' . $thumbFile->getClientOriginalExtension();
|
||||
$thumbFile->storeAs('thumbnails', $thumbnailFilename, 'public');
|
||||
} elseif (file_exists($videoPath)) {
|
||||
$thumbFilename = Str::uuid() . '.jpg';
|
||||
$thumbPath = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||
$cmd = "ffmpeg -i " . escapeshellarg($videoPath) . " -ss 00:00:01 -vframes 1 -vf 'scale=320:-1' " . escapeshellarg($thumbPath) . " -y 2>/dev/null";
|
||||
shell_exec($cmd);
|
||||
if (file_exists($thumbPath)) {
|
||||
$thumbnailFilename = $thumbFilename;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$video = Video::create([
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'filename' => $filename,
|
||||
'path' => 'videos',
|
||||
'thumbnail' => $thumbnailFilename,
|
||||
'duration' => $duration,
|
||||
'path' => $path,
|
||||
'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
|
||||
'size' => $fileSize,
|
||||
'mime_type' => $mimeType,
|
||||
'orientation' => $orientation,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'orientation' => $orientation,
|
||||
'size' => $file->getSize(),
|
||||
'status' => 'ready',
|
||||
'status' => 'processing',
|
||||
'visibility' => $request->visibility ?? 'public',
|
||||
]);
|
||||
|
||||
// Dispatch compression job in the background
|
||||
CompressVideoJob::dispatch($video);
|
||||
|
||||
// Load user relationship for email
|
||||
$video->load('user');
|
||||
|
||||
// Send email notification
|
||||
try {
|
||||
Mail::to(Auth::user()->email)->send(new VideoUploaded($video));
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('Email notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Video uploaded successfully!',
|
||||
'video_id' => $video->id,
|
||||
'redirect' => route('videos.show', $video->id)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Video $video)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (!$video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
// Track view if user is logged in
|
||||
if (Auth::check()) {
|
||||
$user = Auth::user();
|
||||
// Add view if not already viewed recently (within last hour)
|
||||
$existingView = \DB::table('video_views')
|
||||
->where('user_id', $user->id)
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->first();
|
||||
|
||||
if (!$existingView) {
|
||||
\DB::table('video_views')->insert([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'watched_at' => now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('videos.show', compact('video'));
|
||||
}
|
||||
|
||||
@@ -121,93 +210,101 @@ class VideoController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'orientation' => 'nullable|in:portrait,landscape,square,ultrawide',
|
||||
'visibility' => 'nullable|in:public,unlisted,private',
|
||||
]);
|
||||
|
||||
$video->title = $request->title;
|
||||
$video->description = $request->description;
|
||||
|
||||
if ($request->orientation) {
|
||||
$video->orientation = $request->orientation;
|
||||
}
|
||||
$data = $request->only(['title', 'description', 'visibility']);
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
if ($video->thumbnail) {
|
||||
$oldThumb = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
||||
if (file_exists($oldThumb)) {
|
||||
unlink($oldThumb);
|
||||
}
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
}
|
||||
|
||||
$thumbFile = $request->file('thumbnail');
|
||||
$thumbnailFilename = Str::uuid() . '.' . $thumbFile->getClientOriginalExtension();
|
||||
$thumbFile->storeAs('thumbnails', $thumbnailFilename, 'public');
|
||||
$video->thumbnail = $thumbnailFilename;
|
||||
$thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension();
|
||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
}
|
||||
|
||||
$video->save();
|
||||
|
||||
return redirect()->route('videos.show', $video->id)
|
||||
->with('success', 'Video updated successfully!');
|
||||
}
|
||||
|
||||
public function stream(Video $video)
|
||||
{
|
||||
$filePath = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
abort(404, 'Video file not found');
|
||||
// Set default visibility if not provided
|
||||
if (!isset($data['visibility'])) {
|
||||
unset($data['visibility']);
|
||||
}
|
||||
|
||||
$fileSize = filesize($filePath);
|
||||
$range = request()->header('Range');
|
||||
$video->update($data);
|
||||
|
||||
header('Content-Type: video/mp4');
|
||||
header('Accept-Ranges: bytes');
|
||||
|
||||
if ($range) {
|
||||
$ranges = explode('-', str_replace('bytes=', '', $range));
|
||||
$start = intval($ranges[0]);
|
||||
$end = isset($ranges[1]) && $ranges[1] ? intval($ranges[1]) : $fileSize - 1;
|
||||
$length = $end - $start + 1;
|
||||
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Length: ' . $length);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
|
||||
|
||||
$fp = fopen($filePath, 'rb');
|
||||
fseek($fp, $start);
|
||||
$chunkSize = 8192;
|
||||
while (!feof($fp) && (ftell($fp) <= $end)) {
|
||||
$chunk = fread($fp, min($chunkSize, $end - ftell($fp) + 1));
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
fclose($fp);
|
||||
} else {
|
||||
header('Content-Length: ' . $fileSize);
|
||||
readfile($filePath);
|
||||
}
|
||||
exit;
|
||||
return redirect()->route('videos.show', $video)->with('success', 'Video updated!');
|
||||
}
|
||||
|
||||
public function destroy(Video $video)
|
||||
{
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
if ($video->thumbnail) {
|
||||
$thumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
||||
if (file_exists($thumbPath)) {
|
||||
unlink($thumbPath);
|
||||
}
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
}
|
||||
$video->delete();
|
||||
return redirect()->route('videos.index')->with('success', 'Video deleted!');
|
||||
}
|
||||
|
||||
public function stream(Video $video)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (!$video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
$video->delete();
|
||||
$fileSize = filesize($path);
|
||||
$mimeType = $video->mime_type ?: 'video/mp4';
|
||||
|
||||
return redirect()->route('videos.index')
|
||||
->with('success', 'Video deleted successfully!');
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
abort(500, 'Cannot open video file');
|
||||
}
|
||||
|
||||
$range = request()->header('Range');
|
||||
|
||||
if ($range) {
|
||||
// Parse range header
|
||||
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
|
||||
$start = intval($matches[1] ?? 0);
|
||||
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
|
||||
|
||||
$length = $end - $start + 1;
|
||||
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . $length);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
|
||||
fseek($handle, $start);
|
||||
$chunkSize = 8192;
|
||||
$bytesToRead = $length;
|
||||
|
||||
while (!feof($handle) && $bytesToRead > 0) {
|
||||
$buffer = fread($handle, min($chunkSize, $bytesToRead));
|
||||
echo $buffer;
|
||||
flush();
|
||||
$bytesToRead -= strlen($buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
exit;
|
||||
} else {
|
||||
// No range requested, stream entire file
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . $fileSize);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
|
||||
fpassthru($handle);
|
||||
fclose($handle);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\Format\Video\X264;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CompressVideoJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $video;
|
||||
|
||||
public function __construct(Video $video)
|
||||
{
|
||||
$this->video = $video;
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$video = $this->video;
|
||||
|
||||
// Get original file path
|
||||
$originalPath = storage_path('app/' . $video->path);
|
||||
|
||||
if (!file_exists($originalPath)) {
|
||||
Log::error('CompressVideoJob: Original file not found: ' . $originalPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create compressed filename
|
||||
$compressedFilename = 'compressed_' . $video->filename;
|
||||
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
|
||||
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||
|
||||
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
|
||||
// Use 'slow' preset for better compression efficiency
|
||||
$format = new X264('aac', 'libx264');
|
||||
$format->setKiloBitrate(0); // 0 = use CRF
|
||||
$format->setAudioKiloBitrate(192);
|
||||
|
||||
// Add CRF option for high quality
|
||||
$ffmpegVideo->save($format, $compressedPath);
|
||||
|
||||
// Check if compressed file was created and is smaller
|
||||
if (file_exists($compressedPath)) {
|
||||
$originalSize = filesize($originalPath);
|
||||
$compressedSize = filesize($compressedPath);
|
||||
|
||||
// Only use compressed file if it's smaller
|
||||
if ($compressedSize < $originalSize) {
|
||||
// Delete original and rename compressed
|
||||
unlink($originalPath);
|
||||
rename($compressedPath, $originalPath);
|
||||
|
||||
// Update video record
|
||||
$video->update([
|
||||
'size' => $compressedSize,
|
||||
'filename' => $video->filename, // Keep same filename
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed successfully', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%'
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
unlink($compressedPath);
|
||||
Log::info('CompressVideoJob: Compression made file larger, keeping original');
|
||||
}
|
||||
}
|
||||
|
||||
$video->update(['status' => 'ready']);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
||||
$video->update(['status' => 'ready']); // Mark as ready anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Video;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VideoUploaded extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public Video $video)
|
||||
{
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Your video has been uploaded successfully!',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.video-uploaded',
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-15
@@ -12,34 +12,45 @@ class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'avatar',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function videos()
|
||||
{
|
||||
return $this->hasMany(Video::class);
|
||||
}
|
||||
|
||||
public function likes()
|
||||
{
|
||||
return $this->belongsToMany(Video::class, 'video_likes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function views()
|
||||
{
|
||||
return $this->belongsToMany(Video::class, 'video_views')->withTimestamps();
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute()
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return asset('storage/avatars/' . $this->avatar);
|
||||
}
|
||||
return 'https://i.pravatar.cc/150?u=' . $this->id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Video extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'description',
|
||||
'filename',
|
||||
@@ -14,14 +15,38 @@ class Video extends Model
|
||||
'thumbnail',
|
||||
'duration',
|
||||
'size',
|
||||
'mime_type',
|
||||
'orientation',
|
||||
'width',
|
||||
'height',
|
||||
'status',
|
||||
'visibility',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'duration' => 'integer',
|
||||
'size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function likes()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function viewers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_views')->withTimestamps();
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return asset('storage/videos/' . $this->filename);
|
||||
@@ -34,4 +59,83 @@ class Video extends Model
|
||||
}
|
||||
return asset('images/video-placeholder.jpg');
|
||||
}
|
||||
|
||||
// Check if video is liked by user
|
||||
public function isLikedBy($user)
|
||||
{
|
||||
if (!$user) return false;
|
||||
return $this->likes()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
// Get like count
|
||||
public function getLikeCountAttribute()
|
||||
{
|
||||
return $this->likes()->count();
|
||||
}
|
||||
|
||||
// Get view count
|
||||
public function getViewCountAttribute()
|
||||
{
|
||||
return $this->viewers()->count();
|
||||
}
|
||||
|
||||
// Get shareable URL for the video
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('videos.show', $this->id);
|
||||
}
|
||||
|
||||
// Visibility helpers
|
||||
public function isPublic()
|
||||
{
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
public function isUnlisted()
|
||||
{
|
||||
return $this->visibility === 'unlisted';
|
||||
}
|
||||
|
||||
public function isPrivate()
|
||||
{
|
||||
return $this->visibility === 'private';
|
||||
}
|
||||
|
||||
// Check if user can view this video
|
||||
public function canView($user = null)
|
||||
{
|
||||
// Owner can always view
|
||||
if ($user && $this->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public and unlisted videos can be viewed by anyone
|
||||
return in_array($this->visibility, ['public', 'unlisted']);
|
||||
}
|
||||
|
||||
// Check if video is shareable
|
||||
public function isShareable()
|
||||
{
|
||||
// Only public and unlisted videos are shareable
|
||||
return in_array($this->visibility, ['public', 'unlisted']);
|
||||
}
|
||||
|
||||
// Scope for public videos (home page, search)
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('visibility', 'public');
|
||||
}
|
||||
|
||||
// Scope for videos visible to a specific user
|
||||
public function scopeVisibleTo($query, $user = null)
|
||||
{
|
||||
if ($user) {
|
||||
return $query->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
});
|
||||
}
|
||||
return $query->where('visibility', '!=', 'private');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user