tangled
alpha
login
or
join now
teal.fm
/
teal
110
fork
atom
Your music, beautifully tracked. All yours. (coming soon)
teal.fm
teal-fm
atproto
110
fork
atom
overview
issues
pulls
pipelines
add local react ctx for manual stamping flow
Natalie B.
1 year ago
71118aca
af1059e4
+256
-28
5 changed files
expand all
collapse all
unified
split
apps
amethyst
app
(tabs)
(stamp)
_layout.tsx
stamp
_layout.tsx
index.tsx
submit.tsx
success.tsx
+1
-1
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
···
21
21
}
22
22
}, [segment]);
23
23
24
24
-
return <Stack>{rootScreen}</Stack>;
24
24
+
return <Stack screenOptions={{ headerShown: false }}>{rootScreen}</Stack>;
25
25
};
26
26
27
27
export default Layout;
+37
apps/amethyst/app/(tabs)/(stamp)/stamp/_layout.tsx
···
1
1
+
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
2
2
+
import { Slot, Stack } from "expo-router";
3
3
+
import { createContext, useState } from "react";
4
4
+
5
5
+
export enum StampStep {
6
6
+
IDLE = "IDLE",
7
7
+
SUBMITTING = "SUBMITTING",
8
8
+
SUBMITTED = "SUBMITTED",
9
9
+
}
10
10
+
11
11
+
export type StampContextState =
12
12
+
| { step: StampStep.IDLE; resetSearchState: boolean }
13
13
+
| { step: StampStep.SUBMITTING; submittingStamp: MusicBrainzRecording }
14
14
+
| { step: StampStep.SUBMITTED; submittedStamp: PlaySubmittedData };
15
15
+
16
16
+
export type StampContextValue = {
17
17
+
state: StampContextState;
18
18
+
setState: React.Dispatch<React.SetStateAction<StampContextState>>;
19
19
+
};
20
20
+
21
21
+
export const StampContext = createContext<StampContextValue | null>(null);
22
22
+
23
23
+
const Layout = ({ segment }: { segment: string }) => {
24
24
+
const [state, setState] = useState<StampContextState>({
25
25
+
step: StampStep.IDLE,
26
26
+
resetSearchState: false,
27
27
+
});
28
28
+
return (
29
29
+
<StampContext.Provider value={{ state, setState }}>
30
30
+
<Stack>
31
31
+
<Slot />
32
32
+
</Stack>
33
33
+
</StampContext.Provider>
34
34
+
);
35
35
+
};
36
36
+
37
37
+
export default Layout;
+25
-8
apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx
···
3
3
import { Stack, useRouter } from "expo-router";
4
4
import { Check, ChevronDown, ChevronRight } from "lucide-react-native";
5
5
6
6
-
import React, { useRef, useState } from "react";
6
6
+
import React, { useContext, useEffect, useRef, useState } from "react";
7
7
import {
8
8
FlatList,
9
9
Image,
···
22
22
SearchResultProps,
23
23
} from "@/lib/oldStamp";
24
24
import { BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
25
25
-
import SheetBackdrop from "@/components/ui/sheetBackdrop";
25
25
+
import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop";
26
26
+
import { StampContext, StampContextValue, StampStep } from "./_layout";
26
27
27
28
export default function StepOne() {
28
29
const router = useRouter();
30
30
+
const ctx = useContext(StampContext);
31
31
+
const { state, setState } = ctx as StampContextValue;
29
32
const [selectedTrack, setSelectedTrack] =
30
33
useState<MusicBrainzRecording | null>(null);
31
34
···
41
44
const [releaseSelections, setReleaseSelections] = useState<ReleaseSelections>(
42
45
{},
43
46
);
47
47
+
48
48
+
// reset search state if requested
49
49
+
useEffect(() => {
50
50
+
if (state.step === StampStep.IDLE && state.resetSearchState) {
51
51
+
setSearchFields({ track: "", artist: "", release: "" });
52
52
+
setSearchResults([]);
53
53
+
setSelectedTrack(null);
54
54
+
setReleaseSelections({});
55
55
+
}
56
56
+
}, [state]);
44
57
45
58
const handleSearch = async (): Promise<void> => {
46
59
if (!searchFields.track && !searchFields.artist && !searchFields.release) {
···
148
161
{selectedTrack && (
149
162
<View className="mt-4 sticky bottom-0">
150
163
<Button
151
151
-
onPress={() =>
164
164
+
onPress={() => {
165
165
+
setState({
166
166
+
step: StampStep.SUBMITTING,
167
167
+
submittingStamp: selectedTrack,
168
168
+
});
152
169
router.push({
153
170
pathname: "/stamp/submit",
154
154
-
params: { track: JSON.stringify(selectedTrack) },
155
155
-
})
156
156
-
}
171
171
+
});
172
172
+
}}
157
173
className="w-full flex flex-row align-middle"
158
174
>
159
175
<Text>{`Submit "${selectedTrack.title}" as Play`}</Text>
···
258
274
enableDynamicSizing={true}
259
275
detached={true}
260
276
backdropComponent={SheetBackdrop}
277
277
+
handleComponent={SheetHandle}
261
278
>
262
262
-
<View className="pb-4 border-b border-gray-200 -mt-2">
279
279
+
<View className="pb-4 border-b -mt-2 bg-background border-x border-neutral-500/30">
263
280
<Text className="text-lg font-bold text-center">Select Release</Text>
264
281
<TouchableOpacity
265
282
className="absolute right-4 top-1.5"
···
268
285
<Text className="text-primary">Done</Text>
269
286
</TouchableOpacity>
270
287
</View>
271
271
-
<BottomSheetScrollView className="bg-card min-h-64">
288
288
+
<BottomSheetScrollView className="bg-card min-h-64 border-x border-neutral-500/30">
272
289
{result.releases?.map((release) => (
273
290
<TouchableOpacity
274
291
key={release.id}
+159
-12
apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx
···
1
1
import VerticalPlayView from "@/components/play/verticalPlayView";
2
2
import { Button } from "@/components/ui/button";
3
3
import { useStore } from "@/stores/mainStore";
4
4
-
import { ComAtprotoRepoCreateRecord } from "@atproto/api";
4
4
+
import { Agent, ComAtprotoRepoCreateRecord, RichText } from "@atproto/api";
5
5
import {
6
6
Record as PlayRecord,
7
7
validateRecord,
8
8
} from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
9
9
-
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
10
10
-
import { useState } from "react";
9
9
+
import { Stack, useRouter } from "expo-router";
10
10
+
import { useContext, useState } from "react";
11
11
import { Switch, View } from "react-native";
12
12
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
13
13
import { Text } from "@/components/ui/text";
14
14
import { ExternalLink } from "@/components/ExternalLink";
15
15
+
import { StampContext, StampContextValue, StampStep } from "./_layout";
16
16
+
17
17
+
type CardyBResponse = {
18
18
+
error: string;
19
19
+
likely_type: string;
20
20
+
url: string;
21
21
+
title: string;
22
22
+
description: string;
23
23
+
image: string;
24
24
+
};
25
25
+
// call CardyB API to get embed card
26
26
+
const getUrlMetadata = async (url: string): Promise<CardyBResponse> => {
27
27
+
const response = await fetch(`https://cardyb.bsky.app/v1/extract?url=${url}`);
28
28
+
if (response.status === 200) {
29
29
+
return await response.json();
30
30
+
} else {
31
31
+
throw new Error("Failed to fetch metadata from CardyB");
32
32
+
}
33
33
+
};
34
34
+
35
35
+
const getBlueskyEmbedCard = async (
36
36
+
url: string | undefined,
37
37
+
agent: Agent,
38
38
+
customUrl?: string,
39
39
+
customTitle?: string,
40
40
+
customDescription?: string,
41
41
+
) => {
42
42
+
if (!url) return;
43
43
+
44
44
+
try {
45
45
+
const metadata = await getUrlMetadata(url);
46
46
+
const blob = await fetch(metadata.image).then((r) => r.blob());
47
47
+
const { data } = await agent.uploadBlob(blob, { encoding: "image/jpeg" });
48
48
+
49
49
+
return {
50
50
+
$type: "app.bsky.embed.external",
51
51
+
external: {
52
52
+
uri: customUrl || metadata.url,
53
53
+
title: customTitle || metadata.title,
54
54
+
description: customDescription || metadata.description,
55
55
+
thumb: data.blob,
56
56
+
},
57
57
+
};
58
58
+
} catch (error) {
59
59
+
console.error("Error fetching embed card:", error);
60
60
+
return;
61
61
+
}
62
62
+
};
63
63
+
interface EmbedInfo {
64
64
+
urlEmbed: string;
65
65
+
customUrl: string;
66
66
+
}
67
67
+
const getEmbedInfo = async (mbid: string): Promise<EmbedInfo | null> => {
68
68
+
let appleMusicResponse = await fetch(
69
69
+
`https://labs.api.listenbrainz.org/apple-music-id-from-mbid/json?recording_mbid=${mbid}`,
70
70
+
);
71
71
+
if (appleMusicResponse.status === 200) {
72
72
+
const appleMusicData = await appleMusicResponse.json();
73
73
+
console.log("Apple Music data:", appleMusicData);
74
74
+
if (appleMusicData[0].apple_music_track_ids.length > 0) {
75
75
+
let trackId = appleMusicData[0].apple_music_track_ids[0];
76
76
+
return {
77
77
+
urlEmbed: `https://music.apple.com/us/song/its-not-living-if-its-not-with-you/${trackId}`,
78
78
+
customUrl: `https://song.link/i/${trackId}`,
79
79
+
};
80
80
+
} else {
81
81
+
let spotifyResponse = await fetch(
82
82
+
`https://labs.api.listenbrainz.org/spotify-id-from-mbid/json?recording_mbid=${mbid}`,
83
83
+
);
84
84
+
if (spotifyResponse.status === 200) {
85
85
+
const spotifyData = await spotifyResponse.json();
86
86
+
console.log("Spotify data:", spotifyData);
87
87
+
if (spotifyData[0].spotify_track_ids.length > 0) {
88
88
+
let trackId = spotifyData[0].spotify_track_ids[0];
89
89
+
return {
90
90
+
urlEmbed: `https://open.spotify.com/track/${trackId}`,
91
91
+
customUrl: `https://song.link/s/${trackId}`,
92
92
+
};
93
93
+
}
94
94
+
}
95
95
+
}
96
96
+
}
97
97
+
return null;
98
98
+
};
99
99
+
100
100
+
const ms2hms = (ms: number): string => {
101
101
+
let seconds = Math.floor(ms / 1000);
102
102
+
let minutes = Math.floor(seconds / 60);
103
103
+
seconds = seconds % 60;
104
104
+
minutes = minutes % 60;
105
105
+
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
106
106
+
};
15
107
16
108
const createPlayRecord = (result: MusicBrainzRecording): PlayRecord => {
17
109
let artistNames: string[] = [];
···
41
133
export default function Submit() {
42
134
const router = useRouter();
43
135
const agent = useStore((state) => state.pdsAgent);
44
44
-
// awful awful awful!
45
45
-
// I don't wanna use global state for something like this though!
46
46
-
const { track } = useLocalSearchParams();
47
47
-
48
48
-
const selectedTrack: MusicBrainzRecording | null = JSON.parse(
49
49
-
track as string,
50
50
-
);
136
136
+
const ctx = useContext(StampContext);
137
137
+
const { state, setState } = ctx as StampContextValue;
51
138
52
139
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
53
140
const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false);
54
141
142
142
+
if (state.step !== StampStep.SUBMITTING) {
143
143
+
console.log("Stamp step is not SUBMITTING");
144
144
+
console.log(state);
145
145
+
return <Text>No track selected?</Text>;
146
146
+
//return <Redirect href="/stamp" />;
147
147
+
}
148
148
+
149
149
+
const selectedTrack = state.submittingStamp;
150
150
+
55
151
if (selectedTrack === null) {
56
152
return <Text>No track selected</Text>;
57
153
}
58
154
155
155
+
// TODO: PLEASE refactor me ASAP!!!
59
156
const handleSubmit = async () => {
60
157
setIsSubmitting(true);
61
158
try {
···
85
182
playRecord: record,
86
183
blueskyPostUrl: null,
87
184
};
185
185
+
if (shareWithBluesky && agent) {
186
186
+
// lol this type
187
187
+
const rt = new RichText({
188
188
+
text: `💮 now playing:
189
189
+
${record.trackName} by ${record.artistNames.join(", ")}
190
190
+
191
191
+
powered by @teal.fm`,
192
192
+
});
193
193
+
await rt.detectFacets(agent);
194
194
+
// get metadata from Apple if available
195
195
+
// https://labs.api.listenbrainz.org/apple-music-id-from-mbid/json?recording_mbid=81c3eb6e-d8f4-423c-9007-694aefe62754
196
196
+
// https://music.apple.com/us/album/i-always-wanna-die-sometimes/1435546528?i=1435546783
197
197
+
let embedInfo = await getEmbedInfo(selectedTrack.id);
198
198
+
let urlEmbed: string | undefined = embedInfo?.urlEmbed;
199
199
+
let customUrl: string | undefined = embedInfo?.customUrl;
200
200
+
201
201
+
let releaseYear = selectedTrack.selectedRelease?.date?.split("-")[0];
202
202
+
let title = `${record.trackName} by ${record.artistNames.join(", ")}`;
203
203
+
let description = `Song${releaseYear && " · "}${releaseYear} · ${
204
204
+
selectedTrack.length && " · " + ms2hms(selectedTrack.length)
205
205
+
}`;
206
206
+
207
207
+
console.log(urlEmbed, customUrl, title, description);
208
208
+
209
209
+
console.log(
210
210
+
await getBlueskyEmbedCard(
211
211
+
urlEmbed,
212
212
+
agent,
213
213
+
customUrl,
214
214
+
title,
215
215
+
description,
216
216
+
),
217
217
+
);
218
218
+
219
219
+
const post = await agent.post({
220
220
+
text: rt.text,
221
221
+
facets: rt.facets,
222
222
+
embed: urlEmbed
223
223
+
? await getBlueskyEmbedCard(urlEmbed, agent)
224
224
+
: undefined,
225
225
+
});
226
226
+
submittedData.blueskyPostUrl = post.uri;
227
227
+
}
228
228
+
setState({
229
229
+
step: StampStep.SUBMITTED,
230
230
+
submittedStamp: submittedData,
231
231
+
});
232
232
+
// wait for state updates
233
233
+
await Promise.resolve();
88
234
router.replace({
89
235
pathname: "/stamp/success",
90
90
-
params: { submittedData: JSON.stringify(submittedData) },
91
236
});
92
237
} catch (error) {
93
238
console.error("Failed to submit play:", error);
···
111
256
selectedTrack?.title ||
112
257
"No track selected! This should never happen!"
113
258
}
114
114
-
artistName={selectedTrack?.["artist-credit"]?.[0]?.artist?.name}
259
259
+
artistName={selectedTrack?.["artist-credit"]
260
260
+
?.map((a) => a.artist?.name)
261
261
+
.join(", ")}
115
262
releaseTitle={selectedTrack?.selectedRelease?.title}
116
263
/>
117
264
<Text className="text-sm text-gray-500 text-center mt-4">
+34
-7
apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx
···
1
1
import { ExternalLink } from "@/components/ExternalLink";
2
2
-
import { PlaySubmittedData } from "@/lib/oldStamp";
3
3
-
import { Stack, useLocalSearchParams } from "expo-router";
2
2
+
import { Stack, useRouter } from "expo-router";
4
3
import { Check, ExternalLinkIcon } from "lucide-react-native";
5
4
import { View } from "react-native";
6
5
import { Text } from "@/components/ui/text";
6
6
+
import { StampContext, StampContextValue, StampStep } from "./_layout";
7
7
+
import { useContext, useEffect } from "react";
8
8
+
import { Button } from "@/components/ui/button";
7
9
8
10
export default function StepThree() {
9
9
-
const { submittedData } = useLocalSearchParams();
10
10
-
const responseData: PlaySubmittedData = JSON.parse(submittedData as string);
11
11
+
const router = useRouter();
12
12
+
const ctx = useContext(StampContext);
13
13
+
const { state, setState } = ctx as StampContextValue;
14
14
+
// reset on unmount
15
15
+
useEffect(() => {
16
16
+
return () => {
17
17
+
setState({ step: StampStep.IDLE, resetSearchState: true });
18
18
+
};
19
19
+
}, [setState]);
20
20
+
if (state.step !== StampStep.SUBMITTED) {
21
21
+
console.log("Stamp state is not submitted!");
22
22
+
console.log(state.step);
23
23
+
return <Text>No track selected?</Text>;
24
24
+
}
11
25
return (
12
26
<View className="flex-1 p-4 bg-background items-center h-screen-safe">
13
27
<Stack.Screen
···
22
36
You can view your play{" "}
23
37
<ExternalLink
24
38
className="text-blue-600 dark:text-blue-400"
25
25
-
href={`https://pdsls.dev/${responseData.playAtUrl}`}
39
39
+
href={`https://pdsls.dev/${state.submittedStamp.playAtUrl}`}
26
40
>
27
41
on PDSls
28
42
</ExternalLink>
29
43
<ExternalLinkIcon className="inline mb-0.5 ml-0.5" size="1rem" />
30
44
</Text>
31
31
-
{responseData.blueskyPostUrl && (
45
45
+
{state.submittedStamp.blueskyPostUrl && (
32
46
<Text>
33
47
Or you can{" "}
34
48
<ExternalLink
35
49
className="text-blue-600 dark:text-blue-400"
36
36
-
href={`https://pdsls.dev/`}
50
50
+
href={state.submittedStamp.blueskyPostUrl}
37
51
>
38
52
view your Bluesky post.
39
53
</ExternalLink>
40
54
</Text>
41
55
)}
56
56
+
<Button
57
57
+
className="mt-2"
58
58
+
onPress={() => {
59
59
+
setState({ step: StampStep.IDLE, resetSearchState: true });
60
60
+
router.back();
61
61
+
// in case above doesn't work
62
62
+
router.replace({
63
63
+
pathname: "/stamp",
64
64
+
});
65
65
+
}}
66
66
+
>
67
67
+
<Text>Submit another</Text>
68
68
+
</Button>
42
69
</View>
43
70
</View>
44
71
);