Compare commits

...

4 Commits

Author SHA1 Message Date
Jakub Zych
c69eb19af3 deploy: default MPD music_directory to ~/Music
Midgolem was the last fresh rig to set up and the music library lives
in ~/Music/Kolekcja, so ~/HDD/Music — the path inherited from Ignia's
secondary-disk layout — no longer reflects the common case. Three
references flipped: the ASSET_DIRS ensure list, the mpd.conf heredoc,
and the post-install empty-library check + warn message.
2026-05-22 21:07:30 +02:00
Jakub Zych
fd1c78b333 webapp: drop rig:playlist pipeline, source track count from mpc stats
The four overlays that read /data/playlist.js (loading, goodbye,
music-box, music/nc) only ever consumed `tracks.length` for cosmetic
"LIBRARY :: N TRACKS" / "indexed N tracks" flavor text — all live
playback signal flows through bridges/mpd-state.py over OBS WS. The
142 KB JSON, ffprobe walk, and ETag-cached route were all producing
one integer that MPD itself already knows.

RigData::playlistCount() now shells `mpc stats` and parses "Songs: N",
returning 0 on any failure (treated by callers as empty library).
Removes RigPlaylistCommand, DataController::playlist(), the
/data/playlist.js route, the per-overlay <script src> + window.__PLAYLIST
shims, and the stale storage/data/playlist.json artifact.

AudioController still consumes config('rig.music_dirs') for the HTTP
audio stream — that's orthogonal and stays.
2026-05-22 21:04:24 +02:00
Jakub Zych
dd3493921b Styling fixes and Olden Era mmodule 2026-05-21 15:00:12 +02:00
Jakub Zych
4ceff4015a webapp: add /onboard wizard — web equivalent of php artisan rig:loading
Browser-based Project Loading manifest builder at http://127.0.0.1:1118/onboard.
Reuses the existing terminal/CRT styling (hud.css palette + scanlines +
phosphor glow) so it doesn't feel like a separate UI:

 - GET  /onboard           — the wizard, seeded from current loading.json
 - GET  /onboard/search?q= — type-ahead Twitch search/categories proxy
 - POST /onboard           — writes storage/data/loading.json, optionally
                              pushes the title + category via TwitchHelix

UX: prompt-line inputs, ↑/↓/Enter on the search dropdown, stepper for the
countdown, ENABLED/DISABLED toggles for camera + microphone, a live
right-pane preview that renders the same lines the loading overlay's
terminal will type. CSRF-exempt POST so the page can submit JSON without
a token round-trip.

Falls back gracefully when TWITCH_CLIENT_ID / TWITCH_TOKEN are absent —
the search is disabled and the manifest still saves locally.
2026-05-21 13:49:19 +02:00
147 changed files with 1942 additions and 287 deletions

View File

@@ -51,7 +51,6 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
- `php artisan rig:setup` — generate `webapp/public/js/obs-config.js` + `OBS_WS_*` env from `plugin_config/obs-websocket/config.json`. - `php artisan rig:setup` — generate `webapp/public/js/obs-config.js` + `OBS_WS_*` env from `plugin_config/obs-websocket/config.json`.
- `php artisan rig:telemetry [--collect]` — refresh hardware/OBS-profile snapshot at `webapp/storage/data/telemetry.json`. - `php artisan rig:telemetry [--collect]` — refresh hardware/OBS-profile snapshot at `webapp/storage/data/telemetry.json`.
- `php artisan rig:loading` — interactive manifest builder; updates Twitch channel via Helix (no Python shell-out). - `php artisan rig:loading` — interactive manifest builder; updates Twitch channel via Helix (no Python shell-out).
- `php artisan rig:playlist` — index audio dirs with ffprobe → `webapp/storage/data/playlist.json`.
- `php artisan rig:cmd <skip|prev|pause|resume>` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`). - `php artisan rig:cmd <skip|prev|pause|resume>` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`).
**Audio + automation surfaces:** **Audio + automation surfaces:**

View File

@@ -50,8 +50,8 @@ playback from chat.
through the per-app private dir. through the per-app private dir.
- **Laravel 13** webapp under `webapp/`, served by `php artisan serve` on - **Laravel 13** webapp under `webapp/`, served by `php artisan serve` on
`127.0.0.1:1118` and managed by **supervisord**. Routes return Blade-rendered `127.0.0.1:1118` and managed by **supervisord**. Routes return Blade-rendered
overlay pages and a small data API (`/data/playlist.js`, `/cover.jpg`, overlay pages and a small data API (`/cover.jpg`, `/track?p=…`,
`/track?p=…`, `POST /cmd/{skip|prev|pause|resume}`). `POST /cmd/{skip|prev|pause|resume}`).
- **MPD** (system service) is the audio player. A small Python bridge - **MPD** (system service) is the audio player. A small Python bridge
(`bridges/mpd-state.py`, `obs-mpd-bridge.service`) listens to MPD and (`bridges/mpd-state.py`, `obs-mpd-bridge.service`) listens to MPD and
re-broadcasts state as `mpd:state` CustomEvents on the OBS WS bus. re-broadcasts state as `mpd:state` CustomEvents on the OBS WS bus.
@@ -71,7 +71,7 @@ playback from chat.
├── webapp/ Laravel app — every overlay served from here ├── webapp/ Laravel app — every overlay served from here
│ ├── app/ │ ├── app/
│ │ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading, │ │ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
│ │ │ rig:playlist, rig:cmd │ │ │ rig:cmd
│ │ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,…}, Data, │ │ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,…}, Data,
│ │ │ MusicCommand, Audio │ │ │ MusicCommand, Audio
│ │ ├── Services/ ObsWsClient, TwitchHelix, HardwareSnapshot │ │ ├── Services/ ObsWsClient, TwitchHelix, HardwareSnapshot
@@ -107,8 +107,8 @@ sudo supervisorctl restart obs-webapp
# refresh a per-session manifest before going live (game / mode / countdown) # refresh a per-session manifest before going live (game / mode / countdown)
cd webapp && php artisan rig:loading cd webapp && php artisan rig:loading
# reindex the music library (after dropping new tracks into ~/HDD/Music) # rescan the music library (after dropping new tracks into ~/Music/Kolekcja)
cd webapp && php artisan rig:playlist mpc update --wait
# re-snapshot hardware/OBS profile telemetry (kernel/GPU/encoder changes) # re-snapshot hardware/OBS profile telemetry (kernel/GPU/encoder changes)
cd webapp && php artisan rig:telemetry --collect cd webapp && php artisan rig:telemetry --collect

View File

@@ -220,7 +220,7 @@ fi
ASSET_DIRS=( ASSET_DIRS=(
"$HOME/Videos" "$HOME/Videos"
"$HOME/HDD/Music" "$HOME/Music"
"$HOME/HDD/Images/Gifs" "$HOME/HDD/Images/Gifs"
"$HOME/cloud.jakubzych.com/_img/logos" "$HOME/cloud.jakubzych.com/_img/logos"
) )
@@ -372,7 +372,7 @@ write_mpd_conf() {
# ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh. # ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh.
# Re-run the deploy script to regenerate, or hand-edit for local tweaks. # Re-run the deploy script to regenerate, or hand-edit for local tweaks.
music_directory "~/HDD/Music" music_directory "~/Music"
playlist_directory "~/.config/mpd/playlists" playlist_directory "~/.config/mpd/playlists"
db_file "~/.config/mpd/database" db_file "~/.config/mpd/database"
state_file "~/.config/mpd/state" state_file "~/.config/mpd/state"
@@ -421,13 +421,13 @@ else
fi fi
fi fi
if [[ -d "$HOME/HDD/Music" ]] && [[ -n "$(find "$HOME/HDD/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then if [[ -d "$HOME/Music" ]] && [[ -n "$(find "$HOME/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then
if would "mpc update"; then if would "mpc update"; then
mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)" mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)"
ok "MPD library scan kicked off" ok "MPD library scan kicked off"
fi fi
else else
warn "~/HDD/Music is empty — MPD will have no tracks until you populate it" warn "~/Music is empty — MPD will have no tracks until you populate it"
fi fi
# Avahi for zeroconf advertisement (system service). # Avahi for zeroconf advertisement (system service).

View File

@@ -1,58 +1,166 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # OPHI-118 webapp
<p align="center"> Laravel app that renders every on-stream overlay for the OPHI-118 Twitch rig and
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a> hosts the streamer's control surfaces. Runs under supervisord on `http://127.0.0.1:1118/`.
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel > Looking for the rig-wide picture (OBS, MPD, twitch-bot, deployment)?
> See [`../README.md`](../README.md). This file is just the webapp.
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: ## What's in here
- [Simple, fast routing engine](https://laravel.com/docs/routing). ### Control panel — `/`
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. Tile-based dashboard. Entry point from a browser bookmark; not used by OBS itself.
## Learning Laravel ![Control panel](resources/img/screenshots/home.webp)
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. - **ONBOARD** — opens the project-loading wizard.
- **SCENES** — opens the full route index.
- **ophi118.com** — public channel landing page.
- **gamez.ciemnosc.com** — self-hosted game-server roster.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ### Onboard wizard — `/onboard`
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. Web equivalent of `php artisan rig:loading`. Compiles the Project Loading manifest
(game, subtitle, countdown, camera/mic state) and optionally pushes the channel
title + category to Twitch via Helix.
## Agentic Development ![Onboard form](resources/img/screenshots/onboard1.webp)
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: - **Type-ahead game search** against the Twitch Helix `search/categories`
endpoint, debounced. ↑/↓/Enter/Esc keys to pick a match; locks the `gameId`.
- **Live preview** mirrors what the `/loading` overlay will render — every edit
re-renders the terminal-style output immediately.
- **Stepper** for the countdown, **ENABLED/DISABLED toggles** for camera/mic,
**push-to-Twitch** checkbox to commit the channel update via Helix.
- **Graceful fallback**: with `TWITCH_CLIENT_ID` / `TWITCH_ACCESS_TOKEN` unset,
the search disables and the manifest still saves locally.
![Onboard preview](resources/img/screenshots/onboard2.webp)
### Scenes index — `/scenes`
All overlay URLs the OBS browser-sources point at, plus the data endpoints.
![Scenes](resources/img/screenshots/scenes-page.webp)
### Overlay routes
Browser sources in `basic/scenes/Default_Stream_HUD.json` consume these. Edits
to the Blade views are picked up on the next OBS **Refresh cache** — no server
restart needed.
| Route | Purpose |
|---|---|
| `/landing` | Fallout-style "PLEASE STAND BY" + telemetry rotator |
| `/loading` | Terminal-style Project Loading + countdown |
| `/game` | Game HUD (camera-frame OBS-WS sync, status bar) |
| `/desktop` | Desktop HUD (camera + screen-capture frames) |
| `/goodbye` | Sign-off terminal + "OFFLINE FOR" counter |
| `/music-box` | Full-screen music box terminal |
| `/music-box/widget` | Compact 549×880 widget |
| `/music-box/cover` | Album-art widget (`?bars=0` for compact mode) |
| `/music-box/nc` | ncurses-styled 2-pane variant |
| `/music-daemon` | Passive WebSocket listener (diagnostic) |
### Data + control endpoints
| Route | Notes |
|---|---|
| `GET /cover.jpg` | Streams `bridges/cover.jpg` (kept fresh by `mpd-state.py`) |
| `GET /track?p=<base64>` | Audio stream — path-traversal-guarded |
| `POST /cmd/{skip\|prev\|pause\|resume}` | Broadcasts `mpd:cmd` over OBS WebSocket |
## Artisan commands
Each lives at `app/Console/Commands/Rig*Command.php`. Run from the `webapp/`
directory.
```bash ```bash
composer require laravel/boost --dev php artisan rig:setup # gen public/js/obs-config.js + OBS_WS_* in .env
php artisan rig:telemetry # refresh storage/data/telemetry.json
php artisan boost:install php artisan rig:telemetry --collect # re-prompt every field interactively
php artisan rig:loading # interactive Project Loading manifest (CLI mirror of /onboard)
php artisan rig:cmd skip # CLI mirror of POST /cmd/skip
``` ```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. ## Running it
## Contributing The supervisord program file lives at [`../scripts/obs-webapp.supervisord.conf`](../scripts/obs-webapp.supervisord.conf)
and is installed (root-owned) to `/etc/supervisor.d/obs-webapp.conf` by
`scripts/deploy-rig.sh`.
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). ```bash
sudo supervisorctl status obs-webapp # → RUNNING
sudo supervisorctl restart obs-webapp # after route / controller / .env changes
tail -F storage/logs/supervisord.{out,err}.log
```
## Code of Conduct For local hacking without supervisord:
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). ```bash
php artisan serve --host=127.0.0.1 --port=1118
```
## Security Vulnerabilities ## When to restart
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. | Change | Action |
|---|---|
| Blade view (`resources/views/`) | OBS → right-click source → **Refresh cache** |
| Static CSS / JS (`public/`) | Same — Refresh cache |
| Route / controller / service / config / `.env` | `sudo supervisorctl restart obs-webapp` |
## License ## Layout
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). ```
webapp/
├── app/
│ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
│ │ rig:cmd
│ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,Desktop,
│ │ Goodbye,MusicBox,MusicDaemon}, Data,
│ │ MusicCommand, Audio, Onboard
│ ├── Services/ ObsWsClient (Pawl), TwitchHelix,
│ │ HardwareSnapshot
│ └── Support/RigData.php
├── resources/
│ ├── views/
│ │ ├── home.blade.php /
│ │ ├── scenes.blade.php /scenes
│ │ ├── onboard.blade.php /onboard
│ │ ├── overlays/ per-scene overlays
│ │ ├── partials/ topbar, hud-strip, crt-overlays, …
│ │ └── layouts/ overlay base layout
│ └── img/ favicon + screenshots (this README)
├── public/
│ ├── css/hud.css shared HUD chrome, scanlines, terminal styling
│ ├── js/ hud, crt-static, obs-ws-bootstrap, obs-ws-mini
│ ├── audio/static-hum.wav ffmpeg_source (not browser-served)
│ └── favicon.ico → symlink to ../resources/img/favicon.ico
├── routes/web.php
├── config/rig.php storage paths, OBS WS creds, Twitch keys
└── storage/data/ rig:* commands write JSON here
├── loading.json
└── telemetry.json
```
## Config
`.env` keys the webapp cares about:
```
APP_URL=http://127.0.0.1:1118
OBS_WS_URL=ws://localhost:4455
OBS_WS_PASSWORD=… # written by `php artisan rig:setup`
TWITCH_CLIENT_ID=…
TWITCH_ACCESS_TOKEN=… # legacy TWITCH_TOKEN is also accepted
TWITCH_BROADCASTER_ID=…
RIG_NAME=…
OBS_PROFILE=ophi118
MUSIC_DIR_1=…
```
See [`.env.example`](.env.example) for the full set.

View File

@@ -1,125 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Process\Process;
use Throwable;
class RigPlaylistCommand extends Command
{
protected $signature = 'rig:playlist';
protected $description = 'Index audio under the configured music roots → storage/data/playlist.json';
private const EXTS = ['m4a', 'mp3', 'opus', 'ogg', 'flac', 'webm', 'aac', 'wav'];
public function handle(): int
{
$roots = array_values(array_filter((array) config('rig.music_dirs')));
if (empty($roots)) {
$this->warn(' ! no music dirs configured (set MUSIC_DIR_1 in .env)');
}
$ffprobe = (string) config('rig.ffprobe', '/usr/bin/ffprobe');
if (!is_executable($ffprobe)) {
$this->error(" ✗ ffprobe not found at {$ffprobe} (install ffmpeg or set FFPROBE_BIN)");
return self::FAILURE;
}
$this->line('── Indexing audio roots ──');
foreach ($roots as $r) {
$this->line(is_dir($r) ? " + {$r}" : " ! {$r} (missing — skipped)");
}
$tracks = [];
foreach ($roots as $root) {
if (!is_dir($root)) continue;
$finder = (new Finder())
->files()
->in($root)
->name('/\.(' . implode('|', self::EXTS) . ')$/i')
->ignoreUnreadableDirs()
->sortByName();
foreach ($finder as $file) {
$name = $file->getFilename();
$base = $file->getFilenameWithoutExtension();
// Skip yt-dlp intermediates (e.g. foo.f140.m4a, foo.temp.opus).
if (preg_match('/\.(?:f\d+|temp|part)$/i', $base)) continue;
$path = $file->getRealPath();
$meta = $this->probe($ffprobe, $path);
$tracks[] = [
'title' => $this->cleanTitle($base),
'file' => 'file://' . $this->urlEncodePath($path),
'durationSec' => isset($meta['durationSec']) ? (int) $meta['durationSec'] : null,
];
}
}
$doc = [
'syncedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'sourceDirs' => $roots,
'trackCount' => count($tracks),
'tracks' => $tracks,
];
$path = rtrim(config('rig.storage_data'), '/') . '/playlist.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
$this->line('');
$this->info(" ✓ wrote {$path} ({$doc['trackCount']} tracks)");
$this->line(' → refresh the Music Daemon browser source in OBS to reload the queue.');
return self::SUCCESS;
}
private function probe(string $ffprobe, string $path): array
{
try {
$proc = new Process([
$ffprobe, '-v', 'error',
'-show_entries', 'format=duration:format_tags=title,artist',
'-of', 'json',
$path,
]);
$proc->setTimeout(10);
$proc->run();
if (!$proc->isSuccessful()) return [];
$json = json_decode($proc->getOutput(), true) ?: [];
$fmt = $json['format'] ?? [];
return [
'durationSec' => isset($fmt['duration']) ? (float) $fmt['duration'] : null,
];
} catch (Throwable) {
return [];
}
}
/**
* Mirrors scripts/playlist.sh's clean_title():
* - drop "| ..." (or fullwidth ) tails (genre/label noise)
* - strip trailing YouTube IDs in brackets like "[-XxZTgMWKV0]"
* - strip "[NCS Release]" / "(NCS10 Release)" suffixes
*/
private function cleanTitle(string $t): string
{
$t = preg_replace('/\s*[|].*$/u', '', $t) ?? $t;
$t = preg_replace('/\s*\[[A-Za-z0-9_-]{11}\]\s*$/', '', $t) ?? $t;
$t = preg_replace(
'/\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$/i',
'',
$t
) ?? $t;
return trim($t);
}
private function urlEncodePath(string $path): string
{
// Same as Python's urllib.parse.quote(..., safe='/') — keep separators, encode the rest.
return implode('/', array_map('rawurlencode', explode('/', $path)));
}
}

View File

@@ -3,53 +3,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Support\RigData; use App\Support\RigData;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class DataController extends Controller class DataController extends Controller
{ {
/**
* Serve the playlist as a script-tag-friendly JS global. Cached via ETag on file mtime
* so CEF can revalidate cheaply (the playlist file changes rarely but the body is ~142 KB).
*/
public function playlist(Request $request, RigData $rig)
{
$path = $rig->playlistPath();
$tracks = $rig->playlist();
$mtime = is_file($path) ? filemtime($path) : 0;
$etag = '"' . md5('playlist:' . $mtime) . '"';
if ($request->headers->get('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
// Rewrite `file://` URIs into `/audio?p=...` HTTP URLs so the
// music-daemon page (served over HTTP) can load tracks. Chromium
// blocks HTTP-origin pages from loading file:// media.
if (isset($tracks['tracks']) && is_array($tracks['tracks'])) {
foreach ($tracks['tracks'] as $i => $track) {
if (!isset($track['file'])) continue;
if (str_starts_with($track['file'], 'file://')) {
$absolute = rawurldecode(substr($track['file'], 7));
$tracks['tracks'][$i]['file'] = \App\Http\Controllers\AudioController::urlFor($absolute);
}
}
}
$body = 'window.__PLAYLIST = ' . json_encode(
$tracks,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
) . ';';
return response($body, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'public, must-revalidate',
'ETag' => $etag,
]);
}
/** /**
* Serve bridges/cover.jpg. `no-store` so CEF never holds a stale image * Serve bridges/cover.jpg. `no-store` so CEF never holds a stale image
* even if a caller forgets the ?v=<hash> cache-bust. 404 returns a 1×1 * even if a caller forgets the ?v=<hash> cache-bust. 404 returns a 1×1

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers;
use App\Services\TwitchHelix;
use App\Support\OldenEra;
use App\Support\RigData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
/**
* Web-based equivalent of `php artisan rig:loading` guides the streamer
* through the Project Loading manifest fields and pushes the channel
* update to Twitch via Helix.
*
* Lives at /onboard so it's reachable from the same server as the
* overlays (bookmark http://127.0.0.1:1118/onboard).
*/
class OnboardController extends Controller
{
public function show(RigData $rig, TwitchHelix $twitch, OldenEra $olden)
{
return view('onboard', [
'manifest' => $rig->loading(),
'telemetry' => $rig->telemetry(),
'trackCount' => $rig->playlistCount(),
'twitchEnabled' => $twitch->enabled(),
'oldenEra' => [
'gameId' => OldenEra::GAME_ID,
'assetBase' => OldenEra::URL,
'catalog' => $olden->catalog(),
'manifest' => $olden->manifest(),
],
]);
}
public function search(Request $request, TwitchHelix $twitch): JsonResponse
{
if (!$twitch->enabled()) {
return response()->json(['error' => 'twitch not configured'], 503);
}
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 2) {
return response()->json(['matches' => []]);
}
try {
// Use the HTTP API directly to get the top-N candidates, not just
// the single best-match the Artisan command uses.
$hits = $this->rawSearch($twitch, $q);
} catch (Throwable $e) {
return response()->json(['error' => $e->getMessage()], 502);
}
return response()->json(['matches' => $hits]);
}
public function submit(Request $request, RigData $rig, TwitchHelix $twitch, OldenEra $olden): JsonResponse
{
$data = $request->validate([
'game' => 'required|string|max:200',
'gameId' => 'nullable|string|max:32',
'subtitle' => 'nullable|string|max:300',
'countdownMin' => 'required|integer|min:0|max:120',
'camera' => 'required|boolean',
'microphone' => 'required|boolean',
'pushTwitch' => 'nullable|boolean',
]);
$manifest = [
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
'game' => $data['game'],
'gameId' => $data['gameId'] ?? null,
'subtitle' => $data['subtitle'] ?: null,
'countdownMin' => (int) $data['countdownMin'],
'camera' => (bool) $data['camera'],
'microphone' => (bool) $data['microphone'],
];
$path = rtrim(config('rig.storage_data'), '/') . '/loading.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
$oldenPath = null;
if (($manifest['gameId'] ?? null) === OldenEra::GAME_ID) {
$factions = array_keys($olden->catalog());
$extra = $request->validate([
'olden.color' => 'required|in:red,blue',
'olden.my.faction' => 'required|string|in:' . implode(',', $factions),
'olden.my.hero' => 'required|string|max:120',
'olden.enemy.faction' => 'required|string|in:' . implode(',', $factions),
'olden.enemy.hero' => 'required|string|max:120',
'olden.map' => 'nullable|string|max:200',
])['olden'];
$oldenPath = $olden->writeManifest([
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
'gameId' => OldenEra::GAME_ID,
'color' => $extra['color'],
'my' => $extra['my'],
'enemy' => $extra['enemy'],
'map' => isset($extra['map']) && $extra['map'] !== '' ? $extra['map'] : null,
]);
}
$twitchStatus = null;
if (!empty($data['pushTwitch']) && $twitch->enabled() && !empty($manifest['gameId'])) {
try {
$title = $manifest['subtitle'] ?: $manifest['game'];
$twitch->setChannel($title, $manifest['gameId']);
$twitchStatus = "ok: \"{$title}\" / {$manifest['game']}";
} catch (Throwable $e) {
$twitchStatus = 'failed: ' . $e->getMessage();
}
}
return response()->json([
'ok' => true,
'manifest' => $manifest,
'twitch' => $twitchStatus,
'path' => str_replace(base_path() . '/', '', $path),
'oldenPath' => $oldenPath ? str_replace(base_path() . '/', '', $oldenPath) : null,
]);
}
/**
* Raw search/categories Helix call same shape as TwitchHelix internally
* but returns multiple candidates instead of the first exact match.
*/
private function rawSearch(TwitchHelix $twitch, string $q): array
{
$reflection = new \ReflectionClass($twitch);
$http = $reflection->getProperty('http')->getValue($twitch);
$headers = $reflection->getMethod('headers');
$headers->setAccessible(true);
$res = $http->get('search/categories', [
'headers' => $headers->invoke($twitch),
'query' => ['query' => $q, 'first' => 10],
]);
$body = json_decode((string) $res->getBody(), true);
$hits = $body['data'] ?? [];
// Sort so case-insensitive exact matches surface first.
usort($hits, function ($a, $b) use ($q) {
$ax = strcasecmp((string) ($a['name'] ?? ''), $q) === 0 ? 0 : 1;
$bx = strcasecmp((string) ($b['name'] ?? ''), $q) === 0 ? 0 : 1;
return $ax <=> $bx;
});
return array_map(fn($r) => [
'id' => (string) ($r['id'] ?? ''),
'name' => (string) ($r['name'] ?? ''),
'boxArt' => (string) ($r['box_art_url'] ?? ''),
], array_slice($hits, 0, 10));
}
}

View File

@@ -3,14 +3,22 @@
namespace App\Http\Controllers\Overlays; namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\OldenEra;
use App\Support\RigData; use App\Support\RigData;
class GameController extends Controller class GameController extends Controller
{ {
public function show(RigData $rig) public function show(RigData $rig, OldenEra $olden)
{ {
$manifest = $rig->loading();
$isOlden = ($manifest['gameId'] ?? null) === OldenEra::GAME_ID;
$oldenManifest = $isOlden ? $olden->manifest() : [];
return view('overlays.game', [ return view('overlays.game', [
'manifest' => $rig->loading(), 'manifest' => $manifest,
'olden' => !empty($oldenManifest) ? $oldenManifest : null,
'oldenAssetBase' => OldenEra::URL,
'rigName' => (string) (config('rig.streamer_nick') ?: 'OPHI118'),
]); ]);
} }
} }

View File

@@ -10,8 +10,9 @@ use RuntimeException;
* Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py * Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py
* for use from `php artisan rig:loading`. * for use from `php artisan rig:loading`.
* *
* Requires TWITCH_CLIENT_ID + TWITCH_TOKEN (user OAuth token with the * Requires TWITCH_CLIENT_ID + TWITCH_ACCESS_TOKEN (user OAuth token with the
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env. * channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env.
* Legacy TWITCH_TOKEN is still accepted as a fallback (see config/rig.php).
*/ */
class TwitchHelix class TwitchHelix
{ {

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Support;
/**
* Per-game extra manifest for Heroes of Might & Magic: Olden Era.
*
* `loading.json` keeps the generic stream metadata (game, subtitle, mic…).
* Olden Era specifics matchup factions, heroes, the player's team color,
* map name live in their own `olden-era-manifest.json` so the loading
* schema doesn't grow a new key every time a game gets bespoke chrome.
*/
class OldenEra
{
/** Twitch Helix category id for "Heroes of Might & Magic: Olden Era". */
public const GAME_ID = '1426873999';
/** Public URL prefix; resolved via the webapp/public/games symlink. */
public const URL = '/games/olden-era';
private ?array $catalogCache = null;
/**
* Faction {logo URL, heroes list}. Built by globbing
* resources/games/olden-era/{factions,heroes/<Faction>}/*.png.
*/
public function catalog(): array
{
if ($this->catalogCache !== null) return $this->catalogCache;
$root = resource_path('games/olden-era');
$out = [];
foreach (glob($root . '/factions/*.png') ?: [] as $factionPng) {
$faction = pathinfo($factionPng, PATHINFO_FILENAME);
$heroes = [];
foreach (glob($root . '/heroes/' . $faction . '/*.png') ?: [] as $heroPng) {
$name = pathinfo($heroPng, PATHINFO_FILENAME);
$heroes[] = [
'name' => $name,
'portrait' => self::URL . '/heroes/' . rawurlencode($faction) . '/' . rawurlencode($name) . '.png',
];
}
usort($heroes, fn($a, $b) => strcasecmp($a['name'], $b['name']));
$out[$faction] = [
'logo' => self::URL . '/factions/' . rawurlencode($faction) . '.png',
'heroes' => $heroes,
];
}
ksort($out);
return $this->catalogCache = $out;
}
public function manifest(): array
{
$path = $this->manifestPath();
if (!is_file($path)) return [];
$parsed = json_decode((string) @file_get_contents($path), true);
return is_array($parsed) ? $parsed : [];
}
public function writeManifest(array $data): string
{
$path = $this->manifestPath();
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
return $path;
}
public function manifestPath(): string
{
return rtrim(config('rig.storage_data'), '/') . '/olden-era-manifest.json';
}
}

View File

@@ -2,9 +2,13 @@
namespace App\Support; namespace App\Support;
use Symfony\Component\Process\Process;
use Throwable;
class RigData class RigData
{ {
private array $cache = []; private array $cache = [];
private ?int $mpdSongCount = null;
public function loading(): array public function loading(): array
{ {
@@ -23,17 +27,26 @@ class RigData
]); ]);
} }
public function playlist(): array /**
{ * Track count from MPD (the live library, queried via `mpc stats`).
return $this->read('playlist.json', [ * Returns 0 if mpc is missing or MPD is unreachable callers treat
'syncedAt' => null, 'sourceDirs' => [], 'trackCount' => 0, 'tracks' => [], * that as "empty library" and degrade their flavor text accordingly.
]); */
}
public function playlistCount(): int public function playlistCount(): int
{ {
$p = $this->playlist(); if ($this->mpdSongCount !== null) return $this->mpdSongCount;
return (int) ($p['trackCount'] ?? count($p['tracks'] ?? []));
try {
$proc = new Process(['mpc', 'stats']);
$proc->setTimeout(2);
$proc->run();
if ($proc->isSuccessful() && preg_match('/^Songs:\s*(\d+)/m', $proc->getOutput(), $m)) {
return $this->mpdSongCount = (int) $m[1];
}
} catch (Throwable) {
// fall through
}
return $this->mpdSongCount = 0;
} }
public function cmd(): array public function cmd(): array
@@ -49,11 +62,6 @@ class RigData
return $cmd; return $cmd;
} }
public function playlistPath(): string
{
return rtrim(config('rig.storage_data'), '/') . '/playlist.json';
}
public function coverPath(): string public function coverPath(): string
{ {
return (string) config('rig.cover_jpg'); return (string) config('rig.cover_jpg');

View File

@@ -12,6 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'cmd/*', 'cmd/*',
'onboard',
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -31,6 +31,7 @@ return [
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
*/ */
'rig_name' => env('RIG_NAME'), 'rig_name' => env('RIG_NAME'),
'streamer_nick' => env('STREAMER_NICK', 'OPHI118'),
'obs_studio_dir' => env('OBS_STUDIO_DIR', base_path('..')), 'obs_studio_dir' => env('OBS_STUDIO_DIR', base_path('..')),
'obs_profile' => env('OBS_PROFILE', 'ophi118'), 'obs_profile' => env('OBS_PROFILE', 'ophi118'),
@@ -52,7 +53,9 @@ return [
*/ */
'twitch' => [ 'twitch' => [
'client_id' => env('TWITCH_CLIENT_ID'), 'client_id' => env('TWITCH_CLIENT_ID'),
'token' => env('TWITCH_TOKEN'), // Name aligned with twitch-bot/.env (TWITCH_ACCESS_TOKEN); old TWITCH_TOKEN
// kept as a fallback so pre-existing webapp/.env files still work.
'token' => env('TWITCH_ACCESS_TOKEN', env('TWITCH_TOKEN')),
'broadcaster_id' => env('TWITCH_BROADCASTER_ID'), 'broadcaster_id' => env('TWITCH_BROADCASTER_ID'),
], ],
]; ];

View File

@@ -574,3 +574,116 @@ body.camera-hud-body {
} }
.np-block .title { color: var(--ink); } .np-block .title { color: var(--ink); }
.np-block .time { color: var(--ink-dim); } .np-block .time { color: var(--ink-dim); }
/* Olden Era matchup — right-aligned on the status-bar's LIVE/game-name line. */
.matchup {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 14px;
font-size: 16px;
letter-spacing: 0.16em;
}
.matchup .m-side {
display: inline-flex;
align-items: center;
gap: 10px;
}
.matchup .m-faction {
width: 34px;
height: 34px;
object-fit: contain;
filter: drop-shadow(0 0 6px rgba(7, 8, 13, 0.9));
}
.matchup .m-hero {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 50%;
border: 1px solid rgba(232, 232, 224, 0.35);
background: rgba(0, 0, 0, 0.4);
}
.matchup .m-name {
color: var(--ink);
font-weight: 700;
letter-spacing: 0.14em;
}
.matchup .m-vs {
color: var(--hud-dim);
font-weight: 700;
letter-spacing: 0.32em;
font-size: 14px;
}
.matchup .m-on {
color: var(--hud-dim);
font-size: 13px;
letter-spacing: 0.32em;
margin-left: 6px;
}
.matchup .m-map {
color: var(--hud);
font-size: 15px;
letter-spacing: 0.18em;
}
.matchup-red .m-my .m-name {
color: #ff6a5e;
text-shadow: 0 0 6px rgba(230, 58, 46, 0.55);
}
.matchup-blue .m-my .m-name {
color: #6ec5ff;
text-shadow: 0 0 6px rgba(58, 166, 255, 0.55);
}
.matchup-red .m-my .m-hero { border-color: rgba(230, 58, 46, 0.7); }
.matchup-blue .m-my .m-hero { border-color: rgba(58, 166, 255, 0.7); }
/*
* Webapp navigation breadcrumb (used by /, /scenes, /onboard).
* Distinct from the in-scene .hud strip so overlays stay untouched.
* */
.crumb-bar {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
color: var(--hud);
letter-spacing: 0.10em;
padding-bottom: 14px;
margin-bottom: 28px;
border-bottom: 1px solid rgba(79, 210, 255, 0.25);
}
.crumb-bar > :nth-child(2) { justify-self: center; }
.crumb-bar > :nth-child(3) { justify-self: end; }
.crumbs { display: flex; align-items: center; gap: 12px; }
.crumbs .led {
display: inline-block;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--onair);
box-shadow: 0 0 8px var(--onair);
animation: rec-blink 1.6s ease-in-out infinite;
}
.crumb { color: var(--hud); text-decoration: none; transition: color 120ms ease, text-shadow 120ms ease; }
.crumb-root { color: var(--hud-dim); }
.crumb-root:hover{ color: var(--hud); text-shadow: 0 0 8px var(--hud-glow); }
.crumb-here { color: var(--hud); text-shadow: 0 0 8px var(--hud-glow); cursor: default; }
.crumbs .sep { color: var(--hud-dim); opacity: 0.6; }
.crumb-ctx, .crumb-clock { color: var(--hud); }
.crumb-ctx .dim, .crumb-clock .dim { color: var(--hud-dim); margin-right: 8px; }
/*
* Webapp page shell used by every non-overlay browser page
* (/, /scenes, /onboard) so the crumb-bar and content sit at the
* same viewport anchor on every page.
* Overlays keep .overlay-body / .camera-hud-body for OBS sizing.
* */
body.page-shell {
background: var(--bg);
position: relative;
padding: 32px 56px;
min-height: 100vh;
overflow: auto;
}

1
webapp/public/favicon.ico Symbolic link
View File

@@ -0,0 +1 @@
../resources/img/favicon.ico

1
webapp/public/games Symbolic link
View File

@@ -0,0 +1 @@
../resources/games

View File

@@ -0,0 +1 @@
Files here belongs to Unfrozen and are copies from https://wiki.hoodedhorse.com/Heroes_of_Might_and_Magic_Olden_Era - used only for game streaming purposes.

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Some files were not shown because too many files have changed in this diff Show More