# In-Browser Channel Preview Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let an operator verify from the ChicoryTV channels list that a channel is actually streaming right now, and that it is playing what the guide says.

**Architecture:** The server declares per-channel preview capability as one additive DTO field (`Preview`) computed from the real `StreamingMode` enum plus `JwtHelper.IsEnabled`; the SPA renders and acts on it without deriving anything. A new `SlideOver` panel drives the existing reusable `HlsPlayer`, which gains error reporting so failures stop being silent.

**Tech Stack:** C# / .NET 10, MediatR CQRS, NUnit + Shouldly; React + TypeScript SPA (Vite), hls.js, Vitest + Testing Library.

**Spec:** `docs/superpowers/specs/2026-07-21-in-browser-channel-preview-design.md`
**Issue:** ersatztv#60

## Global Constraints

- Work in the worktree `/Users/timothy/etv-wt-60` on branch `feat/60-channel-preview`. **Never** commit in `/Users/timothy/ersatztv` (`process.shared-tree-readonly`).
- Response DTOs live in `ErsatzTV.Core/Api/<Domain>/` with a file-scoped `#nullable enable` pragma (`api.response-dtos`).
- `/api/v1` is **additive-only** (`api.versioning-v1`). Add fields; never change or remove existing ones.
- URLs returned to the SPA are rooted and directly usable; the SPA does no path building (`api.artwork-rooted-urls`).
- Tests are **NUnit + Shouldly + NSubstitute**. xUnit is not used in this repo.
- In web tests, mock `hls.js` wholesale so jsdom never touches a real `MediaSource` (`docs/spa-conventions.md` §5b).
- Client-side preference storage, if any, uses `ctv-`-namespaced `localStorage` keys. (This feature stores nothing.)
- Before any push touching `.cs`: BOM-check the touched set with `xxd -p <file> | head -c 6 | grep -q '^efbbbf'` and run the format gate under `bash -c` (`process.bom-format-detection-recipe`).
- Never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`.
- Copy rule — the forced-HLS caveat text is used verbatim wherever it appears:
  `This channel is configured for Transport Stream, which browsers cannot play. This preview forces an HLS segmenter session — it checks the content, not the channel's configured pipeline.`

---

### Task 1: Server — preview capability DTO + mapper

**Files:**
- Create: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`
- Modify: `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs` (add field to record)
- Modify: `ErsatzTV.Application/Channels/Mapper.cs:99-111` (`ProjectToResponseModel`) and add `GetPreview`
- Modify: `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs`
- Test: `ErsatzTV.Core.Tests/Api/Channels/ChannelPreviewTests.cs`

**Interfaces:**
- Consumes: nothing.
- Produces:
  - `ErsatzTV.Core.Api.Channels.ChannelPreviewAvailability` — enum-like string constants: `"Available"`, `"ForcedHlsOnly"`, `"Unavailable"`.
  - `record ChannelPreviewResponseModel(string Availability, string? ManifestUrl, string? UnavailableReason)`.
  - `ChannelResponseModel` gains a trailing `ChannelPreviewResponseModel Preview` member.
  - `ErsatzTV.Application.Channels.Mapper.GetPreview(StreamingMode streamingMode, string channelNumber, bool iptvJwtEnabled) -> ChannelPreviewResponseModel` (internal static, pure).
  - `Mapper.ProjectToResponseModel(Channel channel, int playoutCount, bool iptvJwtEnabled)` — note the **new third parameter**; Task 2 relies on this signature.

`Availability` is serialized as a plain string (not an enum) so the generated TypeScript is a string union and the SPA needs no enum mapping, matching how `StreamingMode` is already exposed.

- [ ] **Step 1: Write the failing tests**

Create `ErsatzTV.Core.Tests/Api/Channels/ChannelPreviewTests.cs`:

```csharp
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Core.Tests.Api.Channels;

[TestFixture]
public class ChannelPreviewTests
{
    [TestCase(StreamingMode.HttpLiveStreamingSegmenter)]
    [TestCase(StreamingMode.HttpLiveStreamingDirect)]
    public void HlsModes_Are_Available_With_Plain_Manifest_Url(StreamingMode mode)
    {
        ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false);

        result.Availability.ShouldBe("Available");
        result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8");
        result.UnavailableReason.ShouldBeNull();
    }

    [TestCase(StreamingMode.TransportStream)]
    [TestCase(StreamingMode.TransportStreamHybrid)]
    public void TransportStream_Modes_Are_ForcedHlsOnly(StreamingMode mode)
    {
        ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false);

        result.Availability.ShouldBe("ForcedHlsOnly");
        result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8?mode=segmenter");
        result.UnavailableReason.ShouldBeNull();
    }

    [TestCase(StreamingMode.HttpLiveStreamingSegmenter)]
    [TestCase(StreamingMode.HttpLiveStreamingDirect)]
    [TestCase(StreamingMode.TransportStream)]
    [TestCase(StreamingMode.TransportStreamHybrid)]
    public void Jwt_Enabled_Makes_Every_Mode_Unavailable(StreamingMode mode)
    {
        ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: true);

        result.Availability.ShouldBe("Unavailable");
        result.ManifestUrl.ShouldBeNull();
        result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled");
    }

    [Test]
    public void Channel_Number_Is_Used_Verbatim_In_The_Manifest_Url()
    {
        ChannelPreviewResponseModel result =
            Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "7", iptvJwtEnabled: false);

        result.ManifestUrl.ShouldBe("/iptv/channel/7.m3u8");
    }
}
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd /Users/timothy/etv-wt-60 && dotnet test ErsatzTV.Core.Tests --filter ChannelPreviewTests`
Expected: FAIL to compile — `Mapper.GetPreview` and `ChannelPreviewResponseModel` do not exist.

- [ ] **Step 3: Create the DTO**

Create `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`:

```csharp
#nullable enable

namespace ErsatzTV.Core.Api.Channels;

/// <summary>Server-declared browser-preview capability for a channel.</summary>
/// <remarks>
/// The SPA renders and acts on this; it never derives preview eligibility itself (see
/// docs/decisions.md, api.healthcheck-remediation-dto for the same pattern). Deriving it in the
/// SPA would mean keying behavior off the human-readable StreamingMode label.
/// </remarks>
public record ChannelPreviewResponseModel(
    // One of ChannelPreviewAvailability's values. A plain string (not an enum) so the generated
    // TypeScript is a string union the SPA can switch on directly.
    string Availability,
    // Rooted, directly-usable HLS manifest URL; null when Availability is Unavailable.
    string? ManifestUrl,
    // Human-readable reason; non-null only when Availability is Unavailable.
    string? UnavailableReason);

public static class ChannelPreviewAvailability
{
    /// <summary>The channel's configured mode is browser-playable; preview exercises the real pipeline.</summary>
    public const string Available = "Available";

    /// <summary>Configured for Transport Stream; preview must force an HLS session and is content-only.</summary>
    public const string ForcedHlsOnly = "ForcedHlsOnly";

    /// <summary>Preview cannot run at all (IPTV JWT auth is enabled and the SPA cannot mint a token).</summary>
    public const string Unavailable = "Unavailable";
}
```

- [ ] **Step 4: Add the field to `ChannelResponseModel`**

In `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs`, add a trailing member after `string? Logo`:

```csharp
    string? Logo,
    // Server-declared browser-preview capability; see ChannelPreviewResponseModel.
    ChannelPreviewResponseModel Preview);
```

- [ ] **Step 5: Implement the mapper**

In `ErsatzTV.Application/Channels/Mapper.cs`, add next to `GetStreamingMode`:

```csharp
    internal static ChannelPreviewResponseModel GetPreview(
        StreamingMode streamingMode,
        string channelNumber,
        bool iptvJwtEnabled)
    {
        // /iptv/* is gated by ConditionalIptvAuthorizeFilter only when JWT is configured, and the
        // "jwt" scheme does not accept the SPA's ctv-session cookie. Nothing mints a JWT for the
        // SPA today, so preview cannot run at all in that configuration.
        if (iptvJwtEnabled)
        {
            return new ChannelPreviewResponseModel(
                ChannelPreviewAvailability.Unavailable,
                null,
                "IPTV JWT authentication is enabled");
        }

        return streamingMode switch
        {
            StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect =>
                new ChannelPreviewResponseModel(
                    ChannelPreviewAvailability.Available,
                    $"/iptv/channel/{channelNumber}.m3u8",
                    null),

            // A browser cannot play video/mp2t. Forcing ?mode=segmenter yields a playable stream,
            // but one that does not exercise the channel's configured Transport Stream pipeline —
            // the SPA labels this result accordingly.
            StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid =>
                new ChannelPreviewResponseModel(
                    ChannelPreviewAvailability.ForcedHlsOnly,
                    $"/iptv/channel/{channelNumber}.m3u8?mode=segmenter",
                    null),

            _ => throw new ArgumentOutOfRangeException(nameof(streamingMode))
        };
    }
```

Add `using ErsatzTV.Core.Api.Channels;` to the file's usings if not already present.

- [ ] **Step 6: Thread it through `ProjectToResponseModel`**

In `ErsatzTV.Application/Channels/Mapper.cs`, change the signature and the construction:

```csharp
    internal static ChannelResponseModel ProjectToResponseModel(
        Channel channel,
        int playoutCount,
        bool iptvJwtEnabled) =>
        new(
            channel.Id,
            channel.Number,
            channel.SortNumber,
            channel.Name,
            channel.Group,
            channel.Categories,
            channel.FFmpegProfile.Name,
            channel.PreferredAudioLanguageCode,
            GetStreamingMode(channel),
            channel.IsEnabled,
            channel.ShowInEpg,
            playoutCount,
            GetLogoUrl(channel),
            GetPreview(channel.StreamingMode, channel.Number, iptvJwtEnabled));
```

- [ ] **Step 7: Run the tests to verify they pass**

Run: `cd /Users/timothy/etv-wt-60 && dotnet test ErsatzTV.Core.Tests --filter ChannelPreviewTests`
Expected: PASS, 9 tests.

- [ ] **Step 8: Fix the remaining call site and build**

`GetAllChannelsForApiHandler` now fails to compile. It is the only caller of `ProjectToResponseModel`; confirm with:

Run: `cd /Users/timothy/etv-wt-60 && grep -rn "ProjectToResponseModel(" --include=*.cs . | grep -i channel`

Then update `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs`. `JwtHelper` lives in the `ErsatzTV` host assembly, which `ErsatzTV.Application` does **not** reference, so the flag is carried on the query rather than read statically here — this is also what keeps `GetPreview` pure and testable:

```csharp
        IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
        return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), request.IptvJwtEnabled)).ToList();
```

And add the property to the request record in `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs`:

```csharp
public record GetAllChannelsForApi(bool IptvJwtEnabled) : IRequest<List<ChannelResponseModel>>;
```

Run: `cd /Users/timothy/etv-wt-60 && dotnet build ErsatzTV.sln`
Expected: one remaining error, in `ChannelController.GetAll` — fixed in Task 2.

- [ ] **Step 9: Commit**

```bash
cd /Users/timothy/etv-wt-60
git add ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs \
        ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs \
        ErsatzTV.Application/Channels/Mapper.cs \
        ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs \
        ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs \
        ErsatzTV.Core.Tests/Api/Channels/ChannelPreviewTests.cs
git commit -m "feat(60): server-declared channel preview capability"
```

---

### Task 2: Server — wire the flag at the controller and regenerate the API contract

**Files:**
- Modify: `ErsatzTV/Controllers/Api/ChannelController.cs:27-29` (`GetAll`)
- Modify: `ErsatzTV/wwwroot/openapi/v1.json` (generated)
- Modify: `docs/endpoint-index.md` (generated)
- Modify: `web/src/api/generated/v1.d.ts` (generated)
- Modify: `docs/api-conventions.md`

**Interfaces:**
- Consumes: `GetAllChannelsForApi(bool IptvJwtEnabled)` from Task 1.
- Produces: `GET /api/v1/channels` responses carrying `preview: { availability, manifestUrl, unavailableReason }`; the generated `v1.d.ts` type the SPA imports in Tasks 4–5.

- [ ] **Step 1: Pass the flag from the host**

In `ErsatzTV/Controllers/Api/ChannelController.cs`, `JwtHelper` is in scope (same assembly):

```csharp
    [HttpGet("/api/v1/channels")]
    [EndpointGroupName("general")]
    public async Task<List<ChannelResponseModel>> GetAll() =>
        await mediator.Send(new GetAllChannelsForApi(JwtHelper.IsEnabled));
```

- [ ] **Step 2: Build to verify the solution compiles**

Run: `cd /Users/timothy/etv-wt-60 && dotnet build ErsatzTV.sln`
Expected: Build succeeded, 0 errors.

- [ ] **Step 3: Run the full server test suite**

Run: `cd /Users/timothy/etv-wt-60 && dotnet test ErsatzTV.sln`
Expected: PASS. If any test constructs a `ChannelResponseModel` positionally it will fail to compile — add `GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", false)` as its final argument.

- [ ] **Step 4: Regenerate the API artifacts**

Order matters — the app project must build first (`process.pr-routine-sequence`):

```bash
cd /Users/timothy/etv-wt-60
./scripts/update-openapi.sh
cd web && npm run generate:api
```

- [ ] **Step 5: Verify the generated types contain the new field**

Run: `cd /Users/timothy/etv-wt-60 && grep -n "unavailableReason" web/src/api/generated/v1.d.ts ErsatzTV/wwwroot/openapi/v1.json | head`
Expected: at least one hit in each file. If `v1.d.ts` has no hit, the regen did not pick up the build — rerun step 4.

- [ ] **Step 6: Update the API docs**

In `docs/api-conventions.md`, in the section covering response DTOs, add a bullet:

```markdown
- **Server-declared capability fields.** When the SPA must decide whether an action is possible,
  the server declares it as structured metadata on the response DTO (`{Availability, ...}`) rather
  than the SPA inferring it from a display string. Examples: health-check remediation
  (`api.healthcheck-remediation-dto`) and channel preview capability
  (`ChannelPreviewResponseModel`).
```

- [ ] **Step 7: Commit**

```bash
cd /Users/timothy/etv-wt-60
git add ErsatzTV/Controllers/Api/ChannelController.cs ErsatzTV/wwwroot/openapi/v1.json \
        docs/endpoint-index.md web/src/api/generated/v1.d.ts docs/api-conventions.md
git commit -m "feat(60): expose channel preview capability on GET /api/v1/channels"
```

---

### Task 3: SPA — `HlsPlayer` error reporting

**Files:**
- Modify: `web/src/media/HlsPlayer.tsx`
- Test: `web/src/media/HlsPlayer.test.tsx` (create)
- Modify: `docs/spa-conventions.md` §5b

**Interfaces:**
- Consumes: nothing.
- Produces: `HlsPlayerProps` gains `onError?: (message: string) => void`. Tasks 4–5 rely on this exact name and signature. Existing props are unchanged, so `PlaybackTroubleshootingScreen` keeps working untouched.

- [ ] **Step 1: Write the failing test**

Create `web/src/media/HlsPlayer.test.tsx`:

```tsx
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HlsPlayer } from './HlsPlayer';

const hlsMock = {
  isSupported: true,
  loadSource: vi.fn(),
  attachMedia: vi.fn(),
  on: vi.fn(),
  destroy: vi.fn()
};

vi.mock('hls.js', () => {
  class MockHls {
    static isSupported() {
      return hlsMock.isSupported;
    }
    static Events = { MANIFEST_PARSED: 'hlsManifestParsed', ERROR: 'hlsError' };
    loadSource = hlsMock.loadSource;
    attachMedia = hlsMock.attachMedia;
    on = hlsMock.on;
    destroy = hlsMock.destroy;
  }
  return { default: MockHls };
});

describe('HlsPlayer', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    hlsMock.isSupported = true;
  });

  it('reports a fatal hls.js error through onError', () => {
    const onError = vi.fn();
    render(<HlsPlayer onError={onError} src="/iptv/channel/12.1.m3u8" />);

    const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1];
    expect(errorHandler).toBeTypeOf('function');

    errorHandler({}, { fatal: true, type: 'networkError', details: 'manifestLoadError' });

    expect(onError).toHaveBeenCalledWith('networkError: manifestLoadError');
  });

  it('ignores non-fatal hls.js errors', () => {
    const onError = vi.fn();
    render(<HlsPlayer onError={onError} src="/iptv/channel/12.1.m3u8" />);

    const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1];
    errorHandler({}, { fatal: false, type: 'mediaError', details: 'bufferStalledError' });

    expect(onError).not.toHaveBeenCalled();
  });

  it('does not auto-recover a fatal error', () => {
    render(<HlsPlayer onError={vi.fn()} src="/iptv/channel/12.1.m3u8" />);

    const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1];
    errorHandler({}, { fatal: true, type: 'networkError', details: 'manifestLoadError' });

    // A diagnostic player must surface the fault, not silently retry past it.
    expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
  });
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/media/HlsPlayer.test.tsx`
Expected: FAIL — no `hlsError` handler is registered, so `errorHandler` is `undefined`.

- [ ] **Step 3: Implement**

In `web/src/media/HlsPlayer.tsx`, add to `HlsPlayerProps`:

```tsx
  // Called with a human-readable reason when playback fails. Fatal errors are reported, never
  // auto-recovered: a diagnostic player that silently retries hides the fault it exists to reveal.
  onError?: (message: string) => void;
```

Add `onError` to the destructured parameters:

```tsx
export function HlsPlayer({ src, playToken = 0, className, style, onError }: HlsPlayerProps) {
```

Inside the `Hls.isSupported()` branch, after the `MANIFEST_PARSED` handler:

```tsx
      hls.on(Hls.Events.ERROR, (_event, data) => {
        if (data.fatal) {
          onError?.(`${data.type}: ${data.details}`);
        }
      });
```

In the native-HLS (Safari) branch, alongside the `canplay` listener:

```tsx
      const onVideoError = () => {
        onError?.('The browser could not play this stream');
      };
      video.addEventListener('error', onVideoError);
```

and remove it in that branch's cleanup, next to the existing `removeEventListener('canplay', ...)`:

```tsx
        video.removeEventListener('error', onVideoError);
```

Add `onError` to the effect's dependency array: `}, [src, playToken, onError]);`

> Callers must pass a stable `onError` (wrap in `useCallback`) or the effect will re-run and restart the stream on every render. Task 4 does this.

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/media/HlsPlayer.test.tsx src/screens/PlaybackTroubleshootingScreen.test.tsx`
Expected: PASS for both files. The troubleshooting test passing **unchanged** is the proof this extension is additive.

- [ ] **Step 5: Update the SPA conventions doc**

In `docs/spa-conventions.md` §5b, append to the paragraph:

```markdown
It also takes an optional `onError(message)` wired to `Hls.Events.ERROR` (fatal errors only) and,
on the Safari native path, the `<video>` element's own `error` event. Fatal errors are reported,
never auto-recovered — a diagnostic surface must show the fault rather than retry past it. Pass a
**stable** `onError` (`useCallback`); it is in the attach effect's dependency array, so an unstable
identity restarts the stream every render. When mocking `hls.js` in a test that exercises errors,
include `ERROR` in the mock's `static Events`.
```

- [ ] **Step 6: Commit**

```bash
cd /Users/timothy/etv-wt-60
git add web/src/media/HlsPlayer.tsx web/src/media/HlsPlayer.test.tsx docs/spa-conventions.md
git commit -m "feat(60): report fatal HLS errors from HlsPlayer"
```

---

### Task 4: SPA — `ChannelPreviewPanel`

**Files:**
- Create: `web/src/screens/channels/ChannelPreviewPanel.tsx`
- Test: `web/src/screens/channels/ChannelPreviewPanel.test.tsx`

**Interfaces:**
- Consumes: `HlsPlayer` with `onError` (Task 3); `SlideOver` from `web/src/components/overlay.tsx` with props `{ open, onClose, title, subtitle, children, footer, width, style }`; the generated `preview` field (Task 2).
- Produces:

```tsx
export interface ChannelPreviewNowPlaying {
  title: string;
  startUtc: string;
  finishUtc: string;
}

export interface ChannelPreviewPanelProps {
  open: boolean;
  onClose: () => void;
  channel: {
    id: number;
    name: string;
    number: string;
    streamingMode: string;
    preview: { availability: string; manifestUrl: null | string; unavailableReason: null | string };
  };
  nowPlaying: ChannelPreviewNowPlaying | null;
}

export function ChannelPreviewPanel(props: ChannelPreviewPanelProps): JSX.Element;
export const FORCED_HLS_CAVEAT: string;
```

Task 5 renders this component and passes `nowPlaying` from the channel-state data it already holds.

- [ ] **Step 1: Write the failing tests**

Create `web/src/screens/channels/ChannelPreviewPanel.test.tsx`:

```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelPreviewPanel } from './ChannelPreviewPanel';

const hlsMock = {
  isSupported: true,
  loadSource: vi.fn(),
  attachMedia: vi.fn(),
  on: vi.fn(),
  destroy: vi.fn()
};

vi.mock('hls.js', () => {
  class MockHls {
    static isSupported() {
      return hlsMock.isSupported;
    }
    static Events = { MANIFEST_PARSED: 'hlsManifestParsed', ERROR: 'hlsError' };
    loadSource = hlsMock.loadSource;
    attachMedia = hlsMock.attachMedia;
    on = hlsMock.on;
    destroy = hlsMock.destroy;
  }
  return { default: MockHls };
});

function channel(preview: ChannelPreview, streamingMode = 'HLS Segmenter') {
  return { id: 1, name: 'Vaporwave', number: '12.1', preview, streamingMode };
}

interface ChannelPreview {
  availability: string;
  manifestUrl: null | string;
  unavailableReason: null | string;
}

const available: ChannelPreview = {
  availability: 'Available',
  manifestUrl: '/iptv/channel/12.1.m3u8',
  unavailableReason: null
};

const forced: ChannelPreview = {
  availability: 'ForcedHlsOnly',
  manifestUrl: '/iptv/channel/12.1.m3u8?mode=segmenter',
  unavailableReason: null
};

const unavailable: ChannelPreview = {
  availability: 'Unavailable',
  manifestUrl: null,
  unavailableReason: 'IPTV JWT authentication is enabled'
};

describe('ChannelPreviewPanel', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    hlsMock.isSupported = true;
  });

  it('loads the declared manifest url for an available channel', () => {
    render(
      <ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
    );

    expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8');
  });

  it('shows the now-playing title it was given', () => {
    render(
      <ChannelPreviewPanel
        channel={channel(available)}
        nowPlaying={{ finishUtc: '2026-07-21T21:00:00Z', startUtc: '2026-07-21T20:00:00Z', title: 'Neon Nights' }}
        onClose={vi.fn()}
        open
      />
    );

    expect(screen.getByText('Neon Nights')).toBeInTheDocument();
  });

  it('shows nothing-scheduled when there is no now-playing', () => {
    render(
      <ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
    );

    expect(screen.getByText('Nothing scheduled')).toBeInTheDocument();
  });

  it('does not autoplay a forced-hls channel and shows the caveat only after opting in', async () => {
    const user = userEvent.setup();
    render(
      <ChannelPreviewPanel
        channel={channel(forced, 'MPEG-TS')}
        nowPlaying={null}
        onClose={vi.fn()}
        open
      />
    );

    expect(hlsMock.loadSource).not.toHaveBeenCalled();

    await user.click(screen.getByRole('button', { name: /preview via hls anyway/i }));

    expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8?mode=segmenter');
    expect(screen.getByText(/does not exercise|not the channel's configured pipeline/i)).toBeInTheDocument();
  });

  it('never shows the caveat for an available channel', () => {
    render(
      <ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
    );

    expect(screen.queryByText(/not the channel's configured pipeline/i)).not.toBeInTheDocument();
  });

  it('renders the server reason and no player when unavailable', () => {
    render(
      <ChannelPreviewPanel channel={channel(unavailable)} nowPlaying={null} onClose={vi.fn()} open />
    );

    expect(screen.getByText('IPTV JWT authentication is enabled')).toBeInTheDocument();
    expect(hlsMock.loadSource).not.toHaveBeenCalled();
  });

  it('surfaces a fatal playback error', () => {
    render(
      <ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
    );

    const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1];
    errorHandler({}, { details: 'manifestLoadError', fatal: true, type: 'networkError' });

    expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
  });

  it('shows the manifest url so a failure can be reproduced with curl', () => {
    render(
      <ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
    );

    expect(screen.getByText('/iptv/channel/12.1.m3u8')).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/channels/ChannelPreviewPanel.test.tsx`
Expected: FAIL — module `./ChannelPreviewPanel` not found.

- [ ] **Step 3: Implement the panel**

Create `web/src/screens/channels/ChannelPreviewPanel.tsx`:

```tsx
import { useCallback, useEffect, useState } from 'react';
import { SlideOver } from '../../components/overlay';
import { HlsPlayer } from '../../media/HlsPlayer';

export const FORCED_HLS_CAVEAT =
  "This channel is configured for Transport Stream, which browsers cannot play. This preview forces an HLS segmenter session — it checks the content, not the channel's configured pipeline.";

export interface ChannelPreviewNowPlaying {
  title: string;
  startUtc: string;
  finishUtc: string;
}

export interface ChannelPreviewPanelProps {
  open: boolean;
  onClose: () => void;
  channel: {
    id: number;
    name: string;
    number: string;
    streamingMode: string;
    preview: { availability: string; manifestUrl: null | string; unavailableReason: null | string };
  };
  nowPlaying: ChannelPreviewNowPlaying | null;
}

type PlaybackState = 'failed' | 'idle' | 'playing' | 'starting';

export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: ChannelPreviewPanelProps) {
  const { availability, manifestUrl, unavailableReason } = channel.preview;
  const forced = availability === 'ForcedHlsOnly';

  // Forced previews are opt-in: they do not exercise the configured pipeline, so they must never
  // start on their own. Available channels start immediately — that is the whole point of the panel.
  const [started, setStarted] = useState(availability === 'Available');
  const [state, setState] = useState<PlaybackState>('idle');
  const [error, setError] = useState<null | string>(null);

  // Reset when the panel is reopened for a different channel.
  useEffect(() => {
    setStarted(availability === 'Available');
    setState('idle');
    setError(null);
  }, [availability, channel.id]);

  // Stable identity: HlsPlayer keeps onError in its attach effect's dependency array, so an
  // inline arrow would tear down and restart the stream on every render.
  const onError = useCallback((message: string) => {
    setError(message);
    setState('failed');
  }, []);

  const src = started ? manifestUrl : null;

  useEffect(() => {
    if (src) {
      setState('starting');
    }
  }, [src]);

  return (
    <SlideOver onClose={onClose} open={open} subtitle={`Channel ${channel.number}`} title={`Preview: ${channel.name}`}>
      {availability === 'Unavailable' ? (
        <p className="ctv-preview-unavailable">{unavailableReason}</p>
      ) : (
        <>
          {forced && started && <p className="ctv-preview-caveat">{FORCED_HLS_CAVEAT}</p>}
          {forced && !started && (
            <>
              <p className="ctv-preview-caveat">{FORCED_HLS_CAVEAT}</p>
              <button onClick={() => setStarted(true)} type="button">
                Preview via HLS anyway
              </button>
            </>
          )}
          {src && (
            <HlsPlayer
              className="ctv-preview-video"
              onError={onError}
              src={src}
              // The manifest GET starts a server-side session; a repeat play of an identical URL
              // must re-issue the request rather than be a state no-op (spa-conventions §5b).
              playToken={started ? 1 : 0}
            />
          )}
        </>
      )}

      <dl className="ctv-preview-facts">
        <dt>Mode</dt>
        <dd>{channel.streamingMode}</dd>
        <dt>URL</dt>
        <dd>{manifestUrl ?? 'None'}</dd>
        <dt>State</dt>
        <dd>{error ? `Failed — ${error}` : state}</dd>
        <dt>Guide says now</dt>
        <dd>{nowPlaying ? nowPlaying.title : 'Nothing scheduled'}</dd>
      </dl>
    </SlideOver>
  );
}
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/channels/ChannelPreviewPanel.test.tsx`
Expected: PASS, 8 tests.

- [ ] **Step 5: Commit**

```bash
cd /Users/timothy/etv-wt-60
git add web/src/screens/channels/ChannelPreviewPanel.tsx web/src/screens/channels/ChannelPreviewPanel.test.tsx
git commit -m "feat(60): add ChannelPreviewPanel"
```

---

### Task 5: SPA — activate the Play button on the channels list

**Files:**
- Modify: `web/src/screens/ChannelsScreen.tsx` (`ChannelTableRow` ~`:596-615`, and the parent that owns `statesById`)
- Test: `web/src/screens/ChannelsScreen.test.tsx`

**Interfaces:**
- Consumes: `ChannelPreviewPanel` and `FORCED_HLS_CAVEAT` (Task 4); `channel.preview` (Task 2); the existing `statesById` map (`ChannelsScreen.tsx:209`).
- Produces: nothing downstream.

- [ ] **Step 1: Write the failing tests**

Append to `web/src/screens/ChannelsScreen.test.tsx` (keep the file's existing mocks and fixture helpers; add `preview` to whatever channel fixture it already builds):

```tsx
  it('enables preview for an available channel and opens the panel', async () => {
    const user = userEvent.setup();
    renderChannelsScreen({
      channels: [
        channelFixture({
          name: 'Vaporwave',
          preview: { availability: 'Available', manifestUrl: '/iptv/channel/12.1.m3u8', unavailableReason: null }
        })
      ]
    });

    const play = await screen.findByRole('button', { name: 'Preview Vaporwave' });
    expect(play).toBeEnabled();

    await user.click(play);

    expect(await screen.findByText('Preview: Vaporwave')).toBeInTheDocument();
  });

  it('disables preview and explains why when the server says unavailable', async () => {
    renderChannelsScreen({
      channels: [
        channelFixture({
          name: 'Vaporwave',
          preview: {
            availability: 'Unavailable',
            manifestUrl: null,
            unavailableReason: 'IPTV JWT authentication is enabled'
          }
        })
      ]
    });

    const play = await screen.findByRole('button', { name: /IPTV JWT authentication is enabled/ });
    expect(play).toBeDisabled();
  });

  it('offers preview for a transport-stream channel via the forced-hls path', async () => {
    renderChannelsScreen({
      channels: [
        channelFixture({
          name: 'Retro TV',
          preview: {
            availability: 'ForcedHlsOnly',
            manifestUrl: '/iptv/channel/12.2.m3u8?mode=segmenter',
            unavailableReason: null
          },
          streamingMode: 'MPEG-TS'
        })
      ]
    });

    const play = await screen.findByRole('button', { name: /Retro TV/ });
    expect(play).toBeEnabled();
  });
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/ChannelsScreen.test.tsx`
Expected: FAIL — the Play button is still hardcoded `disabled` with title `Preview unavailable for Vaporwave`.

- [ ] **Step 3: Add panel state to the screen**

In `ChannelsScreen.tsx`, in the component that owns `statesById` (~`:209`), add:

```tsx
  const [previewChannelId, setPreviewChannelId] = useState<null | number>(null);
```

Pass `onPreview={setPreviewChannelId}` down to each `ChannelTableRow`, and render the panel once, beside the existing dialogs:

```tsx
      {previewChannelId !== null &&
        (() => {
          const previewChannel = query.data.channels.find((c) => c.id === previewChannelId);
          if (!previewChannel) {
            return null;
          }
          return (
            <ChannelPreviewPanel
              channel={previewChannel}
              nowPlaying={statesById[previewChannelId]?.nowPlaying ?? null}
              onClose={() => setPreviewChannelId(null)}
              open
            />
          );
        })()}
```

Add the import: `import { ChannelPreviewPanel } from './channels/ChannelPreviewPanel';`

- [ ] **Step 4: Replace the hardcoded disabled Play button**

In `ChannelTableRow`, replace the existing `IconButton` at `:598-600` with:

```tsx
          <IconButton
            disabled={channel.preview.availability === 'Unavailable'}
            onClick={() => onPreview(channel.id)}
            size="sm"
            title={
              channel.preview.availability === 'Unavailable'
                ? `Preview unavailable for ${channel.name} — ${channel.preview.unavailableReason}`
                : `Preview ${channel.name}`
            }
          >
            <Play aria-hidden="true" size={15} />
          </IconButton>
```

Add `onPreview: (channelId: number) => void;` to `ChannelTableRow`'s props interface and to its destructured parameters.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/ChannelsScreen.test.tsx`
Expected: PASS.

- [ ] **Step 6: Run the full web verification gate**

Per `docs/spa-conventions.md` §7:

```bash
cd /Users/timothy/etv-wt-60/web
npm run lint && npx tsc --noEmit && npm run test && npm run build
```

Expected: all four succeed.

- [ ] **Step 7: Commit**

```bash
cd /Users/timothy/etv-wt-60
git add web/src/screens/ChannelsScreen.tsx web/src/screens/ChannelsScreen.test.tsx
git commit -m "feat(60): activate channel preview on the channels list"
```

---

### Task 6: Decision record, parity docs, and the live-E2E gate

**Files:**
- Modify: `docs/decisions.md`
- Modify: `docs/blazor-route-parity.md`
- Modify: `docs/README.md` (only if a doc was added or retitled — otherwise leave untouched)

**Interfaces:**
- Consumes: everything above.
- Produces: the merge-ready branch.

- [ ] **Step 1: Add the decision record**

Append to `docs/decisions.md`, following the lifecycle schema enforced by `scripts/decisions_validate.py` (5 metadata fields, plus the `Signals:` line required since #545 — check a neighbouring recent record and copy its exact field layout before writing):

```markdown
## 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)

`key: api.channel-preview-capability`
`status: active`
`since: 2026-07-21`
`supersedes: none`
`superseded-by: none`

Whether a channel can be previewed in the browser is declared by the server as an additive
`Preview` field on `ChannelResponseModel` (`{Availability, ManifestUrl, UnavailableReason}`),
computed in one place from the real `StreamingMode` enum plus `JwtHelper.IsEnabled`. The SPA
renders and acts on it and derives nothing — deriving it client-side would mean keying behavior off
`Mapper.GetStreamingMode`'s human-readable display label, where a copy tweak silently breaks
playback.

Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
Transport Stream modes serve. Those are declared `ForcedHlsOnly`: preview is offered as an explicit
opt-in that requests `?mode=segmenter` and is always labelled as a content-only check that does not
exercise the channel's configured pipeline. Fatal HLS errors are reported, never auto-recovered —
a diagnostic surface must show the fault rather than retry past it.

`/iptv/*` does not accept the SPA's `ctv-session` cookie, and nothing mints a JWT for the SPA, so
under a JWT-enabled deployment preview is declared `Unavailable` with a reason rather than failing
silently.

**Signals:** a Play button that does nothing; preview eligibility inferred from a display string;
a green preview on a Transport Stream channel being read as validating its configured pipeline.
```

- [ ] **Step 2: Validate the decision record**

Run: `cd /Users/timothy/etv-wt-60 && python3 scripts/decisions_validate.py`
Expected: exit 0, no errors. If it reports a missing field, copy the field layout from the record immediately above yours.

- [ ] **Step 3: Update route/capability parity notes**

In `docs/blazor-route-parity.md`, in the Channels section, note that the channels list now offers in-browser preview for HLS-mode channels (a capability with no Blazor predecessor — an addition, not a parity item).

- [ ] **Step 4: Run the full local gate**

```bash
cd /Users/timothy/etv-wt-60
dotnet build ErsatzTV.sln && dotnet test ErsatzTV.sln
cd web && npm run lint && npx tsc --noEmit && npm run test && npm run build
```

Expected: all green.

- [ ] **Step 5: BOM + format check on the touched C# files**

```bash
cd /Users/timothy/etv-wt-60
for f in $(git diff --name-only origin/main | grep '\.cs$'); do
  xxd -p "$f" | head -c 6 | grep -q '^efbbbf' && echo "BOM: $f"
done
bash -c 'dotnet format whitespace . --folder --include $(git diff --name-only origin/main | grep "\.cs$" | tr "\n" " ") --verify-no-changes'
```

Expected: no `BOM:` lines; format reports no changes needed.

- [ ] **Step 6: Live-E2E against a fresh config dir**

Per `testing.live-e2e-prepush-timing` and `testing.e2e-local-fresh-config-dir`, this runs **before** the push:

```bash
cd /Users/timothy/etv-wt-60
ETV_CONFIG_DIR=$(mktemp -d) ./scripts/e2e-local.sh
```

Then, against the running instance, create a segmenter-mode channel and confirm the round trip:

```bash
curl -s localhost:8409/api/v1/channels | python3 -m json.tool | grep -A3 '"preview"'
curl -sI "localhost:8409$(curl -s localhost:8409/api/v1/channels | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["preview"]["manifestUrl"])')"
```

Expected: `preview.availability` is `Available` with a rooted `manifestUrl`; the manifest request returns `200` with `Content-Type: application/vnd.apple.mpegurl`. **Curl it — never open a browser tab** (`testing.playwright-mcp-download-and-recovery`).

- [ ] **Step 7: Commit**

```bash
cd /Users/timothy/etv-wt-60
git add docs/decisions.md docs/blazor-route-parity.md
git commit -m "docs(60): record the channel-preview capability decision"
```

- [ ] **Step 8: Independent cold-context review before the push**

Mandatory here — the diff touches an API response DTO and auth-derived behavior (`process.independent-review-rubric`). Dispatch a **review-only** agent with no implementation history, scoped to `git diff origin/main`. Fold its findings into follow-up commits, then post a `Review-verdict: <verdict> @ <head-sha>` comment on the PR referencing the **current** head sha (`release.review-verdict-gate`).

- [ ] **Step 9: Push and open the PR**

```bash
cd /Users/timothy/etv-wt-60
git push -u origin feat/60-channel-preview
```

Open the PR with `fixes #60` in the body, then **arm the CI monitor on the head sha immediately** (`ci.monitor-armed-at-pr-open`). Batch any further fixes — a run cannot be cancelled agent-side (`ci.batch-pushes-no-cancel-route`).

---

## Self-Review

**Spec coverage:** server-declared `Preview` field → Task 1–2. `HlsPlayer.onError` → Task 3. SlideOver panel, forced-HLS opt-in + caveat, visible URL, error states, guide correlation → Task 4. Play button activation → Task 5. Decision record, docs, live-E2E, independent review → Task 6. The two follow-up issues in the spec are filed separately, outside this plan.

**Placeholder scan:** no TBDs; every code step carries complete code. The one deliberately open step is Task 6 Step 1's instruction to copy the exact decision-record field layout from a neighbouring record — the `Signals:` requirement landed in #545 and the live format is authoritative over anything transcribed here.

**Type consistency:** `GetPreview(StreamingMode, string, bool)` and `ProjectToResponseModel(Channel, int, bool)` are used with those signatures in Tasks 1–2. `onError: (message: string) => void` is defined in Task 3 and consumed with that exact signature in Task 4. `ChannelPreviewPanelProps` as defined in Task 4 matches what Task 5 passes (`channel`, `nowPlaying`, `onClose`, `open`). `FORCED_HLS_CAVEAT` matches the Global Constraints copy rule verbatim.
