+27
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
+27
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
···
1
+
import { Stack } from "expo-router";
2
+
import { useMemo } from "react";
3
+
4
+
const Layout = ({ segment }: { segment: string }) => {
5
+
const rootScreen = useMemo(() => {
6
+
switch (segment) {
7
+
case "(home)":
8
+
return (
9
+
<Stack.Screen
10
+
name="index"
11
+
options={{ title: "Home", headerShown: false }}
12
+
/>
13
+
);
14
+
case "(explore)":
15
+
return (
16
+
<Stack.Screen
17
+
name="explore"
18
+
options={{ title: "Explore", headerShown: false }}
19
+
/>
20
+
);
21
+
}
22
+
}, [segment]);
23
+
24
+
return <Stack>{rootScreen}</Stack>;
25
+
};
26
+
27
+
export default Layout;
+145
apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx
+145
apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx
···
1
+
import React, { useState } from "react";
2
+
import {
3
+
MusicBrainzRecording,
4
+
ReleaseSelections,
5
+
searchMusicbrainz,
6
+
SearchParams,
7
+
SearchResult,
8
+
} from "../../../../lib/oldStamp";
9
+
import { ScrollView, TextInput, View, Text } from "react-native";
10
+
import { Stack, useRouter } from "expo-router";
11
+
import { Button } from "@/components/ui/button";
12
+
import { FlatList } from "react-native";
13
+
import { ChevronRight } from "lucide-react-native";
14
+
15
+
export default function StepOne() {
16
+
const router = useRouter();
17
+
const [selectedTrack, setSelectedTrack] =
18
+
useState<MusicBrainzRecording | null>(null);
19
+
20
+
const [searchFields, setSearchFields] = useState<SearchParams>({
21
+
track: "",
22
+
artist: "",
23
+
release: "",
24
+
});
25
+
const [searchResults, setSearchResults] = useState<MusicBrainzRecording[]>(
26
+
[],
27
+
);
28
+
const [isLoading, setIsLoading] = useState<boolean>(false);
29
+
const [releaseSelections, setReleaseSelections] = useState<ReleaseSelections>(
30
+
{},
31
+
);
32
+
33
+
const handleSearch = async (): Promise<void> => {
34
+
if (!searchFields.track && !searchFields.artist && !searchFields.release) {
35
+
return;
36
+
}
37
+
38
+
setIsLoading(true);
39
+
setSelectedTrack(null);
40
+
const results = await searchMusicbrainz(searchFields);
41
+
setSearchResults(results);
42
+
setIsLoading(false);
43
+
};
44
+
45
+
const clearSearch = () => {
46
+
setSearchFields({ track: "", artist: "", release: "" });
47
+
setSearchResults([]);
48
+
setSelectedTrack(null);
49
+
};
50
+
51
+
return (
52
+
<ScrollView className="flex-1 p-4 bg-background items-center">
53
+
<Stack.Screen
54
+
options={{
55
+
title: "Stamp a play manually",
56
+
headerBackButtonDisplayMode: "generic",
57
+
}}
58
+
/>
59
+
{/* Search Form */}
60
+
<View className="flex gap-4 max-w-screen-md w-screen px-4">
61
+
<Text className="font-bold text-lg">Search for a track</Text>
62
+
<TextInput
63
+
className="p-2 border rounded-lg border-gray-300 bg-white"
64
+
placeholder="Track name..."
65
+
value={searchFields.track}
66
+
onChangeText={(text) =>
67
+
setSearchFields((prev) => ({ ...prev, track: text }))
68
+
}
69
+
/>
70
+
<TextInput
71
+
className="p-2 border rounded-lg border-gray-300 bg-white"
72
+
placeholder="Artist name..."
73
+
value={searchFields.artist}
74
+
onChangeText={(text) =>
75
+
setSearchFields((prev) => ({ ...prev, artist: text }))
76
+
}
77
+
/>
78
+
<View className="flex-row gap-2">
79
+
<Button
80
+
className="flex-1"
81
+
onPress={handleSearch}
82
+
disabled={
83
+
isLoading ||
84
+
(!searchFields.track &&
85
+
!searchFields.artist &&
86
+
!searchFields.release)
87
+
}
88
+
>
89
+
<Text>{isLoading ? "Searching..." : "Search"}</Text>
90
+
</Button>
91
+
<Button className="flex-1" onPress={clearSearch} variant="outline">
92
+
<Text>Clear</Text>
93
+
</Button>
94
+
</View>
95
+
</View>
96
+
97
+
{/* Search Results */}
98
+
<View className="flex gap-4 max-w-screen-md w-screen px-4">
99
+
{searchResults.length > 0 && (
100
+
<View className="mt-4">
101
+
<Text className="text-lg font-bold mb-2">
102
+
Search Results ({searchResults.length})
103
+
</Text>
104
+
<FlatList
105
+
data={searchResults}
106
+
renderItem={({ item }) => (
107
+
<SearchResult
108
+
result={item}
109
+
onSelectTrack={setSelectedTrack}
110
+
selectedRelease={releaseSelections[item.id]}
111
+
isSelected={selectedTrack?.id === item.id}
112
+
onReleaseSelect={(trackId, release) => {
113
+
setReleaseSelections((prev) => ({
114
+
...prev,
115
+
[trackId]: release,
116
+
}));
117
+
}}
118
+
/>
119
+
)}
120
+
keyExtractor={(item) => item.id}
121
+
/>
122
+
</View>
123
+
)}
124
+
125
+
{/* Submit Button */}
126
+
{selectedTrack && (
127
+
<View className="mt-4 sticky bottom-0">
128
+
<Button
129
+
onPress={() =>
130
+
router.push({
131
+
pathname: "/stamp/submit",
132
+
params: { track: JSON.stringify(selectedTrack) },
133
+
})
134
+
}
135
+
className="w-full flex flex-row align-middle"
136
+
>
137
+
<Text>{`Submit "${selectedTrack.title}" as Play`}</Text>
138
+
<ChevronRight className="ml-2 inline" />
139
+
</Button>
140
+
</View>
141
+
)}
142
+
</View>
143
+
</ScrollView>
144
+
);
145
+
}
+137
apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx
+137
apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx
···
1
+
import { useLocalSearchParams, useRouter } from "expo-router";
2
+
import { View, Text } from "react-native";
3
+
import {
4
+
MusicBrainzRecording,
5
+
PlaySubmittedData,
6
+
} from "../../../../lib/oldStamp";
7
+
import {
8
+
validateRecord,
9
+
Record as PlayRecord,
10
+
} from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
11
+
import { Button } from "@/components/ui/button";
12
+
import { useState } from "react";
13
+
import VerticalPlayView from "@/components/play/verticalPlayView";
14
+
import { Switch } from "react-native";
15
+
import { useStore } from "@/stores/mainStore";
16
+
import { ComAtprotoRepoCreateRecord } from "@atproto/api";
17
+
18
+
const createPlayRecord = (result: MusicBrainzRecording): PlayRecord => {
19
+
let artistNames: string[] = [];
20
+
if (result["artist-credit"]) {
21
+
artistNames = result["artist-credit"].map((a) => a.artist.name);
22
+
} else {
23
+
throw new Error("Artist must be specified!");
24
+
}
25
+
26
+
return {
27
+
trackName: result.title ?? "Unknown Title",
28
+
recordingMbId: result.id ?? undefined,
29
+
duration: result.length ? Math.floor(result.length / 1000) : undefined,
30
+
artistNames, // result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist",
31
+
artistMbIds: result["artist-credit"]?.map((a) => a.artist.id) ?? undefined,
32
+
releaseName: result.selectedRelease?.title ?? undefined,
33
+
releaseMbId: result.selectedRelease?.id ?? undefined,
34
+
isrc: result.isrcs?.[0] ?? undefined,
35
+
// not providing unless we have a way to map to tidal/odesli/etc
36
+
//originUrl: `https://tidal.com/browse/track/274816578?u`,
37
+
musicServiceBaseDomain: "tidal.com",
38
+
submissionClientAgent: "tealtracker/0.0.1b",
39
+
playedTime: new Date().toISOString(),
40
+
};
41
+
};
42
+
43
+
export default function Submit() {
44
+
const router = useRouter();
45
+
const agent = useStore((state) => state.pdsAgent);
46
+
// awful awful awful!
47
+
// I don't wanna use global state for something like this though!
48
+
const { track } = useLocalSearchParams();
49
+
50
+
const selectedTrack: MusicBrainzRecording | null = JSON.parse(
51
+
track as string,
52
+
);
53
+
54
+
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
55
+
const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false);
56
+
57
+
if (selectedTrack === null) {
58
+
return <Text>No track selected</Text>;
59
+
}
60
+
61
+
const handleSubmit = async () => {
62
+
setIsSubmitting(true);
63
+
try {
64
+
let record = createPlayRecord(selectedTrack);
65
+
let result = validateRecord(record);
66
+
if (result.success === false) {
67
+
throw new Error("Failed to validate play: " + result.error);
68
+
}
69
+
console.log("Validated play:", result);
70
+
const res = await agent?.call(
71
+
"com.atproto.repo.createRecord",
72
+
{},
73
+
{
74
+
repo: agent.did,
75
+
collection: "fm.teal.alpha.feed.play",
76
+
rkey: undefined,
77
+
record,
78
+
},
79
+
);
80
+
if (!res || res.success === false) {
81
+
throw new Error("Failed to submit play!");
82
+
}
83
+
const typed: ComAtprotoRepoCreateRecord.Response = res;
84
+
console.log("Play submitted successfully:", res);
85
+
let submittedData: PlaySubmittedData = {
86
+
playAtUrl: typed.data.uri,
87
+
playRecord: record,
88
+
blueskyPostUrl: null,
89
+
};
90
+
router.push({
91
+
pathname: "/stamp/success",
92
+
params: { submittedData: JSON.stringify(submittedData) },
93
+
});
94
+
} catch (error) {
95
+
console.error("Failed to submit play:", error);
96
+
}
97
+
setIsSubmitting(false);
98
+
};
99
+
100
+
return (
101
+
<View className="flex-1 p-4 bg-background items-center h-screen-safe">
102
+
<View className="flex justify-between align-middle gap-4 max-w-screen-md w-screen min-h-full px-4">
103
+
<Text className="font-bold text-lg">Submit Play</Text>
104
+
<VerticalPlayView
105
+
releaseMbid={selectedTrack?.selectedRelease?.id || ""}
106
+
trackTitle={
107
+
selectedTrack?.title ||
108
+
"No track selected! This should never happen!"
109
+
}
110
+
artistName={selectedTrack?.["artist-credit"]?.[0]?.artist?.name}
111
+
releaseTitle={selectedTrack?.selectedRelease?.title}
112
+
/>
113
+
114
+
<View className="flex-col gap-2 items-center">
115
+
<View className="flex-row gap-2 items-center">
116
+
<Switch
117
+
value={shareWithBluesky}
118
+
onValueChange={setShareWithBluesky}
119
+
/>
120
+
<Text className="text-lg text-gray-500 text-center">
121
+
Share with Bluesky?
122
+
</Text>
123
+
</View>
124
+
<View className="flex-row gap-2 w-full">
125
+
<Button
126
+
className="flex-1"
127
+
onPress={handleSubmit}
128
+
disabled={isSubmitting || selectedTrack === null}
129
+
>
130
+
<Text>{isSubmitting ? "Submitting..." : "Submit"}</Text>
131
+
</Button>
132
+
</View>
133
+
</View>
134
+
</View>
135
+
</View>
136
+
);
137
+
}
+39
apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx
+39
apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx
···
1
+
import { ExternalLink } from "@/components/ExternalLink";
2
+
import { PlaySubmittedData } from "@/lib/oldStamp";
3
+
import { useLocalSearchParams } from "expo-router";
4
+
import { Check, ExternalLinkIcon } from "lucide-react-native";
5
+
import { View, Text } from "react-native";
6
+
7
+
export default function StepThree() {
8
+
const { submittedData } = useLocalSearchParams();
9
+
const responseData: PlaySubmittedData = JSON.parse(submittedData as string);
10
+
return (
11
+
<View className="flex-1 p-4 bg-background items-center h-screen-safe">
12
+
<View className="flex justify-center items-center gap-2 max-w-screen-md w-screen min-h-full px-4">
13
+
<Check size={48} className="text-green-600 dark:text-green-400" />
14
+
<Text className="text-xl">Play Submitted!</Text>
15
+
<Text>
16
+
You can view your play{" "}
17
+
<ExternalLink
18
+
className="text-blue-600 dark:text-blue-400"
19
+
href={`https://pdsls.dev/${responseData.playAtUrl}`}
20
+
>
21
+
on PDSls
22
+
</ExternalLink>
23
+
<ExternalLinkIcon className="inline mb-0.5 ml-0.5" size="1rem" />
24
+
</Text>
25
+
{responseData.blueskyPostUrl && (
26
+
<Text>
27
+
Or you can{" "}
28
+
<ExternalLink
29
+
className="text-blue-600 dark:text-blue-400"
30
+
href={`https://pdsls.dev/`}
31
+
>
32
+
view your Bluesky post.
33
+
</ExternalLink>
34
+
</Text>
35
+
)}
36
+
</View>
37
+
</View>
38
+
);
39
+
}
+6
-13
apps/amethyst/app/(tabs)/_layout.tsx
+6
-13
apps/amethyst/app/(tabs)/_layout.tsx
···
1
1
import React from "react";
2
-
import {
3
-
FilePen,
4
-
Home,
5
-
Info,
6
-
LogOut,
7
-
type LucideIcon,
8
-
} from "lucide-react-native";
2
+
import { FilePen, Home, LogOut, type LucideIcon } from "lucide-react-native";
9
3
import { Link, Tabs } from "expo-router";
10
4
import { Pressable } from "react-native";
11
5
12
6
import Colors from "../../constants/Colors";
13
7
import { useColorScheme } from "../../components/useColorScheme";
14
-
import { useClientOnlyValue } from "../../components/useClientOnlyValue";
15
8
import { Icon, iconWithClassName } from "../../lib/icons/iconWithClassName";
16
9
import useIsMobile from "@/hooks/useIsMobile";
17
10
import { useStore } from "@/stores/mainStore";
···
35
28
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
36
29
// Disable the static render of the header on web
37
30
// to prevent a hydration error in React Navigation v6.
38
-
headerShown: useClientOnlyValue(false, true),
39
-
tabBarShowLabel: false,
31
+
headerShown: false, // useClientOnlyValue(false, true),
32
+
tabBarShowLabel: true,
40
33
tabBarStyle: {
41
-
height: 75,
34
+
//height: 75,
42
35
display: hideTabBar ? "none" : "flex",
43
36
},
44
37
}}
···
64
57
}}
65
58
/>
66
59
<Tabs.Screen
67
-
name="button"
60
+
name="(stamp)"
68
61
options={{
69
-
title: "Tab Two",
62
+
title: "Stamp",
70
63
tabBarIcon: ({ color }) => (
71
64
<TabBarIcon name={FilePen} color={color} />
72
65
),
-430
apps/amethyst/app/(tabs)/stamp.tsx
-430
apps/amethyst/app/(tabs)/stamp.tsx
···
1
-
import {
2
-
View,
3
-
TextInput,
4
-
ScrollView,
5
-
TouchableOpacity,
6
-
FlatList,
7
-
Image,
8
-
Modal,
9
-
} from "react-native";
10
-
import { useState } from "react";
11
-
import { useStore } from "../../stores/mainStore";
12
-
import { Button } from "../../components/ui/button";
13
-
import { Text } from "../../components/ui/text";
14
-
import { validateRecord } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
15
-
import { Icon } from "@/lib/icons/iconWithClassName";
16
-
import { Brain, Check } from "lucide-react-native";
17
-
import { Link, Stack } from "expo-router";
18
-
import React from "react";
19
-
20
-
// MusicBrainz API Types
21
-
interface MusicBrainzArtistCredit {
22
-
artist: {
23
-
id: string;
24
-
name: string;
25
-
"sort-name"?: string;
26
-
};
27
-
joinphrase?: string;
28
-
name: string;
29
-
}
30
-
31
-
interface MusicBrainzRelease {
32
-
id: string;
33
-
title: string;
34
-
status?: string;
35
-
date?: string;
36
-
country?: string;
37
-
disambiguation?: string;
38
-
"track-count"?: number;
39
-
}
40
-
41
-
interface MusicBrainzRecording {
42
-
id: string;
43
-
title: string;
44
-
length?: number;
45
-
isrcs?: string[];
46
-
"artist-credit"?: MusicBrainzArtistCredit[];
47
-
releases?: MusicBrainzRelease[];
48
-
selectedRelease?: MusicBrainzRelease; // Added for UI state
49
-
}
50
-
51
-
interface SearchParams {
52
-
track?: string;
53
-
artist?: string;
54
-
release?: string;
55
-
}
56
-
57
-
interface SearchResultProps {
58
-
result: MusicBrainzRecording;
59
-
onSelectTrack: (track: MusicBrainzRecording | null) => void;
60
-
isSelected: boolean;
61
-
selectedRelease: MusicBrainzRelease | null;
62
-
onReleaseSelect: (trackId: string, release: MusicBrainzRelease) => void;
63
-
}
64
-
65
-
interface PlayRecord {
66
-
trackName: string;
67
-
recordingMbId?: string;
68
-
duration?: number;
69
-
artistName: string;
70
-
artistMbIds?: string[];
71
-
releaseName?: string;
72
-
releaseMbId?: string;
73
-
isrc?: string;
74
-
originUrl: string;
75
-
musicServiceBaseDomain: string;
76
-
submissionClientAgent: string;
77
-
playedTime: string;
78
-
}
79
-
80
-
interface ReleaseSelections {
81
-
[key: string]: MusicBrainzRelease;
82
-
}
83
-
84
-
async function searchMusicbrainz(
85
-
searchParams: SearchParams,
86
-
): Promise<MusicBrainzRecording[]> {
87
-
try {
88
-
const queryParts: string[] = [];
89
-
if (searchParams.track)
90
-
queryParts.push(`release title:"${searchParams.track}"`);
91
-
if (searchParams.artist)
92
-
queryParts.push(`AND artist:"${searchParams.artist}"`);
93
-
94
-
const query = queryParts.join(" AND ");
95
-
96
-
const res = await fetch(
97
-
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(
98
-
query,
99
-
)}&fmt=json`,
100
-
);
101
-
const data = await res.json();
102
-
return data.recordings || [];
103
-
} catch (error) {
104
-
console.error("Failed to fetch MusicBrainz data:", error);
105
-
return [];
106
-
}
107
-
}
108
-
109
-
const SearchResult: React.FC<SearchResultProps> = ({
110
-
result,
111
-
onSelectTrack,
112
-
isSelected,
113
-
selectedRelease,
114
-
onReleaseSelect,
115
-
}) => {
116
-
const [showReleaseModal, setShowReleaseModal] = useState<boolean>(false);
117
-
118
-
const currentRelease = selectedRelease || result.releases?.[0];
119
-
120
-
return (
121
-
<TouchableOpacity
122
-
onPress={() => {
123
-
onSelectTrack(
124
-
isSelected
125
-
? null
126
-
: {
127
-
...result,
128
-
selectedRelease: currentRelease, // Pass the selected release with the track
129
-
},
130
-
);
131
-
}}
132
-
className={`p-4 mb-2 rounded-lg ${
133
-
isSelected ? "bg-primary/20" : "bg-secondary/10"
134
-
}`}
135
-
>
136
-
<View className="flex-row justify-between items-center gap-2">
137
-
<Image
138
-
className="w-16 h-16 rounded-lg bg-gray-500/50"
139
-
source={{
140
-
uri: `https://coverartarchive.org/release/${currentRelease?.id}/front-250`,
141
-
}}
142
-
/>
143
-
<View className="flex-1">
144
-
<Text className="font-bold">{result.title}</Text>
145
-
<Text className="text-sm text-gray-600">
146
-
{result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist"}
147
-
</Text>
148
-
149
-
{/* Release Selector Button */}
150
-
{result.releases && result.releases?.length > 0 && (
151
-
<TouchableOpacity
152
-
onPress={() => setShowReleaseModal(true)}
153
-
className="p-1 bg-secondary/10 rounded-lg flex md:flex-row items-start md:gap-1"
154
-
>
155
-
<Text className="text-sm text-gray-500">Release:</Text>
156
-
<Text className="text-sm" numberOfLines={1}>
157
-
{currentRelease?.title}
158
-
{currentRelease?.date ? ` (${currentRelease.date})` : ""}
159
-
{currentRelease?.country ? ` - ${currentRelease.country}` : ""}
160
-
</Text>
161
-
</TouchableOpacity>
162
-
)}
163
-
</View>
164
-
{/* Existing icons */}
165
-
<Link href={`https://musicbrainz.org/recording/${result.id}`}>
166
-
<View className="bg-primary/40 rounded-full p-1">
167
-
<Icon icon={Brain} size={20} />
168
-
</View>
169
-
</Link>
170
-
{isSelected ? (
171
-
<View className="bg-primary rounded-full p-1">
172
-
<Icon icon={Check} size={20} />
173
-
</View>
174
-
) : (
175
-
<View className="border-2 border-secondary rounded-full p-3"></View>
176
-
)}
177
-
</View>
178
-
179
-
{/* Release Selection Modal */}
180
-
<Modal
181
-
visible={showReleaseModal}
182
-
transparent={true}
183
-
animationType="slide"
184
-
onRequestClose={() => setShowReleaseModal(false)}
185
-
>
186
-
<View className="flex-1 justify-end bg-black/50">
187
-
<View className="bg-background rounded-t-3xl">
188
-
<View className="p-4 border-b border-gray-200">
189
-
<Text className="text-lg font-bold text-center">
190
-
Select Release
191
-
</Text>
192
-
<TouchableOpacity
193
-
className="absolute right-4 top-4"
194
-
onPress={() => setShowReleaseModal(false)}
195
-
>
196
-
<Text className="text-primary">Done</Text>
197
-
</TouchableOpacity>
198
-
</View>
199
-
200
-
<ScrollView className="max-h-[50vh]">
201
-
{result.releases?.map((release) => (
202
-
<TouchableOpacity
203
-
key={release.id}
204
-
className={`p-4 border-b border-gray-100 ${
205
-
selectedRelease?.id === release.id ? "bg-primary/10" : ""
206
-
}`}
207
-
onPress={() => {
208
-
onReleaseSelect(result.id, release);
209
-
setShowReleaseModal(false);
210
-
}}
211
-
>
212
-
<Text className="font-medium">{release.title}</Text>
213
-
<View className="flex-row gap-2">
214
-
{release.date && (
215
-
<Text className="text-sm text-gray-500">
216
-
{release.date}
217
-
</Text>
218
-
)}
219
-
{release.country && (
220
-
<Text className="text-sm text-gray-500">
221
-
{release.country}
222
-
</Text>
223
-
)}
224
-
{release.status && (
225
-
<Text className="text-sm text-gray-500">
226
-
{release.status}
227
-
</Text>
228
-
)}
229
-
</View>
230
-
{release.disambiguation && (
231
-
<Text className="text-sm text-gray-400 italic">
232
-
{release.disambiguation}
233
-
</Text>
234
-
)}
235
-
</TouchableOpacity>
236
-
))}
237
-
</ScrollView>
238
-
</View>
239
-
</View>
240
-
</Modal>
241
-
</TouchableOpacity>
242
-
);
243
-
};
244
-
245
-
export default function TabTwoScreen() {
246
-
const agent = useStore((state) => state.pdsAgent);
247
-
const [searchFields, setSearchFields] = useState<SearchParams>({
248
-
track: "",
249
-
artist: "",
250
-
release: "",
251
-
});
252
-
const [searchResults, setSearchResults] = useState<MusicBrainzRecording[]>(
253
-
[],
254
-
);
255
-
const [selectedTrack, setSelectedTrack] =
256
-
useState<MusicBrainzRecording | null>(null);
257
-
const [isLoading, setIsLoading] = useState<boolean>(false);
258
-
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
259
-
const [releaseSelections, setReleaseSelections] = useState<ReleaseSelections>(
260
-
{},
261
-
);
262
-
263
-
const handleTrackSelect = (track: MusicBrainzRecording | null): void => {
264
-
setSelectedTrack(track);
265
-
};
266
-
267
-
const handleSearch = async (): Promise<void> => {
268
-
if (!searchFields.track && !searchFields.artist && !searchFields.release) {
269
-
return;
270
-
}
271
-
272
-
setIsLoading(true);
273
-
setSelectedTrack(null);
274
-
const results = await searchMusicbrainz(searchFields);
275
-
setSearchResults(results);
276
-
setIsLoading(false);
277
-
};
278
-
279
-
const createPlayRecord = (result: MusicBrainzRecording): PlayRecord => {
280
-
return {
281
-
trackName: result.title ?? "Unknown Title",
282
-
recordingMbId: result.id ?? undefined,
283
-
duration: result.length ? Math.floor(result.length / 1000) : undefined,
284
-
artistName:
285
-
result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist",
286
-
artistMbIds: result["artist-credit"]?.[0]?.artist?.id
287
-
? [result["artist-credit"][0].artist.id]
288
-
: undefined,
289
-
releaseName: result.selectedRelease?.title ?? undefined,
290
-
releaseMbId: result.selectedRelease?.id ?? undefined,
291
-
isrc: result.isrcs?.[0] ?? undefined,
292
-
originUrl: `https://tidal.com/browse/track/274816578?u`,
293
-
musicServiceBaseDomain: "tidal.com",
294
-
submissionClientAgent: "tealtracker/0.0.1b",
295
-
playedTime: new Date().toISOString(),
296
-
};
297
-
};
298
-
299
-
const submitPlay = async (): Promise<void> => {
300
-
if (!selectedTrack) return;
301
-
302
-
setIsSubmitting(true);
303
-
const play = createPlayRecord(selectedTrack);
304
-
305
-
try {
306
-
let result = validateRecord(play);
307
-
console.log("Validated play:", result);
308
-
const res = await agent?.call(
309
-
"com.atproto.repo.createRecord",
310
-
{},
311
-
{
312
-
repo: agent.did,
313
-
collection: "fm.teal.alpha.feed.play",
314
-
rkey: undefined,
315
-
record: play,
316
-
},
317
-
);
318
-
console.log("Play submitted successfully:", res);
319
-
// Reset after successful submission
320
-
setSelectedTrack(null);
321
-
setSearchResults([]);
322
-
setSearchFields({ track: "", artist: "", release: "" });
323
-
} catch (error) {
324
-
console.error("Failed to submit play:", error);
325
-
} finally {
326
-
setIsSubmitting(false);
327
-
}
328
-
};
329
-
330
-
const clearSearch = () => {
331
-
setSearchFields({ track: "", artist: "", release: "" });
332
-
setSearchResults([]);
333
-
setSelectedTrack(null);
334
-
};
335
-
336
-
return (
337
-
<ScrollView className="flex-1 p-4 bg-background items-center">
338
-
<Stack.Screen
339
-
options={{
340
-
title: "Home",
341
-
headerBackButtonDisplayMode: "minimal",
342
-
headerShown: false,
343
-
}}
344
-
/>
345
-
{/* Search Form */}
346
-
<View className="flex gap-4 max-w-screen-md w-screen px-4">
347
-
<Text className="font-bold text-lg">Search for a track</Text>
348
-
<TextInput
349
-
className="p-2 border rounded-lg border-gray-300 bg-white"
350
-
placeholder="Track name..."
351
-
value={searchFields.track}
352
-
onChangeText={(text) =>
353
-
setSearchFields((prev) => ({ ...prev, track: text }))
354
-
}
355
-
/>
356
-
<TextInput
357
-
className="p-2 border rounded-lg border-gray-300 bg-white"
358
-
placeholder="Artist name..."
359
-
value={searchFields.artist}
360
-
onChangeText={(text) =>
361
-
setSearchFields((prev) => ({ ...prev, artist: text }))
362
-
}
363
-
/>
364
-
<View className="flex-row gap-2">
365
-
<Button
366
-
className="flex-1"
367
-
onPress={handleSearch}
368
-
disabled={
369
-
isLoading ||
370
-
(!searchFields.track &&
371
-
!searchFields.artist &&
372
-
!searchFields.release)
373
-
}
374
-
>
375
-
<Text>{isLoading ? "Searching..." : "Search"}</Text>
376
-
</Button>
377
-
<Button className="flex-1" onPress={clearSearch} variant="outline">
378
-
<Text>Clear</Text>
379
-
</Button>
380
-
</View>
381
-
</View>
382
-
383
-
{/* Search Results */}
384
-
<View className="flex gap-4 max-w-screen-md w-screen px-4">
385
-
{searchResults.length > 0 && (
386
-
<View className="mt-4">
387
-
<Text className="text-lg font-bold mb-2">
388
-
Search Results ({searchResults.length})
389
-
</Text>
390
-
<FlatList
391
-
data={searchResults}
392
-
renderItem={({ item }) => (
393
-
<SearchResult
394
-
result={item}
395
-
onSelectTrack={handleTrackSelect}
396
-
isSelected={selectedTrack?.id === item.id}
397
-
selectedRelease={releaseSelections[item.id]}
398
-
onReleaseSelect={(trackId, release) => {
399
-
setReleaseSelections((prev) => ({
400
-
...prev,
401
-
[trackId]: release,
402
-
}));
403
-
}}
404
-
/>
405
-
)}
406
-
keyExtractor={(item) => item.id}
407
-
/>
408
-
</View>
409
-
)}
410
-
411
-
{/* Submit Button */}
412
-
{selectedTrack && (
413
-
<View className="mt-4 sticky bottom-0">
414
-
<Button
415
-
onPress={submitPlay}
416
-
disabled={isSubmitting}
417
-
className="w-full"
418
-
>
419
-
<Text>
420
-
{isSubmitting
421
-
? "Submitting..."
422
-
: `Submit "${selectedTrack.title}" as Play`}
423
-
</Text>
424
-
</Button>
425
-
</View>
426
-
)}
427
-
</View>
428
-
</ScrollView>
429
-
);
430
-
}
+33
apps/amethyst/components/play/verticalPlayView.tsx
+33
apps/amethyst/components/play/verticalPlayView.tsx
···
1
+
import { View, Image, Text } from "react-native";
2
+
3
+
export default function VerticalPlayView({
4
+
releaseMbid,
5
+
trackTitle,
6
+
artistName,
7
+
releaseTitle,
8
+
}: {
9
+
releaseMbid: string;
10
+
trackTitle: string;
11
+
artistName?: string;
12
+
releaseTitle?: string;
13
+
}) {
14
+
return (
15
+
<View className="flex flex-col items-center">
16
+
<Image
17
+
className="w-48 h-48 rounded-lg bg-gray-500/50 mb-2"
18
+
source={{
19
+
uri: `https://coverartarchive.org/release/${releaseMbid}/front-250`,
20
+
}}
21
+
/>
22
+
<Text className="text-xl text-center">{trackTitle}</Text>
23
+
{artistName && (
24
+
<Text className="text-lg text-gray-500 text-center">{artistName}</Text>
25
+
)}
26
+
{releaseTitle && (
27
+
<Text className="text-lg text-gray-500 text-center">
28
+
{releaseTitle}
29
+
</Text>
30
+
)}
31
+
</View>
32
+
);
33
+
}
+223
apps/amethyst/lib/oldStamp.tsx
+223
apps/amethyst/lib/oldStamp.tsx
···
1
+
import { View, ScrollView, TouchableOpacity, Image, Modal } from "react-native";
2
+
import { useState } from "react";
3
+
import { Text } from "../components/ui/text";
4
+
import { Icon } from "@/lib/icons/iconWithClassName";
5
+
import { Check } from "lucide-react-native";
6
+
import { Record as PlayRecord } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
7
+
import React from "react";
8
+
9
+
// MusicBrainz API Types
10
+
export interface MusicBrainzArtistCredit {
11
+
artist: {
12
+
id: string;
13
+
name: string;
14
+
"sort-name"?: string;
15
+
};
16
+
joinphrase?: string;
17
+
name: string;
18
+
}
19
+
20
+
export interface MusicBrainzRelease {
21
+
id: string;
22
+
title: string;
23
+
status?: string;
24
+
date?: string;
25
+
country?: string;
26
+
disambiguation?: string;
27
+
"track-count"?: number;
28
+
}
29
+
30
+
export interface MusicBrainzRecording {
31
+
id: string;
32
+
title: string;
33
+
length?: number;
34
+
isrcs?: string[];
35
+
"artist-credit"?: MusicBrainzArtistCredit[];
36
+
releases?: MusicBrainzRelease[];
37
+
selectedRelease?: MusicBrainzRelease; // Added for UI state
38
+
}
39
+
40
+
export interface SearchParams {
41
+
track?: string;
42
+
artist?: string;
43
+
release?: string;
44
+
}
45
+
46
+
export interface SearchResultProps {
47
+
result: MusicBrainzRecording;
48
+
onSelectTrack: (track: MusicBrainzRecording | null) => void;
49
+
isSelected: boolean;
50
+
selectedRelease: MusicBrainzRelease | null;
51
+
onReleaseSelect: (trackId: string, release: MusicBrainzRelease) => void;
52
+
}
53
+
54
+
export interface ReleaseSelections {
55
+
[key: string]: MusicBrainzRelease;
56
+
}
57
+
58
+
export interface PlaySubmittedData {
59
+
playRecord: PlayRecord | null;
60
+
playAtUrl: string | null;
61
+
blueskyPostUrl: string | null;
62
+
}
63
+
64
+
export async function searchMusicbrainz(
65
+
searchParams: SearchParams,
66
+
): Promise<MusicBrainzRecording[]> {
67
+
try {
68
+
const queryParts: string[] = [];
69
+
if (searchParams.track)
70
+
queryParts.push(`release title:"${searchParams.track}"`);
71
+
if (searchParams.artist)
72
+
queryParts.push(`AND artist:"${searchParams.artist}"`);
73
+
74
+
const query = queryParts.join(" AND ");
75
+
76
+
const res = await fetch(
77
+
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(
78
+
query,
79
+
)}&fmt=json`,
80
+
);
81
+
const data = await res.json();
82
+
return data.recordings || [];
83
+
} catch (error) {
84
+
console.error("Failed to fetch MusicBrainz data:", error);
85
+
return [];
86
+
}
87
+
}
88
+
89
+
export function SearchResult({
90
+
result,
91
+
onSelectTrack,
92
+
isSelected,
93
+
selectedRelease,
94
+
onReleaseSelect,
95
+
}: SearchResultProps) {
96
+
const [showReleaseModal, setShowReleaseModal] = useState<boolean>(false);
97
+
98
+
const currentRelease = selectedRelease || result.releases?.[0];
99
+
100
+
return (
101
+
<TouchableOpacity
102
+
onPress={() => {
103
+
onSelectTrack(
104
+
isSelected
105
+
? null
106
+
: {
107
+
...result,
108
+
selectedRelease: currentRelease, // Pass the selected release with the track
109
+
},
110
+
);
111
+
}}
112
+
className={`p-4 mb-2 rounded-lg ${
113
+
isSelected ? "bg-primary/20" : "bg-secondary/10"
114
+
}`}
115
+
>
116
+
<View className="flex-row justify-between items-center gap-2">
117
+
<Image
118
+
className="w-16 h-16 rounded-lg bg-gray-500/50"
119
+
source={{
120
+
uri: `https://coverartarchive.org/release/${currentRelease?.id}/front-250`,
121
+
}}
122
+
/>
123
+
<View className="flex-1">
124
+
<Text className="font-bold text-sm">{result.title}</Text>
125
+
<Text className="text-sm text-gray-600">
126
+
{result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist"}
127
+
</Text>
128
+
129
+
{/* Release Selector Button */}
130
+
{result.releases && result.releases?.length > 0 && (
131
+
<TouchableOpacity
132
+
onPress={() => setShowReleaseModal(true)}
133
+
className="p-1 bg-secondary/10 rounded-lg flex md:flex-row items-start md:gap-1"
134
+
>
135
+
<Text className="text-sm text-gray-500">Release:</Text>
136
+
<Text className="text-sm" numberOfLines={1}>
137
+
{currentRelease?.title}
138
+
{currentRelease?.date ? ` (${currentRelease.date})` : ""}
139
+
{currentRelease?.country ? ` - ${currentRelease.country}` : ""}
140
+
</Text>
141
+
</TouchableOpacity>
142
+
)}
143
+
</View>
144
+
{/* Existing icons */}
145
+
{/* <Link href={`https://musicbrainz.org/recording/${result.id}`}>
146
+
<View className="bg-primary/40 rounded-full p-1">
147
+
<Icon icon={Brain} size={20} />
148
+
</View>
149
+
</Link> */}
150
+
{isSelected ? (
151
+
<View className="bg-primary rounded-full p-1">
152
+
<Icon icon={Check} size={20} />
153
+
</View>
154
+
) : (
155
+
<View className="border-2 border-secondary rounded-full p-3"></View>
156
+
)}
157
+
</View>
158
+
159
+
{/* Release Selection Modal */}
160
+
<Modal
161
+
visible={showReleaseModal}
162
+
transparent={true}
163
+
animationType="slide"
164
+
onRequestClose={() => setShowReleaseModal(false)}
165
+
>
166
+
<View className="flex-1 justify-end bg-black/50">
167
+
<View className="bg-background rounded-t-3xl">
168
+
<View className="p-4 border-b border-gray-200">
169
+
<Text className="text-lg font-bold text-center">
170
+
Select Release
171
+
</Text>
172
+
<TouchableOpacity
173
+
className="absolute right-4 top-4"
174
+
onPress={() => setShowReleaseModal(false)}
175
+
>
176
+
<Text className="text-primary">Done</Text>
177
+
</TouchableOpacity>
178
+
</View>
179
+
180
+
<ScrollView className="max-h-[50vh]">
181
+
{result.releases?.map((release) => (
182
+
<TouchableOpacity
183
+
key={release.id}
184
+
className={`p-4 border-b border-gray-100 ${
185
+
selectedRelease?.id === release.id ? "bg-primary/10" : ""
186
+
}`}
187
+
onPress={() => {
188
+
onReleaseSelect(result.id, release);
189
+
setShowReleaseModal(false);
190
+
}}
191
+
>
192
+
<Text className="font-medium">{release.title}</Text>
193
+
<View className="flex-row gap-2">
194
+
{release.date && (
195
+
<Text className="text-sm text-gray-500">
196
+
{release.date}
197
+
</Text>
198
+
)}
199
+
{release.country && (
200
+
<Text className="text-sm text-gray-500">
201
+
{release.country}
202
+
</Text>
203
+
)}
204
+
{release.status && (
205
+
<Text className="text-sm text-gray-500">
206
+
{release.status}
207
+
</Text>
208
+
)}
209
+
</View>
210
+
{release.disambiguation && (
211
+
<Text className="text-sm text-gray-400 italic">
212
+
{release.disambiguation}
213
+
</Text>
214
+
)}
215
+
</TouchableOpacity>
216
+
))}
217
+
</ScrollView>
218
+
</View>
219
+
</View>
220
+
</Modal>
221
+
</TouchableOpacity>
222
+
);
223
+
}