Skip to content

Commit ea47be6

Browse files
committed
feat(player): dynamic player background color from artwork
1 parent 4bb6524 commit ea47be6

File tree

10 files changed

+858
-26
lines changed

10 files changed

+858
-26
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect, useState } from 'react'
2+
import ImageColors from 'react-native-image-colors'
3+
import { useTheme } from 'react-native-paper'
4+
5+
export interface ArtworkGradientColors {
6+
dominant: string // 主导色:从专辑封面提取的最主要颜色
7+
vibrant: string // 鲜艳色:高饱和度的活力颜色
8+
muted: string // 柔和色:低饱和度的温和颜色
9+
background: string // 背景色:最淡的背景颜色
10+
}
11+
12+
export function useArtworkColors(artworkUrl?: string) {
13+
const [colors, setColors] = useState<ArtworkGradientColors>({
14+
dominant: '',
15+
vibrant: '',
16+
muted: '',
17+
background: '',
18+
})
19+
20+
const { colors: themeColors } = useTheme()
21+
22+
useEffect(() => {
23+
if (!artworkUrl) {
24+
// 没有封面时使用主题默认颜色
25+
setColors({
26+
dominant: themeColors.primary,
27+
vibrant: themeColors.secondary,
28+
muted: themeColors.outline,
29+
background: themeColors.elevation.level1,
30+
})
31+
return
32+
}
33+
34+
ImageColors.getColors(artworkUrl, {
35+
cache: true,
36+
}).then((result) => {
37+
if (result.platform === 'android') {
38+
setColors({
39+
dominant: result.dominant || result.vibrant || themeColors.primary,
40+
vibrant: result.vibrant || result.dominant || themeColors.secondary,
41+
muted: result.muted || result.lightMuted || themeColors.outline,
42+
background: result.lightMuted || result.average || themeColors.elevation.level1,
43+
})
44+
}
45+
else if (result.platform === 'ios') {
46+
setColors({
47+
dominant: result.primary || themeColors.primary,
48+
vibrant: result.secondary || themeColors.secondary,
49+
muted: result.detail || themeColors.outline,
50+
background: result.background || themeColors.elevation.level1,
51+
})
52+
}
53+
}).catch((err) => {
54+
console.error('Failed to get image colors:', err)
55+
// 出错时使用主题默认颜色
56+
setColors({
57+
dominant: themeColors.primary,
58+
vibrant: themeColors.secondary,
59+
muted: themeColors.outline,
60+
background: themeColors.elevation.level1,
61+
})
62+
})
63+
}, [artworkUrl, themeColors])
64+
65+
return colors
66+
}

apps/mobile/modules/player/FullPlayer.tsx

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { useDisplayTrack } from '@flow/player'
2-
import { StyleSheet, View } from 'react-native'
1+
import { StyleSheet } from 'react-native'
32
import PagerView from 'react-native-pager-view'
4-
import { Text, useTheme } from 'react-native-paper'
53
import Animated, { Extrapolation, interpolate, useAnimatedStyle } from 'react-native-reanimated'
64
import { usePlayerAnimation } from './Context'
75
import FullPlayerArtwork from './FullPlayerArtwork'
@@ -11,11 +9,8 @@ import FullPlayerHeader from './FullPlayerHeader'
119
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
1210

1311
export default function FullPlayer() {
14-
const { colors } = useTheme()
1512
const { thresholdPercent } = usePlayerAnimation()
1613

17-
const displayTrack = useDisplayTrack()
18-
1914
const animatedStyle = useAnimatedStyle(() => ({
2015
opacity: interpolate(
2116
thresholdPercent.value,
@@ -30,19 +25,7 @@ export default function FullPlayer() {
3025
<FullPlayerHeader />
3126

3227
<AnimatedPagerView style={styles.container} initialPage={1} orientation="horizontal">
33-
<View style={styles.page} key="1">
34-
<Text style={[styles.pageTitle, { color: colors.onBackground }]}>Track Info</Text>
35-
<Text style={[styles.pageSubtitle, { color: colors.onSurfaceVariant }]}>
36-
{displayTrack?.title || 'No track selected'}
37-
</Text>
38-
</View>
39-
4028
<FullPlayerArtwork />
41-
42-
<View style={styles.page} key="3">
43-
<Text style={[styles.pageTitle, { color: colors.onBackground }]}>Lyrics</Text>
44-
<Text style={[styles.pageSubtitle, { color: colors.onSurfaceVariant }]}>No lyrics</Text>
45-
</View>
4629
</AnimatedPagerView>
4730

4831
<FullPlayerControl />

apps/mobile/modules/player/MiniPlayer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ export default function MiniPlayer({ onPress }: { onPress: () => void }) {
3737
}, [])
3838

3939
return (
40-
<Animated.View style={[styles.container, { height: MINI_HEIGHT, backgroundColor: colors.elevation.level1 }, animatedStyle]}>
40+
<Animated.View style={
41+
[
42+
styles.container,
43+
{ height: MINI_HEIGHT, backgroundColor: colors.elevation.level1 },
44+
animatedStyle,
45+
]
46+
}
47+
>
4148
<Pressable onPress={onPress} style={styles.content}>
4249
<View style={styles.trackInfo}>
4350
<Image source={{ uri: displayTrack?.artwork as string }} style={{ width: 40, height: 40, borderRadius: 4 }} />
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { LinearGradient } from 'expo-linear-gradient'
2+
import { View } from 'react-native'
3+
import { useTheme } from 'react-native-paper'
4+
import Animated, { Extrapolation, interpolate, useAnimatedStyle } from 'react-native-reanimated'
5+
import { useArtworkColors } from '@/hooks/useArtworkColors'
6+
import { usePlayerAnimation } from './Context'
7+
8+
interface PlayerBackgroundProps {
9+
artworkUrl?: string
10+
}
11+
12+
export default function PlayerBackground({ artworkUrl }: PlayerBackgroundProps) {
13+
const { dominant, vibrant, muted } = useArtworkColors(artworkUrl)
14+
const { colors } = useTheme()
15+
const { thresholdPercent } = usePlayerAnimation()
16+
17+
// 确保颜色值有效
18+
const safeVibrant = vibrant || colors.primary
19+
const safeDominant = dominant || colors.secondary
20+
const safeMuted = muted || colors.outline
21+
22+
const baseStyle = {
23+
flex: 1,
24+
position: 'absolute' as const,
25+
top: 0,
26+
left: 0,
27+
right: 0,
28+
bottom: 0,
29+
}
30+
31+
const animatedStyle = useAnimatedStyle(() => ({
32+
opacity: interpolate(
33+
thresholdPercent.value,
34+
[0, 1],
35+
[0, 1],
36+
Extrapolation.CLAMP,
37+
),
38+
}))
39+
40+
return (
41+
<Animated.View style={[baseStyle, animatedStyle]}>
42+
{/* 基础背景色 */}
43+
<View style={[baseStyle, { backgroundColor: colors.elevation.level1 }]} />
44+
45+
{/* 从专辑中心向上左发散 */}
46+
<LinearGradient
47+
colors={[
48+
`${safeDominant}25`, // 专辑区域:更柔和
49+
`${safeMuted}15`, // 中间区域:更淡
50+
'transparent', // 边缘:透明
51+
]}
52+
style={baseStyle}
53+
start={{ x: 0.5, y: 0.6 }} // 从专辑位置开始
54+
end={{ x: 0.3, y: 0 }} // 向上左斜发散
55+
locations={[0, 0.4, 1]}
56+
/>
57+
58+
{/* 从专辑中心向下右发散 */}
59+
<LinearGradient
60+
colors={[
61+
`${safeVibrant}20`, // 专辑区域:更柔和
62+
`${safeMuted}12`, // 中间区域:更淡
63+
'transparent', // 边缘:透明
64+
]}
65+
style={baseStyle}
66+
start={{ x: 0.5, y: 0.6 }} // 从专辑位置开始
67+
end={{ x: 0.7, y: 1 }} // 向下右斜发散
68+
locations={[0, 0.5, 1]}
69+
/>
70+
71+
{/* 从专辑中心向左下发散 */}
72+
<LinearGradient
73+
colors={[
74+
`${safeDominant}25`, // 专辑区域:增加亮度
75+
`${safeMuted}15`, // 中间区域:增加亮度
76+
'transparent', // 边缘:透明
77+
]}
78+
style={baseStyle}
79+
start={{ x: 0.5, y: 0.6 }} // 从专辑位置开始
80+
end={{ x: 0, y: 0.8 }} // 向左下斜发散
81+
locations={[0, 0.6, 1]}
82+
/>
83+
84+
{/* 从专辑中心向右上发散 */}
85+
<LinearGradient
86+
colors={[
87+
`${safeVibrant}25`, // 专辑区域:增加亮度
88+
`${safeMuted}15`, // 中间区域:增加亮度
89+
'transparent', // 边缘:透明
90+
]}
91+
style={baseStyle}
92+
start={{ x: 0.5, y: 0.6 }} // 从专辑位置开始
93+
end={{ x: 1, y: 0.4 }} // 向右上斜发散
94+
locations={[0, 0.6, 1]}
95+
/>
96+
97+
{/* 对角线发散增强效果 */}
98+
<LinearGradient
99+
colors={[
100+
`${safeDominant}12`, // 专辑区域:非常柔和
101+
'transparent', // 边缘:透明
102+
]}
103+
style={baseStyle}
104+
start={{ x: 0.5, y: 0.6 }} // 从专辑位置开始
105+
end={{ x: 0, y: 0 }} // 向左上角发散
106+
locations={[0, 1]}
107+
/>
108+
109+
<LinearGradient
110+
colors={[
111+
`${safeVibrant}12`, // 专辑区域:非常柔和
112+
'transparent', // 边缘:透明
113+
]}
114+
style={baseStyle}
115+
start={{ x: 0.5, y: 0.6 }} // 从专辑位置开始
116+
end={{ x: 1, y: 0 }} // 向右上角发散
117+
locations={[0, 1]}
118+
/>
119+
</Animated.View>
120+
)
121+
}

apps/mobile/modules/player/QueueList.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Track } from '@flow/core'
22
import { usePlayerStore } from '@flow/player'
3-
import { FlatList, Image, StyleSheet, View } from 'react-native'
3+
import { FlashList } from '@shopify/flash-list'
4+
import { Image, StyleSheet, View } from 'react-native'
45
import { IconButton, List, Text } from 'react-native-paper'
56
import { ThemedView } from '@/components/ui/ThemedView'
67

@@ -41,12 +42,13 @@ export default function QueueList() {
4142
{
4243
queue.length > 0
4344
? (
44-
<FlatList
45+
<FlashList
4546
data={queue}
4647
renderItem={renderItem}
4748
keyExtractor={item => item.id}
4849
showsVerticalScrollIndicator={false}
4950
extraData={queue}
51+
estimatedItemSize={70}
5052
/>
5153
)
5254
: (

apps/mobile/modules/player/index.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useDisplayTrack } from '@flow/player'
12
import { useMemo, useRef, useState } from 'react'
23
import { Dimensions, StyleSheet } from 'react-native'
34
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -18,7 +19,8 @@ import { useBackHandler } from '@/hooks/useBackHandler'
1819
import { Context } from './Context'
1920
import FullPlayer from './FullPlayer'
2021
import MiniPlayer from './MiniPlayer'
21-
import QueueList from './QueueList'
22+
import PlayerBackground from './PlayerBackground'
23+
// import QueueList from './QueueList'
2224

2325
const { height: SCREEN_HEIGHT } = Dimensions.get('window')
2426
const MIN_VELOCITY = 500 // Velocity Threshold
@@ -125,6 +127,8 @@ export function Player() {
125127
}
126128
})
127129

130+
const displayTrack = useDisplayTrack()
131+
128132
return (
129133
<Context value={contextValue}>
130134
<AnimatedPagerView
@@ -136,16 +140,16 @@ export function Player() {
136140
overScrollMode="never"
137141
>
138142
<GestureDetector gesture={pan}>
139-
{/* TODO: dynamic backgroundColor from artwork */}
140-
<Animated.View style={{ flex: 1, backgroundColor: '#444' }}>
143+
<Animated.View style={{ flex: 1 }}>
144+
<PlayerBackground artworkUrl={displayTrack?.artwork ?? ''} />
141145
<MiniPlayer onPress={() => animateToPosition('FULL')} />
142146
<FullPlayer />
143147
</Animated.View>
144148
</GestureDetector>
145149

146-
<Animated.View style={{ flex: 1, backgroundColor: 'red', paddingVertical: 50 }}>
150+
{/* <AnimatedPagerView style={{ flex: 1, backgroundColor: 'red', paddingVertical: 50 }}>
147151
<QueueList />
148-
</Animated.View>
152+
</AnimatedPagerView> */}
149153
</AnimatedPagerView>
150154
</Context>
151155
)

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"react": "19.0.0",
4242
"react-native": "0.79.4",
4343
"react-native-gesture-handler": "~2.26.0",
44+
"react-native-image-colors": "github:nodefinity/react-native-image-colors",
4445
"react-native-mmkv": "^3.2.0",
4546
"react-native-pager-view": "^6.8.1",
4647
"react-native-paper": "^5.14.5",

packages/hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './providers/colorScheme'
22
export * from './providers/language'
33
export * from './useAppearanceSetting'
4+
export * from './useEffectiveColorScheme'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { useSettingStore } from '@flow/store'
2+
import { useColorScheme } from './providers/colorScheme'
3+
4+
export function useEffectiveColorScheme() {
5+
const currentTheme = useSettingStore.use.theme()
6+
const systemColorScheme = useColorScheme()
7+
8+
return currentTheme === 'auto' ? systemColorScheme : currentTheme
9+
}

0 commit comments

Comments
 (0)