Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/Http/Controllers/DeezerLookupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ private function extractTrackTitles(array $tracks, string $artist, string $query
}

$seen[$title] = true;
$titles[] = $title;
$duration = isset($track['duration']) ? (int) $track['duration'] : null;
$titles[] = ['title' => $title, 'duration' => $duration];

if (count($titles) >= 8) {
break;
Expand Down
187 changes: 187 additions & 0 deletions app/Http/Controllers/LiveJamController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

namespace App\Http\Controllers;

use App\Models\JamSession;
use App\Models\Slot;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;

class LiveJamController extends Controller
{
private const STATES = ['playing_now', 'coming_up', 'pending', 'postponed', 'finished'];

private const TRANSITION_SECONDS_PER_UNIQUE_USER = 45;

/**
* Cache TTL: 12 hours – enough to cover a full jam session evening.
*/
private const CACHE_TTL_SECONDS = 43200;

/**
* Show the organiser management dashboard.
*/
public function manage(Request $request, JamSession $jamSession): View
{
$this->authorize('update', $jamSession);

$sets = $jamSession->sets()
->visibleTo($request->user())
->with(['owner', 'songs.slots.user'])
->get();

$liveState = $this->getLiveState($jamSession->id);

return view('sessions.live.manage', [
'session' => $jamSession,
'sets' => $sets,
'liveState' => $liveState,
'slotOptions' => Slot::options(),
]);
}

/**
* Show the public live dashboard for participants.
*/
public function dashboard(JamSession $jamSession): View
{
return view('sessions.live.dashboard', [
'session' => $jamSession,
]);
}

/**
* Return the current live state as JSON (used for polling from both dashboards).
*/
public function data(Request $request, JamSession $jamSession): JsonResponse
{
$sets = $jamSession->sets()
->visibleTo($request->user())
->with(['owner', 'songs' => fn ($q) => $q->with(['slots.user'])])
->get();

$liveState = $this->getLiveState($jamSession->id);
$slotOptions = Slot::options();

$setsData = $sets->map(function ($set) use ($liveState, $slotOptions): array {
$stateEntry = collect($liveState['sets'] ?? [])->firstWhere('set_id', $set->id);
$status = $stateEntry['status'] ?? 'pending';
$order = $stateEntry['order'] ?? $set->position;

$totalSlots = 0;
$filledSlots = 0;
$checkedInSlots = 0;
$totalDurationSeconds = 0;
$uniqueUsers = collect();

foreach ($set->songs as $song) {
if ($song->source !== null && $song->source !== '' && $song->duration !== null) {
$totalDurationSeconds += $song->duration;
}

foreach ($song->slots as $slot) {
$totalSlots++;

if ($slot->user_id !== null || $slot->manual_performer_name !== null) {
$filledSlots++;

if ($slot->user_id !== null) {
$uniqueUsers->push($slot->user_id);
}
}
}
}

$uniqueUserCount = $uniqueUsers->unique()->count();

if ($uniqueUserCount > 1) {
$totalDurationSeconds += ($uniqueUserCount - 1) * self::TRANSITION_SECONDS_PER_UNIQUE_USER;
}

$health = $totalSlots > 0 ? round($filledSlots / $totalSlots * 100) : 0;

return [
'id' => $set->id,
'name' => $set->name,
'owner' => $set->owner?->name,
'status' => $status,
'order' => $order,
'health' => $health,
'total_slots' => $totalSlots,
'filled_slots' => $filledSlots,
'duration_seconds' => $totalDurationSeconds,
'songs' => $set->songs->map(fn ($song) => [
'id' => $song->id,
'artist' => $song->artist,
'title' => $song->title,
'duration' => $song->duration,
'source' => $song->source,
'slots' => $song->slots->map(fn ($slot) => [
'id' => $slot->id,
'name' => $slotOptions[$slot->name] ?? $slot->name,
'user_name' => $slot->user?->name ?? $slot->manual_performer_name,
'filled' => $slot->user_id !== null || $slot->manual_performer_name !== null,
])->values()->all(),
])->values()->all(),
];
});

return response()->json([
'sets' => $setsData->values()->all(),
'updated_at' => $liveState['updated_at'] ?? null,
]);
}

/**
* Save updated live state to the cache.
*/
public function update(Request $request, JamSession $jamSession): JsonResponse
{
$this->authorize('update', $jamSession);

$validated = $request->validate([
'sets' => ['required', 'array'],
'sets.*.set_id' => ['required', 'integer', 'exists:sets,id'],
'sets.*.status' => ['required', 'string', 'in:'.implode(',', self::STATES)],
'sets.*.order' => ['required', 'integer', 'min:0'],
]);

$state = [
'sets' => $validated['sets'],
'updated_at' => now()->toIso8601String(),
];

Cache::put($this->cacheKey($jamSession->id), $state, self::CACHE_TTL_SECONDS);

return response()->json(['message' => 'Live state updated.']);
}

/**
* Clear the live state cache for a session.
*/
public function clear(Request $request, JamSession $jamSession): JsonResponse
{
$this->authorize('update', $jamSession);

Cache::forget($this->cacheKey($jamSession->id));

return response()->json(['message' => 'Live state cleared.']);
}

/**
* Retrieve the current live state from cache, or return a default.
*
* @return array{sets: array<int, array{set_id: int, status: string, order: int}>, updated_at: string|null}
*/
private function getLiveState(int $sessionId): array
{
return Cache::get($this->cacheKey($sessionId), ['sets' => [], 'updated_at' => null]);
}

private function cacheKey(int $sessionId): string
{
return 'live_jam_session:'.$sessionId;
}
}
4 changes: 4 additions & 0 deletions app/Http/Controllers/SongController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public function store(Request $request, Set $set): JsonResponse|RedirectResponse
'artist' => ['required', 'string', 'max:255'],
'title' => ['required', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
'duration' => ['nullable', 'integer', 'min:0'],
'source' => ['nullable', 'string', 'max:50'],
'band_template_id' => ['nullable', 'integer', 'exists:band_templates,id'],
'slot_names' => ['nullable', 'array'],
'slot_names.*' => ['string', 'in:'.implode(',', Slot::keys())],
Expand All @@ -34,6 +36,8 @@ public function store(Request $request, Set $set): JsonResponse|RedirectResponse
'artist' => $validated['artist'],
'title' => $validated['title'],
'notes' => $validated['notes'] ?? null,
'duration' => $validated['duration'] ?? null,
'source' => $validated['source'] ?? null,
'position' => $nextSongPosition,
]);

Expand Down
5 changes: 4 additions & 1 deletion app/Models/Song.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;

class Song extends Model
{
protected $fillable = [
'artist',
'title',
'notes',
'duration',
'source',
'set_id',
'position',
];
Expand All @@ -20,6 +22,7 @@ protected function casts(): array
{
return [
'position' => 'integer',
'duration' => 'integer',
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('songs', function (Blueprint $table) {
$table->unsignedInteger('duration')->nullable()->after('notes');
$table->string('source')->nullable()->after('duration');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('songs', function (Blueprint $table) {
$table->dropColumn(['duration', 'source']);
});
}
};
12 changes: 11 additions & 1 deletion resources/js/components/sessionCards.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export function sessionSetCard(config) {
songArtistQuery: '',
songTitleQuery: '',
selectedArtistName: '',
selectedDeezerDuration: null,
deezerTitleSelected: false,
artistSuggestions: [],
titleSuggestions: [],
artistLookupBusy: false,
Expand Down Expand Up @@ -584,6 +586,8 @@ export function sessionSetCard(config) {
this.songArtistQuery = '';
this.songTitleQuery = '';
this.selectedArtistName = '';
this.selectedDeezerDuration = null;
this.deezerTitleSelected = false;
this.artistSuggestions = [];
this.titleSuggestions = [];
this.artistLookupBusy = false;
Expand All @@ -605,6 +609,8 @@ export function sessionSetCard(config) {
this.artistLookupError = '';
this.showTitleSuggestions = false;
this.titleSuggestions = [];
this.selectedDeezerDuration = null;
this.deezerTitleSelected = false;

if (this.artistLookupTimer) {
clearTimeout(this.artistLookupTimer);
Expand Down Expand Up @@ -669,6 +675,8 @@ export function sessionSetCard(config) {
},
queueTitleLookup() {
this.titleLookupError = '';
this.selectedDeezerDuration = null;
this.deezerTitleSelected = false;

if (this.titleLookupTimer) {
clearTimeout(this.titleLookupTimer);
Expand Down Expand Up @@ -721,8 +729,10 @@ export function sessionSetCard(config) {
}
}
},
selectTitleSuggestion(title) {
selectTitleSuggestion(title, duration) {
this.songTitleQuery = title;
this.selectedDeezerDuration = duration ?? null;
this.deezerTitleSelected = true;
this.titleSuggestions = [];
this.showTitleSuggestions = false;
},
Expand Down
8 changes: 5 additions & 3 deletions resources/views/components/sessions/set-card.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -595,19 +595,21 @@ class="mt-1 block w-full rounded-lg border-slate-300 bg-white px-3 py-2 text-sla
class="absolute z-20 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-lg"
@click.outside="showTitleSuggestions = false"
>
<template x-for="title in titleSuggestions" :key="`title-${title}`">
<template x-for="track in titleSuggestions" :key="`title-${track.title}`">
<li>
<button
type="button"
@click="selectTitleSuggestion(title)"
@click="selectTitleSuggestion(track.title, track.duration)"
class="w-full px-3 py-2 text-left text-sm text-slate-700 transition hover:bg-slate-50"
x-text="title"
x-text="track.title"
></button>
</li>
</template>
</ul>
</div>
</div>
<input type="hidden" name="duration" :value="deezerTitleSelected && selectedDeezerDuration ? selectedDeezerDuration : ''">
<input type="hidden" name="source" :value="deezerTitleSelected ? 'deezer' : ''">
<div>
<x-input-label :value="'Notes'" />
<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-amber-500 focus:ring-2 focus:ring-amber-200"></textarea>
Expand Down
2 changes: 2 additions & 0 deletions resources/views/layouts/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4"
{{ $slot }}
</main>

@stack('scripts')

<footer class="border-t border-slate-800/70 px-4 py-6 text-xs text-slate-500 sm:px-6 lg:px-8">
<div class="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p>&copy; {{ date('Y') }} TJD Tech</p>
Expand Down
Loading