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-device": "55.0.9",
"expo-font": "55.0.4",
"expo-glass-effect": "~55.0.8",
"expo-haptics": "55.0.8",
"expo-image": "55.0.6",
"expo-image-picker": "55.0.12",
"expo-linear-gradient": "~55.0.8",
"expo-linking": "55.0.7",
"expo-localization": "55.0.8",
"expo-network": "55.0.8",
+67 -3
View File
@@ -8,6 +8,8 @@ import {
IconUsers,
} from "@tabler/icons-react-native";
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 * as WebBrowser from "expo-web-browser";
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 { StarRating } from "@/components/ui/star-rating";
import { Text } from "@/components/ui/text";
import { useTitleTheme } from "@/hooks/use-title-theme";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
@@ -143,6 +146,9 @@ export default function TitleDetailScreen() {
}, []);
const title = detail.data?.title;
const palette = title?.colorPalette ?? null;
useTitleTheme(palette);
const seasons = detail.data?.seasons ?? [];
const cast = detail.data?.cast ?? [];
const availability = detail.data?.availability ?? [];
@@ -268,9 +274,39 @@ export default function TitleDetailScreen() {
contentFit="cover"
/>
)}
<View
{/* 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
className="absolute inset-0"
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"
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
/>
{title.trailerVideoKey && (
@@ -295,7 +331,14 @@ export default function TitleDetailScreen() {
{title.posterPath && (
<View
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
source={{ uri: title.posterPath }}
@@ -342,6 +385,27 @@ export default function TitleDetailScreen() {
</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 */}
{title.genres && title.genres.length > 0 && (
<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]),
);
}