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
51 changes: 51 additions & 0 deletions src/Api/ApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<list<SubtitleTrack>>
*/
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<string>
*/
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<list<ContinueWatchingItem>> */
public function continueWatching(): PromiseInterface
{
Expand Down
36 changes: 36 additions & 0 deletions src/Api/Dto/SubtitleTrack.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Phlix\Console\Api\Dto;

/**
* A selectable text subtitle track, mirroring a row of
* `GET /api/v1/media/{id}/subtitles`. `index` is the `0:s:{index}` selector used
* to fetch the track's WebVTT. Immutable.
*/
final readonly class SubtitleTrack
{
public function __construct(
public int $index,
public string $language,
public string $label,
public bool $default,
public string $codec,
) {
}

/**
* @param array<string,mixed> $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'] ?? ''),
);
}
}
22 changes: 22 additions & 0 deletions src/Msg/SubtitleVttLoadedMsg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Phlix\Console\Msg;

use SugarCraft\Core\Msg;
use SugarCraft\Reel\Subtitle\WebVtt;

/**
* The chosen subtitle track has been fetched + parsed (or null when the item has
* no usable text track / the fetch failed). The
* {@see \Phlix\Console\Screen\PlayerScreen} shows its active cue while captions
* are on.
*/
final readonly class SubtitleVttLoadedMsg implements Msg
{
public function __construct(
public ?WebVtt $captions,
) {
}
}
128 changes: 124 additions & 4 deletions src/Screen/PlayerScreen.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Phlix\Console\Api\Dto\MediaItem;
use Phlix\Console\Api\Dto\MediaPage;
use Phlix\Console\Api\Dto\PlaybackMarkers;
use Phlix\Console\Api\Dto\SubtitleTrack;
use Phlix\Console\Api\MediaQuery;
use Phlix\Console\Config\Config;
use Phlix\Console\Msg\NavigateBackMsg;
Expand All @@ -21,6 +22,7 @@
use Phlix\Console\Msg\SessionExpiredMsg;
use Phlix\Console\Msg\SessionStartedMsg;
use Phlix\Console\Msg\SiblingsLoadedMsg;
use Phlix\Console\Msg\SubtitleVttLoadedMsg;
use Phlix\Console\Msg\UpNextTickMsg;
use Phlix\Console\Ui\Chrome;
use Phlix\Console\Ui\Scrubber;
Expand Down Expand Up @@ -65,8 +67,8 @@ final class PlayerScreen implements Model, Teardownable
{
use SubscriptionCapable;

/** Rows reserved below the video: the scrubber + status line + 1 slack for the inner player's own status line. */
private const CHROME_ROWS = 3;
/** Rows reserved below the video: caption + scrubber + status line + 1 slack for the inner player's own status line. */
private const CHROME_ROWS = 4;
private const SESSION_EXPIRED = 'Your session expired. Please sign in again.';
/** Jellyfin-style 100ns ticks per second (the server's progress unit). */
private const TICKS_PER_SECOND = 10_000_000;
Expand All @@ -92,6 +94,11 @@ final class PlayerScreen implements Model, Teardownable
private int $currentIndex = -1;
/** Remaining seconds on the end-of-episode up-next countdown, or null when inactive. */
private ?int $upNext = null;
/** The parsed caption track, once fetched (null = none / not yet loaded). */
private ?\SugarCraft\Reel\Subtitle\WebVtt $captions = null;
private bool $captionsOn = false;
/** True once a caption fetch has been kicked off (so `c` doesn't refetch). */
private bool $captionsFetched = false;

/**
* @param \Closure(string $url, int $cols, int $rows): Player $playerFactory
Expand Down Expand Up @@ -166,6 +173,15 @@ public function update(Msg $msg): array
if ($msg instanceof UpNextTickMsg) {
return $this->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);
}
Expand Down Expand Up @@ -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 -----------------------------------------------------
Expand Down Expand Up @@ -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<SubtitleTrack> $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. */
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
{
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
43 changes: 43 additions & 0 deletions tests/Api/ApiClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading