feat: add plugin model

initial implementation of playlist

feat: added support for playlists
This commit is contained in:
Benjamin Nussbaum 2025-03-11 22:34:28 +01:00
parent 276511fc98
commit 4195269414
17 changed files with 669 additions and 15 deletions

View file

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
@ -50,4 +51,29 @@ class Device extends Model
return 3; // Strong signal (3 bars)
}
}
public function playlists(): HasMany
{
return $this->hasMany(Playlist::class);
}
public function getNextPlaylistItem(): ?PlaylistItem
{
// Get all active playlists
$playlists = $this->playlists()
->where('is_active', true)
->get();
// Find the first active playlist with an available item
foreach ($playlists as $playlist) {
if ($playlist->isActiveNow()) {
$nextItem = $playlist->getNextPlaylistItem();
if ($nextItem) {
return $nextItem;
}
}
}
return null;
}
}

94
app/Models/Playlist.php Normal file
View file

@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Playlist extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
'is_active' => 'boolean',
'weekdays' => 'array',
'active_from' => 'datetime:H:i',
'active_until' => 'datetime:H:i',
];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function items(): HasMany
{
return $this->hasMany(PlaylistItem::class);
}
public function isActiveNow(): bool
{
if (! $this->is_active) {
return false;
}
// Check weekday
if ($this->weekdays !== null) {
if (! in_array(now()->dayOfWeek, $this->weekdays)) {
return false;
}
}
// Check time range
if ($this->active_from !== null && $this->active_until !== null) {
if (! now()->between($this->active_from, $this->active_until)) {
return false;
}
}
return true;
}
public function getNextPlaylistItem(): ?PlaylistItem
{
if (! $this->isActiveNow()) {
return null;
}
// Get active playlist items ordered by display order
$playlistItems = $this->items()
->where('is_active', true)
->orderBy('order')
->get();
if ($playlistItems->isEmpty()) {
return null;
}
// Get the last displayed item
$lastDisplayed = $playlistItems
->sortByDesc('last_displayed_at')
->first();
if (! $lastDisplayed || ! $lastDisplayed->last_displayed_at) {
// If no item has been displayed yet, return the first one
return $playlistItems->first();
}
// Find the next item in sequence
$currentOrder = $lastDisplayed->order;
$nextItem = $playlistItems
->where('order', '>', $currentOrder)
->first();
// If there's no next item, loop back to the first one
if (! $nextItem) {
return $playlistItems->first();
}
return $nextItem;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlaylistItem extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
'is_active' => 'boolean',
'last_displayed_at' => 'datetime',
];
public function playlist(): BelongsTo
{
return $this->belongsTo(Playlist::class);
}
public function plugin(): BelongsTo
{
return $this->belongsTo(Plugin::class);
}
}

29
app/Models/Plugin.php Normal file
View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Plugin extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
'data_payload' => 'json',
];
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = Str::uuid();
}
});
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Device;
use App\Models\Playlist;
use App\Models\Plugin;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class PlaylistFactory extends Factory
{
protected $model = Playlist::class;
public function definition(): array
{
return [
'order' => $this->faker->randomNumber(),
'is_active' => $this->faker->boolean(),
'last_displayed_at' => Carbon::now(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'device_id' => Device::factory(),
'plugin_id' => Plugin::factory(),
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Playlist;
use App\Models\PlaylistItem;
use App\Models\Plugin;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class PlaylistItemFactory extends Factory
{
protected $model = PlaylistItem::class;
public function definition(): array
{
return [
'order' => $this->faker->randomNumber(),
'is_active' => $this->faker->boolean(),
'last_displayed_at' => Carbon::now(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'playlist_id' => Playlist::factory(),
'plugin_id' => Plugin::factory(),
];
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('playlists', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->boolean('is_active')->default(true);
$table->json('weekdays')->nullable(); // Array of weekday numbers (0-6)
$table->time('active_from')->nullable();
$table->time('active_until')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('playlists');
}
};

View file

@ -0,0 +1,32 @@
<?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::create('playlist_items', function (Blueprint $table) {
$table->id();
$table->foreignId('playlist_id')->constrained()->onDelete('cascade');
$table->foreignId('plugin_id')->constrained()->onDelete('cascade');
$table->integer('order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamp('last_displayed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('playlist_items');
}
};

View file

@ -17,11 +17,11 @@
Dashboard
</flux:navbar.item>
<flux:navbar.item icon="square-chart-gantt" href="{{ route('devices') }}" wire:navigate
:current="request()->routeIs('devices')">
:current="request()->routeIs(['devices', 'devices.configure'])">
Devices
</flux:navbar.item>
<flux:navbar.item icon="puzzle-piece" href="{{ route('plugins.index') }}" wire:navigate
:current="request()->routeIs(['plugins.index', 'plugins.markup'])">
:current="request()->routeIs(['plugins.index', 'plugins.markup', 'plugins.api', 'plugins.receipt'])">
Plugins
</flux:navbar.item>
</flux:navbar>

View file

@ -34,13 +34,17 @@ new class extends Component {
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;
if($current_image_uuid) {
file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp';
$current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension;
} else {
$current_image_path = 'storage/images/setup-logo.bmp';
}
@endphp
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1>
<p class="text-sm dark:text-zinc-400">{{$device->mac_address}}</p>
@if($current_image_uuid)
@if($current_image_path)
<flux:separator class="mt-2 mb-4"/>
<img src="{{ asset($current_image_path) }}" alt="Current Image"/>
@endif

View file

@ -68,8 +68,12 @@ new class extends Component {
<div class="px-10 py-8 min-w-lg">
@php
$current_image_uuid =$device->current_screen_image;
if($current_image_uuid) {
file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp';
$current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension;
} else {
$current_image_path = 'storage/images/setup-logo.bmp';
}
@endphp
<div class="flex items-center justify-between">
@ -125,7 +129,7 @@ new class extends Component {
<flux:input label="Friendly ID" wire:model="friendly_id"/>
<flux:input label="MAC Address" wire:model="mac_address"/>
<flux:input label="Default Refresh Interval" wire:model="default_refresh_interval"/>
<flux:input label="Default Refresh Interval (seconds)" wire:model="default_refresh_interval" type="number"/>
<div class="flex">
<flux:spacer/>
@ -153,7 +157,7 @@ new class extends Component {
</flux:modal>
@if($current_image_uuid)
@if($current_image_path)
<flux:separator class="mt-6 mb-6" text="Next Screen"/>
<img src="{{ asset($current_image_path) }}" alt="Next Image"/>
@endif

View file

@ -77,8 +77,12 @@ new class extends Component {
</flux:modal.trigger>
</div>
@if (session()->has('message'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4">
{{ session('message') }}
<div class="mb-4">
<flux:callout variant="success" icon="check-circle" heading=" {{ session('message') }}">
<x-slot name="controls">
<flux:button icon="x-mark" variant="ghost" x-on:click="$el.closest('[data-flux-callout]').remove()" />
</x-slot>
</flux:callout>
</div>
@endif
@ -116,7 +120,7 @@ new class extends Component {
<div class="mb-4">
<flux:input label="Refresh Rate (seconds)" wire:model="default_refresh_interval"
id="default_refresh_interval"
class="block mt-1 w-full" type="text" name="default_refresh_interval"
class="block mt-1 w-full" type="number" name="default_refresh_interval"
autofocus/>
</div>
<div class="flex">

View file

@ -124,7 +124,7 @@ new class extends Component {
@foreach($plugins as $plugin)
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<a href="{{ isset($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : '#' }}" class="block">
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.receipt', ['plugin' => $plugin['id']]) }}" class="block">
<div class="flex items-center space-x-4 px-10 py-8">
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}" class="text-4xl text-accent"/>
<h3 class="text-xl font-medium dark:text-zinc-200">{{$plugin['name']}}</h3>

View file

@ -0,0 +1,325 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
new class extends Component {
public Plugin $plugin;
public string|null $blade_code;
public string $name;
public int $data_stale_minutes;
public string $data_strategy;
public string $polling_url;
public string $polling_verb;
public string|null $polling_header;
public $data_payload;
public array $checked_devices = [];
public string $playlist_name = '';
public array $selected_weekdays = [];
public string $active_from = '';
public string $active_until = '';
public string $selected_playlist = '';
public function mount(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->blade_code = $this->plugin->render_markup;
$this->fillformFields();
}
public function fillFormFields(): void
{
$this->name = $this->plugin->name;
$this->data_stale_minutes = $this->plugin->data_stale_minutes;
$this->data_strategy = $this->plugin->data_strategy;
$this->polling_url = $this->plugin->polling_url;
$this->polling_verb = $this->plugin->polling_verb;
$this->polling_header = $this->plugin->polling_header;
$this->data_payload = json_encode($this->plugin->data_payload);
}
public function saveMarkup(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->validate();
$this->plugin->update(['render_markup' => $this->blade_code]);
}
protected array $rules = [
'name' => 'required|string|max:255',
'data_stale_minutes' => 'required|integer|min:1',
'data_strategy' => 'required|string|in:polling,webhook',
'polling_url' => 'required|url',
'polling_verb' => 'required|string|in:get,post',
'polling_header' => 'nullable|string|max:255',
'blade_code' => 'nullable|string',
'checked_devices' => 'array',
'playlist_name' => 'required_if:selected_playlist,new|string|max:255',
'selected_weekdays' => 'array',
'active_from' => 'nullable|date_format:H:i',
'active_until' => 'nullable|date_format:H:i',
'selected_playlist' => 'nullable|string',
];
public function editSettings()
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$validated = $this->validate();
$this->plugin->update($validated);
}
public function updateData()
{
if ($this->plugin->data_strategy === 'polling') {
$response = Http::get($this->plugin->polling_url)->json();
$this->plugin->update(['data_payload' => $response]);
$this->data_payload = json_encode($response);
}
}
public function addToPlaylist()
{
$this->validate([
'checked_devices' => 'required|array|min:1',
'selected_playlist' => 'required|string',
]);
foreach ($this->checked_devices as $deviceId) {
$playlist = null;
if ($this->selected_playlist === 'new') {
// Create new playlist
$this->validate([
'playlist_name' => 'required|string|max:255',
]);
$playlist = \App\Models\Playlist::create([
'device_id' => $deviceId,
'name' => $this->playlist_name,
'weekdays' => !empty($this->selected_weekdays) ? $this->selected_weekdays : null,
'active_from' => $this->active_from ?: null,
'active_until' => $this->active_until ?: null,
]);
} else {
$playlist = \App\Models\Playlist::findOrFail($this->selected_playlist);
}
// Add plugin to playlist
$maxOrder = $playlist->items()->max('order') ?? 0;
$playlist->items()->create([
'plugin_id' => $this->plugin->id,
'order' => $maxOrder + 1,
]);
}
$this->reset(['checked_devices', 'playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'selected_playlist']);
Flux::modal('add-plugin')->close();
}
public function getDevicePlaylists($deviceId)
{
return \App\Models\Playlist::where('device_id', $deviceId)->get();
}
public function renderExample(string $example)
{
switch ($example) {
case 'layoutTitle':
$markup = $this->renderLayoutWithTitleBar();
break;
case 'layout':
$markup = $this->renderLayoutBlank();
break;
default:
$markup = '<h1>Hello World!</h1>';
break;
}
$this->blade_code = $markup;
}
public function renderLayoutWithTitleBar(): string
{
return <<<HTML
<x-trmnl::view>
<x-trmnl::layout>
<!-- ADD YOUR CONTENT HERE-->
</x-trmnl::layout>
<x-trmnl::title-bar/>
</x-trmnl::view>
HTML;
}
public function renderLayoutBlank(): string
{
return <<<HTML
<x-trmnl::view>
<x-trmnl::layout>
<!-- ADD YOUR CONTENT HERE-->
</x-trmnl::layout>
</x-trmnl::view>
HTML;
}
}
?>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">{{$plugin->name}}</h2>
<flux:modal.trigger name="add-plugin">
<flux:button icon="play" variant="primary">Add to Playlist</flux:button>
</flux:modal.trigger>
</div>
<flux:modal name="add-plugin" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Add to Playlist</flux:heading>
</div>
<form wire:submit="addToPlaylist">
<div class="mb-4">
<flux:checkbox.group wire:model.live="checked_devices" label="Select Devices">
@foreach(auth()->user()->devices as $device)
<flux:checkbox label="{{ $device->name }}" value="{{ $device->id }}"/>
@endforeach
</flux:checkbox.group>
</div>
@if(count($checked_devices) === 1)
<div class="mb-4">
<flux:radio.group wire:model="selected_playlist" label="Select Playlist" variant="segmented">
<flux:radio value="new" label="Create New"/>
@foreach($this->getDevicePlaylists($checked_devices[0]) as $playlist)
<flux:radio value="{{ $playlist->id }}" label="{{ $playlist->name }}"/>
@endforeach
</flux:radio.group>
</div>
@if($selected_playlist === 'new')
<div class="mb-4">
<flux:input label="Playlist Name" wire:model="playlist_name"/>
</div>
<div class="mb-4">
<flux:checkbox.group wire:model="selected_weekdays" label="Active Days">
<flux:checkbox label="Monday" value="1"/>
<flux:checkbox label="Tuesday" value="2"/>
<flux:checkbox label="Wednesday" value="3"/>
<flux:checkbox label="Thursday" value="4"/>
<flux:checkbox label="Friday" value="5"/>
<flux:checkbox label="Saturday" value="6"/>
<flux:checkbox label="Sunday" value="0"/>
</flux:checkbox.group>
</div>
<div class="mb-4">
<flux:input type="time" label="Active From" wire:model="active_from"/>
</div>
<div class="mb-4">
<flux:input type="time" label="Active Until" wire:model="active_until"/>
</div>
@endif
@endif
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Add to Playlist</flux:button>
</div>
</form>
</div>
</flux:modal>
<div class="mt-5 mb-5">
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
</div>
<div class="grid lg:grid-cols-2 lg:gap-8">
<div>
<form wire:submit="editSettings" class="mb-6">
<div class="mb-4">
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus/>
</div>
<div class="mb-4">
<flux:input label="Data is stale after minutes" wire:model="data_stale_minutes"
id="data_stale_minutes"
class="block mt-1 w-full" type="number" name="data_stale_minutes" autofocus/>
</div>
<div class="mb-4">
<flux:radio.group wire:model="data_strategy" label="Data Strategy" variant="segmented">
<flux:radio value="polling" label="Polling"/>
<flux:radio value="webhook" label="Webhook" disabled/>
</flux:radio.group>
</div>
<div class="mb-4">
<flux:input label="Polling URL" wire:model="polling_url" id="polling_url"
placeholder="https://example.com/api"
class="block mt-1 w-full" type="text" name="polling_url" autofocus>
<x-slot name="iconTrailing">
<flux:button size="sm" variant="subtle" icon="cloud-arrow-down" wire:click="updateData"
tooltip="Fetch data now" class="-mr-1"/>
</x-slot>
</flux:input>
</div>
<div class="mb-4">
<flux:radio.group wire:model="polling_verb" label="Polling Verb" variant="segmented">
<flux:radio value="get" label="GET"/>
<flux:radio value="post" label="POST"/>
</flux:radio.group>
</div>
<div class="mb-4">
<flux:input label="Polling Header" wire:model="polling_header" id="polling_header"
class="block mt-1 w-full" type="text" name="polling_header" autofocus/>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Save</flux:button>
</div>
</form>
</div>
<div>
<flux:textarea label="Data Payload" wire:model="data_payload" id="data_payload"
class="block mt-1 w-full font-mono" type="text" name="data_payload"
readonly rows="24"/>
</div>
</div>
<flux:separator/>
<div class="mt-5 mb-5 ">
<h3 class="text-xl font-semibold dark:text-gray-100">Markup</h3>
<div class="text-accent">
<a href="#" wire:click="renderExample('layoutTitle')" class="text-xl">Layout with Title Bar</a> |
<a href="#" wire:click="renderExample('layout')" class="text-xl">Blank Layout</a>
</div>
</div>
<form wire:submit="saveMarkup">
<div class="mb-4">
<flux:textarea
label="Blade Code"
class="font-mono"
wire:model="blade_code"
id="blade_code"
name="blade_code"
rows="15"
placeholder="Enter your blade code here..."
/>
</div>
<div class="flex">
<flux:button type="submit" variant="primary">
Save
</flux:button>
</div>
</form>
</div>
</div>

View file

@ -8,7 +8,6 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
Route::get('/display', function (Request $request) {
$mac_address = $request->header('id');
$access_token = $request->header('access-token');
$device = Device::where('mac_address', $mac_address)
@ -42,6 +41,18 @@ Route::get('/display', function (Request $request) {
'last_firmware_version' => $request->header('fw-version'),
]);
// Skip if cloud proxy is enabled for device
if (! $device->proxy_cloud) {
$playlistItem = $device->getNextPlaylistItem();
if ($playlistItem) {
$playlistItem->update(['last_displayed_at' => now()]);
$markup = Blade::render($playlistItem->plugin->render_markup, ['data' => $playlistItem->plugin->data_payload]);
GenerateScreenJob::dispatchSync($device->id, $markup);
}
}
$image_uuid = $device->current_screen_image;
if (! $image_uuid) {
$image_path = 'images/setup-logo.bmp';
@ -55,7 +66,7 @@ Route::get('/display', function (Request $request) {
'status' => '0',
'image_url' => url('storage/'.$image_path),
'filename' => $filename,
'refresh_rate' => 900,
'refresh_rate' => $device->default_refresh_interval,
'reset_firmware' => false,
'update_firmware' => false,
'firmware_url' => null,

View file

@ -20,6 +20,7 @@ Route::middleware(['auth'])->group(function () {
Volt::route('plugins', 'plugins.index')->name('plugins.index');
Volt::route('plugins/receipt/{plugin}', 'plugins.receipt')->name('plugins.receipt');
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
});

View file

@ -16,6 +16,12 @@ pest()->extend(Tests\TestCase::class)
->in('Feature');
registerSpatiePestHelpers();
arch()->preset()->laravel();
arch()
->expect('App')
->not->toUse(['die', 'dd', 'dump']);
/*
|--------------------------------------------------------------------------
| Expectations