diff --git a/app/Enums/OrderedSongStatus.php b/app/Enums/OrderedSongStatus.php new file mode 100644 index 0000000..4d05b3e --- /dev/null +++ b/app/Enums/OrderedSongStatus.php @@ -0,0 +1,35 @@ + __('enums.NotPlayed'), + self::Playing => __('enums.Playing'), + self::Played => __('enums.Played'), + self::NoFile => __('enums.NoFile'), + self::Skipped => __('enums.Skipped'), + self::InsertPlayback => __('enums.InsertPlayback'), + }; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/RoomSongController.php b/app/Http/Controllers/Api/RoomSongController.php new file mode 100644 index 0000000..a626d03 --- /dev/null +++ b/app/Http/Controllers/Api/RoomSongController.php @@ -0,0 +1,301 @@ +getRoomSession($request->api_token) ; + + // 取得歌曲名稱 + $song = Song::findOrFail($request->song_id); + + // 建立 OrderedSong + $orderedSong = OrderedSong::create([ + 'room_session_id' => $roomSession->id, + 'from_by' => $request->from_by, + 'status' => $request->status, + 'song_id' => $request->song_id, + 'song_name' => $song->name, + 'artist_name' => $song->str_artists_plus(), + 'ordered_at' => now(), + ]); + + // 檢查這首歌在此 session 是否第一次點 + $countInSession = OrderedSong::where('room_session_id', $roomSession->id) + ->where('song_id', $request->song_id) + ->count(); + + if ($countInSession === 1) { // 第一次點才加 + $song->increment('song_counts'); + } + + return ApiResponse::success([ + 'ordered_song' => $orderedSong, + 'next_song_name' => $this->nextSongName($roomSession), + ]); + } + /** + * @OA\Get( + * path="/api/room/ordered-songs", + * summary="取得包廂點歌列表", + * description="取得指定包廂的已播、正在播放、待播/插播歌曲列表", + * tags={"Room Control Song"}, + * security={{"Authorization":{}}}, + * @OA\Parameter(ref="#/components/parameters/ApiTokenQuery"), + * @OA\Response( + * response=200, + * description="成功取得點歌列表", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="played", + * type="array", + * description="已播放 (Played, Skipped, NoFile)", + * @OA\Items(ref="#/components/schemas/OrderedSong") + * ), + * @OA\Property( + * property="playing", + * type="array", + * description="正在播放中 (Playing)", + * @OA\Items(ref="#/components/schemas/OrderedSong") + * ), + * @OA\Property( + * property="not_played", + * type="array", + * description="未播放 (NotPlayed, InsertPlayback)", + * @OA\Items(ref="#/components/schemas/OrderedSong") + * ) + * ) + * ), + * @OA\Response( + * response=422, + * description="驗證失敗", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="message", type="string", example="The given data was invalid."), + * @OA\Property(property="errors", type="object") + * ) + * ) + * ) + */ + public function listOrderedSongs(RoomSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + // 已結束 (finished + canceled) + $played = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['Played', 'Skipped', 'NoFile']) + ->orderByDesc('finished_at') + ->get(); + $playing = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['Playing']) + ->get(); + + // 正在播 + 插播 + 待播 + $not_played = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['Playing', 'InsertPlayback', 'NotPlayed']) + ->orderByRaw("FIELD(status, 'Playing', 'InsertPlayback', 'NotPlayed')") // playing > InsertPlayback > NotPlayed + ->orderByRaw("CASE + WHEN status = 'InsertPlayback' THEN ordered_at END DESC") // InsertPlayback 越後排越前 + ->orderByRaw("CASE + WHEN status = 'NotPlayed' THEN ordered_at END ASC") // NotPlayed 越後排越後 + ->get(); + + return ApiResponse::success([ + 'played' => $played, + 'playing' => $playing, + 'not_played' => $not_played, + ]); + } + /** + * @OA\Post( + * path="/api/room/current-song", + * summary="取得目前播放中的歌曲", + * description="回傳當前房間正在播放的歌曲資訊 (包含部分 song 欄位)", + * tags={"Room Control Song"}, + * security={{"Authorization":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/RoomSongRequest") + * ), + * @OA\Response( + * response=200, + * description="成功回傳目前播放的歌曲", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="success", + * type="boolean", + * example=true + * ), + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="current", + * ref="#/components/schemas/OrderedSongWithPartialSong" + * ) + * ) + * ) + * ) + * ) + */ + public function currentSong(RoomSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + $current = OrderedSong::where('room_session_id', $roomSession->id) + ->where('status', 'Playing') + ->withPartialSong() + ->first(); + return ApiResponse::success([ + 'current' => $current , + ]); + } + /** + * @OA\Post( + * path="/api/room/next-song", + * summary="播放下一首歌曲", + * description="將目前播放的歌標記為結束,並將下一首設為播放中,回傳下首與下下首資訊。", + * tags={"Room Control Song"}, + * security={{"Authorization":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/RoomSongRequest") + * ), + * @OA\Response( + * response=200, + * description="成功回傳歌曲資訊", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="current", + * type="object", + * nullable=true, + * ref="#/components/schemas/OrderedSongWithPartialSong" + * ), + * @OA\Property( + * property="next", + * type="object", + * nullable=true, + * ref="#/components/schemas/OrderedSongWithPartialSong" + * ) + * ) + * ), + * @OA\Response( + * response=401, + * description="未授權或 Token 錯誤" + * ), + * @OA\Response( + * response=404, + * description="找不到房間或歌曲" + * ) + * ) + */ + public function nextSong(RoomSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + // 找目前正在播 + $current = OrderedSong::where('room_session_id', $roomSession->id) + ->where('status', 'Playing') + ->first(); + if ($current) { + // 把當前設為 finished,並記錄結束時間 + $current->update([ + 'status' => 'Played', + 'finished_at' => now(), + ]); + } + // 撈出候播清單(下首 + 下下首) + $queue = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['InsertPlayback', 'NotPlayed']) + ->withPartialSong() + ->orderByRaw("FIELD(status, 'InsertPlayback', 'NotPlayed')") // InsertPlayback > NotPlayed + ->orderByRaw("CASE WHEN status = 'InsertPlayback' THEN ordered_at END DESC") // InsertPlayback 後插播的先播 + ->orderByRaw("CASE WHEN status = 'NotPlayed' THEN ordered_at END ASC") // NotPlayed 先點的先播 + ->limit(2) + ->get(); + $current =$queue->get(0); + if ($current) { + $current->update([ + 'status' => 'Playing', + 'started_at' => now(), + ]); + } + return ApiResponse::success([ + 'current' => $current,// 下首 + 'next' => $queue->get(1) ?? null,// 下下首 + ]); + } + + private function nextSongName(RoomSession $roomSession) + { + // 找下首 + $next = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['InsertPlayback', 'NotPlayed']) + ->with('song') + ->orderByRaw("FIELD(status, 'InsertPlayback', 'NotPlayed')") + ->orderByRaw("CASE WHEN status = 'InsertPlayback' THEN ordered_at END DESC") + ->orderByRaw("CASE WHEN status = 'NotPlayed' THEN ordered_at END ASC") + ->first(); + return $next?->song?->name; + + } + /** + * 取得對應的 RoomSession + */ + private function getRoomSession($api_token): RoomSession + { + return RoomSession::where('api_token', $api_token) + ->whereIn('status', ['active', 'maintain']) + ->firstOrFail(); + } +} \ No newline at end of file diff --git a/app/Http/Requests/OrderSongRequest.php b/app/Http/Requests/OrderSongRequest.php new file mode 100644 index 0000000..7beb1bc --- /dev/null +++ b/app/Http/Requests/OrderSongRequest.php @@ -0,0 +1,33 @@ + [ + 'required', + Rule::exists('room_sessions', 'api_token') + ->where(fn ($q) => $q->whereIn('status', ['active', 'maintain'])) + ], + 'song_id' => 'required|exists:songs,id', + 'status' => 'required|in:NotPlayed,InsertPlayback,Skipped', + 'from_by' => 'nullable', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/RoomSongRequest.php b/app/Http/Requests/RoomSongRequest.php new file mode 100644 index 0000000..257afd1 --- /dev/null +++ b/app/Http/Requests/RoomSongRequest.php @@ -0,0 +1,43 @@ + [ + 'required', + Rule::exists('room_sessions', 'api_token') + ->where(fn ($q) => $q->whereIn('status', ['active', 'maintain'])) + ] + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/SendRoomSwitchCommandRequest.php b/app/Http/Requests/SendRoomSwitchCommandRequest.php index 3dc8237..5dd5fa9 100644 --- a/app/Http/Requests/SendRoomSwitchCommandRequest.php +++ b/app/Http/Requests/SendRoomSwitchCommandRequest.php @@ -2,7 +2,6 @@ namespace App\Http\Requests; -use Illuminate\Foundation\Http\FormRequest; /** * @OA\Schema( @@ -10,7 +9,7 @@ use Illuminate\Foundation\Http\FormRequest; * required={"branch_name", "room_name", "command"}, * @OA\Property(property="branch_name", type="string", example="測試"), * @OA\Property(property="room_name", type="string", example="pc102"), - * @OA\Property(property="command", type="string", enum={"active", "closed", "fire",maintain, "error"}, example="active"), + * @OA\Property(property="command", type="string", enum={"active", "closed", "fire","maintain", "error"}, example="active"), * @OA\Property(property="started_at", type="string", nullable=true, example="2025-05-19 09:31:00"), * @OA\Property(property="ended_at", type="string", nullable=true, example="2025-05-19 09:31:00") * ) diff --git a/app/Livewire/Modals/RoomDetailModal.php b/app/Livewire/Modals/RoomDetailModal.php index 3fa97f1..598bbe0 100644 --- a/app/Livewire/Modals/RoomDetailModal.php +++ b/app/Livewire/Modals/RoomDetailModal.php @@ -4,7 +4,7 @@ namespace App\Livewire\Modals; use App\Models\Room; use App\Models\Branch; - +use Illuminate\Support\Carbon; use Livewire\Component; use App\Services\ApiClient; use Illuminate\Support\Facades\Auth; @@ -35,20 +35,19 @@ class RoomDetailModal extends Component $this->showModal = false; } public function startNotify() - { - $data = $this->buildNotifyData('maintain', null, null); + { $data = $this->buildNotifyData('maintain', Carbon::now(), null); $this->send($data); } public function stopNotify() { - $data = $this->buildNotifyData('closed', null, null); + $data = $this->buildNotifyData('closed', null, Carbon::now()); $this->send($data); } public function fireNotify() { - $data = $this->buildNotifyData('fire', null, null); + $data = $this->buildNotifyData('fire', null, Carbon::now()); $this->send($data); } diff --git a/app/Models/OrderedSong.php b/app/Models/OrderedSong.php new file mode 100644 index 0000000..fb21747 --- /dev/null +++ b/app/Models/OrderedSong.php @@ -0,0 +1,87 @@ + 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'status' => \App\Enums\OrderedSongStatus::class, + ]; + + + public function session() + { + return $this->belongsTo(RoomSession::class, 'room_session_id'); + } + + public function song() + { + return $this->belongsTo(Song::class); + } + public function scopeWithPartialSong($query) + { + return $query->with([ + 'song' => function ($q) { + $q->select('id', 'name','filename','db_change','vocal','situation'); // 精簡版 + } + ]); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index 80ad07b..20d79d2 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -92,4 +92,8 @@ class Room extends Model public function statusLogs() { return $this->hasMany(RoomStatusLog::class); } + public function sessions() + { + return $this->hasMany(RoomSession::class); + } } diff --git a/app/Models/RoomSession.php b/app/Models/RoomSession.php new file mode 100644 index 0000000..7b17096 --- /dev/null +++ b/app/Models/RoomSession.php @@ -0,0 +1,42 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; + + // 狀態常數 + public const STATUS_ACTIVE = 'active'; + public const STATUS_CLOSED = 'closed'; + public const STATUS_FORCE_CLOSED = 'force_closed'; + public const STATUS_FIRE_CLOSED = 'fire_closed'; + + // 模式常數 + public const MODE_NORMAL = 'normal'; + public const MODE_VIP = 'vip'; + public const MODE_TEST = 'test'; + + public function room() + { + return $this->belongsTo(Room::class); + } +} diff --git a/app/Models/Song.php b/app/Models/Song.php index 0346ce3..ba70219 100644 --- a/app/Models/Song.php +++ b/app/Models/Song.php @@ -53,6 +53,10 @@ class Song extends Model public function str_artists(){ return $this->artists->pluck('name')->implode(', '); } + public function str_artists_plus(): string + { + return $this->artists->pluck('name')->implode(' + '); + } public function artists(){ return $this->belongsToMany(Artist::class); } diff --git a/app/Observers/RoomObserver.php b/app/Observers/RoomObserver.php index f359712..e7bd0b9 100644 --- a/app/Observers/RoomObserver.php +++ b/app/Observers/RoomObserver.php @@ -15,17 +15,8 @@ class RoomObserver */ public function created(Room $room): void { - RoomStatusLog::create([ - 'branch_id' => $room->branch->id, - 'room_id' => $room->id, - 'user_id' => Auth::id()?? 0, - 'is_online' => $room->is_online, - 'status' => $room->status ?? 'error', - 'started_at' => $room->started_at, - 'ended_at' => $room->ended_at, - 'message' => $room->log_message ?? '', - 'source' => $this->getSource($room), - ]); + // 建立初始 log + $this->createStatusLog($room); } /** @@ -35,17 +26,32 @@ class RoomObserver { // 檢查是否有變更狀態 if ($room->wasChanged()) { - RoomStatusLog::create([ - 'branch_id' => $room->branch->id, - 'room_id' => $room->id, - 'user_id' => Auth::id() ?? 0, - 'is_online' => $room->is_online, - 'status' => $room->status, - 'started_at' =>$room->started_at, - 'ended_at' =>$room->ended_at, - 'message' => $room->log_message ?? '', - 'source' => $this->getSource($room), - ]); + $this->createStatusLog($room); + } + if ($room->isDirty('status')) { + $now = now(); + + // 找到最後一筆未結束 session + $lastSession = $room->sessions()->whereNull('ended_at')->latest('started_at')->first(); + + if ($lastSession) { + // 結束上一筆 session + $lastSession->update([ + 'status' => $room->status->value, + 'ended_at' => $now, + ]); + } + + // 如果狀態是 active 或 maintain,開新 session + if (in_array($room->status->value, ['active', 'maintain'])) { + $mode = $room->status->value === 'active' ? 'normal' : 'test'; + $room->sessions()->create([ + 'mode' => $mode, + 'status' => $room->status->value, + 'started_at' => $now, + 'api_token' => bin2hex(random_bytes(32)), + ]); + } } } @@ -62,35 +68,28 @@ class RoomObserver $room->internal_ip, $room->port ); + $this->createStatusLog($room,$message); + } + + /** + * 建立 RoomStatusLog + */ + private function createStatusLog(Room $room,$log_message =null): void + { + $message=($log_message !=null)?$log_message:$room->log_message ?? ''; RoomStatusLog::create([ - 'branch_id' => $room->branch->id, - 'room_id' => $room->id, - 'user_id' => Auth::id() ?? 0, - 'is_online' => $room->is_online, - 'status' => $room->status, - 'started_at' =>$room->started_at, - 'ended_at' =>$room->ended_at, + 'branch_id' => $room->branch->id, + 'room_id' => $room->id, + 'user_id' => Auth::id() ?? 0, + 'is_online' => $room->is_online, + 'status' => $room->status, + 'started_at' => $room->started_at, + 'ended_at' => $room->ended_at, 'message' => $message, - 'source' => $this->getSource($room), + 'source' => $this->getSource($room), ]); } - /** - * Handle the Room "restored" event. - */ - public function restored(Room $room): void - { - // - } - - /** - * Handle the Room "force deleted" event. - */ - public function forceDeleted(Room $room): void - { - // - } - private function getSource(Room $room): string { return app()->runningInConsole() ? 'system' : ($room->log_source ?? 'manual'); diff --git a/database/migrations/2025_08_18_103818_create_room_sessions_table.php b/database/migrations/2025_08_18_103818_create_room_sessions_table.php new file mode 100644 index 0000000..664ca15 --- /dev/null +++ b/database/migrations/2025_08_18_103818_create_room_sessions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('room_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['active', 'closed','fire', 'error', 'maintain'])->default('error'); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->enum('mode', ['normal', 'test'])->default('normal'); + $table->string('close_reason')->nullable(); + $table->string('api_token', 64)->unique()->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('room_sessions'); + } +}; diff --git a/database/migrations/2025_08_18_104152_create_ordered_songs_table.php b/database/migrations/2025_08_18_104152_create_ordered_songs_table.php new file mode 100644 index 0000000..c596df4 --- /dev/null +++ b/database/migrations/2025_08_18_104152_create_ordered_songs_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('room_session_id')->constrained('room_sessions')->cascadeOnDelete(); + $table->string('from_by')->nullable(); + + // 歌曲資訊 + $table->foreignId('song_id')->constrained()->cascadeOnDelete(); + $table->string('song_name')->nullable(); + $table->string('artist_name')->nullable(); + + + // 狀態:未播 / 播放中 / 已播 / 刪除 + $table->enum('status', ['NotPlayed', 'Playing', 'Played', 'NoFile', 'Skipped' , 'InsertPlayback'])->default('NotPlayed'); + + // 播放流程 + $table->timestamp('ordered_at')->useCurrent(); // 點歌時間 + $table->timestamp('started_at')->nullable(); // 開始播放時間 + $table->timestamp('finished_at')->nullable(); // 播放結束時間 + + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ordered_songs'); + } +}; diff --git a/resources/lang/zh-tw/enums.php b/resources/lang/zh-tw/enums.php index 70aa0af..bda751f 100644 --- a/resources/lang/zh-tw/enums.php +++ b/resources/lang/zh-tw/enums.php @@ -5,6 +5,12 @@ return [ 'Other' => '其他', 'Male' =>'男', 'Female' =>'女', + 'NotPlayed' =>'未播放', + 'Playing' =>'播放中', + 'Played' =>'播畢', + 'NoFile' =>'無文件', + 'Skipped' =>'刪除', + 'InsertPlayback' =>'插播', 'arist.category.Group' => '團', 'arist.category.Foreign' => '外', 'song.situation.Romantic' => '浪漫', diff --git a/routes/api.php b/routes/api.php index 2201fba..d77ed88 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,8 @@ use App\Http\Controllers\ArtistController; use App\Http\Controllers\AuthController; use App\Http\Controllers\BranchControlController; use App\Http\Controllers\RoomControlController; +use App\Http\Controllers\Api\RoomSongController; +use App\Http\Controllers\Api\SongSearchController; Route::get('/artists/search', [App\Http\Controllers\ArtistController::class, 'search'])->name('api.artists.search'); @@ -12,9 +14,13 @@ Route::post('/login', [AuthController::class, 'login']); Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegister']); Route::middleware('auth:sanctum')->group(function () { - Route::get('/profile', [AuthController::class, 'profile']); - Route::get('/branches',[BranchControlController::class, 'Branches']); + Route::get ('/profile', [AuthController::class, 'profile']); + Route::get ('/branches',[BranchControlController::class, 'Branches']); Route::post('/room/sendSwitch', [RoomControlController::class, 'sendSwitch']); Route::post('/room/receiveSwitch', [RoomControlController::class, 'receiveSwitch']); Route::post('/room/heartbeat', [RoomControlController::class, 'StatusReport']); + Route::post('/room/order-song', [RoomSongController::class, 'orderSong']); + Route::get ('/room/ordered-songs', [RoomSongController::class, 'listOrderedSongs']); + Route::post ('/room/current-song', [RoomSongController::class, 'currentSong']); + Route::post ('/room/next-song', [RoomSongController::class, 'nextSong']); }); \ No newline at end of file