tangled
alpha
login
or
join now
stream.place
/
streamplace
74
fork
atom
Live video on the AT Protocol
74
fork
atom
overview
issues
1
pulls
pipelines
add a gradient thingy
Natalie B.
1 month ago
24f34966
9b8591e5
+186
-75
3 changed files
expand all
collapse all
unified
split
js
app
components
mobile
desktop-ui
bottom-controls.tsx
desktop-ui.tsx
ui
gradient.tsx
+16
-15
js/app/components/mobile/desktop-ui.tsx
···
10
10
View,
11
11
zero,
12
12
} from "@streamplace/components";
13
13
+
import { AnimatedGradient } from "components/ui/gradient";
13
14
import React, { useCallback, useEffect, useRef, useState } from "react";
14
15
import { Platform } from "react-native";
15
16
import { Gesture, GestureDetector } from "react-native-gesture-handler";
···
228
229
layout.position.absolute,
229
230
position.bottom[0],
230
231
w.percent[100],
231
231
-
{
232
232
-
backgroundColor: "rgba(0, 0, 0, 0.6)",
233
233
-
paddingHorizontal: 16,
234
234
-
paddingVertical: 2,
235
235
-
paddingBottom: 2,
236
236
-
},
237
232
animatedFadeStyle,
238
233
]}
239
234
>
240
240
-
<BottomControlBar
241
241
-
ingest={ingest}
242
242
-
pipSupported={pipSupported}
243
243
-
pipActive={pipActive}
244
244
-
onHandlePip={handlePip}
245
245
-
dropdownPortalContainer={fullscreen && portalContainerID}
246
246
-
showChat={isChatOpen || false}
247
247
-
setShowChat={setIsChatOpen || undefined}
248
248
-
/>
235
235
+
<AnimatedGradient
236
236
+
fromColor="#00000080"
237
237
+
toColor="#000000"
238
238
+
opacityColor1={0}
239
239
+
>
240
240
+
<BottomControlBar
241
241
+
ingest={ingest}
242
242
+
pipSupported={pipSupported}
243
243
+
pipActive={pipActive}
244
244
+
onHandlePip={handlePip}
245
245
+
dropdownPortalContainer={fullscreen && portalContainerID}
246
246
+
showChat={isChatOpen || false}
247
247
+
setShowChat={setIsChatOpen || undefined}
248
248
+
/>
249
249
+
</AnimatedGradient>
249
250
</Animated.View>
250
251
251
252
{isSelfAndNotLive && (
+93
-60
js/app/components/mobile/desktop-ui/bottom-controls.tsx
···
17
17
PictureInPicture2,
18
18
} from "lucide-react-native";
19
19
import { Platform, Pressable } from "react-native";
20
20
+
import { Mu } from "./mu";
20
21
import { VolumeSlider } from "./volume-slider";
21
22
22
22
-
import { Mu } from "./mu";
23
23
-
24
24
-
const { gap, layout, p, r, py, px } = zero;
23
23
+
const { gap, layout, p, r, px } = zero;
25
24
26
25
interface BottomControlBarProps {
27
26
ingest: string | null;
···
33
32
setShowChat?: (show: boolean) => void;
34
33
}
35
34
35
35
+
function PipButton({
36
36
+
pipActive,
37
37
+
onHandlePip,
38
38
+
}: {
39
39
+
pipActive: boolean;
40
40
+
onHandlePip: () => void;
41
41
+
}) {
42
42
+
const { theme } = useTheme();
43
43
+
if (Platform.OS !== "web") return null;
44
44
+
return (
45
45
+
<Pressable onPress={onHandlePip} disabled={pipActive}>
46
46
+
<View style={{ opacity: pipActive ? 0.5 : 1 }}>
47
47
+
<PictureInPicture2 color={theme.colors.text} />
48
48
+
</View>
49
49
+
</Pressable>
50
50
+
);
51
51
+
}
52
52
+
53
53
+
function DanmuButton() {
54
54
+
const { theme } = useTheme();
55
55
+
const danmuUnlocked = useDanmuUnlocked();
56
56
+
const danmuEnabled = useDanmuEnabled();
57
57
+
const setDanmuEnabled = useSetDanmuEnabled();
58
58
+
if (!danmuUnlocked) return null;
59
59
+
return (
60
60
+
<Pressable
61
61
+
onPress={() => setDanmuEnabled(!danmuEnabled)}
62
62
+
style={[px[2], r[1]]}
63
63
+
>
64
64
+
<Mu
65
65
+
size={22}
66
66
+
color={theme.colors.text}
67
67
+
style={{ opacity: danmuEnabled ? 1 : 0.5 }}
68
68
+
/>
69
69
+
</Pressable>
70
70
+
);
71
71
+
}
72
72
+
73
73
+
function ContextMenuButton({
74
74
+
dropdownPortalContainer,
75
75
+
}: {
76
76
+
dropdownPortalContainer?: any;
77
77
+
}) {
78
78
+
return (
79
79
+
<PlayerUI.ContextMenu dropdownPortalContainer={dropdownPortalContainer} />
80
80
+
);
81
81
+
}
82
82
+
83
83
+
function FullscreenButton() {
84
84
+
const { theme } = useTheme();
85
85
+
const fullscreen = usePlayerStore((state) => state.fullscreen);
86
86
+
const setFullscreen = usePlayerStore((state) => state.setFullscreen);
87
87
+
if (Platform.OS !== "web") return null;
88
88
+
return (
89
89
+
<Pressable onPress={() => setFullscreen(!fullscreen)} style={[p[2], r[1]]}>
90
90
+
{fullscreen ? (
91
91
+
<Minimize color={theme.colors.text} />
92
92
+
) : (
93
93
+
<Fullscreen color={theme.colors.text} />
94
94
+
)}
95
95
+
</Pressable>
96
96
+
);
97
97
+
}
98
98
+
99
99
+
function CollapseChatButton({
100
100
+
showChat,
101
101
+
setShowChat,
102
102
+
}: {
103
103
+
showChat: boolean;
104
104
+
setShowChat: (show: boolean) => void;
105
105
+
}) {
106
106
+
if (Platform.OS === "web") return null;
107
107
+
return (
108
108
+
<Button variant="outline" size="sm" onPress={() => setShowChat(!showChat)}>
109
109
+
{showChat ? (
110
110
+
<ChevronRight color="white" size={16} />
111
111
+
) : (
112
112
+
<ChevronLeft color="white" size={16} />
113
113
+
)}
114
114
+
</Button>
115
115
+
);
116
116
+
}
117
117
+
36
118
export function BottomControlBar({
37
119
ingest,
38
120
pipSupported,
···
42
124
showChat,
43
125
setShowChat,
44
126
}: BottomControlBarProps) {
45
45
-
let { theme } = useTheme();
46
46
-
const fullscreen = usePlayerStore((state) => state.fullscreen);
47
47
-
const setFullscreen = usePlayerStore((state) => state.setFullscreen);
48
48
-
const danmuUnlocked = useDanmuUnlocked();
49
49
-
const danmuEnabled = useDanmuEnabled();
50
50
-
const setDanmuEnabled = useSetDanmuEnabled();
51
51
-
52
127
return (
53
128
<View
54
129
style={[
55
130
layout.flex.row,
56
131
layout.flex.spaceBetween,
57
132
layout.flex.alignCenter,
133
133
+
zero.px[4],
58
134
]}
59
135
>
60
136
<View style={[layout.flex.row, layout.flex.alignCenter, gap.all[4]]}>
···
62
138
</View>
63
139
64
140
<View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}>
65
65
-
{Platform.OS === "web" && pipSupported && (
66
66
-
<Pressable onPress={onHandlePip} disabled={pipActive}>
67
67
-
<View style={{ opacity: pipActive ? 0.5 : 1 }}>
68
68
-
<PictureInPicture2 color={theme.colors.text} />
69
69
-
</View>
70
70
-
</Pressable>
71
71
-
)}
72
72
-
{danmuUnlocked && (
73
73
-
<Pressable
74
74
-
onPress={() => {
75
75
-
setDanmuEnabled(!danmuEnabled);
76
76
-
}}
77
77
-
style={[px[0], r[1]]}
78
78
-
>
79
79
-
<Mu
80
80
-
size={22}
81
81
-
color={theme.colors.text}
82
82
-
style={{ opacity: danmuEnabled ? 1 : 0.5 }}
83
83
-
/>
84
84
-
</Pressable>
141
141
+
{pipSupported && (
142
142
+
<PipButton pipActive={pipActive} onHandlePip={onHandlePip} />
85
143
)}
144
144
+
<DanmuButton />
86
145
{ingest === null && (
87
87
-
<PlayerUI.ContextMenu
146
146
+
<ContextMenuButton
88
147
dropdownPortalContainer={dropdownPortalContainer}
89
148
/>
90
149
)}
91
91
-
{Platform.OS === "web" && (
92
92
-
<Pressable
93
93
-
onPress={() => {
94
94
-
setFullscreen(!fullscreen);
95
95
-
}}
96
96
-
style={[p[2], r[1]]}
97
97
-
>
98
98
-
{fullscreen ? (
99
99
-
<Minimize color={theme.colors.text} />
100
100
-
) : (
101
101
-
<Fullscreen color={theme.colors.text} />
102
102
-
)}
103
103
-
</Pressable>
104
104
-
)}
105
105
-
{/* if not web, then add the collapse chat controls here */}
106
106
-
{Platform.OS !== "web" && setShowChat && (
107
107
-
<Button
108
108
-
variant="outline"
109
109
-
size="sm"
110
110
-
onPress={() => {
111
111
-
setShowChat(!showChat);
112
112
-
}}
113
113
-
>
114
114
-
{showChat ? (
115
115
-
<ChevronRight color="white" size={16} />
116
116
-
) : (
117
117
-
<ChevronLeft color="white" size={16} />
118
118
-
)}
119
119
-
</Button>
150
150
+
<FullscreenButton />
151
151
+
{setShowChat && (
152
152
+
<CollapseChatButton showChat={showChat} setShowChat={setShowChat} />
120
153
)}
121
154
</View>
122
155
</View>
+77
js/app/components/ui/gradient.tsx
···
1
1
+
// Source - https://stackoverflow.com/a/74182982
2
2
+
// Posted by TOPKAT, modified by community. See post 'Timeline' for change history
3
3
+
// Retrieved 2026-02-18, License - CC BY-SA 4.0
4
4
+
5
5
+
import { DimensionValue, StyleSheet, View, ViewProps } from "react-native";
6
6
+
import Animated from "react-native-reanimated";
7
7
+
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
8
8
+
9
9
+
type GradientProps = {
10
10
+
fromColor: string;
11
11
+
toColor: string;
12
12
+
children?: any;
13
13
+
height?: DimensionValue;
14
14
+
opacityColor1?: number;
15
15
+
opacityColor2?: number;
16
16
+
} & ViewProps;
17
17
+
18
18
+
function Gradient({
19
19
+
children,
20
20
+
fromColor,
21
21
+
toColor,
22
22
+
height = "100%",
23
23
+
opacityColor1 = 1,
24
24
+
opacityColor2 = 1,
25
25
+
...otherViewProps
26
26
+
}: GradientProps) {
27
27
+
const gradientUniqueId = `grad${fromColor}+${toColor}`.replace(
28
28
+
/[^a-zA-Z0-9 ]/g,
29
29
+
"",
30
30
+
);
31
31
+
return (
32
32
+
<>
33
33
+
<View
34
34
+
style={[
35
35
+
{
36
36
+
...StyleSheet.absoluteFillObject,
37
37
+
height,
38
38
+
zIndex: -1,
39
39
+
top: 0,
40
40
+
left: 0,
41
41
+
},
42
42
+
otherViewProps.style,
43
43
+
]}
44
44
+
{...otherViewProps}
45
45
+
>
46
46
+
<Svg height="100%" width="100%" style={StyleSheet.absoluteFillObject}>
47
47
+
<Defs>
48
48
+
<LinearGradient
49
49
+
id={gradientUniqueId}
50
50
+
x1="0%"
51
51
+
y1="0%"
52
52
+
x2="0%"
53
53
+
y2="100%"
54
54
+
>
55
55
+
<Stop
56
56
+
offset="0"
57
57
+
stopColor={fromColor}
58
58
+
stopOpacity={opacityColor1}
59
59
+
/>
60
60
+
<Stop
61
61
+
offset="1"
62
62
+
stopColor={toColor}
63
63
+
stopOpacity={opacityColor2}
64
64
+
/>
65
65
+
</LinearGradient>
66
66
+
</Defs>
67
67
+
<Rect width="100%" height="100%" fill={`url(#${gradientUniqueId})`} />
68
68
+
</Svg>
69
69
+
</View>
70
70
+
{children}
71
71
+
</>
72
72
+
);
73
73
+
}
74
74
+
75
75
+
export const AnimatedGradient = Animated.createAnimatedComponent(Gradient);
76
76
+
77
77
+
export default Gradient;