@streamplace/components 0.7.34 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/components/content-metadata/content-metadata-form.js +404 -0
  2. package/dist/components/content-metadata/content-rights.js +78 -0
  3. package/dist/components/content-metadata/content-warnings.js +68 -0
  4. package/dist/components/content-metadata/index.js +11 -0
  5. package/dist/components/dashboard/header.js +16 -2
  6. package/dist/components/dashboard/problems.js +29 -28
  7. package/dist/components/mobile-player/player.js +4 -0
  8. package/dist/components/mobile-player/ui/report-modal.js +3 -2
  9. package/dist/components/mobile-player/ui/viewer-context-menu.js +44 -1
  10. package/dist/components/ui/button.js +9 -9
  11. package/dist/components/ui/checkbox.js +87 -0
  12. package/dist/components/ui/dialog.js +188 -83
  13. package/dist/components/ui/dropdown.js +15 -10
  14. package/dist/components/ui/icons.js +6 -0
  15. package/dist/components/ui/primitives/button.js +0 -7
  16. package/dist/components/ui/primitives/input.js +13 -1
  17. package/dist/components/ui/primitives/modal.js +2 -2
  18. package/dist/components/ui/select.js +89 -0
  19. package/dist/components/ui/textarea.js +23 -4
  20. package/dist/components/ui/toast.js +464 -114
  21. package/dist/components/ui/tooltip.js +103 -0
  22. package/dist/index.js +2 -0
  23. package/dist/lib/metadata-constants.js +157 -0
  24. package/dist/lib/theme/theme.js +5 -3
  25. package/dist/lib/theme/tokens.js +9 -0
  26. package/dist/streamplace-provider/index.js +14 -4
  27. package/dist/streamplace-store/content-metadata-actions.js +118 -0
  28. package/dist/streamplace-store/graph.js +195 -0
  29. package/dist/streamplace-store/streamplace-store.js +18 -5
  30. package/dist/streamplace-store/user.js +67 -7
  31. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  32. package/package.json +3 -3
  33. package/src/components/content-metadata/content-metadata-form.tsx +761 -0
  34. package/src/components/content-metadata/content-rights.tsx +104 -0
  35. package/src/components/content-metadata/content-warnings.tsx +100 -0
  36. package/src/components/content-metadata/index.tsx +18 -0
  37. package/src/components/dashboard/header.tsx +37 -3
  38. package/src/components/dashboard/index.tsx +1 -1
  39. package/src/components/dashboard/problems.tsx +57 -46
  40. package/src/components/mobile-player/player.tsx +5 -0
  41. package/src/components/mobile-player/ui/report-modal.tsx +13 -7
  42. package/src/components/mobile-player/ui/viewer-context-menu.tsx +100 -1
  43. package/src/components/ui/button.tsx +10 -13
  44. package/src/components/ui/checkbox.tsx +147 -0
  45. package/src/components/ui/dialog.tsx +319 -99
  46. package/src/components/ui/dropdown.tsx +27 -13
  47. package/src/components/ui/icons.tsx +14 -0
  48. package/src/components/ui/primitives/button.tsx +0 -7
  49. package/src/components/ui/primitives/input.tsx +19 -2
  50. package/src/components/ui/primitives/modal.tsx +4 -2
  51. package/src/components/ui/select.tsx +175 -0
  52. package/src/components/ui/textarea.tsx +47 -29
  53. package/src/components/ui/toast.tsx +785 -179
  54. package/src/components/ui/tooltip.tsx +131 -0
  55. package/src/index.tsx +3 -0
  56. package/src/lib/metadata-constants.ts +180 -0
  57. package/src/lib/theme/theme.tsx +10 -6
  58. package/src/lib/theme/tokens.ts +9 -0
  59. package/src/streamplace-provider/index.tsx +20 -2
  60. package/src/streamplace-store/content-metadata-actions.tsx +142 -0
  61. package/src/streamplace-store/graph.tsx +232 -0
  62. package/src/streamplace-store/streamplace-store.tsx +30 -4
  63. package/src/streamplace-store/user.tsx +71 -7
  64. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,104 @@
1
+ import { forwardRef } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { LICENSE_URL_LABELS } from "../../lib/metadata-constants";
4
+ import { useTheme } from "../../lib/theme/theme";
5
+ import { Text } from "../ui/text";
6
+
7
+ export interface ContentRightsProps {
8
+ contentRights: {
9
+ creator?: string;
10
+ copyrightNotice?: string;
11
+ copyrightYear?: string | number;
12
+ license?: string;
13
+ creditLine?: string;
14
+ };
15
+ compact?: boolean;
16
+ }
17
+
18
+ export const ContentRights = forwardRef<any, ContentRightsProps>(
19
+ ({ contentRights }, ref) => {
20
+ const { theme } = useTheme();
21
+
22
+ if (!contentRights || Object.keys(contentRights).length === 0) {
23
+ return null;
24
+ }
25
+
26
+ const styles = createStyles(theme);
27
+
28
+ const formatLicense = (license: string) => {
29
+ return LICENSE_URL_LABELS[license] || license;
30
+ };
31
+
32
+ // Display rights in bottom metadata view
33
+ const elements: string[] = [];
34
+
35
+ // TODO: Map DID to handle creator
36
+ // if (contentRights.creator) {
37
+ // elements.push(`Creator: ${contentRights.creator}`);
38
+ // }
39
+
40
+ if (contentRights.copyrightYear) {
41
+ elements.push(`© ${contentRights.copyrightYear.toString()}`);
42
+ }
43
+
44
+ if (contentRights.license) {
45
+ elements.push(formatLicense(contentRights.license));
46
+ }
47
+
48
+ if (contentRights.copyrightNotice) {
49
+ elements.push(contentRights.copyrightNotice);
50
+ }
51
+
52
+ if (contentRights.creditLine) {
53
+ elements.push(contentRights.creditLine);
54
+ }
55
+
56
+ return (
57
+ <View ref={ref} style={styles.compactContainer}>
58
+ <Text style={styles.compactText}>{elements.join(" • ")}</Text>
59
+ </View>
60
+ );
61
+ },
62
+ );
63
+
64
+ ContentRights.displayName = "ContentRights";
65
+
66
+ function createStyles(theme: any) {
67
+ return StyleSheet.create({
68
+ container: {
69
+ paddingVertical: theme.spacing[3],
70
+ },
71
+ title: {
72
+ fontSize: 14,
73
+ fontWeight: "600",
74
+ color: theme.colors.text,
75
+ marginBottom: theme.spacing[2],
76
+ },
77
+ content: {
78
+ gap: theme.spacing[2],
79
+ },
80
+ row: {
81
+ flexDirection: "row",
82
+ gap: theme.spacing[2],
83
+ },
84
+ label: {
85
+ fontSize: 13,
86
+ color: theme.colors.textMuted,
87
+ },
88
+ value: {
89
+ fontSize: 13,
90
+ color: theme.colors.text,
91
+ },
92
+ compactContainer: {
93
+ flexDirection: "row",
94
+ gap: theme.spacing[2],
95
+ flexWrap: "wrap",
96
+ marginTop: theme.spacing[1],
97
+ },
98
+ compactText: {
99
+ fontSize: 14,
100
+ fontWeight: "500",
101
+ color: theme.colors.text,
102
+ },
103
+ });
104
+ }
@@ -0,0 +1,100 @@
1
+ import { forwardRef } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { C2PA_WARNING_LABELS } from "../../lib/metadata-constants";
4
+ import { useTheme } from "../../lib/theme/theme";
5
+ import { Text } from "../ui/text";
6
+
7
+ export interface ContentWarningsProps {
8
+ warnings: string[];
9
+ compact?: boolean;
10
+ }
11
+
12
+ export const ContentWarnings = forwardRef<any, ContentWarningsProps>(
13
+ ({ warnings, compact = false }, ref) => {
14
+ const { theme } = useTheme();
15
+
16
+ if (!warnings || warnings.length === 0) {
17
+ return null;
18
+ }
19
+
20
+ const styles = createStyles(theme, compact);
21
+
22
+ const getWarningLabel = (warning: string): string => {
23
+ return C2PA_WARNING_LABELS[warning] || warning;
24
+ };
25
+
26
+ if (compact) {
27
+ return (
28
+ <View ref={ref} style={styles.compactContainer}>
29
+ {warnings.map((warning, index) => (
30
+ <View key={index} style={styles.compactWarning}>
31
+ <Text style={styles.compactWarningText}>
32
+ {getWarningLabel(warning)}
33
+ </Text>
34
+ </View>
35
+ ))}
36
+ </View>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <View ref={ref} style={styles.container}>
42
+ <Text style={styles.title}>Content Warnings</Text>
43
+ <View style={styles.warningsContainer}>
44
+ {warnings.map((warning, index) => (
45
+ <View key={index} style={styles.warning}>
46
+ <Text style={styles.warningText}>{getWarningLabel(warning)}</Text>
47
+ </View>
48
+ ))}
49
+ </View>
50
+ </View>
51
+ );
52
+ },
53
+ );
54
+
55
+ ContentWarnings.displayName = "ContentWarnings";
56
+
57
+ function createStyles(theme: any, compact: boolean) {
58
+ return StyleSheet.create({
59
+ container: {
60
+ flexDirection: "column",
61
+ gap: theme.spacing[2],
62
+ },
63
+ title: {
64
+ fontSize: 14,
65
+ fontWeight: "600",
66
+ color: theme.colors.text,
67
+ },
68
+ warningsContainer: {
69
+ flexDirection: "row",
70
+ flexWrap: "wrap",
71
+ gap: theme.spacing[2],
72
+ },
73
+ warning: {
74
+ backgroundColor: theme.colors.warning,
75
+ borderRadius: theme.borderRadius.md,
76
+ padding: theme.spacing[2],
77
+ },
78
+ warningText: {
79
+ color: theme.colors.warningForeground,
80
+ fontSize: 12,
81
+ fontWeight: "500",
82
+ },
83
+ compactContainer: {
84
+ flexDirection: "row",
85
+ flexWrap: "wrap",
86
+ gap: theme.spacing[1],
87
+ },
88
+ compactWarning: {
89
+ backgroundColor: theme.colors.warning,
90
+ borderRadius: theme.borderRadius.full,
91
+ paddingHorizontal: 10,
92
+ paddingVertical: 4,
93
+ },
94
+ compactWarningText: {
95
+ color: theme.colors.warningForeground,
96
+ fontSize: 14,
97
+ fontWeight: "600",
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,18 @@
1
+ // Main form component
2
+ export { ContentMetadataForm } from "./content-metadata-form";
3
+
4
+ // Display components
5
+ export { ContentRights } from "./content-rights";
6
+ export { ContentWarnings } from "./content-warnings";
7
+
8
+ // Types
9
+ export type {
10
+ ContentMetadata,
11
+ ContentMetadataFormProps,
12
+ DistributionPolicy,
13
+ Rights,
14
+ } from "./content-metadata-form";
15
+
16
+ export type { ContentRightsProps } from "./content-rights";
17
+
18
+ export type { ContentWarningsProps } from "./content-warnings";
@@ -1,5 +1,5 @@
1
- import { Car, Radio, Users } from "lucide-react-native";
2
- import { Text, View } from "react-native";
1
+ import { AlertCircle, Car, Radio, Users } from "lucide-react-native";
2
+ import { Pressable, Text, View } from "react-native";
3
3
  import * as zero from "../../ui";
4
4
 
5
5
  const { bg, r, borders, px, py, text, layout, gap } = zero;
@@ -103,6 +103,8 @@ interface HeaderProps {
103
103
  bitrate?: string;
104
104
  timeBetweenSegments?: number;
105
105
  connectionStatus?: "excellent" | "good" | "poor" | "offline";
106
+ problemsCount?: number;
107
+ onProblemsPress?: () => void;
106
108
  }
107
109
 
108
110
  export default function Header({
@@ -113,6 +115,8 @@ export default function Header({
113
115
  bitrate = "0 mbps",
114
116
  timeBetweenSegments = 0,
115
117
  connectionStatus = "offline",
118
+ problemsCount = 0,
119
+ onProblemsPress,
116
120
  }: HeaderProps) {
117
121
  const getConnectionQuality = (): "good" | "warning" | "error" => {
118
122
  if (timeBetweenSegments <= 1500) return "good";
@@ -139,7 +143,37 @@ export default function Header({
139
143
  <Text style={[text.white, { fontSize: 18, fontWeight: "600" }]}>
140
144
  {streamTitle}
141
145
  </Text>
142
- <StatusIndicator status={connectionStatus} isLive={isLive} />
146
+ <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}>
147
+ <StatusIndicator status={connectionStatus} isLive={isLive} />
148
+ {problemsCount > 0 && (
149
+ <Pressable onPress={onProblemsPress}>
150
+ <View
151
+ style={[
152
+ layout.flex.row,
153
+ layout.flex.alignCenter,
154
+ gap.all[1],
155
+ px[2],
156
+ py[1],
157
+ r.md,
158
+ bg.orange[900],
159
+ borders.width.thin,
160
+ borders.color.orange[700],
161
+ { marginVertical: -8 },
162
+ ]}
163
+ >
164
+ <AlertCircle size={14} color="#fb923c" />
165
+ <Text
166
+ style={[
167
+ text.orange[400],
168
+ { fontSize: 11, fontWeight: "600" },
169
+ ]}
170
+ >
171
+ {problemsCount} {problemsCount === 1 ? "Issue" : "Issues"}
172
+ </Text>
173
+ </View>
174
+ </Pressable>
175
+ )}
176
+ </View>
143
177
  </View>
144
178
  </View>
145
179
 
@@ -2,4 +2,4 @@ export { default as ChatPanel } from "./chat-panel";
2
2
  export { default as Header } from "./header";
3
3
  export { default as InformationWidget } from "./information-widget";
4
4
  export { default as ModActions } from "./mod-actions";
5
- export { default as Problems } from "./problems";
5
+ export { default as Problems, ProblemsWrapperRef } from "./problems";
@@ -1,12 +1,32 @@
1
- import { ExternalLink } from "lucide-react-native";
2
- import { useState } from "react";
3
- import { Linking, Pressable, Text, View } from "react-native";
1
+ import {
2
+ CircleAlert,
3
+ CircleX,
4
+ ExternalLink,
5
+ Info,
6
+ Sparkle,
7
+ } from "lucide-react-native";
8
+ import { forwardRef, useImperativeHandle, useState } from "react";
9
+ import { Linking, Pressable, View } from "react-native";
4
10
  import { useLivestreamStore } from "../../livestream-store";
5
11
  import { LivestreamProblem } from "../../livestream-store/livestream-state";
6
12
  import * as zero from "../../ui";
13
+ import { Button, Text } from "../ui";
7
14
 
8
15
  const { bg, r, borders, p, text, layout, gap } = zero;
9
16
 
17
+ const getIcon = (severity: string) => {
18
+ switch (severity) {
19
+ case "error":
20
+ return <CircleX size={24} color="white" />;
21
+ case "warning":
22
+ return <CircleAlert size={24} color="white" />;
23
+ case "info":
24
+ return <Info size={24} color="white" />;
25
+ default:
26
+ return <Sparkle size={24} color="white" />;
27
+ }
28
+ };
29
+
10
30
  const Problems = ({
11
31
  probs,
12
32
  onIgnore,
@@ -15,9 +35,9 @@ const Problems = ({
15
35
  onIgnore: () => void;
16
36
  }) => {
17
37
  return (
18
- <View style={[gap.all[3]]}>
19
- <View>
20
- <Text style={[text.white, { fontSize: 24, fontWeight: "bold" }]}>
38
+ <View style={[gap.all[4]]}>
39
+ <View style={[gap.all[2]]}>
40
+ <Text size="2xl" style={[text.white, { fontWeight: "600" }]}>
21
41
  Optimize Your Stream
22
42
  </Text>
23
43
  <Text style={[text.gray[300]]}>
@@ -34,26 +54,22 @@ const Problems = ({
34
54
  { gap: 8, alignItems: "flex-start" },
35
55
  ]}
36
56
  >
37
- <Text
57
+ <View
38
58
  style={[
39
- r.sm,
40
- p[2],
59
+ zero.r.full,
60
+ zero.p[1],
41
61
  {
42
- width: 82,
43
- textAlign: "center",
44
62
  backgroundColor:
45
63
  p.severity === "error"
46
64
  ? "#7f1d1d"
47
65
  : p.severity === "warning"
48
66
  ? "#7c2d12"
49
67
  : "#1e3a8a",
50
- color: "white",
51
- fontSize: 12,
52
68
  },
53
69
  ]}
54
70
  >
55
- {p.severity}
56
- </Text>
71
+ {getIcon(p.severity)}
72
+ </View>
57
73
  <View style={[{ flex: 1 }, gap.all[1]]}>
58
74
  <Text style={[text.white, { fontWeight: "600" }]}>{p.code}</Text>
59
75
  <Text style={[text.gray[400], { fontSize: 14 }]}>
@@ -79,39 +95,34 @@ const Problems = ({
79
95
  </View>
80
96
  </View>
81
97
  ))}
82
-
83
- <Pressable
84
- onPress={onIgnore}
85
- style={[
86
- bg.blue[600],
87
- r.md,
88
- p[3],
89
- layout.flex.center,
90
- { marginTop: 16 },
91
- ]}
92
- >
93
- <Text style={[text.white, { fontWeight: "600" }]}>Ignore</Text>
94
- </Pressable>
98
+ <View style={[layout.flex.row, layout.flex.justify.end]}>
99
+ <Button onPress={onIgnore} variant="secondary">
100
+ <Text style={[text.white, { fontWeight: "600" }]}>Acknowledge</Text>
101
+ </Button>
102
+ </View>
95
103
  </View>
96
104
  );
97
105
  };
98
106
 
99
- export const ProblemsWrapper = ({
100
- children,
101
- }: {
102
- children: React.ReactElement;
103
- }) => {
107
+ export interface ProblemsWrapperRef {
108
+ setDismiss: (value: boolean) => void;
109
+ }
110
+
111
+ export const ProblemsWrapper = forwardRef<
112
+ ProblemsWrapperRef,
113
+ {
114
+ children: React.ReactElement;
115
+ }
116
+ >(({ children }, ref) => {
104
117
  const problems = useLivestreamStore((x) => x.problems);
105
118
  const [dismiss, setDismiss] = useState(false);
106
119
 
120
+ useImperativeHandle(ref, () => ({
121
+ setDismiss,
122
+ }));
123
+
107
124
  return (
108
- <View
109
- style={[
110
- { position: "relative", flex: 1 },
111
- layout.flex.center,
112
- { flexBasis: 0 },
113
- ]}
114
- >
125
+ <>
115
126
  {children}
116
127
  {problems.length > 0 && !dismiss && (
117
128
  <View
@@ -127,16 +138,16 @@ export const ProblemsWrapper = ({
127
138
  },
128
139
  layout.flex.center,
129
140
  { justifyContent: "flex-start" },
130
- p[8],
141
+ p[12],
131
142
  ]}
132
143
  >
133
144
  <View
134
145
  style={[
135
- bg.gray[900],
136
- borders.color.gray[700],
146
+ bg.neutral[900],
147
+ borders.color.neutral[700],
137
148
  borders.width.thin,
138
149
  r.lg,
139
- p[4],
150
+ p[8],
140
151
  { maxWidth: 700, width: "100%" },
141
152
  ]}
142
153
  >
@@ -144,8 +155,8 @@ export const ProblemsWrapper = ({
144
155
  </View>
145
156
  </View>
146
157
  )}
147
- </View>
158
+ </>
148
159
  );
149
- };
160
+ });
150
161
 
151
162
  export default Problems;
@@ -23,6 +23,7 @@ export function Player(
23
23
  const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout);
24
24
 
25
25
  const setReportingURL = usePlayerStore((x) => x.setReportingURL);
26
+ const setEmbedded = usePlayerStore((x) => x.setEmbedded);
26
27
 
27
28
  const reportModalOpen = usePlayerStore((x) => x.reportModalOpen);
28
29
  const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
@@ -32,6 +33,10 @@ export function Player(
32
33
  setReportingURL(props.reportingURL ?? null);
33
34
  }, [props.reportingURL]);
34
35
 
36
+ useEffect(() => {
37
+ setEmbedded(props.embedded ?? false);
38
+ }, [props.embedded]);
39
+
35
40
  // Will call back every few seconds to send health updates
36
41
  usePlayerStatus();
37
42
 
@@ -9,11 +9,11 @@ import { zero } from "../../..";
9
9
  import { useSubmitReport } from "../../../livestream-store";
10
10
  import {
11
11
  Button,
12
- Dialog,
13
12
  DialogFooter,
14
- ModalContent,
13
+ ResponsiveDialog,
15
14
  Text,
16
15
  Textarea,
16
+ useTheme,
17
17
  } from "../../ui";
18
18
 
19
19
  // AT Protocol moderation reason types with proper labels
@@ -72,6 +72,8 @@ export const ReportModal: React.FC<ReportModalProps> = ({
72
72
  const [isSubmitting, setIsSubmitting] = useState(false);
73
73
  const [submitError, setSubmitError] = useState<string | null>(null);
74
74
 
75
+ const { theme } = useTheme();
76
+
75
77
  const submitReport = useSubmitReport();
76
78
 
77
79
  const handleCancel = () => {
@@ -107,7 +109,7 @@ export const ReportModal: React.FC<ReportModalProps> = ({
107
109
  };
108
110
 
109
111
  return (
110
- <Dialog
112
+ <ResponsiveDialog
111
113
  open={open}
112
114
  onOpenChange={onOpenChange}
113
115
  title={title}
@@ -118,7 +120,7 @@ export const ReportModal: React.FC<ReportModalProps> = ({
118
120
  dismissible={false}
119
121
  position="center"
120
122
  >
121
- <ModalContent style={[zero.pb[2]]}>
123
+ <View style={[zero.pb[2]]}>
122
124
  {REPORT_REASONS.map((reason) => (
123
125
  <TouchableOpacity
124
126
  key={reason.value}
@@ -136,7 +138,11 @@ export const ReportModal: React.FC<ReportModalProps> = ({
136
138
  ]}
137
139
  >
138
140
  <View>
139
- {selectedReason === reason.value ? <CheckCircle /> : <Circle />}
141
+ {selectedReason === reason.value ? (
142
+ <CheckCircle color={theme.colors.foreground} />
143
+ ) : (
144
+ <Circle color={theme.colors.foreground} />
145
+ )}
140
146
  </View>
141
147
  <View
142
148
  style={[zero.layout.flex.column, zero.gap.all[1], zero.flex[1]]}
@@ -164,7 +170,7 @@ export const ReportModal: React.FC<ReportModalProps> = ({
164
170
  </Text>
165
171
  )}
166
172
  </View>
167
- </ModalContent>
173
+ </View>
168
174
  <DialogFooter>
169
175
  <Button
170
176
  variant="secondary"
@@ -188,7 +194,7 @@ export const ReportModal: React.FC<ReportModalProps> = ({
188
194
  )}
189
195
  </Button>
190
196
  </DialogFooter>
191
- </Dialog>
197
+ </ResponsiveDialog>
192
198
  );
193
199
  };
194
200
 
@@ -1,9 +1,11 @@
1
1
  import { useRootContext } from "@rn-primitives/dropdown-menu";
2
2
  import { Menu } from "lucide-react-native";
3
- import { Platform, View } from "react-native";
3
+ import { Image, Linking, Platform, Pressable, View } from "react-native";
4
+ import { useAvatars, useLivestreamInfo, zero } from "../../..";
4
5
  import { colors } from "../../../lib/theme";
5
6
  import { useLivestreamStore } from "../../../livestream-store";
6
7
  import { PlayerProtocol, usePlayerStore } from "../../../player-store/";
8
+ import { useGraphManager } from "../../../streamplace-store/graph";
7
9
  import {
8
10
  DropdownMenu,
9
11
  DropdownMenuCheckboxItem,
@@ -14,6 +16,7 @@ import {
14
16
  DropdownMenuPortal,
15
17
  DropdownMenuRadioGroup,
16
18
  DropdownMenuRadioItem,
19
+ DropdownMenuSeparator,
17
20
  DropdownMenuTrigger,
18
21
  ResponsiveDropdownMenuContent,
19
22
  Text,
@@ -38,6 +41,12 @@ export function ContextMenu({
38
41
  const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
39
42
  const setReportSubject = usePlayerStore((x) => x.setReportSubject);
40
43
 
44
+ const { profile } = useLivestreamInfo();
45
+ const avatars = useAvatars(profile?.did ? [profile?.did] : []);
46
+ const ls = useLivestreamStore((x) => x.livestream);
47
+
48
+ let graphManager = useGraphManager(profile?.did);
49
+
41
50
  const lowLatency = protocol === "webrtc";
42
51
  const setLowLatency = (value: boolean) => {
43
52
  setProtocol(value ? PlayerProtocol.WEBRTC : PlayerProtocol.HLS);
@@ -61,6 +70,96 @@ export function ContextMenu({
61
70
  </DropdownMenuTrigger>
62
71
  <Portal container={dropdownPortalContainer}>
63
72
  <DropdownMenuContent side="top" align="end">
73
+ {Platform.OS !== "web" && (
74
+ <DropdownMenuGroup title="Streamer">
75
+ <View
76
+ style={[
77
+ zero.layout.flex.row,
78
+ zero.layout.flex.center,
79
+ zero.gap.all[3],
80
+ { flex: 1, minWidth: 0 },
81
+ ]}
82
+ >
83
+ {profile?.did && avatars[profile?.did]?.avatar && (
84
+ <Image
85
+ key="avatar"
86
+ source={{
87
+ uri: avatars[profile?.did]?.avatar,
88
+ }}
89
+ style={{ width: 42, height: 42, borderRadius: 999 }}
90
+ resizeMode="cover"
91
+ />
92
+ )}
93
+ <View style={{ flex: 1, minWidth: 0 }}>
94
+ <View
95
+ style={[
96
+ zero.layout.flex.row,
97
+ zero.layout.flex.alignCenter,
98
+ zero.gap.all[2],
99
+ ]}
100
+ >
101
+ <Pressable
102
+ onPress={() => {
103
+ if (profile?.handle) {
104
+ const url = `https://bsky.app/profile/${profile.handle}`;
105
+ Linking.openURL(url);
106
+ }
107
+ }}
108
+ >
109
+ <Text>@{profile?.handle || "user"}</Text>
110
+ </Pressable>
111
+ {/*{did && profile && (
112
+ <FollowButton streamerDID={profile?.did} currentUserDID={did} />
113
+ )}*/}
114
+ </View>
115
+ <Text
116
+ color="muted"
117
+ size="sm"
118
+ numberOfLines={2}
119
+ ellipsizeMode="tail"
120
+ >
121
+ {ls?.record.title || "Stream Title"}
122
+ </Text>
123
+ </View>
124
+ </View>
125
+ <DropdownMenuSeparator />
126
+ <DropdownMenuItem
127
+ disabled={graphManager.isLoading || !profile?.did}
128
+ onPress={async () => {
129
+ try {
130
+ if (graphManager.isFollowing) {
131
+ await graphManager.unfollow();
132
+ } else {
133
+ await graphManager.follow();
134
+ }
135
+ } catch (err) {
136
+ console.error("Follow/unfollow error:", err);
137
+ }
138
+ }}
139
+ >
140
+ <Text
141
+ color={graphManager.isFollowing ? "destructive" : "default"}
142
+ >
143
+ {graphManager.isLoading
144
+ ? "Loading..."
145
+ : graphManager.isFollowing
146
+ ? "Unfollow"
147
+ : "Follow"}
148
+ </Text>
149
+ </DropdownMenuItem>
150
+ <DropdownMenuSeparator />
151
+ <DropdownMenuItem
152
+ onPress={() => {
153
+ if (profile?.handle) {
154
+ const url = `https://bsky.app/profile/${profile.handle}`;
155
+ Linking.openURL(url);
156
+ }
157
+ }}
158
+ >
159
+ <Text>View Profile on Bluesky</Text>
160
+ </DropdownMenuItem>
161
+ </DropdownMenuGroup>
162
+ )}
64
163
  <DropdownMenuGroup title="Resolution">
65
164
  <DropdownMenuRadioGroup value={quality} onValueChange={setQuality}>
66
165
  <DropdownMenuRadioItem value="source">