feat(native): port title page color tinting from web to mobile (#11)

This commit is contained in:
2026-03-13 11:19:24 -04:00
committed by GitHub
parent 3a57de2ed6
commit c5d12482e2
4 changed files with 122 additions and 3 deletions
+2
View File
@@ -40,9 +40,11 @@
"expo-dev-client": "55.0.16", "expo-dev-client": "55.0.16",
"expo-device": "55.0.9", "expo-device": "55.0.9",
"expo-font": "55.0.4", "expo-font": "55.0.4",
"expo-glass-effect": "~55.0.8",
"expo-haptics": "55.0.8", "expo-haptics": "55.0.8",
"expo-image": "55.0.6", "expo-image": "55.0.6",
"expo-image-picker": "55.0.12", "expo-image-picker": "55.0.12",
"expo-linear-gradient": "~55.0.8",
"expo-linking": "55.0.7", "expo-linking": "55.0.7",
"expo-localization": "55.0.8", "expo-localization": "55.0.8",
"expo-network": "55.0.8", "expo-network": "55.0.8",
+66 -2
View File
@@ -8,6 +8,8 @@ import {
IconUsers, IconUsers,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -33,6 +35,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { StarRating } from "@/components/ui/star-rating"; import { StarRating } from "@/components/ui/star-rating";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { useTitleTheme } from "@/hooks/use-title-theme";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -143,6 +146,9 @@ export default function TitleDetailScreen() {
}, []); }, []);
const title = detail.data?.title; const title = detail.data?.title;
const palette = title?.colorPalette ?? null;
useTitleTheme(palette);
const seasons = detail.data?.seasons ?? []; const seasons = detail.data?.seasons ?? [];
const cast = detail.data?.cast ?? []; const cast = detail.data?.cast ?? [];
const availability = detail.data?.availability ?? []; const availability = detail.data?.availability ?? [];
@@ -268,9 +274,39 @@ export default function TitleDetailScreen() {
contentFit="cover" contentFit="cover"
/> />
)} )}
{/* Backdrop overlay: glass effect on supported devices, colored Views elsewhere */}
{isLiquidGlassAvailable() && palette?.vibrant ? (
<GlassView
glassEffectStyle="regular"
tintColor={palette.vibrant}
className="absolute inset-0"
/>
) : (
<>
{/* Base darkening overlay */}
<View <View
className="absolute inset-0" className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.55)" }} style={{ backgroundColor: "rgba(0,0,0,0.45)" }}
/>
{/* Colored tint from palette */}
{palette?.darkMuted && (
<View
className="absolute inset-0"
style={{ backgroundColor: palette.darkMuted, opacity: 0.25 }}
/>
)}
{palette?.vibrant && (
<View
className="absolute inset-0"
style={{ backgroundColor: palette.vibrant, opacity: 0.06 }}
/>
)}
</>
)}
{/* Bottom fade to background */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)"]}
className="absolute inset-0"
/> />
{title.trailerVideoKey && ( {title.trailerVideoKey && (
@@ -295,7 +331,14 @@ export default function TitleDetailScreen() {
{title.posterPath && ( {title.posterPath && (
<View <View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg" className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={{ borderCurve: "continuous" }} style={[
{ borderCurve: "continuous" },
palette?.darkVibrant
? {
boxShadow: `0 12px 28px -8px ${palette.darkVibrant}80`,
}
: undefined,
]}
> >
<Image <Image
source={{ uri: title.posterPath }} source={{ uri: title.posterPath }}
@@ -342,6 +385,27 @@ export default function TitleDetailScreen() {
</View> </View>
</View> </View>
{/* Ambient color glow below hero */}
{(() => {
const glowColor =
palette?.vibrant ?? palette?.darkMuted ?? palette?.muted;
if (!glowColor) return null;
return (
<View
pointerEvents="none"
style={{
height: 0,
overflow: "visible",
}}
>
<LinearGradient
colors={[`${glowColor}14`, "transparent"]}
style={{ height: 300 }}
/>
</View>
);
})()}
{/* Genres */} {/* Genres */}
{title.genres && title.genres.length > 0 && ( {title.genres && title.genres.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(100)}> <Animated.View entering={FadeInDown.duration(300).delay(100)}>
+49
View File
@@ -0,0 +1,49 @@
import type { ColorPalette } from "@sofa/api/schemas";
import { useFocusEffect } from "expo-router";
import { useCallback } from "react";
import { Uniwind } from "uniwind";
function hexToRelativeLuminance(hex: string): number {
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
const DEFAULTS: Record<string, string> = {
"--color-primary": "oklch(0.8 0.14 65)",
"--color-ring": "oklch(0.8 0.14 65)",
"--color-primary-foreground": "oklch(0.13 0.006 55)",
"--color-status-watching": "oklch(0.78 0.14 65)",
};
function applyPalette(vibrant: string): void {
const luminance = hexToRelativeLuminance(vibrant);
const foreground =
luminance > 0.3 ? "oklch(0.13 0.006 55)" : "oklch(0.93 0.015 80)";
Uniwind.updateCSSVariables("dark", {
"--color-primary": vibrant,
"--color-ring": vibrant,
"--color-primary-foreground": foreground,
"--color-status-watching": vibrant,
});
}
export function useTitleTheme(palette: ColorPalette | null | undefined): void {
const vibrant = palette?.vibrant ?? null;
useFocusEffect(
useCallback(() => {
if (vibrant) {
applyPalette(vibrant);
}
return () => {
Uniwind.updateCSSVariables("dark", DEFAULTS);
};
}, [vibrant]),
);
}
+4
View File
@@ -40,9 +40,11 @@
"expo-dev-client": "55.0.16", "expo-dev-client": "55.0.16",
"expo-device": "55.0.9", "expo-device": "55.0.9",
"expo-font": "55.0.4", "expo-font": "55.0.4",
"expo-glass-effect": "~55.0.8",
"expo-haptics": "55.0.8", "expo-haptics": "55.0.8",
"expo-image": "55.0.6", "expo-image": "55.0.6",
"expo-image-picker": "55.0.12", "expo-image-picker": "55.0.12",
"expo-linear-gradient": "~55.0.8",
"expo-linking": "55.0.7", "expo-linking": "55.0.7",
"expo-localization": "55.0.8", "expo-localization": "55.0.8",
"expo-network": "55.0.8", "expo-network": "55.0.8",
@@ -1599,6 +1601,8 @@
"expo-keep-awake": ["expo-keep-awake@55.0.4", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg=="], "expo-keep-awake": ["expo-keep-awake@55.0.4", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg=="],
"expo-linear-gradient": ["expo-linear-gradient@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-nCMgZXcHesnFslH1TUFMdqlDiE4jYTTTSHb97g1jq1gyGX2xDEOyqBxQJmiIVGrZJ+kTWH6RljJ5tYyva9mrAg=="],
"expo-linking": ["expo-linking@55.0.7", "", { "dependencies": { "expo-constants": "~55.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ=="], "expo-linking": ["expo-linking@55.0.7", "", { "dependencies": { "expo-constants": "~55.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ=="],
"expo-localization": ["expo-localization@55.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-uFmpTsoDT7JE5Nwgt0EQ5gBvFVo7/u458SlY6V9Ep9wY/WPucL0o00VpXoFULaMtKHquKBgVUdHwk6E+JFz4dg=="], "expo-localization": ["expo-localization@55.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-uFmpTsoDT7JE5Nwgt0EQ5gBvFVo7/u458SlY6V9Ep9wY/WPucL0o00VpXoFULaMtKHquKBgVUdHwk6E+JFz4dg=="],