Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto

refactor stamps flow to use built-in stack navigation

authored by Natalie and committed by Natalie B. fb4982a8 2c8c0290

Changed files
+610 -443
apps
amethyst
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }