diff --git a/src/Api/ApiClient.php b/src/Api/ApiClient.php index 7040394..17920a4 100644 --- a/src/Api/ApiClient.php +++ b/src/Api/ApiClient.php @@ -13,6 +13,7 @@ use Phlix\Console\Api\Dto\MediaPage; use Phlix\Console\Api\Dto\PlaybackInfo; use Phlix\Console\Api\Dto\PlaybackMarkers; +use Phlix\Console\Api\Dto\SubtitleTrack; use Phlix\Console\Config\TokenBundle; use Psr\Http\Message\ResponseInterface; use React\Promise\Deferred; @@ -234,6 +235,56 @@ public function endSession(string $sessionId): PromiseInterface ->then(static fn (array $data): bool => true); } + // ---- subtitles ----------------------------------------------------- + + /** + * List an item's text subtitle tracks (for the player's caption toggle). + * + * @return PromiseInterface> + */ + public function subtitleTracks(string $id): PromiseInterface + { + return $this->authed('GET', '/api/v1/media/' . rawurlencode($id) . '/subtitles')->then(static function (array $data): array { + $tracks = []; + foreach (Coerce::map($data['tracks'] ?? null) as $row) { + if (is_array($row)) { + $tracks[] = SubtitleTrack::fromArray($row); + } + } + + return $tracks; + }); + } + + /** + * Fetch one subtitle track as raw WebVTT text (a `text/vtt` body, not JSON). + * Best-effort — no refresh-and-retry; a failure just leaves captions off. + * + * @return PromiseInterface + */ + public function subtitleVtt(string $id, int $index): PromiseInterface + { + $headers = ['Accept' => 'text/vtt']; + if ($this->token !== null) { + $headers['Authorization'] = $this->token->authorizationHeader(); + } + $url = $this->url('/api/v1/media/' . rawurlencode($id) . '/subtitles/' . $index, []); + + return $this->transport->send('GET', $url, $headers, '')->then( + static function (ResponseInterface $response): string { + $status = $response->getStatusCode(); + if ($status < 200 || $status >= 300) { + throw new ApiError("Subtitle fetch failed (HTTP {$status})", $status); + } + + return (string) $response->getBody(); + }, + static fn (\Throwable $error): never => throw $error instanceof ApiError + ? $error + : new NetworkError('Could not reach the server: ' . $error->getMessage(), 0, null, $error), + ); + } + /** @return PromiseInterface> */ public function continueWatching(): PromiseInterface { diff --git a/src/Api/Dto/SubtitleTrack.php b/src/Api/Dto/SubtitleTrack.php new file mode 100644 index 0000000..a9d9060 --- /dev/null +++ b/src/Api/Dto/SubtitleTrack.php @@ -0,0 +1,36 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + index: Coerce::int($data['index'] ?? 0), + language: Coerce::str($data['language'] ?? 'und', 'und'), + label: Coerce::str($data['label'] ?? ''), + default: Coerce::bool($data['default'] ?? false), + codec: Coerce::str($data['codec'] ?? ''), + ); + } +} diff --git a/src/Msg/SubtitleVttLoadedMsg.php b/src/Msg/SubtitleVttLoadedMsg.php new file mode 100644 index 0000000..c1ce4c4 --- /dev/null +++ b/src/Msg/SubtitleVttLoadedMsg.php @@ -0,0 +1,22 @@ +onUpNextTick(); } + if ($msg instanceof SubtitleVttLoadedMsg) { + $next = clone $this; + $next->captions = $msg->captions; + if ($msg->captions === null) { + $next->captionsOn = false; // nothing to show + } + + return [$next, null]; + } if ($msg instanceof SessionStartedMsg) { return $this->onSessionStarted($msg->sessionId); } @@ -197,7 +213,10 @@ public function view(): string return $frame; } - return $frame . "\n" . $this->scrubberLine($this->inner) . "\n" . $this->statusLine($this->inner); + return $frame + . "\n" . $this->captionLine($this->inner) + . "\n" . $this->scrubberLine($this->inner) + . "\n" . $this->statusLine($this->inner); } // ---- lifecycle ----------------------------------------------------- @@ -401,6 +420,74 @@ private function upNextTickCmd(): \Closure return Cmd::tick(1.0, static fn (): Msg => new UpNextTickMsg()); } + // ---- captions ------------------------------------------------------ + + /** + * Toggle captions. The track is fetched lazily on first enable (tracks → + * default track → WebVTT, in one Cmd); afterwards `c` just flips visibility. + */ + private function toggleCaptions(): array + { + // Already loaded → flip visibility. + if ($this->captions !== null) { + $next = clone $this; + $next->captionsOn = !$this->captionsOn; + + return [$next, null]; + } + // Fetched once and found nothing → nothing to toggle. + if ($this->captionsFetched) { + return [$this, null]; + } + + // First enable: optimistically on, kick off the fetch. + $next = clone $this; + $next->captionsFetched = true; + $next->captionsOn = true; + + return [$next, $this->fetchCaptionsCmd()]; + } + + /** + * Fetch the subtitle tracks, pick the default (or first), fetch + parse its + * WebVTT → SubtitleVttLoadedMsg(?WebVtt). Any failure / no track → null. + */ + private function fetchCaptionsCmd(): \Closure + { + $id = $this->item->id; + + return Cmd::promise(fn (): PromiseInterface => $this->api->subtitleTracks($id)->then( + function (array $tracks) use ($id): PromiseInterface { + $track = $this->pickDefaultTrack($tracks); + if ($track === null) { + return resolve(new SubtitleVttLoadedMsg(null)); + } + + return $this->api->subtitleVtt($id, $track->index)->then( + static fn (string $vtt): Msg => new SubtitleVttLoadedMsg(\SugarCraft\Reel\Subtitle\WebVtt::parse($vtt)), + static fn (\Throwable $e): Msg => new SubtitleVttLoadedMsg(null), + ); + }, + static fn (\Throwable $e): Msg => new SubtitleVttLoadedMsg(null), + )); + } + + /** + * The default track (or the first) from a track list, or null when empty. + * + * @param list $tracks + */ + private function pickDefaultTrack(array $tracks): ?SubtitleTrack + { + foreach ($tracks as $track) { + if ($track->default) { + return $track; + } + } + + return $tracks[0] ?? null; + } + // ---- progress reporting -------------------------------------------- /** Open a playback session; on success → SessionStartedMsg. Failure is swallowed. */ @@ -589,6 +676,11 @@ private function handleKey(KeyMsg $msg): array return $this->startOver(); } + // c → toggle captions (lazily fetching the track on first enable). + if ($msg->type === KeyType::Char && ($msg->rune === 'c' || $msg->rune === 'C')) { + return $this->toggleCaptions(); + } + // n / p → next / previous episode (manual binge nav). if ($msg->type === KeyType::Char && ($msg->rune === 'n' || $msg->rune === 'N')) { $next = $this->nextItem(); @@ -666,6 +758,23 @@ private function onResize(int $cols, int $rows): array // ---- rendering ----------------------------------------------------- + /** The active caption, centered, while captions are on — else a blank line. */ + private function captionLine(Player $inner): string + { + if (!$this->captionsOn || $this->captions === null) { + return ''; + } + $text = $this->captions->cueAt($inner->position()); + if ($text === null || $text === '') { + return ''; + } + + $text = Width::truncate(str_replace("\n", ' ', $text), max(1, $this->cols - 2)); + $pad = max(0, intdiv($this->cols - Width::of($text), 2)); + + return str_repeat(' ', $pad) . Style::new()->bold()->render($text); + } + /** The progress bar with chapter ticks. */ private function scrubberLine(Player $inner): string { @@ -685,7 +794,8 @@ private function statusLine(Player $inner): string // A live resume hint wins over the skip prompt (resume usually clears the intro). $prompt = $this->resumeHint($inner) ?? $this->skipPrompt($inner); $nav = $this->nextItem() !== null ? ' n next' : ''; - $hint = 'Space ⏯ ←→ ±10s [ ] speed m mode' . $nav . ' q back'; + $cc = $this->captionsOn ? ' c cc✓' : ' c cc'; + $hint = 'Space ⏯ ←→ ±10s [ ] speed m mode' . $cc . $nav . ' q back'; $title = Width::truncate($this->item->name, max(8, $this->cols - 60)); $line = sprintf('%s %s%s · %s', $glyph, $title, $prompt, $hint); @@ -852,4 +962,14 @@ public function hasPrev(): bool { return $this->prevItem() !== null; } + + public function captionsOn(): bool + { + return $this->captionsOn; + } + + public function hasCaptions(): bool + { + return $this->captions !== null; + } } diff --git a/tests/Api/ApiClientTest.php b/tests/Api/ApiClientTest.php index de288f2..c194760 100644 --- a/tests/Api/ApiClientTest.php +++ b/tests/Api/ApiClientTest.php @@ -17,6 +17,7 @@ use Phlix\Console\Api\Dto\MediaPage; use Phlix\Console\Api\Dto\PlaybackInfo; use Phlix\Console\Api\Dto\PlaybackMarkers; +use Phlix\Console\Api\Dto\SubtitleTrack; use Phlix\Console\Config\TokenBundle; use PHPUnit\Framework\TestCase; use React\EventLoop\Loop; @@ -292,6 +293,48 @@ public function testEndSessionDeletes(): void self::assertStringEndsWith('/api/v1/sessions/sess-9', $req['url']); } + public function testSubtitleTracksMapsRows(): void + { + $t = (new FakeTransport())->json(200, ['tracks' => [ + ['index' => 0, 'language' => 'eng', 'label' => 'English', 'default' => true, 'codec' => 'subrip'], + ['index' => 1, 'language' => 'fra', 'label' => 'French', 'default' => false, 'codec' => 'ass'], + ]]); + $client = new ApiClient(self::BASE, $t); + $client->setToken(new TokenBundle('t', 'r')); + + $tracks = $this->await($client->subtitleTracks('m1')); + + self::assertCount(2, $tracks); + self::assertInstanceOf(SubtitleTrack::class, $tracks[0]); + self::assertSame('eng', $tracks[0]->language); + self::assertTrue($tracks[0]->default); + self::assertSame(1, $tracks[1]->index); + self::assertStringEndsWith('/api/v1/media/m1/subtitles', $t->requestAt(0)['url']); + } + + public function testSubtitleVttReturnsTheRawBody(): void + { + $vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHi"; + $t = (new FakeTransport())->raw(200, $vtt); + $client = new ApiClient(self::BASE, $t); + $client->setToken(new TokenBundle('t', 'r')); + + $body = $this->await($client->subtitleVtt('m1', 2)); + + self::assertSame($vtt, $body); + self::assertStringEndsWith('/api/v1/media/m1/subtitles/2', $t->requestAt(0)['url']); + } + + public function testSubtitleVttThrowsOnNon2xx(): void + { + $t = (new FakeTransport())->raw(404, 'nope'); + $client = new ApiClient(self::BASE, $t); + $client->setToken(new TokenBundle('t', 'r')); + + $this->expectException(ApiError::class); + $this->await($client->subtitleVtt('m1', 0)); + } + // ---- 401 refresh-and-retry ---------------------------------------- public function testUnauthorizedTriggersRefreshAndRetry(): void diff --git a/tests/Screen/PlayerScreenTest.php b/tests/Screen/PlayerScreenTest.php index 30c9fea..06515b1 100644 --- a/tests/Screen/PlayerScreenTest.php +++ b/tests/Screen/PlayerScreenTest.php @@ -472,6 +472,100 @@ public function testMarkersFetchFailureLeavesAPlainScrubber(): void self::assertNull($cmd); } + // ---- captions ------------------------------------------------------ + + /** + * A ready screen with captions toggled on and a track loaded. + * + * @param list> $tracks + */ + private function readyWithCaptions(array $tracks, string $vtt): PlayerScreen + { + $transport = (new FakeTransport()) + ->json(200, $this->markersResponse()) // 1: markers (init) + ->json(200, $this->continueWatching()) // 2: resume (init) + ->json(200, ['tracks' => $tracks]) // 3: subtitle tracks (on `c`) + ->raw(200, $vtt); // 4: the WebVTT body + [$screen] = $this->screen(transport: $transport); + $ready = $this->ready($screen); + + [$on, $cmd] = $ready->update(new KeyMsg(KeyType::Char, 'c')); + foreach ($this->runBatch($cmd) as $msg) { + [$on] = $on->update($msg); + } + + return $on; + } + + public function testCaptionToggleFetchesAndShowsTheActiveCue(): void + { + $on = $this->readyWithCaptions( + [['index' => 0, 'default' => true, 'language' => 'eng', 'label' => 'English', 'codec' => 'subrip']], + "WEBVTT\n\n00:00:00.000 --> 00:00:20.000\nHello caption", + ); + + self::assertTrue($on->captionsOn()); + self::assertTrue($on->hasCaptions()); + self::assertStringContainsString('Hello caption', $on->view(), 'the active cue is shown at position 0'); + self::assertStringContainsString('cc✓', $on->view(), 'the status hint marks captions on'); + } + + public function testCaptionToggleOffHidesTheCue(): void + { + $on = $this->readyWithCaptions( + [['index' => 0, 'default' => true, 'language' => 'eng', 'label' => 'English', 'codec' => 'subrip']], + "WEBVTT\n\n00:00:00.000 --> 00:00:20.000\nHello caption", + ); + + [$off] = $on->update(new KeyMsg(KeyType::Char, 'c')); + + self::assertFalse($off->captionsOn()); + self::assertStringNotContainsString('Hello caption', $off->view()); + } + + public function testNoCaptionShownInACueGap(): void + { + $on = $this->readyWithCaptions( + [['index' => 0, 'default' => true, 'language' => 'eng', 'label' => 'English', 'codec' => 'subrip']], + "WEBVTT\n\n00:00:05.000 --> 00:00:08.000\nLater caption", + ); + + // Position 0 is before the only cue (5–8s) → nothing shown, but captions are on. + self::assertTrue($on->captionsOn()); + self::assertStringNotContainsString('Later caption', $on->view()); + } + + public function testNoSubtitleTracksLeavesCaptionsOff(): void + { + $on = $this->readyWithCaptions([], 'unused'); + + self::assertFalse($on->captionsOn(), 'nothing to show → captions stay off'); + self::assertFalse($on->hasCaptions()); + + // A second `c` after the empty fetch is a no-op (doesn't refetch). + [$same, $cmd] = $on->update(new KeyMsg(KeyType::Char, 'c')); + self::assertSame($on, $same); + self::assertNull($cmd); + } + + public function testCaptionFetchFailureIsSwallowed(): void + { + $transport = (new FakeTransport()) + ->json(200, $this->markersResponse()) + ->json(200, $this->continueWatching()) + ->fail(new \RuntimeException('boom')); // subtitle-tracks fetch fails + [$screen] = $this->screen(transport: $transport); + $ready = $this->ready($screen); + + [$on, $cmd] = $ready->update(new KeyMsg(KeyType::Char, 'c')); + foreach ($this->runBatch($cmd) as $msg) { + [$on] = $on->update($msg); + } + + self::assertFalse($on->captionsOn(), 'a failed fetch leaves captions off'); + self::assertFalse($on->hasCaptions()); + } + // ---- up-next (episode queue) --------------------------------------- private function episodeItem(string $id): MediaItem