From d756661b456d7abe962df895c3eaf1a4a0b5110d Mon Sep 17 00:00:00 2001 From: Meghdad Date: Fri, 15 May 2026 16:33:34 +0330 Subject: [PATCH] start project --- README.md | 24 + .../Controllers/AdminDashboardController.php | 58 ++ .../Controllers/AdminModerationController.php | 89 +++ app/Http/Controllers/ChannelController.php | 87 +++ .../Controllers/ChatMessageController.php | 28 + app/Http/Controllers/Controller.php | 8 + .../CreatorDashboardController.php | 182 ++++++ app/Http/Controllers/FollowController.php | 28 + app/Http/Controllers/HomeController.php | 67 +++ app/Http/Controllers/MediaMtxController.php | 64 +++ app/Http/Middleware/EnsureUserIsAdmin.php | 17 + app/Models/AdminAuditLog.php | 26 + app/Models/Broadcast.php | 54 ++ app/Models/Category.php | 21 + app/Models/Channel.php | 89 +++ app/Models/ChatMessage.php | 29 + app/Models/Follow.php | 21 + app/Models/StreamKey.php | 24 + app/Models/Vod.php | 42 ++ app/Services/Streaming/MediaMtxService.php | 207 +++++++ app/Services/Streaming/StreamKeyManager.php | 35 ++ database/.gitignore | 1 + database/factories/BroadcastFactory.php | 29 + database/factories/CategoryFactory.php | 20 + database/factories/ChannelFactory.php | 26 + ...00_add_platform_columns_to_users_table.php | 23 + ...6_05_15_000001_create_streaming_tables.php | 119 ++++ database/seeders/DatabaseSeeder.php | 38 ++ deploy/mediamtx.yml | 31 + deploy/mediamtx.yml.example | 31 + resources/js/pages/admin/dashboard.tsx | 198 +++++++ resources/js/pages/channels/show.tsx | 264 +++++++++ resources/js/pages/dashboard.tsx | 541 ++++++++++++++++++ resume | 1 + tests/Feature/Auth/AuthenticationTest.php | 92 +++ tests/Feature/Auth/EmailVerificationTest.php | 119 ++++ .../Feature/Auth/PasswordConfirmationTest.php | 33 ++ tests/Feature/Auth/PasswordResetTest.php | 95 +++ tests/Feature/Auth/RegistrationTest.php | 39 ++ tests/Feature/Auth/TwoFactorChallengeTest.php | 49 ++ .../Auth/VerificationNotificationTest.php | 48 ++ tests/Feature/CreatorChannelTest.php | 54 ++ tests/Feature/DashboardTest.php | 27 + tests/Feature/ExampleTest.php | 18 + tests/Feature/MediaMtxIntegrationTest.php | 99 ++++ tests/Feature/Settings/ProfileUpdateTest.php | 99 ++++ tests/Feature/Settings/SecurityTest.php | 129 +++++ tests/Feature/ViewerInteractionTest.php | 61 ++ tests/TestCase.php | 23 + tests/Unit/ExampleTest.php | 16 + 50 files changed, 3523 insertions(+) create mode 100644 README.md create mode 100644 app/Http/Controllers/AdminDashboardController.php create mode 100644 app/Http/Controllers/AdminModerationController.php create mode 100644 app/Http/Controllers/ChannelController.php create mode 100644 app/Http/Controllers/ChatMessageController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/CreatorDashboardController.php create mode 100644 app/Http/Controllers/FollowController.php create mode 100644 app/Http/Controllers/HomeController.php create mode 100644 app/Http/Controllers/MediaMtxController.php create mode 100644 app/Http/Middleware/EnsureUserIsAdmin.php create mode 100644 app/Models/AdminAuditLog.php create mode 100644 app/Models/Broadcast.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Channel.php create mode 100644 app/Models/ChatMessage.php create mode 100644 app/Models/Follow.php create mode 100644 app/Models/StreamKey.php create mode 100644 app/Models/Vod.php create mode 100644 app/Services/Streaming/MediaMtxService.php create mode 100644 app/Services/Streaming/StreamKeyManager.php create mode 100644 database/.gitignore create mode 100644 database/factories/BroadcastFactory.php create mode 100644 database/factories/CategoryFactory.php create mode 100644 database/factories/ChannelFactory.php create mode 100644 database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php create mode 100644 database/migrations/2026_05_15_000001_create_streaming_tables.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 deploy/mediamtx.yml create mode 100644 deploy/mediamtx.yml.example create mode 100644 resources/js/pages/admin/dashboard.tsx create mode 100644 resources/js/pages/channels/show.tsx create mode 100644 resources/js/pages/dashboard.tsx create mode 100644 resume create mode 100644 tests/Feature/Auth/AuthenticationTest.php create mode 100644 tests/Feature/Auth/EmailVerificationTest.php create mode 100644 tests/Feature/Auth/PasswordConfirmationTest.php create mode 100644 tests/Feature/Auth/PasswordResetTest.php create mode 100644 tests/Feature/Auth/RegistrationTest.php create mode 100644 tests/Feature/Auth/TwoFactorChallengeTest.php create mode 100644 tests/Feature/Auth/VerificationNotificationTest.php create mode 100644 tests/Feature/CreatorChannelTest.php create mode 100644 tests/Feature/DashboardTest.php create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/MediaMtxIntegrationTest.php create mode 100644 tests/Feature/Settings/ProfileUpdateTest.php create mode 100644 tests/Feature/Settings/SecurityTest.php create mode 100644 tests/Feature/ViewerInteractionTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..0db60f1 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Nyone + +Nyone is a Laravel/Inertia live streaming platform scaffold. The first implementation supports one creator channel per user, OBS publishing through MediaMTX RTMP, HLS playback, channel follows, live chat, creator-selected VOD recording, and basic admin moderation. + +## Local Setup + +```bash +composer install +npm install +cp .env.example .env +php artisan key:generate +php artisan migrate --seed +npm run dev +php artisan serve +``` + +Configure MediaMTX from `deploy/mediamtx.yml.example`, replacing `change-this-secret` with `MEDIAMTX_SHARED_SECRET`. + +Creators use: + +- Server: `STREAMING_RTMP_INGEST_URL` +- Stream key: `?token=` + +Viewer playback uses `STREAMING_HLS_PUBLIC_URL//index.m3u8`. diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php new file mode 100644 index 0000000..e78d430 --- /dev/null +++ b/app/Http/Controllers/AdminDashboardController.php @@ -0,0 +1,58 @@ + [ + 'users' => User::query()->count(), + 'channels' => Channel::query()->count(), + 'live_channels' => Channel::query()->where('is_live', true)->count(), + 'vods' => Vod::query()->count(), + ], + 'liveBroadcasts' => Broadcast::query() + ->where('status', Broadcast::STATUS_LIVE) + ->with('channel.user') + ->latest('started_at') + ->limit(20) + ->get() + ->map(fn (Broadcast $broadcast) => [ + 'id' => $broadcast->id, + 'title' => $broadcast->title, + 'started_at' => $broadcast->started_at?->toIso8601String(), + 'channel' => [ + 'id' => $broadcast->channel->id, + 'slug' => $broadcast->channel->slug, + 'display_name' => $broadcast->channel->display_name, + 'owner' => $broadcast->channel->user->name, + ], + ]), + 'channels' => Channel::query() + ->with('user') + ->latest() + ->limit(25) + ->get() + ->map(fn (Channel $channel) => [ + 'id' => $channel->id, + 'slug' => $channel->slug, + 'display_name' => $channel->display_name, + 'is_live' => $channel->is_live, + 'suspended_at' => $channel->suspended_at?->toIso8601String(), + 'owner' => [ + 'id' => $channel->user->id, + 'name' => $channel->user->name, + ], + ]), + ]); + } +} diff --git a/app/Http/Controllers/AdminModerationController.php b/app/Http/Controllers/AdminModerationController.php new file mode 100644 index 0000000..a2b1887 --- /dev/null +++ b/app/Http/Controllers/AdminModerationController.php @@ -0,0 +1,89 @@ +forceFill([ + 'suspended_at' => now(), + 'is_live' => false, + 'live_broadcast_id' => null, + 'viewer_count' => 0, + ])->save(); + + $this->audit($request, $channel, 'channel.suspended'); + + return back()->with('success', 'Channel suspended.'); + } + + public function restoreChannel(Request $request, Channel $channel): RedirectResponse + { + $channel->forceFill(['suspended_at' => null])->save(); + + $this->audit($request, $channel, 'channel.restored'); + + return back()->with('success', 'Channel restored.'); + } + + public function suspendUser(Request $request, User $user): RedirectResponse + { + $user->forceFill(['suspended_at' => now()])->save(); + $user->channel?->forceFill([ + 'is_live' => false, + 'live_broadcast_id' => null, + 'viewer_count' => 0, + ])->save(); + + $this->audit($request, $user, 'user.suspended'); + + return back()->with('success', 'User suspended.'); + } + + public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse + { + $broadcast->forceFill([ + 'status' => Broadcast::STATUS_ENDED, + 'ended_at' => $broadcast->ended_at ?? now(), + ])->save(); + + $broadcast->channel->forceFill([ + 'is_live' => false, + 'live_broadcast_id' => null, + 'viewer_count' => 0, + ])->save(); + + $this->audit($request, $broadcast, 'broadcast.stopped'); + + return back()->with('success', 'Broadcast stopped.'); + } + + public function deleteVod(Request $request, Vod $vod): RedirectResponse + { + $vod->delete(); + + $this->audit($request, $vod, 'vod.deleted'); + + return back()->with('success', 'VOD deleted.'); + } + + private function audit(Request $request, object $subject, string $action): void + { + AdminAuditLog::query()->create([ + 'admin_user_id' => $request->user()?->id, + 'subject_type' => $subject::class, + 'subject_id' => $subject->id, + 'action' => $action, + 'created_at' => now(), + ]); + } +} diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php new file mode 100644 index 0000000..7f54d6a --- /dev/null +++ b/app/Http/Controllers/ChannelController.php @@ -0,0 +1,87 @@ +isSuspended(), 404); + + $channel->load(['category', 'liveBroadcast', 'user']); + $channel->loadCount('follows'); + + $currentBroadcast = $channel->liveBroadcast; + + return Inertia::render('channels/show', [ + 'channel' => [ + 'id' => $channel->id, + 'slug' => $channel->slug, + 'display_name' => $channel->display_name, + 'description' => $channel->description, + 'is_live' => $channel->is_live, + 'viewer_count' => $channel->viewer_count, + 'followers_count' => $channel->follows_count, + 'category' => $channel->category ? [ + 'id' => $channel->category->id, + 'name' => $channel->category->name, + 'slug' => $channel->category->slug, + ] : null, + 'owner' => [ + 'id' => $channel->user->id, + 'name' => $channel->user->name, + ], + ], + 'currentBroadcast' => $currentBroadcast ? [ + 'id' => $currentBroadcast->id, + 'title' => $currentBroadcast->title, + 'hls_url' => $currentBroadcast->hls_url, + 'recording_enabled' => $currentBroadcast->recording_enabled, + 'started_at' => $currentBroadcast->started_at?->toIso8601String(), + ] : null, + 'vods' => $channel->vods() + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->latest('published_at') + ->limit(12) + ->get() + ->map(fn (Vod $vod) => [ + 'id' => $vod->id, + 'title' => $vod->title, + 'playback_url' => $vod->playback_url, + 'duration_seconds' => $vod->duration_seconds, + 'published_at' => $vod->published_at?->toIso8601String(), + 'expires_at' => $vod->expires_at?->toIso8601String(), + ]), + 'chatMessages' => ChatMessage::query() + ->where('channel_id', $channel->id) + ->when($currentBroadcast, fn ($query) => $query->where('broadcast_id', $currentBroadcast->id)) + ->with('user') + ->latest() + ->limit(50) + ->get() + ->reverse() + ->values() + ->map(fn (ChatMessage $message) => [ + 'id' => $message->id, + 'body' => $message->body, + 'created_at' => $message->created_at?->toIso8601String(), + 'user' => [ + 'id' => $message->user->id, + 'name' => $message->user->name, + ], + ]), + 'isFollowing' => $request->user() + ? $channel->follows()->where('user_id', $request->user()->id)->exists() + : false, + ]); + } +} diff --git a/app/Http/Controllers/ChatMessageController.php b/app/Http/Controllers/ChatMessageController.php new file mode 100644 index 0000000..0995946 --- /dev/null +++ b/app/Http/Controllers/ChatMessageController.php @@ -0,0 +1,28 @@ +user()->isSuspended() || $channel->isSuspended(), 403); + abort_unless($channel->is_live && $channel->live_broadcast_id, 422, 'Chat is available only while the channel is live.'); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:500'], + ]); + + $channel->chatMessages()->create([ + 'broadcast_id' => $channel->live_broadcast_id, + 'user_id' => $request->user()->id, + 'body' => trim($validated['body']), + ]); + + return back(); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +user() + ->channel() + ->with(['category', 'liveBroadcast', 'streamKey']) + ->first(); + + return Inertia::render('dashboard', [ + 'channel' => $channel ? $this->channelPayload($channel) : null, + 'plainStreamKey' => session('plain_stream_key'), + 'streaming' => [ + 'ingestServer' => $streamKeys->ingestServer(), + 'tokenizedPath' => $channel && session('plain_stream_key') + ? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key')) + : null, + ], + 'categories' => Category::query() + ->orderBy('name') + ->get(['id', 'name', 'slug']) + ->map(fn (Category $category) => [ + 'id' => $category->id, + 'name' => $category->name, + 'slug' => $category->slug, + ]), + 'recentBroadcasts' => $channel + ? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [ + 'id' => $broadcast->id, + 'title' => $broadcast->title, + 'status' => $broadcast->status, + 'recording_enabled' => $broadcast->recording_enabled, + 'started_at' => $broadcast->started_at?->toIso8601String(), + 'ended_at' => $broadcast->ended_at?->toIso8601String(), + 'has_vod' => $broadcast->vod !== null, + ]) + : [], + ]); + } + + public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse + { + abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.'); + + $validated = $request->validate([ + 'display_name' => ['required', 'string', 'max:80'], + 'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'], + 'description' => ['nullable', 'string', 'max:1000'], + 'category_id' => ['nullable', 'exists:categories,id'], + ]); + + $channel = $request->user()->channel()->create([ + ...$validated, + 'display_name' => trim($validated['display_name']), + 'slug' => Str::slug($validated['slug']), + ]); + + $plainStreamKey = $streamKeys->rotate($channel); + + return redirect() + ->route('dashboard') + ->with('plain_stream_key', $plainStreamKey) + ->with('success', 'Channel created. Save the stream key now; it will not be shown again.'); + } + + public function updateChannel(Request $request): RedirectResponse + { + $channel = $request->user()->channel; + + abort_unless($channel, 404); + + $validated = $request->validate([ + 'display_name' => ['required', 'string', 'max:80'], + 'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)], + 'description' => ['nullable', 'string', 'max:1000'], + 'category_id' => ['nullable', 'exists:categories,id'], + ]); + + $channel->update([ + ...$validated, + 'display_name' => trim($validated['display_name']), + 'slug' => Str::slug($validated['slug']), + ]); + + return back()->with('success', 'Channel updated.'); + } + + public function rotateStreamKey(Request $request, StreamKeyManager $streamKeys): RedirectResponse + { + $channel = $request->user()->channel; + + abort_unless($channel, 404); + + $plainStreamKey = $streamKeys->rotate($channel); + + return back() + ->with('plain_stream_key', $plainStreamKey) + ->with('success', 'Stream key rotated. Update OBS before going live again.'); + } + + public function storeBroadcast(Request $request): RedirectResponse + { + $channel = $request->user()->channel; + + abort_unless($channel, 404); + abort_if($channel->isSuspended(), 403); + abort_if( + $channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(), + 422, + 'Finish the current broadcast before creating another.', + ); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:120'], + 'recording_enabled' => ['nullable', 'boolean'], + ]); + + $channel->broadcasts()->create([ + 'title' => $validated['title'], + 'status' => Broadcast::STATUS_PENDING, + 'recording_enabled' => (bool) ($validated['recording_enabled'] ?? false), + 'mediamtx_path' => $channel->slug, + ]); + + return back()->with('success', 'Broadcast is ready. Start streaming from OBS.'); + } + + public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse + { + abort_unless($broadcast->channel->user_id === $request->user()->id, 403); + + $broadcast->forceFill([ + 'status' => Broadcast::STATUS_ENDED, + 'ended_at' => $broadcast->ended_at ?? now(), + ])->save(); + + $broadcast->channel->forceFill([ + 'is_live' => false, + 'viewer_count' => 0, + 'live_broadcast_id' => null, + ])->save(); + + return back()->with('success', 'Broadcast ended.'); + } + + private function channelPayload(Channel $channel): array + { + return [ + 'id' => $channel->id, + 'slug' => $channel->slug, + 'display_name' => $channel->display_name, + 'description' => $channel->description, + 'is_live' => $channel->is_live, + 'viewer_count' => $channel->viewer_count, + 'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(), + 'category_id' => $channel->category_id, + 'live_broadcast' => $channel->liveBroadcast ? [ + 'id' => $channel->liveBroadcast->id, + 'title' => $channel->liveBroadcast->title, + 'status' => $channel->liveBroadcast->status, + 'hls_url' => $channel->liveBroadcast->hls_url, + 'recording_enabled' => $channel->liveBroadcast->recording_enabled, + 'started_at' => $channel->liveBroadcast->started_at?->toIso8601String(), + ] : null, + ]; + } +} diff --git a/app/Http/Controllers/FollowController.php b/app/Http/Controllers/FollowController.php new file mode 100644 index 0000000..cb14e1b --- /dev/null +++ b/app/Http/Controllers/FollowController.php @@ -0,0 +1,28 @@ +isSuspended(), 404); + + $channel->follows()->firstOrCreate([ + 'user_id' => $request->user()->id, + ]); + + return back(); + } + + public function destroy(Request $request, Channel $channel): RedirectResponse + { + $channel->follows()->where('user_id', $request->user()->id)->delete(); + + return back(); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..c0029c9 --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,67 @@ +where('is_live', true) + ->whereNull('suspended_at') + ->with(['category', 'liveBroadcast', 'user']) + ->withCount('follows') + ->orderByDesc('viewer_count') + ->latest() + ->limit(24) + ->get() + ->map(fn (Channel $channel) => $this->channelCard($channel)); + + return Inertia::render('welcome', [ + 'canRegister' => Features::enabled(Features::registration()), + 'liveChannels' => $liveChannels, + 'categories' => Category::query() + ->orderBy('name') + ->get(['id', 'name', 'slug']) + ->map(fn (Category $category) => [ + 'id' => $category->id, + 'name' => $category->name, + 'slug' => $category->slug, + ]), + ]); + } + + private function channelCard(Channel $channel): array + { + return [ + 'id' => $channel->id, + 'slug' => $channel->slug, + 'display_name' => $channel->display_name, + 'description' => $channel->description, + 'thumbnail_path' => $channel->thumbnail_path, + 'viewer_count' => $channel->viewer_count, + 'followers_count' => $channel->follows_count ?? 0, + 'category' => $channel->category ? [ + 'id' => $channel->category->id, + 'name' => $channel->category->name, + 'slug' => $channel->category->slug, + ] : null, + 'broadcast' => $channel->liveBroadcast ? [ + 'id' => $channel->liveBroadcast->id, + 'title' => $channel->liveBroadcast->title, + 'started_at' => $channel->liveBroadcast->started_at?->toIso8601String(), + ] : null, + 'owner' => [ + 'id' => $channel->user->id, + 'name' => $channel->user->name, + ], + ]; + } +} diff --git a/app/Http/Controllers/MediaMtxController.php b/app/Http/Controllers/MediaMtxController.php new file mode 100644 index 0000000..c090741 --- /dev/null +++ b/app/Http/Controllers/MediaMtxController.php @@ -0,0 +1,64 @@ +ensureSecret($request); + + return $mediaMtx->authorize($request->all()) + ? response()->noContent() + : response('Unauthorized', 403); + } + + public function ready(Request $request, MediaMtxService $mediaMtx): Response + { + $this->ensureSecret($request); + + $mediaMtx->markReady( + (string) $request->input('path', $request->query('path', '')), + (string) $request->input('source_type', $request->query('source_type', '')), + (string) $request->input('source_id', $request->query('source_id', '')), + ); + + return response()->noContent(); + } + + public function notReady(Request $request, MediaMtxService $mediaMtx): Response + { + $this->ensureSecret($request); + + $mediaMtx->markNotReady((string) $request->input('path', $request->query('path', ''))); + + return response()->noContent(); + } + + public function recordingComplete(Request $request, MediaMtxService $mediaMtx): Response + { + $this->ensureSecret($request); + + $mediaMtx->recordSegmentComplete( + (string) $request->input('path', $request->query('path', '')), + (string) $request->input('segment_path', $request->query('segment_path', '')), + $request->input('duration', $request->query('duration')), + ); + + return response()->noContent(); + } + + private function ensureSecret(Request $request): void + { + $expected = (string) config('streaming.mediamtx_shared_secret'); + $provided = (string) ($request->header('X-MediaMTX-Secret') + ?: $request->query('secret') + ?: $request->input('secret')); + + abort_if($expected === '' || ! hash_equals($expected, $provided), 403); + } +} diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php new file mode 100644 index 0000000..7b062a7 --- /dev/null +++ b/app/Http/Middleware/EnsureUserIsAdmin.php @@ -0,0 +1,17 @@ +user()?->is_admin, 403); + + return $next($request); + } +} diff --git a/app/Models/AdminAuditLog.php b/app/Models/AdminAuditLog.php new file mode 100644 index 0000000..99eff3e --- /dev/null +++ b/app/Models/AdminAuditLog.php @@ -0,0 +1,26 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_user_id'); + } +} diff --git a/app/Models/Broadcast.php b/app/Models/Broadcast.php new file mode 100644 index 0000000..e7f7bed --- /dev/null +++ b/app/Models/Broadcast.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + public const STATUS_PENDING = 'pending'; + + public const STATUS_LIVE = 'live'; + + public const STATUS_ENDED = 'ended'; + + protected function casts(): array + { + return [ + 'recording_enabled' => 'boolean', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + ]; + } + + public function channel(): BelongsTo + { + return $this->belongsTo(Channel::class); + } + + public function vod(): HasOne + { + return $this->hasOne(Vod::class); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..526d0d3 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,21 @@ + */ + use HasFactory; + + public function channels(): HasMany + { + return $this->hasMany(Channel::class); + } +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php new file mode 100644 index 0000000..f035cb6 --- /dev/null +++ b/app/Models/Channel.php @@ -0,0 +1,89 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'is_live' => 'boolean', + 'suspended_at' => 'datetime', + ]; + } + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function streamKey(): HasOne + { + return $this->hasOne(StreamKey::class); + } + + public function broadcasts(): HasMany + { + return $this->hasMany(Broadcast::class); + } + + public function liveBroadcast(): BelongsTo + { + return $this->belongsTo(Broadcast::class, 'live_broadcast_id'); + } + + public function vods(): HasMany + { + return $this->hasMany(Vod::class); + } + + public function follows(): HasMany + { + return $this->hasMany(Follow::class); + } + + public function chatMessages(): HasMany + { + return $this->hasMany(ChatMessage::class); + } + + public function isSuspended(): bool + { + return $this->suspended_at !== null || $this->user?->suspended_at !== null; + } +} diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php new file mode 100644 index 0000000..24f0c11 --- /dev/null +++ b/app/Models/ChatMessage.php @@ -0,0 +1,29 @@ +belongsTo(Channel::class); + } + + public function broadcast(): BelongsTo + { + return $this->belongsTo(Broadcast::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Follow.php b/app/Models/Follow.php new file mode 100644 index 0000000..cc3b0d2 --- /dev/null +++ b/app/Models/Follow.php @@ -0,0 +1,21 @@ +belongsTo(Channel::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/StreamKey.php b/app/Models/StreamKey.php new file mode 100644 index 0000000..6cc6ff7 --- /dev/null +++ b/app/Models/StreamKey.php @@ -0,0 +1,24 @@ + 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function channel(): BelongsTo + { + return $this->belongsTo(Channel::class); + } +} diff --git a/app/Models/Vod.php b/app/Models/Vod.php new file mode 100644 index 0000000..18d9e5b --- /dev/null +++ b/app/Models/Vod.php @@ -0,0 +1,42 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + } + + public function broadcast(): BelongsTo + { + return $this->belongsTo(Broadcast::class); + } + + public function channel(): BelongsTo + { + return $this->belongsTo(Channel::class); + } +} diff --git a/app/Services/Streaming/MediaMtxService.php b/app/Services/Streaming/MediaMtxService.php new file mode 100644 index 0000000..17b881e --- /dev/null +++ b/app/Services/Streaming/MediaMtxService.php @@ -0,0 +1,207 @@ +extractPath($payload); + $channel = $this->channelForPath($path); + + if (! $channel || $channel->isSuspended()) { + return false; + } + + if (in_array($action, ['read', 'playback'], true)) { + return true; + } + + if ($action !== 'publish') { + return false; + } + + $token = (string) ($payload['token'] ?? $this->queryValue((string) ($payload['query'] ?? ''), 'token')); + + if ($token === '' || ! $channel->streamKey || $channel->streamKey->revoked_at) { + return false; + } + + $authorized = Hash::check($token, $channel->streamKey->key_hash); + + if ($authorized) { + $channel->streamKey->forceFill(['last_used_at' => now()])->save(); + } + + return $authorized; + } + + public function markReady(string $path, ?string $sourceType = null, ?string $sourceId = null): ?Broadcast + { + $channel = $this->channelForPath($path); + + if (! $channel || $channel->isSuspended()) { + return null; + } + + $broadcast = $channel->broadcasts() + ->where('status', Broadcast::STATUS_PENDING) + ->latest() + ->first(); + + if (! $broadcast) { + $broadcast = $channel->broadcasts()->create([ + 'title' => 'Untitled live stream', + 'status' => Broadcast::STATUS_PENDING, + 'recording_enabled' => false, + ]); + } + + $broadcast->forceFill([ + 'status' => Broadcast::STATUS_LIVE, + 'mediamtx_path' => $channel->slug, + 'source_type' => $sourceType, + 'source_id' => $sourceId, + 'hls_url' => $this->hlsUrl($channel), + 'started_at' => $broadcast->started_at ?? now(), + 'ended_at' => null, + ])->save(); + + $channel->forceFill([ + 'is_live' => true, + 'live_broadcast_id' => $broadcast->id, + ])->save(); + + return $broadcast; + } + + public function markNotReady(string $path): ?Broadcast + { + $channel = $this->channelForPath($path); + + if (! $channel) { + return null; + } + + $broadcast = $channel->live_broadcast_id + ? Broadcast::find($channel->live_broadcast_id) + : $channel->broadcasts()->where('status', Broadcast::STATUS_LIVE)->latest()->first(); + + if ($broadcast) { + $broadcast->forceFill([ + 'status' => Broadcast::STATUS_ENDED, + 'ended_at' => $broadcast->ended_at ?? now(), + ])->save(); + } + + $channel->forceFill([ + 'is_live' => false, + 'viewer_count' => 0, + 'live_broadcast_id' => null, + ])->save(); + + return $broadcast; + } + + public function recordSegmentComplete(string $path, string $segmentPath, ?string $duration = null): ?Vod + { + $channel = $this->channelForPath($path); + + if (! $channel) { + return null; + } + + $broadcast = $channel->broadcasts() + ->whereIn('status', [Broadcast::STATUS_LIVE, Broadcast::STATUS_ENDED]) + ->latest('started_at') + ->first(); + + if (! $broadcast || ! $broadcast->recording_enabled) { + $this->deleteSegmentIfSafe($segmentPath); + + return null; + } + + return Vod::query()->firstOrCreate( + ['broadcast_id' => $broadcast->id], + [ + 'channel_id' => $channel->id, + 'title' => $broadcast->title, + 'storage_path' => $segmentPath, + 'duration_seconds' => $this->durationSeconds($duration), + 'size_bytes' => is_file($segmentPath) ? filesize($segmentPath) : null, + 'published_at' => now(), + 'expires_at' => now()->addDays((int) config('streaming.vod_retention_days')), + ], + ); + } + + public function hlsUrl(Channel $channel): string + { + return rtrim((string) config('streaming.hls_public_url'), '/').'/'.rawurlencode($channel->slug).'/index.m3u8'; + } + + public function extractPath(array $payload): string + { + $path = trim((string) ($payload['path'] ?? ''), '/'); + + if ($path === '') { + return ''; + } + + return Str::afterLast($path, '/'); + } + + private function channelForPath(string $path): ?Channel + { + if ($path === '') { + return null; + } + + return Channel::query() + ->where('slug', $path) + ->with(['streamKey', 'user']) + ->first(); + } + + private function queryValue(string $query, string $key): ?string + { + parse_str($query, $values); + + return isset($values[$key]) ? (string) $values[$key] : null; + } + + private function durationSeconds(?string $duration): ?int + { + if ($duration === null || $duration === '') { + return null; + } + + preg_match('/[\d.]+/', $duration, $matches); + + return isset($matches[0]) ? (int) round((float) $matches[0]) : null; + } + + private function deleteSegmentIfSafe(string $segmentPath): void + { + if ($segmentPath === '' || ! is_file($segmentPath)) { + return; + } + + $recordingsRoot = realpath((string) config('streaming.recordings_path')); + $segmentRealPath = realpath($segmentPath); + + if (! $recordingsRoot || ! $segmentRealPath || ! Str::startsWith($segmentRealPath, $recordingsRoot)) { + return; + } + + @unlink($segmentRealPath); + } +} diff --git a/app/Services/Streaming/StreamKeyManager.php b/app/Services/Streaming/StreamKeyManager.php new file mode 100644 index 0000000..a3b7ba7 --- /dev/null +++ b/app/Services/Streaming/StreamKeyManager.php @@ -0,0 +1,35 @@ +streamKey()->updateOrCreate( + ['channel_id' => $channel->id], + [ + 'key_hash' => Hash::make($plainTextKey), + 'revoked_at' => null, + ], + ); + + return $plainTextKey; + } + + public function tokenizedPath(Channel $channel, string $plainTextKey): string + { + return "{$channel->slug}?token={$plainTextKey}"; + } + + public function ingestServer(): string + { + return rtrim((string) config('streaming.rtmp_ingest_url'), '/'); + } +} diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/BroadcastFactory.php b/database/factories/BroadcastFactory.php new file mode 100644 index 0000000..6079ea7 --- /dev/null +++ b/database/factories/BroadcastFactory.php @@ -0,0 +1,29 @@ + Channel::factory(), + 'title' => fake()->sentence(4), + 'status' => Broadcast::STATUS_PENDING, + 'recording_enabled' => false, + ]; + } + + public function live(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Broadcast::STATUS_LIVE, + 'started_at' => now(), + 'hls_url' => 'http://localhost:8888/demo/index.m3u8', + ]); + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..9e07238 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,20 @@ +unique()->words(2, true); + + return [ + 'name' => Str::headline($name), + 'slug' => Str::slug($name), + 'description' => fake()->optional()->sentence(), + ]; + } +} diff --git a/database/factories/ChannelFactory.php b/database/factories/ChannelFactory.php new file mode 100644 index 0000000..bc79715 --- /dev/null +++ b/database/factories/ChannelFactory.php @@ -0,0 +1,26 @@ +unique()->words(2, true); + + return [ + 'user_id' => User::factory(), + 'category_id' => Category::factory(), + 'slug' => Str::slug($name), + 'display_name' => Str::headline($name), + 'description' => fake()->sentence(), + 'is_live' => false, + 'viewer_count' => 0, + ]; + } +} diff --git a/database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php b/database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php new file mode 100644 index 0000000..a73c1ab --- /dev/null +++ b/database/migrations/2026_05_15_000000_add_platform_columns_to_users_table.php @@ -0,0 +1,23 @@ +boolean('is_admin')->default(false)->index(); + $table->timestamp('suspended_at')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['is_admin', 'suspended_at']); + }); + } +}; diff --git a/database/migrations/2026_05_15_000001_create_streaming_tables.php b/database/migrations/2026_05_15_000001_create_streaming_tables.php new file mode 100644 index 0000000..93d6d46 --- /dev/null +++ b/database/migrations/2026_05_15_000001_create_streaming_tables.php @@ -0,0 +1,119 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('channels', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('live_broadcast_id')->nullable()->index(); + $table->string('slug')->unique(); + $table->string('display_name'); + $table->text('description')->nullable(); + $table->string('avatar_path')->nullable(); + $table->string('banner_path')->nullable(); + $table->string('thumbnail_path')->nullable(); + $table->boolean('is_live')->default(false)->index(); + $table->unsignedInteger('viewer_count')->default(0); + $table->timestamp('suspended_at')->nullable()->index(); + $table->timestamps(); + }); + + Schema::create('stream_keys', function (Blueprint $table) { + $table->id(); + $table->foreignId('channel_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('key_hash'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable()->index(); + $table->timestamps(); + }); + + Schema::create('broadcasts', function (Blueprint $table) { + $table->id(); + $table->foreignId('channel_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('status')->default('pending')->index(); + $table->boolean('recording_enabled')->default(false); + $table->string('mediamtx_path')->nullable()->index(); + $table->string('source_type')->nullable(); + $table->string('source_id')->nullable(); + $table->string('hls_url')->nullable(); + $table->unsignedInteger('viewer_peak')->default(0); + $table->timestamp('started_at')->nullable()->index(); + $table->timestamp('ended_at')->nullable()->index(); + $table->timestamps(); + }); + + Schema::create('vods', function (Blueprint $table) { + $table->id(); + $table->foreignId('broadcast_id')->unique()->constrained()->cascadeOnDelete(); + $table->foreignId('channel_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('storage_path'); + $table->string('playback_url')->nullable(); + $table->unsignedInteger('duration_seconds')->nullable(); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamp('expires_at')->nullable()->index(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('follows', function (Blueprint $table) { + $table->id(); + $table->foreignId('channel_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['channel_id', 'user_id']); + }); + + Schema::create('chat_messages', function (Blueprint $table) { + $table->id(); + $table->foreignId('channel_id')->constrained()->cascadeOnDelete(); + $table->foreignId('broadcast_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('body', 500); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('admin_audit_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('admin_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('subject_type'); + $table->unsignedBigInteger('subject_id'); + $table->string('action'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['subject_type', 'subject_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('admin_audit_logs'); + Schema::dropIfExists('chat_messages'); + Schema::dropIfExists('follows'); + Schema::dropIfExists('vods'); + Schema::dropIfExists('broadcasts'); + Schema::dropIfExists('stream_keys'); + Schema::dropIfExists('channels'); + Schema::dropIfExists('categories'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..3010971 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,38 @@ + 'Gaming', 'slug' => 'gaming'], + ['name' => 'Music', 'slug' => 'music'], + ['name' => 'Talk Shows', 'slug' => 'talk-shows'], + ['name' => 'Education', 'slug' => 'education'], + ['name' => 'Creative', 'slug' => 'creative'], + ])->each(fn (array $category) => Category::query()->firstOrCreate( + ['slug' => $category['slug']], + ['name' => $category['name']], + )); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + User::factory()->create([ + 'name' => 'Admin User', + 'email' => 'admin@example.com', + 'is_admin' => true, + ]); + } +} diff --git a/deploy/mediamtx.yml b/deploy/mediamtx.yml new file mode 100644 index 0000000..f3df484 --- /dev/null +++ b/deploy/mediamtx.yml @@ -0,0 +1,31 @@ +authMethod: http +authHTTPAddress: http://127.0.0.1:8001/internal/mediamtx/auth?secret=change-this-secret +authHTTPExclude: + - action: read + - action: playback + - action: api + - action: metrics + - action: pprof + +rtmp: yes +rtmpAddress: :19935 + +hls: yes +hlsAddress: :19988 +hlsVariant: mpegts +hlsAllowOrigins: + - http://127.0.0.1:8001 + - http://localhost:8001 + +record: yes +recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f + +pathDefaults: + runOnReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID" + runOnReadyRestart: no + runOnNotReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID" + runOnRecordSegmentComplete: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION" + +paths: + all_others: + source: publisher diff --git a/deploy/mediamtx.yml.example b/deploy/mediamtx.yml.example new file mode 100644 index 0000000..f3df484 --- /dev/null +++ b/deploy/mediamtx.yml.example @@ -0,0 +1,31 @@ +authMethod: http +authHTTPAddress: http://127.0.0.1:8001/internal/mediamtx/auth?secret=change-this-secret +authHTTPExclude: + - action: read + - action: playback + - action: api + - action: metrics + - action: pprof + +rtmp: yes +rtmpAddress: :19935 + +hls: yes +hlsAddress: :19988 +hlsVariant: mpegts +hlsAllowOrigins: + - http://127.0.0.1:8001 + - http://localhost:8001 + +record: yes +recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f + +pathDefaults: + runOnReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID" + runOnReadyRestart: no + runOnNotReady: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID" + runOnRecordSegmentComplete: curl -X POST "http://127.0.0.1:8001/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION" + +paths: + all_others: + source: publisher diff --git a/resources/js/pages/admin/dashboard.tsx b/resources/js/pages/admin/dashboard.tsx new file mode 100644 index 0000000..39122cc --- /dev/null +++ b/resources/js/pages/admin/dashboard.tsx @@ -0,0 +1,198 @@ +import { Head, Link, router } from '@inertiajs/react'; +import { Ban, Radio, Shield, StopCircle, Undo2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +type LiveBroadcast = { + id: number; + title: string; + started_at: string | null; + channel: { + id: number; + slug: string; + display_name: string; + owner: string; + }; +}; + +type AdminChannel = { + id: number; + slug: string; + display_name: string; + is_live: boolean; + suspended_at: string | null; + owner: { + id: number; + name: string; + }; +}; + +type Props = { + stats: { + users: number; + channels: number; + live_channels: number; + vods: number; + }; + liveBroadcasts: LiveBroadcast[]; + channels: AdminChannel[]; +}; + +export default function AdminDashboard({ + stats, + liveBroadcasts, + channels, +}: Props) { + return ( + <> + +
+
+ + + +
+

Admin

+

+ Moderate live streams, channels, users, and + recordings. +

+
+
+ +
+ + + + +
+ +
+
+ +

Live broadcasts

+
+
+ {liveBroadcasts.map((broadcast) => ( +
+
+
+ {broadcast.title} +
+
+ {broadcast.channel.display_name} by{' '} + {broadcast.channel.owner} +
+
+
+ + +
+
+ ))} + {liveBroadcasts.length === 0 && ( +
+ No channels are live. +
+ )} +
+
+ +
+

Recent channels

+
+ {channels.map((channel) => ( +
+
+
+ {channel.display_name} +
+
+ @{channel.slug} owned by{' '} + {channel.owner.name} +
+
+
+ + {channel.suspended_at ? ( + + ) : ( + + )} +
+
+ ))} +
+
+
+ + ); +} + +AdminDashboard.layout = { + breadcrumbs: [ + { + title: 'Admin', + href: '/admin', + }, + ], +}; + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/resources/js/pages/channels/show.tsx b/resources/js/pages/channels/show.tsx new file mode 100644 index 0000000..6b897e3 --- /dev/null +++ b/resources/js/pages/channels/show.tsx @@ -0,0 +1,264 @@ +import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; +import { + Heart, + LogIn, + MessageSquare, + Radio, + Send, + Users, + Video, +} from 'lucide-react'; +import type { FormEvent } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { VideoPlayer } from '@/components/video-player'; +import type { + BroadcastSummary, + ChannelDetail, + ChatMessage, + VodSummary, +} from '@/types'; + +type Props = { + channel: ChannelDetail; + currentBroadcast: BroadcastSummary | null; + vods: VodSummary[]; + chatMessages: ChatMessage[]; + isFollowing: boolean; +}; + +export default function ChannelShow({ + channel, + currentBroadcast, + vods, + chatMessages, + isFollowing, +}: Props) { + const { auth } = usePage().props; + const chatForm = useForm({ body: '' }); + + function submitChat(event: FormEvent) { + event.preventDefault(); + chatForm.post(`/channels/${channel.slug}/chat`, { + preserveScroll: true, + onSuccess: () => chatForm.reset('body'), + }); + } + + return ( + <> + +
+
+
+ +
+ +
+
+
+
+ {channel.is_live ? ( + + + LIVE + + ) : ( + + Offline + + )} + {channel.category && ( + + {channel.category.name} + + )} +
+

+ {currentBroadcast?.title ?? + channel.display_name} +

+
+ {channel.display_name} + + + {channel.viewer_count} viewers + + + {channel.followers_count} followers + +
+ {channel.description && ( +

+ {channel.description} +

+ )} +
+ + {auth.user ? ( + + ) : ( + + )} +
+ +
+
+
+ {vods.length > 0 ? ( +
+ {vods.map((vod) => ( +
+
+ {vod.title} +
+
+ Expires{' '} + {vod.expires_at + ? new Date( + vod.expires_at, + ).toLocaleDateString() + : 'later'} +
+ {vod.playback_url ? ( + + ) : ( +
+ Processing recording +
+ )} +
+ ))} +
+ ) : ( +
+ No public recordings yet. +
+ )} +
+
+
+ + +
+ + ); +} + +ChannelShow.layout = { + breadcrumbs: [ + { + title: 'Channel', + href: '/', + }, + ], +}; diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx new file mode 100644 index 0000000..86c1070 --- /dev/null +++ b/resources/js/pages/dashboard.tsx @@ -0,0 +1,541 @@ +import { Head, router, useForm } from '@inertiajs/react'; +import { + Copy, + KeyRound, + Radio, + RotateCw, + Save, + StopCircle, + Video, +} from 'lucide-react'; +import type { FormEvent } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useClipboard } from '@/hooks/use-clipboard'; +import type { BroadcastSummary, Category } from '@/types'; + +type CreatorChannel = { + id: number; + slug: string; + display_name: string; + description: string | null; + is_live: boolean; + viewer_count: number; + stream_key_last_used_at: string | null; + category_id: number | null; + live_broadcast: BroadcastSummary | null; +}; + +type Props = { + channel: CreatorChannel | null; + plainStreamKey: string | null; + streaming: { + ingestServer: string; + tokenizedPath: string | null; + }; + categories: Category[]; + recentBroadcasts: BroadcastSummary[]; +}; + +export default function Dashboard({ + channel, + plainStreamKey, + streaming, + categories, + recentBroadcasts, +}: Props) { + const [, copy] = useClipboard(); + const createForm = useForm({ + display_name: '', + slug: '', + description: '', + category_id: '', + }); + const channelForm = useForm({ + display_name: channel?.display_name ?? '', + slug: channel?.slug ?? '', + description: channel?.description ?? '', + category_id: channel?.category_id ? String(channel.category_id) : '', + }); + const broadcastForm = useForm({ + title: '', + recording_enabled: false, + }); + + function createChannel(event: FormEvent) { + event.preventDefault(); + createForm.post('/creator/channel'); + } + + function updateChannel(event: FormEvent) { + event.preventDefault(); + channelForm.patch('/creator/channel'); + } + + function createBroadcast(event: FormEvent) { + event.preventDefault(); + broadcastForm.post('/creator/broadcasts', { + onSuccess: () => broadcastForm.reset('title', 'recording_enabled'), + }); + } + + return ( + <> + +
+
+
+

+ Creator Studio +

+

+ Manage your channel, stream key, and OBS broadcast + sessions. +

+
+ {channel && ( + + )} +
+ + {!channel ? ( +
+
+

+ Create your channel +

+

+ Each account can own one channel in this + version. +

+
+
+ + + createForm.setData( + 'display_name', + event.target.value, + ) + } + placeholder="Nyone Live" + /> + + + + createForm.setData( + 'slug', + event.target.value.toLowerCase(), + ) + } + placeholder="nyone-live" + /> + + + + createForm.setData('category_id', value) + } + /> + + +