feat(tv): add background theme music playback

This commit is contained in:
Fredrik Burmester
2026-01-28 22:08:32 +01:00
parent 2ff9625903
commit 409629bb4a
10 changed files with 278 additions and 0 deletions

View File

@@ -506,6 +506,11 @@ export default function SettingsTV() {
updateSettings({ showSeriesPosterOnEpisode: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.theme_music")}
value={settings.tvThemeMusicEnabled}
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* User Section */}
<TVSectionHeader

View File

@@ -25,6 +25,7 @@
"expo": "~54.0.31",
"expo-application": "~7.0.8",
"expo-asset": "~12.0.12",
"expo-av": "^16.0.8",
"expo-background-task": "~1.0.10",
"expo-blur": "~15.0.8",
"expo-brightness": "~14.0.8",
@@ -1008,6 +1009,8 @@
"expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="],
"expo-av": ["expo-av@16.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ=="],
"expo-background-task": ["expo-background-task@1.0.10", "", { "dependencies": { "expo-task-manager": "~14.0.9" }, "peerDependencies": { "expo": "*" } }, "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ=="],
"expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="],

View File

@@ -47,6 +47,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -86,6 +87,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const _itemColors = useImageColorsReturn({ item });
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item?.Id);
// State for first episode card ref (used for focus guide)
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);

View File

@@ -34,6 +34,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
@@ -230,6 +231,9 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
const isSeasonModalVisible = seasonModalState !== null;
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item.Id);
// Season state
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const selectedSeasonIndex = useMemo(

View File

@@ -0,0 +1,78 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, View } from "react-native";
import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
interface TVThemeMusicIndicatorProps {
isPlaying: boolean;
isMuted: boolean;
hasThemeMusic: boolean;
onToggleMute: () => void;
disabled?: boolean;
}
export const TVThemeMusicIndicator: React.FC<TVThemeMusicIndicatorProps> = ({
isPlaying,
isMuted,
hasThemeMusic,
onToggleMute,
disabled = false,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
if (!hasThemeMusic || !isPlaying) return null;
return (
<Pressable
onPress={onToggleMute}
onFocus={() => {
setFocused(true);
animateTo(1.15);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
backgroundColor: focused
? "rgba(255,255,255,0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 12,
padding: 12,
alignItems: "center",
justifyContent: "center",
width: 48,
height: 48,
}}
>
{isMuted ? (
<Ionicons name='volume-mute' size={22} color='#FFFFFF' />
) : (
<View style={{ marginRight: 0 }}>
<AnimatedEqualizer
color='#FFFFFF'
barWidth={3}
barCount={3}
height={18}
gap={2}
/>
</View>
)}
</Animated.View>
</Pressable>
);
};

View File

@@ -59,6 +59,7 @@ export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";
export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails";
export { TVTechnicalDetails } from "./TVTechnicalDetails";
export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator";
// Subtitle sheet components
export type { TVTrackCardProps } from "./TVTrackCard";
export { TVTrackCard } from "./TVTrackCard";

179
hooks/useTVThemeMusic.ts Normal file
View File

@@ -0,0 +1,179 @@
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import type { Audio as AudioType } from "expo-av";
import { Audio } from "expo-av";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
const TARGET_VOLUME = 0.3;
const FADE_IN_DURATION = 2000;
const FADE_OUT_DURATION = 1000;
const FADE_STEP_MS = 50;
/**
* Smoothly transitions audio volume from `from` to `to` over `duration` ms.
* Returns a cleanup function that cancels the fade.
*/
function fadeVolume(
sound: AudioType.Sound,
from: number,
to: number,
duration: number,
): { promise: Promise<void>; cancel: () => void } {
let cancelled = false;
const cancel = () => {
cancelled = true;
};
const steps = Math.max(1, Math.floor(duration / FADE_STEP_MS));
const delta = (to - from) / steps;
const promise = new Promise<void>((resolve) => {
let current = from;
let step = 0;
const tick = () => {
if (cancelled || step >= steps) {
if (!cancelled) {
sound.setVolumeAsync(to).catch(() => {});
}
resolve();
return;
}
step++;
current += delta;
sound
.setVolumeAsync(Math.max(0, Math.min(1, current)))
.catch(() => {})
.then(() => {
if (!cancelled) {
setTimeout(tick, FADE_STEP_MS);
} else {
resolve();
}
});
};
tick();
});
return { promise, cancel };
}
export function useTVThemeMusic(itemId: string | undefined) {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { settings } = useSettings();
const soundRef = useRef<AudioType.Sound | null>(null);
const fadeRef = useRef<{ cancel: () => void } | null>(null);
const enabled =
Platform.isTV &&
!!api &&
!!user?.Id &&
!!itemId &&
settings.tvThemeMusicEnabled;
// Fetch theme songs
const { data: themeSongs } = useQuery({
queryKey: ["themeSongs", itemId],
queryFn: async () => {
const result = await getLibraryApi(api!).getThemeSongs({
itemId: itemId!,
userId: user!.Id!,
inheritFromParent: true,
});
return result.data;
},
enabled,
staleTime: 5 * 60 * 1000,
});
// Load and play audio when theme songs are available and enabled
useEffect(() => {
if (!enabled || !themeSongs?.Items?.length || !api) {
return;
}
const themeItem = themeSongs.Items[0];
let mounted = true;
const sound = new Audio.Sound();
soundRef.current = sound;
const loadAndPlay = async () => {
try {
await Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: false,
});
const params = new URLSearchParams({
UserId: user!.Id!,
DeviceId: api.deviceInfo.id ?? "",
MaxStreamingBitrate: "140000000",
Container: "mp3,aac,m4a|aac,m4b|aac,flac,wav",
TranscodingContainer: "mp4",
TranscodingProtocol: "http",
AudioCodec: "aac",
ApiKey: api.accessToken ?? "",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`;
await sound.loadAsync({ uri: url });
if (!mounted) {
await sound.unloadAsync();
return;
}
await sound.setIsLoopingAsync(true);
await sound.setVolumeAsync(0);
await sound.playAsync();
if (mounted) {
// Fade in
const fade = fadeVolume(sound, 0, TARGET_VOLUME, FADE_IN_DURATION);
fadeRef.current = fade;
await fade.promise;
}
} catch (e) {
console.warn("Theme music playback error:", e);
}
};
loadAndPlay();
// Cleanup: fade out then unload
return () => {
mounted = false;
// Cancel any in-progress fade
fadeRef.current?.cancel();
fadeRef.current = null;
const cleanupSound = async () => {
try {
const status = await sound.getStatusAsync();
if (status.isLoaded) {
const currentVolume = status.volume ?? TARGET_VOLUME;
const fade = fadeVolume(sound, currentVolume, 0, FADE_OUT_DURATION);
await fade.promise;
await sound.stopAsync();
await sound.unloadAsync();
}
} catch {
// Sound may already be unloaded
try {
await sound.unloadAsync();
} catch {
// ignore
}
}
};
cleanupSound();
soundRef.current = null;
};
}, [enabled, themeSongs, api]);
}

View File

@@ -45,6 +45,7 @@
"expo": "~54.0.31",
"expo-application": "~7.0.8",
"expo-asset": "~12.0.12",
"expo-av": "^16.0.8",
"expo-background-task": "~1.0.10",
"expo-blur": "~15.0.8",
"expo-brightness": "~14.0.8",

View File

@@ -128,6 +128,7 @@
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",

View File

@@ -220,6 +220,7 @@ export type Settings = {
showTVHeroCarousel: boolean;
tvTypographyScale: TVTypographyScale;
showSeriesPosterOnEpisode: boolean;
tvThemeMusicEnabled: boolean;
// Appearance
hideRemoteSessionButton: boolean;
hideWatchlistsTab: boolean;
@@ -316,6 +317,7 @@ export const defaultValues: Settings = {
showTVHeroCarousel: true,
tvTypographyScale: TVTypographyScale.Default,
showSeriesPosterOnEpisode: false,
tvThemeMusicEnabled: true,
// Appearance
hideRemoteSessionButton: false,
hideWatchlistsTab: false,