feat(tv): add scalable typography with user-configurable text size

This commit is contained in:
Fredrik Burmester
2026-01-25 22:55:44 +01:00
parent 0c6c20f563
commit 875a017e8c
59 changed files with 712 additions and 494 deletions

View File

@@ -17,7 +17,11 @@ import {
} from "@/components/tv";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import {
AudioTranscodeMode,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
export default function SettingsTV() {
const { t } = useTranslation();
@@ -39,6 +43,8 @@ export default function SettingsTV() {
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default;
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
@@ -130,6 +136,33 @@ export default function SettingsTV() {
[currentAlignY],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
{
label: t("home.settings.appearance.text_size_small"),
value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small,
},
{
label: t("home.settings.appearance.text_size_default"),
value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default,
},
{
label: t("home.settings.appearance.text_size_large"),
value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large,
},
{
label: t("home.settings.appearance.text_size_extra_large"),
value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
},
],
[t, currentTypographyScale],
);
// Get display labels for option buttons
const audioTranscodeLabel = useMemo(() => {
const option = audioTranscodeModeOptions.find((o) => o.selected);
@@ -151,6 +184,11 @@ export default function SettingsTV() {
return option?.label || "Bottom";
}, [alignYOptions]);
const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.text_size_default");
}, [typographyScaleOptions, t]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -344,6 +382,18 @@ export default function SettingsTV() {
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton
label={t("home.settings.appearance.text_size")}
value={typographyScaleLabel}
onPress={() =>
showOptions({
title: t("home.settings.appearance.text_size"),
options: typographyScaleOptions,
onSelect: (value) =>
updateSettings({ tvTypographyScale: value }),
})
}
/>
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",

View File

@@ -42,6 +42,7 @@ import {
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
@@ -83,6 +84,7 @@ const Page = () => {
};
const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions();
@@ -947,7 +949,7 @@ const Page = () => {
paddingTop: 100,
}}
>
<Text style={{ fontSize: 20, color: "#737373" }}>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>

View File

@@ -29,7 +29,7 @@ import MoviePoster, {
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import {
@@ -46,17 +46,22 @@ import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => (
<View style={{ marginTop: 12 }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout - 2,
fontSize: typography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -67,6 +72,7 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
);
export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const router = useRouter();
const navigation = useNavigation();
@@ -212,11 +218,11 @@ export default function WatchlistDetailScreen() {
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} />
<TVItemCardText item={item} typography={typography} />
</View>
);
},
[router],
[router, typography],
);
const renderItem = useCallback(
@@ -356,7 +362,7 @@ export default function WatchlistDetailScreen() {
{watchlist.description && (
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
@@ -376,9 +382,7 @@ export default function WatchlistDetailScreen() {
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
@@ -395,18 +399,14 @@ export default function WatchlistDetailScreen() {
size={20}
color='#9ca3af'
/>
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text
style={{ fontSize: TVTypography.callout, color: "#737373" }}
>
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
{t("watchlists.by_owner")}
</Text>
)}
@@ -426,7 +426,7 @@ export default function WatchlistDetailScreen() {
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,

View File

@@ -17,7 +17,7 @@ import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRo
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
@@ -30,6 +30,7 @@ import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/
import { store } from "@/utils/store";
export default function TVRequestModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
@@ -336,8 +337,12 @@ export default function TVRequestModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.advanced")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{isDataLoaded && isReady ? (
<ScrollView
@@ -390,7 +395,12 @@ export default function TVRequestModalPage() {
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
<Text
style={[
styles.buttonText,
{ fontSize: typography.callout },
]}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
@@ -451,13 +461,11 @@ const styles = StyleSheet.create({
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
@@ -482,7 +490,6 @@ const styles = StyleSheet.create({
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},

View File

@@ -16,7 +16,7 @@ import {
import { Text } from "@/components/common/Text";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
@@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
};
export default function TVSeasonSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeasonSelectModalAtom);
const { t } = useTranslation();
@@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.select_seasons")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{/* Season cards horizontal scroll */}
<ScrollView
@@ -343,7 +348,9 @@ export default function TVSeasonSelectModalPage() {
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
<Text
style={[styles.buttonText, { fontSize: typography.callout }]}
>
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
</Text>
@@ -377,13 +384,11 @@ const styles = StyleSheet.create({
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
@@ -413,7 +418,6 @@ const styles = StyleSheet.create({
flex: 1,
},
seasonTitle: {
fontSize: TVTypography.callout,
fontWeight: "600",
marginBottom: 4,
},
@@ -436,7 +440,6 @@ const styles = StyleSheet.create({
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},

View File

@@ -12,12 +12,13 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton, TVOptionCard } from "@/components/tv";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import { store } from "@/utils/store";
export default function TVSeriesSeasonModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
const { t } = useTranslation();
@@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{t("item_card.select_season")}</Text>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{t("item_card.select_season")}
</Text>
{isReady && (
<ScrollView
@@ -164,7 +167,6 @@ const styles = StyleSheet.create({
overflow: "visible",
},
title: {
fontSize: TVTypography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,