mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-15 22:06:17 +00:00
feat(tv): add background theme music playback
This commit is contained in:
@@ -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
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
78
components/tv/TVThemeMusicIndicator.tsx
Normal file
78
components/tv/TVThemeMusicIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
179
hooks/useTVThemeMusic.ts
Normal 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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user