diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index b9a0849c..3c92b8af 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -506,6 +506,11 @@ export default function SettingsTV() { updateSettings({ showSeriesPosterOnEpisode: value }) } /> + updateSettings({ tvThemeMusicEnabled: value })} + /> {/* User Section */} = 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(null); diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index e6440e1d..2dfe4044 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -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 = ({ 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( diff --git a/components/tv/TVThemeMusicIndicator.tsx b/components/tv/TVThemeMusicIndicator.tsx new file mode 100644 index 00000000..93be4dfb --- /dev/null +++ b/components/tv/TVThemeMusicIndicator.tsx @@ -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 = ({ + 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 ( + { + setFocused(true); + animateTo(1.15); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + disabled={disabled} + focusable={!disabled} + > + + {isMuted ? ( + + ) : ( + + + + )} + + + ); +}; diff --git a/components/tv/index.ts b/components/tv/index.ts index 8352ba76..527ef22e 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -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"; diff --git a/hooks/useTVThemeMusic.ts b/hooks/useTVThemeMusic.ts new file mode 100644 index 00000000..753b3e64 --- /dev/null +++ b/hooks/useTVThemeMusic.ts @@ -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; 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((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(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]); +} diff --git a/package.json b/package.json index d4750406..f231e2bf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/translations/en.json b/translations/en.json index bcc2c03d..722a105c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 59aea126..63b2ee16 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -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,