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

add local react ctx for manual stamping flow

+256 -28
+1 -1
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
··· 21 21 } 22 22 }, [segment]); 23 23 24 - return <Stack>{rootScreen}</Stack>; 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 + import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp"; 2 + import { Slot, Stack } from "expo-router"; 3 + import { createContext, useState } from "react"; 4 + 5 + export enum StampStep { 6 + IDLE = "IDLE", 7 + SUBMITTING = "SUBMITTING", 8 + SUBMITTED = "SUBMITTED", 9 + } 10 + 11 + export type StampContextState = 12 + | { step: StampStep.IDLE; resetSearchState: boolean } 13 + | { step: StampStep.SUBMITTING; submittingStamp: MusicBrainzRecording } 14 + | { step: StampStep.SUBMITTED; submittedStamp: PlaySubmittedData }; 15 + 16 + export type StampContextValue = { 17 + state: StampContextState; 18 + setState: React.Dispatch<React.SetStateAction<StampContextState>>; 19 + }; 20 + 21 + export const StampContext = createContext<StampContextValue | null>(null); 22 + 23 + const Layout = ({ segment }: { segment: string }) => { 24 + const [state, setState] = useState<StampContextState>({ 25 + step: StampStep.IDLE, 26 + resetSearchState: false, 27 + }); 28 + return ( 29 + <StampContext.Provider value={{ state, setState }}> 30 + <Stack> 31 + <Slot /> 32 + </Stack> 33 + </StampContext.Provider> 34 + ); 35 + }; 36 + 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 - import React, { useRef, useState } from "react"; 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 - import SheetBackdrop from "@/components/ui/sheetBackdrop"; 25 + import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop"; 26 + import { StampContext, StampContextValue, StampStep } from "./_layout"; 26 27 27 28 export default function StepOne() { 28 29 const router = useRouter(); 30 + const ctx = useContext(StampContext); 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 + 48 + // reset search state if requested 49 + useEffect(() => { 50 + if (state.step === StampStep.IDLE && state.resetSearchState) { 51 + setSearchFields({ track: "", artist: "", release: "" }); 52 + setSearchResults([]); 53 + setSelectedTrack(null); 54 + setReleaseSelections({}); 55 + } 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 - onPress={() => 164 + onPress={() => { 165 + setState({ 166 + step: StampStep.SUBMITTING, 167 + submittingStamp: selectedTrack, 168 + }); 152 169 router.push({ 153 170 pathname: "/stamp/submit", 154 - params: { track: JSON.stringify(selectedTrack) }, 155 - }) 156 - } 171 + }); 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 + handleComponent={SheetHandle} 261 278 > 262 - <View className="pb-4 border-b border-gray-200 -mt-2"> 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 - <BottomSheetScrollView className="bg-card min-h-64"> 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 - import { ComAtprotoRepoCreateRecord } from "@atproto/api"; 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 - import { Stack, useLocalSearchParams, useRouter } from "expo-router"; 10 - import { useState } from "react"; 9 + import { Stack, useRouter } from "expo-router"; 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 + import { StampContext, StampContextValue, StampStep } from "./_layout"; 16 + 17 + type CardyBResponse = { 18 + error: string; 19 + likely_type: string; 20 + url: string; 21 + title: string; 22 + description: string; 23 + image: string; 24 + }; 25 + // call CardyB API to get embed card 26 + const getUrlMetadata = async (url: string): Promise<CardyBResponse> => { 27 + const response = await fetch(`https://cardyb.bsky.app/v1/extract?url=${url}`); 28 + if (response.status === 200) { 29 + return await response.json(); 30 + } else { 31 + throw new Error("Failed to fetch metadata from CardyB"); 32 + } 33 + }; 34 + 35 + const getBlueskyEmbedCard = async ( 36 + url: string | undefined, 37 + agent: Agent, 38 + customUrl?: string, 39 + customTitle?: string, 40 + customDescription?: string, 41 + ) => { 42 + if (!url) return; 43 + 44 + try { 45 + const metadata = await getUrlMetadata(url); 46 + const blob = await fetch(metadata.image).then((r) => r.blob()); 47 + const { data } = await agent.uploadBlob(blob, { encoding: "image/jpeg" }); 48 + 49 + return { 50 + $type: "app.bsky.embed.external", 51 + external: { 52 + uri: customUrl || metadata.url, 53 + title: customTitle || metadata.title, 54 + description: customDescription || metadata.description, 55 + thumb: data.blob, 56 + }, 57 + }; 58 + } catch (error) { 59 + console.error("Error fetching embed card:", error); 60 + return; 61 + } 62 + }; 63 + interface EmbedInfo { 64 + urlEmbed: string; 65 + customUrl: string; 66 + } 67 + const getEmbedInfo = async (mbid: string): Promise<EmbedInfo | null> => { 68 + let appleMusicResponse = await fetch( 69 + `https://labs.api.listenbrainz.org/apple-music-id-from-mbid/json?recording_mbid=${mbid}`, 70 + ); 71 + if (appleMusicResponse.status === 200) { 72 + const appleMusicData = await appleMusicResponse.json(); 73 + console.log("Apple Music data:", appleMusicData); 74 + if (appleMusicData[0].apple_music_track_ids.length > 0) { 75 + let trackId = appleMusicData[0].apple_music_track_ids[0]; 76 + return { 77 + urlEmbed: `https://music.apple.com/us/song/its-not-living-if-its-not-with-you/${trackId}`, 78 + customUrl: `https://song.link/i/${trackId}`, 79 + }; 80 + } else { 81 + let spotifyResponse = await fetch( 82 + `https://labs.api.listenbrainz.org/spotify-id-from-mbid/json?recording_mbid=${mbid}`, 83 + ); 84 + if (spotifyResponse.status === 200) { 85 + const spotifyData = await spotifyResponse.json(); 86 + console.log("Spotify data:", spotifyData); 87 + if (spotifyData[0].spotify_track_ids.length > 0) { 88 + let trackId = spotifyData[0].spotify_track_ids[0]; 89 + return { 90 + urlEmbed: `https://open.spotify.com/track/${trackId}`, 91 + customUrl: `https://song.link/s/${trackId}`, 92 + }; 93 + } 94 + } 95 + } 96 + } 97 + return null; 98 + }; 99 + 100 + const ms2hms = (ms: number): string => { 101 + let seconds = Math.floor(ms / 1000); 102 + let minutes = Math.floor(seconds / 60); 103 + seconds = seconds % 60; 104 + minutes = minutes % 60; 105 + return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; 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 - // awful awful awful! 45 - // I don't wanna use global state for something like this though! 46 - const { track } = useLocalSearchParams(); 47 - 48 - const selectedTrack: MusicBrainzRecording | null = JSON.parse( 49 - track as string, 50 - ); 136 + const ctx = useContext(StampContext); 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 + if (state.step !== StampStep.SUBMITTING) { 143 + console.log("Stamp step is not SUBMITTING"); 144 + console.log(state); 145 + return <Text>No track selected?</Text>; 146 + //return <Redirect href="/stamp" />; 147 + } 148 + 149 + const selectedTrack = state.submittingStamp; 150 + 55 151 if (selectedTrack === null) { 56 152 return <Text>No track selected</Text>; 57 153 } 58 154 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 + if (shareWithBluesky && agent) { 186 + // lol this type 187 + const rt = new RichText({ 188 + text: `💮 now playing: 189 + ${record.trackName} by ${record.artistNames.join(", ")} 190 + 191 + powered by @teal.fm`, 192 + }); 193 + await rt.detectFacets(agent); 194 + // get metadata from Apple if available 195 + // https://labs.api.listenbrainz.org/apple-music-id-from-mbid/json?recording_mbid=81c3eb6e-d8f4-423c-9007-694aefe62754 196 + // https://music.apple.com/us/album/i-always-wanna-die-sometimes/1435546528?i=1435546783 197 + let embedInfo = await getEmbedInfo(selectedTrack.id); 198 + let urlEmbed: string | undefined = embedInfo?.urlEmbed; 199 + let customUrl: string | undefined = embedInfo?.customUrl; 200 + 201 + let releaseYear = selectedTrack.selectedRelease?.date?.split("-")[0]; 202 + let title = `${record.trackName} by ${record.artistNames.join(", ")}`; 203 + let description = `Song${releaseYear && " · "}${releaseYear} · ${ 204 + selectedTrack.length && " · " + ms2hms(selectedTrack.length) 205 + }`; 206 + 207 + console.log(urlEmbed, customUrl, title, description); 208 + 209 + console.log( 210 + await getBlueskyEmbedCard( 211 + urlEmbed, 212 + agent, 213 + customUrl, 214 + title, 215 + description, 216 + ), 217 + ); 218 + 219 + const post = await agent.post({ 220 + text: rt.text, 221 + facets: rt.facets, 222 + embed: urlEmbed 223 + ? await getBlueskyEmbedCard(urlEmbed, agent) 224 + : undefined, 225 + }); 226 + submittedData.blueskyPostUrl = post.uri; 227 + } 228 + setState({ 229 + step: StampStep.SUBMITTED, 230 + submittedStamp: submittedData, 231 + }); 232 + // wait for state updates 233 + await Promise.resolve(); 88 234 router.replace({ 89 235 pathname: "/stamp/success", 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 - artistName={selectedTrack?.["artist-credit"]?.[0]?.artist?.name} 259 + artistName={selectedTrack?.["artist-credit"] 260 + ?.map((a) => a.artist?.name) 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 - import { PlaySubmittedData } from "@/lib/oldStamp"; 3 - import { Stack, useLocalSearchParams } from "expo-router"; 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 + import { StampContext, StampContextValue, StampStep } from "./_layout"; 7 + import { useContext, useEffect } from "react"; 8 + import { Button } from "@/components/ui/button"; 7 9 8 10 export default function StepThree() { 9 - const { submittedData } = useLocalSearchParams(); 10 - const responseData: PlaySubmittedData = JSON.parse(submittedData as string); 11 + const router = useRouter(); 12 + const ctx = useContext(StampContext); 13 + const { state, setState } = ctx as StampContextValue; 14 + // reset on unmount 15 + useEffect(() => { 16 + return () => { 17 + setState({ step: StampStep.IDLE, resetSearchState: true }); 18 + }; 19 + }, [setState]); 20 + if (state.step !== StampStep.SUBMITTED) { 21 + console.log("Stamp state is not submitted!"); 22 + console.log(state.step); 23 + return <Text>No track selected?</Text>; 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 - href={`https://pdsls.dev/${responseData.playAtUrl}`} 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 - {responseData.blueskyPostUrl && ( 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 - href={`https://pdsls.dev/`} 50 + href={state.submittedStamp.blueskyPostUrl} 37 51 > 38 52 view your Bluesky post. 39 53 </ExternalLink> 40 54 </Text> 41 55 )} 56 + <Button 57 + className="mt-2" 58 + onPress={() => { 59 + setState({ step: StampStep.IDLE, resetSearchState: true }); 60 + router.back(); 61 + // in case above doesn't work 62 + router.replace({ 63 + pathname: "/stamp", 64 + }); 65 + }} 66 + > 67 + <Text>Submit another</Text> 68 + </Button> 42 69 </View> 43 70 </View> 44 71 );