mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
feat(native): introduce title-accent CSS variable for per-title color theming
Replace hardcoded `--color-primary` references on the title detail screen
with a dedicated `--color-title-accent` / `--color-title-accent-foreground`
variable pair, allowing the poster-derived palette to style title UI
elements without polluting the global primary color.
- Add `--color-title-accent` and `--color-title-accent-foreground` to
`global.css` with default warm amber values
- Update `use-title-theme.ts` to set `--color-title-accent` instead of
`--color-primary`; switch from `useFocusEffect` to `useEffect`
- Replace all `bg-primary`, `text-primary`, `border-primary` etc. in
`title/[id].tsx` and title components with `title-accent` equivalents
- Add `accentColor` prop to `StarRating` so it can receive the resolved
CSS variable value at runtime
- Pass `iconColor={titleAccent}` to all `SectionHeader` instances on the
title detail screen
- Use platform-appropriate store icon (App Store / Google Play) for the
"Where to Watch" section header
- Switch progress bar in `ContinueWatchingBanner` from `bg-status-watching`
to `bg-title-accent`
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
IconBrandAppstore,
|
||||
IconBrandGooglePlay,
|
||||
IconCheck,
|
||||
IconList,
|
||||
IconMovie,
|
||||
@@ -15,6 +17,7 @@ import * as WebBrowser from "expo-web-browser";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
@@ -45,10 +48,10 @@ export default function TitleDetailScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { back } = useRouter();
|
||||
|
||||
const [primary, mutedForeground, primaryForeground] = useCSSVariable([
|
||||
"--color-primary",
|
||||
const [titleAccent, mutedForeground, titleAccentForeground] = useCSSVariable([
|
||||
"--color-title-accent",
|
||||
"--color-muted-foreground",
|
||||
"--color-primary-foreground",
|
||||
"--color-title-accent-foreground",
|
||||
]) as [string, string, string];
|
||||
|
||||
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
|
||||
@@ -246,7 +249,7 @@ export default function TitleDetailScreen() {
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColorClassName="accent-primary"
|
||||
tintColorClassName="accent-title-accent"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -305,11 +308,7 @@ export default function TitleDetailScreen() {
|
||||
)}
|
||||
{/* Bottom fade to background */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
"transparent",
|
||||
"rgba(0,0,0,0.6)",
|
||||
"rgba(0,0,0,0.95)",
|
||||
]}
|
||||
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
|
||||
locations={[0, 0.5, 1]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -370,8 +369,8 @@ export default function TitleDetailScreen() {
|
||||
{title.title}
|
||||
</Text>
|
||||
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
|
||||
<View className="rounded-full bg-primary px-2 py-0.5">
|
||||
<Text className="font-sans-medium text-[10px] text-primary-foreground">
|
||||
<View className="rounded-full bg-title-accent px-2 py-0.5">
|
||||
<Text className="font-sans-medium text-[10px] text-title-accent-foreground">
|
||||
{title.type === "movie" ? "Movie" : "TV"}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -385,8 +384,8 @@ export default function TitleDetailScreen() {
|
||||
) : null}
|
||||
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||
<View className="flex-row items-center gap-0.5">
|
||||
<IconStarFilled size={12} color={primary} />
|
||||
<Text className="text-primary text-xs">
|
||||
<IconStarFilled size={12} color={titleAccent} />
|
||||
<Text className="text-title-accent text-xs">
|
||||
{title.voteAverage.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -454,20 +453,21 @@ export default function TitleDetailScreen() {
|
||||
<StarRating
|
||||
rating={userInfo.data?.rating ?? 0}
|
||||
onRate={(stars) => updateRating.mutate({ id, stars })}
|
||||
accentColor={titleAccent}
|
||||
/>
|
||||
|
||||
{title.type === "movie" && (
|
||||
<Pressable
|
||||
onPress={() => watchMovie.mutate({ id })}
|
||||
disabled={watchMovie.isPending}
|
||||
className="flex-row items-center gap-1.5 rounded-full bg-primary px-4 py-2"
|
||||
className="flex-row items-center gap-1.5 rounded-full bg-title-accent px-4 py-2"
|
||||
>
|
||||
{watchMovie.isPending ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={16} color={primaryForeground} />
|
||||
<Text className="font-sans-medium text-[13px] text-primary-foreground">
|
||||
<IconCheck size={16} color={titleAccentForeground} />
|
||||
<Text className="font-sans-medium text-[13px] text-title-accent-foreground">
|
||||
Mark Watched
|
||||
</Text>
|
||||
</>
|
||||
@@ -493,7 +493,13 @@ export default function TitleDetailScreen() {
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Where to Watch" icon={IconPlayerPlay} />
|
||||
<SectionHeader
|
||||
title="Where to Watch"
|
||||
icon={
|
||||
Platform.OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay
|
||||
}
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -540,7 +546,11 @@ export default function TitleDetailScreen() {
|
||||
entering={FadeInDown.duration(300).delay(400)}
|
||||
className="mt-6 px-4"
|
||||
>
|
||||
<SectionHeader title="Seasons" icon={IconList} />
|
||||
<SectionHeader
|
||||
title="Seasons"
|
||||
icon={IconList}
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
{seasons.map((season) => (
|
||||
<SeasonAccordion
|
||||
key={season.id}
|
||||
@@ -554,7 +564,7 @@ export default function TitleDetailScreen() {
|
||||
|
||||
{hydrateMutation.isPending && (
|
||||
<View className="items-center py-6">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
<Spinner colorClassName="accent-title-accent" />
|
||||
<Text className="mt-2 text-[13px] text-muted-foreground">
|
||||
Loading season data...
|
||||
</Text>
|
||||
@@ -568,7 +578,11 @@ export default function TitleDetailScreen() {
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Cast" icon={IconUsers} />
|
||||
<SectionHeader
|
||||
title="Cast"
|
||||
icon={IconUsers}
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
@@ -590,7 +604,11 @@ export default function TitleDetailScreen() {
|
||||
className="mt-6"
|
||||
>
|
||||
<View className="px-4">
|
||||
<SectionHeader title="More Like This" icon={IconThumbUp} />
|
||||
<SectionHeader
|
||||
title="More Like This"
|
||||
icon={IconThumbUp}
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconPlayerPlay } from "@tabler/icons-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
@@ -19,6 +20,8 @@ export function ContinueWatchingBanner({
|
||||
userStatus: string | null;
|
||||
backdropPath: string | null;
|
||||
}) {
|
||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||
|
||||
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
|
||||
() => getNextEpisode(seasons, watchedEpisodeIds),
|
||||
[seasons, watchedEpisodeIds],
|
||||
@@ -35,7 +38,11 @@ export function ContinueWatchingBanner({
|
||||
entering={FadeInDown.duration(300).delay(350)}
|
||||
className="mt-6 px-4"
|
||||
>
|
||||
<SectionHeader title="Next Episode" icon={IconPlayerPlay} />
|
||||
<SectionHeader
|
||||
title="Next Episode"
|
||||
icon={IconPlayerPlay}
|
||||
iconColor={titleAccentColor}
|
||||
/>
|
||||
<View
|
||||
className="overflow-hidden rounded-[12px] border bg-card"
|
||||
style={{
|
||||
@@ -59,8 +66,8 @@ export function ContinueWatchingBanner({
|
||||
/>
|
||||
<View className="absolute right-3 bottom-2.5 left-3">
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<View className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
<Text className="font-sans-medium text-[10px] text-primary uppercase tracking-wider">
|
||||
<View className="h-1.5 w-1.5 rounded-full bg-title-accent" />
|
||||
<Text className="font-sans-medium text-[10px] text-title-accent uppercase tracking-wider">
|
||||
Up next
|
||||
</Text>
|
||||
</View>
|
||||
@@ -80,7 +87,7 @@ export function ContinueWatchingBanner({
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<View
|
||||
className="h-full bg-status-watching"
|
||||
className="h-full bg-title-accent"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function EpisodeRow({
|
||||
isWatched: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export function EpisodeRow({
|
||||
style={{ borderBottomWidth: 0.5 }}
|
||||
>
|
||||
{isWatched ? (
|
||||
<IconCircleCheckFilled size={22} color={completedColor} />
|
||||
<IconCircleCheckFilled size={22} color={titleAccentColor} />
|
||||
) : (
|
||||
<IconCircleDashed size={22} color={mutedFgColor} />
|
||||
)}
|
||||
|
||||
@@ -34,8 +34,7 @@ export function SeasonAccordion({
|
||||
}>;
|
||||
watchedEpisodeIds: Set<string>;
|
||||
}) {
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
const watchingColor = useCSSVariable("--color-status-watching") as string;
|
||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -145,7 +144,7 @@ export function SeasonAccordion({
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: progress === 1 ? completedColor : watchingColor,
|
||||
backgroundColor: titleAccentColor,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -165,7 +164,7 @@ export function SeasonAccordion({
|
||||
onPress={() => watchSeason.mutate({ id: season.id })}
|
||||
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
|
||||
>
|
||||
<Text className="font-sans-medium text-primary text-xs">
|
||||
<Text className="font-sans-medium text-title-accent text-xs">
|
||||
Mark All Watched
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function StatusActionButton({
|
||||
{ status: "completed", label: "Completed", Icon: IconCircleCheck },
|
||||
];
|
||||
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
const primaryColor = useCSSVariable("--color-title-accent") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
return (
|
||||
@@ -46,13 +46,13 @@ export function StatusActionButton({
|
||||
disabled={isPending}
|
||||
className={`flex-row items-center gap-1.5 rounded-full border px-3 py-2 ${
|
||||
isActive
|
||||
? "border-primary bg-primary/10"
|
||||
? "border-title-accent bg-title-accent/10"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} color={isActive ? primaryColor : mutedFgColor} />
|
||||
<Text
|
||||
className={`font-sans-medium text-xs ${isActive ? "text-primary" : "text-foreground"}`}
|
||||
className={`font-sans-medium text-xs ${isActive ? "text-title-accent" : "text-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface StarRatingProps {
|
||||
onRate?: (rating: number) => void;
|
||||
size?: number;
|
||||
interactive?: boolean;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
@@ -15,12 +16,15 @@ export function StarRating({
|
||||
onRate,
|
||||
size = 22,
|
||||
interactive = true,
|
||||
accentColor,
|
||||
}: StarRatingProps) {
|
||||
const [primary, mutedForeground] = useCSSVariable([
|
||||
const [defaultPrimary, mutedForeground] = useCSSVariable([
|
||||
"--color-primary",
|
||||
"--color-muted-foreground",
|
||||
]) as [string, string];
|
||||
|
||||
const primary = accentColor ?? defaultPrimary;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
--color-status-watchlist: oklch(0.72 0.12 220);
|
||||
--color-status-watching: oklch(0.78 0.14 65);
|
||||
--color-status-completed: oklch(0.75 0.14 155);
|
||||
--color-title-accent: oklch(0.8 0.14 65);
|
||||
--color-title-accent-foreground: oklch(0.13 0.006 55);
|
||||
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ColorPalette } from "@sofa/api/schemas";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Uniwind } from "uniwind";
|
||||
|
||||
function hexToRelativeLuminance(hex: string): number {
|
||||
@@ -12,38 +11,19 @@ function hexToRelativeLuminance(hex: string): number {
|
||||
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)",
|
||||
};
|
||||
export function useTitleTheme(palette: ColorPalette | null | undefined): void {
|
||||
const vibrant = palette?.vibrant ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!vibrant) return;
|
||||
|
||||
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,
|
||||
"--color-title-accent": vibrant,
|
||||
"--color-title-accent-foreground": foreground,
|
||||
});
|
||||
}
|
||||
|
||||
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]),
|
||||
);
|
||||
}, [vibrant]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user