mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
fix(mobile): show the player on pushed screens with the JS tab bar
On iPad, Android and iOS before 26 the player bar lives in the JS tab bar, which screens pushed onto the root stack cover. Starting TTS from an article or a folder timeline gave no feedback and no way to pause or stop it until returning to a tab root. Render a floating player bar through the root portal while a pushed screen is on top. It hides behind modals and the lightbox, which the root portal host is drawn above on Android, and the article and feed screens keep their content clear of it. iPhone on iOS 26+ keeps the native tab bar and its glass player bar unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,20 @@
|
|||||||
import { Portal } from "@gorhom/portal"
|
import { Portal } from "@gorhom/portal"
|
||||||
import { use, useLayoutEffect } from "react"
|
import { use, useLayoutEffect } from "react"
|
||||||
import { Platform, View } from "react-native"
|
import { View } from "react-native"
|
||||||
import DeviceInfo from "react-native-device-info"
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||||
|
|
||||||
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
||||||
import { TabBarPortal } from "@/src/lib/navigation/bottom-tab/TabBarPortal"
|
import { TabBarPortal } from "@/src/lib/navigation/bottom-tab/TabBarPortal"
|
||||||
import { useNavigation } from "@/src/lib/navigation/hooks"
|
import { useNavigation } from "@/src/lib/navigation/hooks"
|
||||||
import { NavigationInstanceContext } from "@/src/lib/navigation/NavigationInstanceContext"
|
import { NavigationInstanceContext } from "@/src/lib/navigation/NavigationInstanceContext"
|
||||||
import { isIos26 } from "@/src/lib/platform"
|
|
||||||
import { GlassPlayerTabBar } from "@/src/modules/player/GlassPlayerTabBar"
|
import { GlassPlayerTabBar } from "@/src/modules/player/GlassPlayerTabBar"
|
||||||
|
|
||||||
import { BottomTabs } from "./BottomTabs"
|
import { BottomTabs } from "./BottomTabs"
|
||||||
import { SetBottomTabBarHeightContext } from "./contexts/BottomTabBarHeightContext"
|
import { SetBottomTabBarHeightContext } from "./contexts/BottomTabBarHeightContext"
|
||||||
|
import { isNativeTabBarEnabled } from "./native-tab-bar"
|
||||||
const isIpad = Platform.OS === "ios" && DeviceInfo.isTablet()
|
|
||||||
|
|
||||||
export const ReactNativeTab = () => {
|
export const ReactNativeTab = () => {
|
||||||
if (isIos26 && !isIpad) {
|
if (isNativeTabBarEnabled) {
|
||||||
return <NativeTabBarHolder />
|
return <NativeTabBarHolder />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { gentleSpringPreset, quickSpringPreset, softSpringPreset } from "@/src/c
|
|||||||
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
||||||
import type { ResolvedTabScreenProps, TabbarIconProps } from "@/src/lib/navigation/bottom-tab/types"
|
import type { ResolvedTabScreenProps, TabbarIconProps } from "@/src/lib/navigation/bottom-tab/types"
|
||||||
import { isAndroid } from "@/src/lib/platform"
|
import { isAndroid } from "@/src/lib/platform"
|
||||||
|
import { FloatingPlayerBar } from "@/src/modules/player/FloatingPlayerBar"
|
||||||
import { PlayerTabBar } from "@/src/modules/player/PlayerTabBar"
|
import { PlayerTabBar } from "@/src/modules/player/PlayerTabBar"
|
||||||
import { accentColor } from "@/src/theme/colors"
|
import { accentColor } from "@/src/theme/colors"
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@ export const Tabbar: FC<{
|
|||||||
<TabBarBackground />
|
<TabBarBackground />
|
||||||
|
|
||||||
<PlayerTabBar />
|
<PlayerTabBar />
|
||||||
|
{/* Pushed screens cover the tab bar, so they get a floating player bar instead */}
|
||||||
|
<FloatingPlayerBar />
|
||||||
<Grid columns={renderTabScreens.length} gap={10} className="mt-[7]">
|
<Grid columns={renderTabScreens.length} gap={10} className="mt-[7]">
|
||||||
{renderTabScreens.map((route) => {
|
{renderTabScreens.map((route) => {
|
||||||
const focused = route.tabScreenIndex === selectedIndex
|
const focused = route.tabScreenIndex === selectedIndex
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Platform } from "react-native"
|
||||||
|
import DeviceInfo from "react-native-device-info"
|
||||||
|
|
||||||
|
import { isIos26 } from "@/src/lib/platform"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iPhone on iOS 26+ uses the native tab bar, whose glass player bar floats over pushed screens.
|
||||||
|
* iPad, older iOS versions and Android use the JS tab bar, which pushed screens cover.
|
||||||
|
*/
|
||||||
|
export const isNativeTabBarEnabled = Platform.OS === "ios" && isIos26 && !DeviceInfo.isTablet()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { cn } from "@follow/utils"
|
||||||
|
import { Portal } from "@gorhom/portal"
|
||||||
|
import { GlassView } from "expo-glass-effect"
|
||||||
|
import { useAtomValue } from "jotai"
|
||||||
|
import type { PropsWithChildren } from "react"
|
||||||
|
import { use } from "react"
|
||||||
|
import { Pressable, StyleSheet, View } from "react-native"
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||||
|
|
||||||
|
import { ThemedBlurView } from "@/src/components/common/ThemedBlurView"
|
||||||
|
import { Image } from "@/src/components/ui/image/Image"
|
||||||
|
import { useLightbox } from "@/src/components/ui/lightbox/lightboxState"
|
||||||
|
import { Text } from "@/src/components/ui/typography/Text"
|
||||||
|
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
||||||
|
import { ChainNavigationContext } from "@/src/lib/navigation/ChainNavigationContext"
|
||||||
|
import { useNavigation } from "@/src/lib/navigation/hooks"
|
||||||
|
import { NavigationInstanceContext } from "@/src/lib/navigation/NavigationInstanceContext"
|
||||||
|
import { isAndroid, isIos26 } from "@/src/lib/platform"
|
||||||
|
import { useActivePlayable } from "@/src/lib/player"
|
||||||
|
import { PlayerScreen } from "@/src/screens/PlayerScreen"
|
||||||
|
import { usePrefetchImageColors } from "@/src/store/image/hooks"
|
||||||
|
|
||||||
|
import { PlayPauseButton, SeekButton, StopButton } from "./control"
|
||||||
|
import {
|
||||||
|
FLOATING_PLAYER_BAR_HEIGHT,
|
||||||
|
FLOATING_PLAYER_BAR_SPACING,
|
||||||
|
isRootStackPushedScreenOnTop,
|
||||||
|
} from "./floating-player-bar"
|
||||||
|
import { useShouldShowPlayerBar } from "./hooks"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player bar for screens pushed over the JS tab bar, which hide the tab bar and its player bar.
|
||||||
|
* It lives in the root portal so it stays in place while screens are pushed and popped.
|
||||||
|
*/
|
||||||
|
export function FloatingPlayerBar() {
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const bottomTabContext = use(BottomTabContext)
|
||||||
|
const chainNavigationContext = use(ChainNavigationContext)
|
||||||
|
const navigation = useNavigation()
|
||||||
|
|
||||||
|
// The root portal host is outside of these providers
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<BottomTabContext value={bottomTabContext}>
|
||||||
|
<ChainNavigationContext value={chainNavigationContext}>
|
||||||
|
<NavigationInstanceContext value={navigation}>
|
||||||
|
<FloatingPlayerBarContent bottomInset={insets.bottom} />
|
||||||
|
</NavigationInstanceContext>
|
||||||
|
</ChainNavigationContext>
|
||||||
|
</BottomTabContext>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FloatingPlayerBarContent({ bottomInset }: { bottomInset: number }) {
|
||||||
|
const showPlayerBar = useShouldShowPlayerBar()
|
||||||
|
const { routesAtom } = use(ChainNavigationContext)
|
||||||
|
const routes = useAtomValue(routesAtom)
|
||||||
|
// The lightbox is drawn below the root portal host, so the bar would cover it
|
||||||
|
const { activeLightbox } = useLightbox()
|
||||||
|
const activePlayable = useActivePlayable()
|
||||||
|
usePrefetchImageColors(activePlayable?.artwork ?? undefined)
|
||||||
|
const navigation = useNavigation()
|
||||||
|
|
||||||
|
if (!showPlayerBar || !isRootStackPushedScreenOnTop(routes) || activeLightbox) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// No fade in or out: glass and blur effects don't render under a parent with opacity below 1
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
className="absolute inset-x-0 px-4"
|
||||||
|
style={{ bottom: bottomInset + FLOATING_PLAYER_BAR_SPACING }}
|
||||||
|
>
|
||||||
|
<PlayerBarSurface>
|
||||||
|
<Pressable
|
||||||
|
testID="floating-player-bar"
|
||||||
|
className="flex-1 flex-row items-center gap-3"
|
||||||
|
onPress={() => {
|
||||||
|
navigation.presentControllerView(PlayerScreen, void 0, "transparentModal")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: activePlayable?.artwork ?? "",
|
||||||
|
}}
|
||||||
|
className="size-10 rounded-full"
|
||||||
|
/>
|
||||||
|
<View className="flex-1 overflow-hidden">
|
||||||
|
<Text className="text-base font-semibold text-label" numberOfLines={1}>
|
||||||
|
{activePlayable?.title ?? ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<View className="flex-row items-center gap-4">
|
||||||
|
<PlayPauseButton />
|
||||||
|
<SeekButton />
|
||||||
|
<StopButton />
|
||||||
|
</View>
|
||||||
|
</PlayerBarSurface>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const surfaceClassName =
|
||||||
|
"w-full max-w-[680px] flex-row items-center gap-3 self-center rounded-full pl-2 pr-4"
|
||||||
|
const surfaceStyle = { height: FLOATING_PLAYER_BAR_HEIGHT }
|
||||||
|
const glassStyle = { ...StyleSheet.absoluteFill, borderRadius: FLOATING_PLAYER_BAR_HEIGHT / 2 }
|
||||||
|
|
||||||
|
// Liquid glass like the iPhone player bar where available, a blur on older iOS versions and an
|
||||||
|
// elevated surface on Android
|
||||||
|
function PlayerBarSurface({ children }: PropsWithChildren) {
|
||||||
|
if (isAndroid) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn(surfaceClassName, "bg-secondary-system-grouped-background shadow-lg")}
|
||||||
|
style={surfaceStyle}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIos26) {
|
||||||
|
return (
|
||||||
|
<View className={surfaceClassName} style={surfaceStyle}>
|
||||||
|
<GlassView style={glassStyle} glassEffectStyle="regular" />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn(surfaceClassName, "border-hairline overflow-hidden border-opaque-separator/50")}
|
||||||
|
style={surfaceStyle}
|
||||||
|
>
|
||||||
|
<ThemedBlurView style={StyleSheet.absoluteFill} />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,20 @@
|
|||||||
import { clsx } from "@follow/utils"
|
import { clsx } from "@follow/utils"
|
||||||
import { GlassView } from "expo-glass-effect"
|
import { GlassView } from "expo-glass-effect"
|
||||||
import { useAtomValue } from "jotai"
|
|
||||||
import { use } from "react"
|
|
||||||
import { Pressable, StyleSheet, View } from "react-native"
|
import { Pressable, StyleSheet, View } from "react-native"
|
||||||
|
|
||||||
import { Image } from "@/src/components/ui/image/Image"
|
import { Image } from "@/src/components/ui/image/Image"
|
||||||
import { Text } from "@/src/components/ui/typography/Text"
|
import { Text } from "@/src/components/ui/typography/Text"
|
||||||
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
|
||||||
import { useNavigation } from "@/src/lib/navigation/hooks"
|
import { useNavigation } from "@/src/lib/navigation/hooks"
|
||||||
import { useActivePlayable } from "@/src/lib/player"
|
import { useActivePlayable } from "@/src/lib/player"
|
||||||
import { PlayerScreen } from "@/src/screens/PlayerScreen"
|
import { PlayerScreen } from "@/src/screens/PlayerScreen"
|
||||||
import { usePrefetchImageColors } from "@/src/store/image/hooks"
|
import { usePrefetchImageColors } from "@/src/store/image/hooks"
|
||||||
|
|
||||||
import { PlayPauseButton, SeekButton, StopButton } from "./control"
|
import { PlayPauseButton, SeekButton, StopButton } from "./control"
|
||||||
|
import { useShouldShowPlayerBar } from "./hooks"
|
||||||
|
|
||||||
const allowedTabIdentifiers = new Set(["IndexTabScreen", "SubscriptionsTabScreen"])
|
|
||||||
export function GlassPlayerTabBar({ className }: { className?: string }) {
|
export function GlassPlayerTabBar({ className }: { className?: string }) {
|
||||||
const activePlayable = useActivePlayable()
|
const activePlayable = useActivePlayable()
|
||||||
const tabRootCtx = use(BottomTabContext)
|
const isVisible = useShouldShowPlayerBar()
|
||||||
const tabScreens = useAtomValue(tabRootCtx.tabScreensAtom)
|
|
||||||
const currentIndex = useAtomValue(tabRootCtx.currentIndexAtom)
|
|
||||||
const currentTabProps = tabScreens.find((tabScreen) => tabScreen.tabScreenIndex === currentIndex)
|
|
||||||
const identifier = currentTabProps?.identifier
|
|
||||||
const isVisible = !!activePlayable && identifier && allowedTabIdentifiers.has(identifier)
|
|
||||||
|
|
||||||
usePrefetchImageColors(activePlayable?.artwork ?? undefined)
|
usePrefetchImageColors(activePlayable?.artwork ?? undefined)
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { cn } from "@follow/utils"
|
import { cn } from "@follow/utils"
|
||||||
import { useAtomValue } from "jotai"
|
import { useEffect } from "react"
|
||||||
import { use, useEffect } from "react"
|
|
||||||
import { Pressable, View } from "react-native"
|
import { Pressable, View } from "react-native"
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -11,23 +10,17 @@ import Animated, {
|
|||||||
|
|
||||||
import { Image } from "@/src/components/ui/image/Image"
|
import { Image } from "@/src/components/ui/image/Image"
|
||||||
import { Text } from "@/src/components/ui/typography/Text"
|
import { Text } from "@/src/components/ui/typography/Text"
|
||||||
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
|
||||||
import { useNavigation } from "@/src/lib/navigation/hooks"
|
import { useNavigation } from "@/src/lib/navigation/hooks"
|
||||||
import { useActivePlayable } from "@/src/lib/player"
|
import { useActivePlayable } from "@/src/lib/player"
|
||||||
import { PlayerScreen } from "@/src/screens/PlayerScreen"
|
import { PlayerScreen } from "@/src/screens/PlayerScreen"
|
||||||
import { usePrefetchImageColors } from "@/src/store/image/hooks"
|
import { usePrefetchImageColors } from "@/src/store/image/hooks"
|
||||||
|
|
||||||
import { PlayPauseButton, SeekButton, StopButton } from "./control"
|
import { PlayPauseButton, SeekButton, StopButton } from "./control"
|
||||||
|
import { useShouldShowPlayerBar } from "./hooks"
|
||||||
|
|
||||||
const allowedTabIdentifiers = new Set(["IndexTabScreen", "SubscriptionsTabScreen"])
|
|
||||||
export function PlayerTabBar({ className }: { className?: string }) {
|
export function PlayerTabBar({ className }: { className?: string }) {
|
||||||
const activePlayable = useActivePlayable()
|
const activePlayable = useActivePlayable()
|
||||||
const tabRootCtx = use(BottomTabContext)
|
const isVisible = useShouldShowPlayerBar()
|
||||||
const tabScreens = useAtomValue(tabRootCtx.tabScreensAtom)
|
|
||||||
const currentIndex = useAtomValue(tabRootCtx.currentIndexAtom)
|
|
||||||
const currentTabProps = tabScreens.find((tabScreen) => tabScreen.tabScreenIndex === currentIndex)
|
|
||||||
const identifier = currentTabProps?.identifier
|
|
||||||
const isVisible = !!activePlayable && identifier && allowedTabIdentifiers.has(identifier)
|
|
||||||
const isVisibleSV = useSharedValue(isVisible ? 1 : 0)
|
const isVisibleSV = useSharedValue(isVisible ? 1 : 0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isVisibleSV.value = withTiming(isVisible ? 1 : 0)
|
isVisibleSV.value = withTiming(isVisible ? 1 : 0)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import type { Route } from "@/src/lib/navigation/ChainNavigationContext"
|
||||||
|
|
||||||
|
import { isRootStackPushedScreenOnTop } from "./floating-player-bar"
|
||||||
|
|
||||||
|
describe("isRootStackPushedScreenOnTop", () => {
|
||||||
|
it.each<{ expected: boolean; routes: Pick<Route, "type">[]; scenario: string }>([
|
||||||
|
{ expected: false, routes: [], scenario: "a tab root" },
|
||||||
|
{ expected: true, routes: [{ type: "push" }], scenario: "an article opened from a timeline" },
|
||||||
|
{
|
||||||
|
expected: true,
|
||||||
|
routes: [{ type: "push" }, { type: "push" }],
|
||||||
|
scenario: "an article opened from a folder timeline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: false,
|
||||||
|
routes: [{ type: "push" }, { type: "transparentModal" }],
|
||||||
|
scenario: "the player screen over an article",
|
||||||
|
},
|
||||||
|
{ expected: false, routes: [{ type: "formSheet" }], scenario: "a form sheet over a tab root" },
|
||||||
|
{
|
||||||
|
expected: false,
|
||||||
|
routes: [{ type: "modal" }, { type: "push" }],
|
||||||
|
scenario: "a screen pushed inside a modal",
|
||||||
|
},
|
||||||
|
])("returns $expected for $scenario", ({ expected, routes }) => {
|
||||||
|
expect(isRootStackPushedScreenOnTop(routes)).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Route } from "@/src/lib/navigation/ChainNavigationContext"
|
||||||
|
|
||||||
|
export const FLOATING_PLAYER_BAR_HEIGHT = 56
|
||||||
|
/** Gap below the floating player bar and between it and the content above. */
|
||||||
|
export const FLOATING_PLAYER_BAR_SPACING = 8
|
||||||
|
/** Bottom space a pushed screen keeps free so its content can scroll above the floating player bar. */
|
||||||
|
export const FLOATING_PLAYER_BAR_INSET =
|
||||||
|
FLOATING_PLAYER_BAR_HEIGHT + FLOATING_PLAYER_BAR_SPACING * 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JS tab bar and its player bar live in the root screen, so any screen pushed onto the root
|
||||||
|
* stack covers them. Modals and the screens pushed inside them don't count: on Android they are
|
||||||
|
* drawn below the root portal host, so a floating player bar would cover them.
|
||||||
|
*/
|
||||||
|
export const isRootStackPushedScreenOnTop = (routes: readonly Pick<Route, "type">[]) =>
|
||||||
|
routes.length > 0 && routes.every((route) => route.type === "push")
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useImageColors } from "@follow/store/image/hooks"
|
import { useImageColors } from "@follow/store/image/hooks"
|
||||||
import { getLuminance, shadeColor } from "@follow/utils"
|
import { getLuminance, shadeColor } from "@follow/utils"
|
||||||
import { useMemo } from "react"
|
import { useAtomValue } from "jotai"
|
||||||
|
import { use, useMemo } from "react"
|
||||||
|
|
||||||
|
import { isNativeTabBarEnabled } from "@/src/components/layouts/tabbar/native-tab-bar"
|
||||||
|
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
|
||||||
|
import { useScreenIsInModal } from "@/src/lib/navigation/hooks"
|
||||||
|
import { useActivePlayable } from "@/src/lib/player"
|
||||||
|
|
||||||
|
import { FLOATING_PLAYER_BAR_INSET } from "./floating-player-bar"
|
||||||
|
|
||||||
const defaultBackgroundColor = "#000000"
|
const defaultBackgroundColor = "#000000"
|
||||||
|
|
||||||
@@ -27,3 +35,30 @@ export function useCoverGradient(url?: string) {
|
|||||||
|
|
||||||
return { isGradientLight, gradientColors }
|
return { isGradientLight, gradientColors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playerBarTabIdentifiers = new Set(["IndexTabScreen", "SubscriptionsTabScreen"])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player bars show while something is playing and the Home or Subscriptions tab is selected,
|
||||||
|
* which includes the screens pushed from those tabs.
|
||||||
|
*/
|
||||||
|
export function useShouldShowPlayerBar() {
|
||||||
|
const activePlayable = useActivePlayable()
|
||||||
|
const { tabScreensAtom, currentIndexAtom } = use(BottomTabContext)
|
||||||
|
const tabScreens = useAtomValue(tabScreensAtom)
|
||||||
|
const currentIndex = useAtomValue(currentIndexAtom)
|
||||||
|
const identifier = tabScreens.find(
|
||||||
|
(tabScreen) => tabScreen.tabScreenIndex === currentIndex,
|
||||||
|
)?.identifier
|
||||||
|
return !!activePlayable && !!identifier && playerBarTabIdentifiers.has(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom space a pushed screen keeps free for the floating player bar. Zero with the native tab
|
||||||
|
* bar, whose glass player bar floats over content, and inside modals, which never show the bar.
|
||||||
|
*/
|
||||||
|
export function useFloatingPlayerBarInset() {
|
||||||
|
const showPlayerBar = useShouldShowPlayerBar()
|
||||||
|
const isInModal = useScreenIsInModal()
|
||||||
|
return showPlayerBar && !isNativeTabBarEnabled && !isInModal ? FLOATING_PLAYER_BAR_INSET : 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { EntryAISummary } from "@/src/modules/entry-content/EntryAISummary"
|
|||||||
import { EntryNavigationHeader } from "@/src/modules/entry-content/EntryNavigationHeader"
|
import { EntryNavigationHeader } from "@/src/modules/entry-content/EntryNavigationHeader"
|
||||||
import { usePullUpToNext } from "@/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation"
|
import { usePullUpToNext } from "@/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation"
|
||||||
import { usePreferredFeedTitle } from "@/src/modules/feed/feed-title"
|
import { usePreferredFeedTitle } from "@/src/modules/feed/feed-title"
|
||||||
|
import { useFloatingPlayerBarInset } from "@/src/modules/player/hooks"
|
||||||
|
|
||||||
import { EntrySocialTitle, EntryTitle } from "../../../../modules/entry-content/EntryTitle"
|
import { EntrySocialTitle, EntryTitle } from "../../../../modules/entry-content/EntryTitle"
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export const EntryDetailScreen: NavigationControllerView<{
|
|||||||
const isLoggedIn = useIsLoggedIn()
|
const isLoggedIn = useIsLoggedIn()
|
||||||
useAutoMarkAsRead(entryId, !!entry && isLoggedIn)
|
useAutoMarkAsRead(entryId, !!entry && isLoggedIn)
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
const floatingPlayerBarInset = useFloatingPlayerBarInset()
|
||||||
const ctxValue = useMemo(
|
const ctxValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
showAISummaryAtom: atom(entry?.summary || false),
|
showAISummaryAtom: atom(entry?.summary || false),
|
||||||
@@ -98,7 +100,7 @@ export const EntryDetailScreen: NavigationControllerView<{
|
|||||||
return (
|
return (
|
||||||
<EntryContentContext value={ctxValue}>
|
<EntryContentContext value={ctxValue}>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<BottomTabBarHeightContext value={insets.bottom}>
|
<BottomTabBarHeightContext value={insets.bottom + floatingPlayerBarInset}>
|
||||||
<GestureWrapper {...gestureWrapperProps}>
|
<GestureWrapper {...gestureWrapperProps}>
|
||||||
<SafeNavigationScrollView
|
<SafeNavigationScrollView
|
||||||
Header={<EntryNavigationHeader entryId={entryId} />}
|
Header={<EntryNavigationHeader entryId={entryId} />}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Text } from "@/src/components/ui/typography/Text"
|
|||||||
import { useNavigation } from "@/src/lib/navigation/hooks"
|
import { useNavigation } from "@/src/lib/navigation/hooks"
|
||||||
import type { NavigationControllerView } from "@/src/lib/navigation/types"
|
import type { NavigationControllerView } from "@/src/lib/navigation/types"
|
||||||
import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector"
|
import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector"
|
||||||
|
import { useFloatingPlayerBarInset } from "@/src/modules/player/hooks"
|
||||||
import { EntryListContext, useEntries, useSelectedView } from "@/src/modules/screen/atoms"
|
import { EntryListContext, useEntries, useSelectedView } from "@/src/modules/screen/atoms"
|
||||||
import { TimelineHeader } from "@/src/modules/screen/TimelineSelectorProvider"
|
import { TimelineHeader } from "@/src/modules/screen/TimelineSelectorProvider"
|
||||||
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
|
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
|
||||||
@@ -23,6 +24,7 @@ export const FeedScreen: NavigationControllerView<{
|
|||||||
feedId: string
|
feedId: string
|
||||||
}> = ({ feedId: feedIdentifier }) => {
|
}> = ({ feedId: feedIdentifier }) => {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
const floatingPlayerBarInset = useFloatingPlayerBarInset()
|
||||||
const feed = useFeedById(feedIdentifier)
|
const feed = useFeedById(feedIdentifier)
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const isSubscribed = useIsSubscribed(feedIdentifier)
|
const isSubscribed = useIsSubscribed(feedIdentifier)
|
||||||
@@ -33,7 +35,7 @@ export const FeedScreen: NavigationControllerView<{
|
|||||||
return (
|
return (
|
||||||
<EntryListContext value={entryListContextValue}>
|
<EntryListContext value={entryListContextValue}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<BottomTabBarHeightContext value={insets.bottom}>
|
<BottomTabBarHeightContext value={insets.bottom + floatingPlayerBarInset}>
|
||||||
<TimelineHeader feedId={feed?.id} />
|
<TimelineHeader feedId={feed?.id} />
|
||||||
<FeedScreenEntryList />
|
<FeedScreenEntryList />
|
||||||
{isPreview && (
|
{isPreview && (
|
||||||
@@ -41,7 +43,7 @@ export const FeedScreen: NavigationControllerView<{
|
|||||||
className="absolute left-1/2 z-10 min-w-[112px] -translate-x-1/2 items-center justify-center overflow-hidden rounded-full bg-accent px-5 py-3"
|
className="absolute left-1/2 z-10 min-w-[112px] -translate-x-1/2 items-center justify-center overflow-hidden rounded-full bg-accent px-5 py-3"
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
style={{
|
style={{
|
||||||
bottom: Math.max(20, insets.bottom + 12),
|
bottom: Math.max(20, insets.bottom + 12) + floatingPlayerBarInset,
|
||||||
}}
|
}}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.presentControllerView(FollowScreen, {
|
navigation.presentControllerView(FollowScreen, {
|
||||||
|
|||||||
Reference in New Issue
Block a user