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
55 changes: 49 additions & 6 deletions apps/api/src/modules/platforms/queries/list-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ type TmdbProvidersResponse = {
results?: TmdbProvider[];
};

// Cap the catalog so the picker stays scannable. TMDB returns 100+ entries per
// region, most of them niche or reseller channels.
const MAX_PROVIDERS = 30;

// Ad-funded tiers and reseller "channels" (e.g. "Netflix Standard with Ads",
// "Max Amazon Channel") are the same service to the user. Collapse them onto a
// single canonical key so the list shows one row per provider.
function canonicalKey(name: string): string {
return name
.toLowerCase()
.replace(/\s+(amazon|apple tv|roku premium|verizon)\s+channel$/, "")
.replace(/\s+(standard|basic|premium)?\s*with ads$/, "")
.replace(/\bplus\b/g, "+")
.replace(/[^a-z0-9+]/g, "");
}

// Prefer the clean base entry over an ad/reseller variant when collapsing.
function isVariant(name: string): boolean {
return /(with ads|amazon channel|apple tv channel|roku premium channel)$/i.test(name.trim());
}

async function fetchCatalog(mediaType: "movie" | "tv", region: string): Promise<TmdbProvider[]> {
const res = await tmdbFetch<TmdbProvidersResponse>(
`/watch/providers/${mediaType}`,
Expand Down Expand Up @@ -77,12 +98,34 @@ export async function listProviders(region: string): Promise<ProviderRefDto[]> {
}
}

const list = [...dedup.values()].sort((a, b) => {
const pa = priorities.get(a.providerId) ?? 9999;
const pb = priorities.get(b.providerId) ?? 9999;
if (pa !== pb) return pa - pb;
return a.name.localeCompare(b.name);
});
const priorityOf = (id: number) => priorities.get(id) ?? 9999;

// Collapse ad/reseller variants onto one canonical row, keeping the cleanest,
// highest-priority entry of each group, and drop entries with no logo so the
// picker can render a logo per row.
const byCanonical = new Map<string, ProviderRefDto>();
for (const entry of dedup.values()) {
if (!entry.logoPath) continue;
const key = canonicalKey(entry.name);
const current = byCanonical.get(key);
if (!current) {
byCanonical.set(key, entry);
continue;
}
const better =
(isVariant(current.name) ? 1 : 0) - (isVariant(entry.name) ? 1 : 0) ||
priorityOf(current.providerId) - priorityOf(entry.providerId);
if (better > 0) byCanonical.set(key, entry);
}

const list = [...byCanonical.values()]
.sort((a, b) => {
const pa = priorityOf(a.providerId);
const pb = priorityOf(b.providerId);
if (pa !== pb) return pa - pb;
return a.name.localeCompare(b.name);
})
.slice(0, MAX_PROVIDERS);

// Await the warm so the `providers` table is authoritative before the client
// can POST a selection back: setUserPlatforms validates provider ids against
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/app/(tabs)/profile/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export default function ProfileLayout() {
contentStyle: { backgroundColor: theme.background },
}}>
<Stack.Screen name="index" />
<Stack.Screen
name="platforms"
options={{
title: t("platforms.title"),
headerLargeTitle: false,
headerBackButtonDisplayMode: "minimal",
}}
/>
<Stack.Screen
name="edit"
options={{
Expand Down
26 changes: 2 additions & 24 deletions apps/mobile/src/components/discover/container.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";

import { Button } from "@/components/ui/button/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Text } from "@/components/ui/text";
import { SPACING } from "@/constants/design-tokens";
import { useMyPlatforms } from "@/hooks/platforms/use-my-platforms";
import { useNotInterestedList } from "@/hooks/not-interested/use-not-interested-list";
import { useAvailableFeed } from "@/hooks/recommendations/use-available-feed";
import { useDiscoverMedia } from "@/hooks/tmdb/use-discover-media";
import { hapticTap } from "@/lib/haptics";
import type { MediaFilter, TmdbMovieSummary } from "@/lib/tmdb";

import { DiscoverSkeleton } from "./discover-skeleton";
import { HeroCard } from "./hero-card";
import { PlatformsPrompt } from "./platforms-prompt";
import { PosterCard } from "./poster-card";
import { RankingCard } from "./ranking-card";
import { Shelf } from "./shelf";
Expand All @@ -24,7 +22,6 @@ const keyOf = (media: TmdbMovieSummary, index: number) =>

export const DiscoverContainer = ({ filter }: { filter: MediaFilter }) => {
const { t } = useTranslation();
const router = useRouter();
const { trending, topToday, newReleases, genres, isLoading, error, isOffline } =
useDiscoverMedia(filter);
const myPlatforms = useMyPlatforms();
Expand Down Expand Up @@ -121,22 +118,7 @@ export const DiscoverContainer = ({ filter }: { filter: MediaFilter }) => {
/>
</>
) : !myPlatforms.isLoading ? (
<View style={styles.platformsPrompt}>
<EmptyState
icon="tv"
title={t("discover.pickPlatformsTitle")}
subtitle={t("discover.pickPlatformsSubtitle")}
action={
<Button
title={t("discover.pickPlatformsAction")}
onPress={() => {
hapticTap();
router.push("/profile/platforms");
}}
/>
}
/>
</View>
<PlatformsPrompt />
) : null}

<Shelf
Expand Down Expand Up @@ -185,8 +167,4 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
platformsPrompt: {
paddingHorizontal: SPACING.MD,
paddingVertical: SPACING.LG,
},
});
73 changes: 73 additions & 0 deletions apps/mobile/src/components/discover/platforms-prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { SymbolView } from "expo-symbols";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";

import { Button } from "@/components/ui/button/button";
import { GlassPanel } from "@/components/ui/glass-panel";
import { Text } from "@/components/ui/text";
import { BORDER_RADIUS, SPACING } from "@/constants/design-tokens";
import { useAccentColor } from "@/hooks/use-accent-color";
import { useTheme } from "@/hooks/use-theme";
import { hapticTap } from "@/lib/haptics";

// Compact promo card nudging the user to pick their streaming services. Lives
// inline between the discover shelves, so it stays on the page rhythm instead of
// reading as a full-screen empty state.
export function PlatformsPrompt() {
const { t } = useTranslation();
const router = useRouter();
const theme = useTheme();
const { accentHex } = useAccentColor();

return (
<View style={styles.container}>
<GlassPanel fallbackColor={theme.backgroundElement} style={styles.panel}>
<View style={styles.header}>
<SymbolView name="tv" size={20} tintColor={accentHex} style={styles.icon} />
<View style={styles.copy}>
<Text inline size="md" weight="semibold" color={theme.text} numberOfLines={1}>
{t("discover.pickPlatformsTitle")}
</Text>
<Text inline size="sm" weight="regular" color={theme.textSecondary} numberOfLines={2}>
{t("discover.pickPlatformsSubtitle")}
</Text>
</View>
</View>
<Button
title={t("discover.pickPlatformsAction")}
size="sm"
width="fill"
onPress={() => {
hapticTap();
router.push("/profile/platforms");
}}
/>
</GlassPanel>
</View>
);
}

const styles = StyleSheet.create({
container: {
paddingHorizontal: SPACING.MD,
},
panel: {
borderRadius: BORDER_RADIUS.LG,
padding: SPACING.MD,
gap: SPACING.MD,
overflow: "hidden",
},
header: {
flexDirection: "row",
alignItems: "flex-start",
gap: SPACING.SM,
},
icon: {
marginTop: SPACING.XXS,
},
copy: {
flex: 1,
gap: SPACING.XXS,
},
});
2 changes: 0 additions & 2 deletions apps/mobile/src/components/navigation/screen-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ interface ScreenToolbarProps {
actions: ScreenAction[];
}

// Declarative header toolbar: pass a list of actions instead of hand-writing
// Stack.Toolbar.Button blocks at every call-site.
export function ScreenToolbar({ placement, actions }: ScreenToolbarProps) {
return (
<Stack.Toolbar placement={placement}>
Expand Down
Loading
Loading