160 lines
6.2 KiB
PHP
160 lines
6.2 KiB
PHP
<?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));
|
|
}
|
|
}
|