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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import type {
CategoryRequestIdentity,
CategoryTab,
ClipSort,
LiveSort,
PlatformScope,
VideoSort,
Expand Down Expand Up @@ -167,7 +168,12 @@ function sortOptions(tab: CategoryTab): readonly {
{ label: "Viewers (low)", value: "viewers-asc" },
];
}
if (tab === "clips") return [{ label: "Views", value: "views" }];
if (tab === "clips") {
return [
{ label: "Views", value: "views" },
{ label: "Recent", value: "recent" },
];
}
return [
{ label: "Recent", value: "recent" },
{ label: "Views", value: "views" },
Expand Down Expand Up @@ -202,7 +208,7 @@ function applySort(
if (identity.tab === "videos") {
return { ...identity, videoSort: value as VideoSort };
}
return identity;
return { ...identity, clipSort: value as ClipSort };
}

const styles = StyleSheet.create({
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/features/discovery/domain/category-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function selectMedia(input: {
}
return {
kind: "clips",
items: sortClips(clipsOf(input.twitch)),
items: sortClips(clipsOf(input.twitch), input.identity.clipSort),
};
}
if (input.identity.tab === "videos") {
Expand Down Expand Up @@ -176,8 +176,15 @@ function sortLive(items: readonly Stream[], sort: "viewers-desc" | "viewers-asc"
);
}

function sortClips(items: readonly Clip[]): Clip[] {
return sortCopy(items, (left, right) => right.viewCount - left.viewCount);
function sortClips(
items: readonly Clip[],
sort: CategoryRequestIdentity["clipSort"],
): Clip[] {
return sortCopy(items, (left, right) =>
sort === "recent"
? right.createdAt.localeCompare(left.createdAt)
: right.viewCount - left.viewCount,
);
}

function sortVideos(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type CategoryTab = "live" | "clips" | "videos";
export type PlatformScope = "all" | Platform;
export type TagFilter = "all" | string;
export type LiveSort = "viewers-desc" | "viewers-asc";
export type ClipSort = "views";
export type ClipSort = "views" | "recent";
export type VideoSort = "views" | "recent";

export type CategoryFollowState = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,15 @@ export function fixtureClip(
platform: Platform,
id: string,
views: number,
createdAt = "2026-09-11T00:00:00.000Z" as SerializedTimestamp,
): Clip {
return {
channelAvatar: "",
channelDisplayName: "Clip Creator",
channelId: `${platform}-${id}`,
channelName: `${platform}-clip`,
clipUrl: `https://example.com/clip/${id}`,
createdAt: "2026-09-11T00:00:00.000Z" as SerializedTimestamp,
createdAt,
creatorName: "Creator",
duration: 12,
id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,29 @@ describe("Category detail screen", () => {
nodes.some((node) => node.props.testID === "category-unsupported"),
).toBe(true);
});

it("offers Views and Recent clip sort chips", () => {
const root = CategoryDetailView({
onChangeIdentity: () => undefined,
onChangeQuery: () => undefined,
onOpenAccounts: () => undefined,
onRetry: () => undefined,
query: "",
view: composeCategoryDetail({
identity: {
...defaultCategoryRequest(chatting, "all", "all"),
tab: "clips",
},
loading: false,
twitch: fixtureOutcome("twitch", "ready"),
}),
});
const nodes = descendants(root);
expect(nodes.some((node) => node.props.testID === "category-sort-views")).toBe(
true,
);
expect(
nodes.some((node) => node.props.testID === "category-sort-recent"),
).toBe(true);
});
});
36 changes: 35 additions & 1 deletion apps/mobile/src/features/discovery/tests/category-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("composeCategoryDetail", () => {
expect(view.media.items.map((item) => item.id)).toEqual(["new", "old"]);
});

it("sorts clips by views only", () => {
it("sorts clips by views by default", () => {
const view = composeCategoryDetail({
identity: {
...defaultCategoryRequest(chatting, "all", "day"),
Expand All @@ -111,4 +111,38 @@ describe("composeCategoryDetail", () => {
if (view.media.kind !== "clips") throw new Error("expected clips");
expect(view.media.items.map((item) => item.id)).toEqual(["high", "low"]);
});

it("sorts clips by created time when Recent is selected", () => {
const view = composeCategoryDetail({
identity: {
...defaultCategoryRequest(chatting, "all", "day"),
clipSort: "recent",
tab: "clips",
},
loading: false,
twitch: {
cache: { kind: "miss" },
items: [
fixtureClip(
"twitch",
"old",
40,
"2026-01-01T00:00:00.000Z" as SerializedTimestamp,
),
fixtureClip(
"twitch",
"new",
2,
"2026-09-01T00:00:00.000Z" as SerializedTimestamp,
),
],
path: { kind: "direct", platform: "twitch" },
platform: "twitch",
status: "complete",
},
});
expect(view.media.kind).toBe("clips");
if (view.media.kind !== "clips") throw new Error("expected clips");
expect(view.media.items.map((item) => item.id)).toEqual(["new", "old"]);
});
});
17 changes: 15 additions & 2 deletions apps/mobile/tests/coverage-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ test("coverage matrix reconciles prototype, contract, and shell routes", () => {
assert.equal(ledger.schemaVersion, 1);
assert.equal(ledger.issue, 195);
assert.equal(ledger.totals.discovered, 185);
assert.equal(ledger.totals.implemented, 32);
assert.equal(ledger.totals.implemented, 49);
assert.equal(ledger.totals.partial, 14);
assert.equal(ledger.totals.placeholder, 9);
assert.equal(ledger.totals.missing, 130);
assert.equal(ledger.totals.missing, 113);
assert.equal(ledger.gaps.length, 5);
assert.ok(ledger.gaps.some((gap) => gap.id === "GAP-195-01"));
assert.ok(
Expand Down Expand Up @@ -91,4 +91,17 @@ test("coverage matrix reconciles prototype, contract, and shell routes", () => {
entry.status === "implemented",
),
);
for (const id of [
"tab:search:all",
"tab:category-detail:clips",
"tab:channel:home",
"tab:following:live",
]) {
assert.ok(
ledger.entries.some(
(entry) => entry.id === id && entry.status === "implemented",
),
`${id} must stay implemented`,
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Android parity record: `category-discovery`

- Capability ID: `category-discovery`
- Desktop baseline: `docs/research/streamfusion-mobile/desktop-parity-inventory.md` Category discovery section
- Observed `main` at branch start: `aa0e52ab71030daa48f72d5cea1d250e8be07cd8`
- Android owner: Mobile `discovery`
- Progress: `implemented`
- Delivery: `direct`
- Adaptation: Equivalent. Categories live under More. Category Detail keeps Live, Clips, and Videos with a bottom search dock.
- Freshness: `current` for APK `1596d999e5ca19d5404bcfc89a5c182bad55d6397c5cb1946eb5550022844d5e` via `verification/evidence/issue-150-categories.json`

## Desktop outcome

Browse categories and inspect category-specific streams, videos, and clips. Filter by platform, language, tags, time, and sort. Clips sort by views or recency.

## Android outcome

Guest Categories work signed-out. Categories list Twitch and Kick cards with a Search categories dock. Category Detail keeps Follow as explained-unavailable (`guest-category-follow-requires-account`). Tabs are LIVE, CLIPS, and VIDEOS. Platform All, Twitch, and Kick stay on the merged card. Language chips persist. Clip time chips are day, week, month, and all. Clip sort chips are Views and Recent. Helix Get Clips has no sort param, so recency sorts the fetched page in `composeCategoryDetail`. Kick clips and videos stay explained-unavailable on the official public catalog.

## Required evidence

- `change-gate`
- `api30-journey`
- `official-live-catalog`
- `clips-videos-language-and-tags`

## Evidence residuals

`official-live-catalog` and `clips-videos-language-and-tags` stay residual on this APK. Live catalog success still needs official provider secrets on the signed-out relay. Language chips apply to Live only. Helix has no clip or video language query. The 2026-09-12 frames show Sort stayed Recent and Views on Videos. They do not isolate a Clips Recent chip tap. Unit tests cover Views default and Recent created-time order.

## Blocking for public release

Live Twitch and Kick category success still depends on Relay credentials or official app tokens. Signed-in category Follow stays behind OAuth. OAuth stays on #145 and #146.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Android parity record: `home-live-discovery`

- Capability ID: `home-live-discovery`
- Desktop baseline: `docs/research/streamfusion-mobile/desktop-parity-inventory.md` Home live discovery section
- Observed `main` at branch start: `aa0e52ab71030daa48f72d5cea1d250e8be07cd8`
- Android owner: Mobile `discovery`
- Progress: `implemented`
- Delivery: `direct`
- Adaptation: Equivalent. Home lives under More. Channel Detail is a stacked destination with Home, Videos, and Clips.
- Freshness: `current` for APK `1596d999e5ca19d5404bcfc89a5c182bad55d6397c5cb1946eb5550022844d5e` via `verification/evidence/issue-148-d05-home-channel.json`

## Desktop outcome

Browse live streams, channel cards, and current platform availability from Home. Open a channel to Home, Videos, and Clips without breaking the player on an ended channel.

## Android outcome

Guest Home works signed-out. More lists Home first. Combined Twitch and Kick recommendation cards use 16:9 thumbs, live state, and viewer counts. Twitch without Relay or an app token reports `guest-unavailable` with Retry. It does not require Sign in. Stale cache shows cached recommendations with age copy.

Channel Detail keeps Home, Videos, and Clips. Kick videos and clips show first-class unsupported copy. Follow uses `channel-follow`. Guest Follow writes a local channel follow. Watch opens the guest live player when a playback URL exists. OAuth stays on #145 and #146.

## Required evidence

- `change-gate`
- `api30-journey`
- `offline-cache`
- `tablet-layout`
- `live-platforms`
- `accessibility`

## Evidence residuals

`tablet-layout`, `live-platforms`, and `accessibility` stay residual on this APK. Live catalog success still needs Relay credentials or official app tokens. TalkBack, large text, and a tablet AVD were not driven. The 2026-09-12 frames predate Guest Follow (#225) and guest Watch (#227). Those PNGs still show the earlier degrade copy.

## Blocking for public release

Live Twitch and Kick catalog success still depends on Relay credentials or official app tokens. OAuth stays on #145 and #146.
85 changes: 85 additions & 0 deletions docs/research/streamfusion-mobile/coverage-matrix-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,23 @@ const implemented = new Set([
"screen:following",
"screen:channel",
"screen:watch",
"tab:search:all",
"tab:search:channels",
"tab:search:streams",
"tab:search:videos",
"tab:search:clips",
"tab:search:categories",
"tab:category-detail:live",
"tab:category-detail:clips",
"tab:category-detail:videos",
"tab:channel:home",
"tab:channel:videos",
"tab:channel:clips",
"tab:following:live",
"tab:following:videos",
"tab:following:clips",
"tab:following:categories",
"tab:following:channels",
"shell-route:search",
"shell-route:following",
"shell-route:following/manage",
Expand Down Expand Up @@ -195,6 +212,23 @@ const owners = {
"screen:settings-proxy": [162, 169],
"panel:proxy": [162],
"shell-route:more/settings": [162, 169],
"tab:search:all": [147, 149],
"tab:search:channels": [147, 149],
"tab:search:streams": [147, 149],
"tab:search:videos": [147, 149],
"tab:search:clips": [147, 149],
"tab:search:categories": [147, 149],
"tab:category-detail:live": [147, 150],
"tab:category-detail:clips": [147, 150],
"tab:category-detail:videos": [147, 150],
"tab:channel:home": [147, 148],
"tab:channel:videos": [147, 148],
"tab:channel:clips": [147, 148],
"tab:following:live": [147, 151],
"tab:following:videos": [147, 151],
"tab:following:clips": [147, 151],
"tab:following:categories": [147, 151],
"tab:following:channels": [147, 151],
};

const paths = {
Expand Down Expand Up @@ -232,6 +266,57 @@ const paths = {
"screen:channel": [
"apps/mobile/src/features/discovery/components/channel-detail-screen.tsx",
],
"tab:search:all": [
"apps/mobile/src/features/discovery/components/search-results-view.tsx",
],
"tab:search:channels": [
"apps/mobile/src/features/discovery/components/search-results-view.tsx",
],
"tab:search:streams": [
"apps/mobile/src/features/discovery/components/search-results-view.tsx",
],
"tab:search:videos": [
"apps/mobile/src/features/discovery/components/search-results-view.tsx",
],
"tab:search:clips": [
"apps/mobile/src/features/discovery/components/search-results-view.tsx",
],
"tab:search:categories": [
"apps/mobile/src/features/discovery/components/search-results-view.tsx",
],
"tab:category-detail:live": [
"apps/mobile/src/features/discovery/components/category-detail-view.tsx",
],
"tab:category-detail:clips": [
"apps/mobile/src/features/discovery/components/category-detail-view.tsx",
],
"tab:category-detail:videos": [
"apps/mobile/src/features/discovery/components/category-detail-view.tsx",
],
"tab:channel:home": [
"apps/mobile/src/features/discovery/components/channel-tabs.tsx",
],
"tab:channel:videos": [
"apps/mobile/src/features/discovery/components/channel-tabs.tsx",
],
"tab:channel:clips": [
"apps/mobile/src/features/discovery/components/channel-tabs.tsx",
],
"tab:following:live": [
"apps/mobile/src/features/follows/components/following-tab-body.tsx",
],
"tab:following:videos": [
"apps/mobile/src/features/follows/components/following-tab-body.tsx",
],
"tab:following:clips": [
"apps/mobile/src/features/follows/components/following-tab-body.tsx",
],
"tab:following:categories": [
"apps/mobile/src/features/follows/components/following-tab-body.tsx",
],
"tab:following:channels": [
"apps/mobile/src/features/follows/components/following-tab-body.tsx",
],
"shell-route:search": [
"apps/mobile/src/features/discovery/components/unified-search-screen.tsx",
],
Expand Down
Loading