mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
feat(native): port title page color tinting from web to mobile (#11)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user