diff --git a/frontend/js/src/common/listens/ListenCard.tsx b/frontend/js/src/common/listens/ListenCard.tsx index 6996d8f090..d7d4092958 100644 --- a/frontend/js/src/common/listens/ListenCard.tsx +++ b/frontend/js/src/common/listens/ListenCard.tsx @@ -29,6 +29,7 @@ import { useQuery } from "@tanstack/react-query"; import { get, isEmpty, isEqual, isNil, isNumber, merge } from "lodash"; import { Link } from "react-router"; import { toast } from "react-toastify"; + import { fullLocalizedDateFromTimestampOrISODate, getAlbumArtFromListenMetadata, @@ -57,10 +58,7 @@ import PinRecordingModal from "../../pins/PinRecordingModal"; import { millisecondsToStr } from "../../playlists/utils"; import { dataSourcesInfo } from "../../settings/brainzplayer/BrainzPlayerSettings"; import GlobalAppContext from "../../utils/GlobalAppContext"; -import { - BrainzPlayerActionType, - useBrainzPlayerDispatch, -} from "../brainzplayer/BrainzPlayerContext"; +import { useBrainzPlayerDispatch } from "../brainzplayer/BrainzPlayerContext"; import SoundcloudPlayer from "../brainzplayer/SoundcloudPlayer"; import SpotifyPlayer from "../brainzplayer/SpotifyPlayer"; import YoutubePlayer from "../brainzplayer/YoutubePlayer"; @@ -96,706 +94,615 @@ export type ListenCardProps = { additionalActions?: JSX.Element; }; -export type ListenCardState = { - listen: Listen; - isCurrentlyPlaying: boolean; -}; - -type ListenCardPropsWithDispatch = ListenCardProps & { - thumbnailSrc?: string; - dispatch: (action: BrainzPlayerActionType, callback?: () => void) => void; - isMobile: boolean; -}; - -export class ListenCard extends React.Component< - ListenCardPropsWithDispatch, - ListenCardState -> { - static coverartPlaceholder = "/static/img/cover-art-placeholder.jpg"; - static contextType = GlobalAppContext; - declare context: React.ContextType; - constructor(props: ListenCardPropsWithDispatch) { - super(props); - this.state = { - listen: props.listen, - isCurrentlyPlaying: false, - }; - } - - async componentDidMount() { - window.addEventListener("message", this.receiveBrainzPlayerMessage); - } - - async componentDidUpdate( - oldProps: ListenCardProps, - oldState: ListenCardState - ) { - const { listen: oldListen } = oldProps; - const { listen, customThumbnail } = this.props; - if (Boolean(listen) && !isEqual(listen, oldListen)) { - this.setState({ listen }); - } - } - - componentWillUnmount() { - window.removeEventListener("message", this.receiveBrainzPlayerMessage); - } - - playListen = () => { - const { listen, isCurrentlyPlaying } = this.state; - if (isCurrentlyPlaying) { - return; - } - window.postMessage( - { brainzplayer_event: "play-listen", payload: listen }, - window.location.origin - ); - }; - - /** React to events sent by BrainzPlayer */ - receiveBrainzPlayerMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) { - // Received postMessage from different origin, ignoring it - return; - } - const { brainzplayer_event, payload } = event.data; - switch (brainzplayer_event) { - case "current-listen-change": - this.onCurrentListenChange(payload); - break; - default: - // do nothing - } - }; - - onCurrentListenChange = (newListen: BaseListenFormat) => { - this.setState({ isCurrentlyPlaying: this.isCurrentlyPlaying(newListen) }); - }; - - isCurrentlyPlaying = (element: BaseListenFormat): boolean => { - const { listen } = this.state; - if (isNil(listen)) { - return false; - } - return isEqual(element, listen); - }; +export default function ListenCard(props: ListenCardProps) { + const { + listen: listenProp, + className, + showTimestamp, + showUsername, + additionalContent, + beforeThumbnailContent, + customThumbnail, + listenDetails, + customTimestamp, + compact, + feedbackComponent, + additionalMenuItems, + additionalActions, + ...otherProps + } = props; + + const [displayListen, setDisplayListen] = React.useState(listenProp); + const [isCurrentlyPlaying, setIsCurrentlyPlaying] = React.useState(false); + + const { + APIService, + currentUser, + userPreferences, + spotifyAuth, + } = React.useContext(GlobalAppContext); + const dispatch = useBrainzPlayerDispatch(); + const isMobile = useMediaQuery("(max-width: 480px)"); - recommendListenToFollowers = async () => { - const { listen } = this.state; - const { APIService, currentUser } = this.context; + const albumArtQueryKey = React.useMemo( + () => getAlbumArtFromListenMetadataKey(displayListen, spotifyAuth), + [displayListen, spotifyAuth] + ); - if (currentUser?.auth_token) { - const metadata: UserTrackRecommendationMetadata = {}; + const albumArtDisabled = + Boolean(customThumbnail) || !displayListen || userPreferences?.saveData; - const recording_mbid = getRecordingMBID(listen); - if (recording_mbid) { - metadata.recording_mbid = recording_mbid; + const { data: thumbnailSrc } = useQuery({ + queryKey: ["album-art", albumArtQueryKey, albumArtDisabled], + queryFn: async () => { + if (albumArtDisabled) return ""; + try { + const albumArtURL = await getAlbumArtFromListenMetadata( + displayListen, + spotifyAuth + ); + return albumArtURL ?? ""; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching album art", error); + return ""; } + }, + staleTime: 1000 * 60 * 60 * 12, + gcTime: 1000 * 60 * 60 * 12, + }); - const recording_msid = getRecordingMSID(listen); - if (recording_msid) { - metadata.recording_msid = recording_msid; + const receiveBrainzPlayerMessage = React.useCallback( + (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + // Received postMessage from different origin, ignoring it + return; } - - try { - const status = await APIService.recommendTrackToFollowers( - currentUser.name, - currentUser.auth_token, - metadata - ); - if (status === 200) { - toast.success( - , - { toastId: "recommended-success" } + const { brainzplayer_event, payload: incomingListen } = event.data; + switch (brainzplayer_event) { + case "current-listen-change": + setIsCurrentlyPlaying( + isEqual(incomingListen, displayListen) || + isEqual(incomingListen, listenProp) ); - } - } catch (error) { - this.handleError( - error, - "We encountered an error when trying to recommend the track to your followers" - ); + break; + default: + // do nothing } - } - }; + }, + [displayListen, listenProp] + ); + React.useEffect(() => { + // Set up and clean up BrainzPlayer event listener + window.addEventListener("message", receiveBrainzPlayerMessage); + return () => { + window.removeEventListener("message", receiveBrainzPlayerMessage); + }; + }, [receiveBrainzPlayerMessage]); - handleError = (error: string | Error, title?: string): void => { - if (!error) { - return; - } - toast.error( - , - { toastId: "recommended-error" } + const playListen = React.useCallback(() => { + if (isCurrentlyPlaying) return; + window.postMessage( + { brainzplayer_event: "play-listen", payload: displayListen }, + window.location.origin ); - }; + }, [isCurrentlyPlaying, displayListen]); - addToTopOfQueue = () => { - const { dispatch } = this.props; - const { listen } = this.state; - dispatch({ type: "ADD_LISTEN_TO_TOP_OF_QUEUE", data: listen }); - }; + const recommendListenToFollowers = React.useCallback(async () => { + if (!currentUser?.auth_token) return; - addToBottomOfQueue = () => { - const { dispatch } = this.props; - const { listen } = this.state; - dispatch({ type: "ADD_LISTEN_TO_BOTTOM_OF_QUEUE", data: listen }); - }; + const metadata: UserTrackRecommendationMetadata = {}; + const recording_mbid = getRecordingMBID(displayListen); + if (recording_mbid) metadata.recording_mbid = recording_mbid; - render() { - const { - additionalContent, - beforeThumbnailContent, - customThumbnail, - className, - showUsername, - showTimestamp, - listenDetails, - customTimestamp, - compact, - feedbackComponent, - additionalMenuItems, - additionalActions, - listen: listenFromProps, - dispatch: dispatchProp, - thumbnailSrc, - isMobile, - ...otherProps - } = this.props; - const { listen, isCurrentlyPlaying } = this.state; - const { currentUser, userPreferences } = this.context; - - const isLoggedIn = !isEmpty(currentUser); - - const recordingMSID = getRecordingMSID(listen); - const recordingMBID = getRecordingMBID(listen); - const trackMBID = get(listen, "track_metadata.additional_info.track_mbid"); - const releaseMBID = getReleaseMBID(listen); - const releaseGroupMBID = getReleaseGroupMBID(listen); - const releaseName = getReleaseName(listen); - const artistMBIDs = getArtistMBIDs(listen); - const spotifyURL = SpotifyPlayer.getURLFromListen(listen); - const youtubeURL = YoutubePlayer.getURLFromListen(listen); - const soundcloudURL = SoundcloudPlayer.getURLFromListen(listen); - - const trackName = getTrackName(listen); - const artistName = getArtistName(listen); - const trackDurationMs = getTrackDurationInMs(listen); + const recording_msid = getRecordingMSID(displayListen); + if (recording_msid) metadata.recording_msid = recording_msid; - const hasRecordingMSID = Boolean(recordingMSID); - const hasRecordingMBID = Boolean(recordingMBID); - const hasInfoAndMBID = - artistName && trackName && (hasRecordingMSID || hasRecordingMBID); - const isListenReviewable = - Boolean(recordingMBID) || - artistMBIDs?.length || - Boolean(trackMBID) || - Boolean(releaseGroupMBID); - - // Hide the actions menu if in compact mode or no buttons to be shown - const hasActionOptions = - additionalMenuItems?.length || - hasInfoAndMBID || - recordingMBID || - spotifyURL || - youtubeURL || - soundcloudURL; - const hideActionsMenu = compact || !hasActionOptions; - - const renderBrainzplayer = - userPreferences?.brainzplayer?.brainzplayerEnabled ?? true; - - let timeStampForDisplay; - if (customTimestamp) { - timeStampForDisplay = customTimestamp; - } else if (listen.playing_now) { - timeStampForDisplay = ( - - - Listening now — - - + try { + const status = await APIService.recommendTrackToFollowers( + currentUser.name, + currentUser.auth_token, + metadata ); - } else { - timeStampForDisplay = ( - - {preciseTimestamp( - listen.listened_at_iso || listen.listened_at * 1000 - )} - - ); - } - let thumbnail; - if (customThumbnail) { - thumbnail = customThumbnail; - } else if (thumbnailSrc) { - let thumbnailLink; - let thumbnailTitle; - let optionalAttributes = {}; - if (releaseMBID) { - thumbnailLink = `/release/${releaseMBID}`; - thumbnailTitle = releaseName; - } else if (releaseGroupMBID) { - thumbnailLink = `/album/${releaseGroupMBID}`; - thumbnailTitle = get( - listen, - "track_metadata.mbid_mapping.release_group_name" + if (status === 200) { + toast.success( + , + { toastId: "recommended-success" } ); - } else { - thumbnailLink = spotifyURL || youtubeURL || soundcloudURL; - thumbnailTitle = "Cover art"; - optionalAttributes = { - target: "_blank", - rel: "noopener noreferrer", - }; } - thumbnail = thumbnailLink ? ( -
- - - -
- ) : ( -
- -
+ } catch (error) { + toast.error( + , + { toastId: "recommended-error" } ); - } else if (releaseMBID) { - thumbnail = ( - -
- - - - - -
-
+ } + }, [currentUser, APIService, displayListen]); + + const addToTopOfQueue = React.useCallback(() => { + dispatch({ type: "ADD_LISTEN_TO_TOP_OF_QUEUE", data: displayListen }); + }, [dispatch, displayListen]); + + const addToBottomOfQueue = React.useCallback(() => { + dispatch({ type: "ADD_LISTEN_TO_BOTTOM_OF_QUEUE", data: displayListen }); + }, [dispatch, displayListen]); + + const openMBIDMappingModal = React.useCallback(async () => { + try { + const linkedTrackMetadata: TrackMetadata = await NiceModal.show( + MBIDMappingModal, + { + listenToMap: displayListen, + } ); - } else if (isLoggedIn && Boolean(recordingMSID)) { - const openModal = () => { - NiceModal.show(MBIDMappingModal, { - listenToMap: listen, - }).then((linkedTrackMetadata: any) => { - this.setState((prevState) => { - return { - listen: merge({}, prevState.listen, { - track_metadata: linkedTrackMetadata, - }), - }; - }); + setDisplayListen((prevState) => { + const newVal = merge({}, prevState, { + track_metadata: linkedTrackMetadata, }); - }; - thumbnail = ( -
-
- -
-
- ); - } else if (recordingMBID || releaseGroupMBID) { - if (recordingMBID) { - thumbnail = ( - -
- - - - -
- - ); - } else { - thumbnail = ( - -
- - - - -
- - ); - } - } else { - // eslint-disable-next-line react/jsx-no-useless-fragment - thumbnail = ( -
-
- - - - -
-
- ); + return newVal; + }); + } catch (error) { + console.error("Error mapping a listen:", error); } - - return ( - + + Listening now — + + + ); + } else { + timeStampForDisplay = ( + -
- {beforeThumbnailContent} - {thumbnail} - {listenDetails ? ( -
{listenDetails}
- ) : ( -
-
-
- {trackName ? getTrackLink(listen) : getAlbumLink(listen)} -
- {trackDurationMs && ( -
- {isNumber(trackDurationMs) && - millisecondsToStr(trackDurationMs)} -
- )} -
-
- {getArtistLink(listen)} + {preciseTimestamp( + displayListen.listened_at_iso || displayListen.listened_at * 1000 + )} + + ); + } + + const thumbnail = + customThumbnail ?? + getThumbnailElement( + thumbnailSrc, + displayListen, + currentUser, + openMBIDMappingModal + ); + + return ( + +
+ {beforeThumbnailContent} + {thumbnail} + {listenDetails || ( +
+
+
+ {trackName + ? getTrackLink(displayListen) + : getAlbumLink(displayListen)}
+ {trackDurationMs && ( +
+ {isNumber(trackDurationMs) && + millisecondsToStr(trackDurationMs)} +
+ )} +
+
+ {getArtistLink(displayListen)} +
+
+ )} +
+ {(showUsername || showTimestamp) && ( +
+ {showUsername && displayListen.user_name && ( + + )} + {showTimestamp && timeStampForDisplay}
)} -
- {(showUsername || showTimestamp) && ( -
- {showUsername && listen.user_name && ( - - )} - {showTimestamp && timeStampForDisplay} -
- )} -
- {isLoggedIn && - !isMobile && - (feedbackComponent ?? ( - - ))} - {hideActionsMenu ? null : ( - <> - -
    - {isMobile && ( - - )} - {recordingMBID && ( - - )} - {renderBrainzplayer && ( - <> - - - - )} - - {spotifyURL && ( - - )} - {youtubeURL && ( - - )} - {soundcloudURL && ( - - )} - {isLoggedIn && hasInfoAndMBID && ( - { - NiceModal.show(PinRecordingModal, { - recordingToPin: listen, - }); - }} - /> - )} - {isLoggedIn && hasInfoAndMBID && ( - - )} - {isLoggedIn && hasInfoAndMBID && ( - { - NiceModal.show(PersonalRecommendationModal, { - listenToPersonallyRecommend: listen, - }); - }} - /> - )} - {isLoggedIn && Boolean(recordingMSID) && ( - { - NiceModal.show(MBIDMappingModal, { - listenToMap: listen, - }); - }} - /> - )} - {isLoggedIn && isListenReviewable && ( +
    + {isLoggedIn && + !isMobile && + (feedbackComponent ?? ( + + ))} + {!hideActionsMenu && ( + <> + +
      + {isMobile && ( + + )} + {recordingMBID && ( + + )} + {renderBrainzplayer && ( + <> { - NiceModal.show(CBReviewModal, { - listen, - }); - }} + text="Play Next" + icon={faPlay} + title="Play Next" + action={addToTopOfQueue} /> - )} - {isLoggedIn && ( { - NiceModal.show(AddToPlaylist, { - listen, - }); - }} + title="Add to Queue" + action={addToBottomOfQueue} /> - )} - {additionalMenuItems} + + )} + {spotifyURL && ( { - NiceModal.show(ListenPayloadModal, { - listen, - }); + icon={faSpotify} + iconColor={dataSourcesInfo.spotify.color} + title="Open in Spotify" + text="Open in Spotify" + link={spotifyURL} + anchorTagAttributes={{ + target: "_blank", + rel: "noopener noreferrer", }} /> -
    - - )} - {renderBrainzplayer && ( - - )} - {additionalActions} -
    +
+ + )} + {renderBrainzplayer && ( + + )} + {additionalActions}
- {additionalContent && ( -
- {additionalContent} -
- )} - - ); - } -} - -export default function ListenCardWrapper(props: ListenCardProps) { - const dispatch = useBrainzPlayerDispatch(); - const { spotifyAuth, APIService, userPreferences } = React.useContext( - GlobalAppContext - ); - const { listen, customThumbnail } = props; - - const albumArtQueryKey = React.useMemo( - () => getAlbumArtFromListenMetadataKey(listen, spotifyAuth), - [listen, spotifyAuth] +
+ {additionalContent && ( +
+ {additionalContent} +
+ )} + ); +} - const albumArtDisabled = - Boolean(customThumbnail) || !listen || userPreferences?.saveData; - - const { data: thumbnailSrc } = useQuery({ - queryKey: ["album-art", albumArtQueryKey, albumArtDisabled], - queryFn: async () => { - if (albumArtDisabled) { - return ""; - } - try { - const albumArtURL = await getAlbumArtFromListenMetadata( - listen, - spotifyAuth - ); - return albumArtURL ?? ""; - } catch (error) { - // eslint-disable-next-line no-console - console.error("Error fetching album art", error); - return ""; - } - }, - staleTime: 1000 * 60 * 60 * 12, - gcTime: 1000 * 60 * 60 * 12, - }); - - const isMobile = useMediaQuery("(max-width: 480px)"); - - return ( - - ); +function getThumbnailElement( + thumbnailSrc: string | undefined, + displayListen: Listen, + currentUser: ListenBrainzUser, + openMBIDMappingModal: () => Promise +): JSX.Element { + const isLoggedIn = !isEmpty(currentUser); + const recordingMSID = getRecordingMSID(displayListen); + const recordingMBID = getRecordingMBID(displayListen); + const releaseMBID = getReleaseMBID(displayListen); + const releaseGroupMBID = getReleaseGroupMBID(displayListen); + const releaseName = getReleaseName(displayListen); + const spotifyURL = SpotifyPlayer.getURLFromListen(displayListen); + const youtubeURL = YoutubePlayer.getURLFromListen(displayListen); + const soundcloudURL = SoundcloudPlayer.getURLFromListen(displayListen); + + let thumbnail; + if (thumbnailSrc) { + let thumbnailLink; + let thumbnailTitle; + let optionalAttributes = {}; + if (releaseMBID) { + thumbnailLink = `/release/${releaseMBID}`; + thumbnailTitle = releaseName; + } else if (releaseGroupMBID) { + thumbnailLink = `/album/${releaseGroupMBID}`; + thumbnailTitle = get( + displayListen, + "track_metadata.mbid_mapping.release_group_name" + ); + } else { + thumbnailLink = spotifyURL || youtubeURL || soundcloudURL; + thumbnailTitle = "Cover art"; + optionalAttributes = { + target: "_blank", + rel: "noopener noreferrer", + }; + } + thumbnail = thumbnailLink ? ( +
+ + + +
+ ) : ( +
+ +
+ ); + } else if (releaseMBID) { + thumbnail = ( + +
+ + + + + +
+
+ ); + } else if (isLoggedIn && Boolean(recordingMSID)) { + thumbnail = ( +
+
+ +
+
+ ); + } else if (recordingMBID || releaseGroupMBID) { + const link = recordingMBID + ? `/track/${recordingMBID}` + : `/album/${releaseGroupMBID}`; + thumbnail = ( + +
+ + + + +
+ + ); + } else { + thumbnail = ( +
+
+ + + + +
+
+ ); + } + return thumbnail; } diff --git a/frontend/js/src/common/listens/MBIDMappingModal.tsx b/frontend/js/src/common/listens/MBIDMappingModal.tsx index 3e11029676..632b0c40d0 100644 --- a/frontend/js/src/common/listens/MBIDMappingModal.tsx +++ b/frontend/js/src/common/listens/MBIDMappingModal.tsx @@ -11,6 +11,7 @@ import * as React from "react"; import { toast } from "react-toastify"; import Tooltip from "react-tooltip"; import { Link } from "react-router"; +import { merge } from "lodash"; import ListenCard from "./ListenCard"; import ListenControl from "./ListenControl"; import { ToastMsg } from "../../notifications/Notifications"; @@ -42,7 +43,6 @@ function getListenFromSelectedRecording( export default NiceModal.create(({ listenToMap }: MBIDMappingModalProps) => { const modal = useModal(); - const { resolve, visible } = modal; const [copyTextClickCounter, setCopyTextClickCounter] = React.useState(0); const [selectedRecording, setSelectedRecording] = React.useState< TrackMetadata @@ -78,6 +78,7 @@ export default NiceModal.create(({ listenToMap }: MBIDMappingModalProps) => { if (!listenToMap || !selectedRecording || !auth_token) { return; } + let resolvedValue: TrackMetadata = selectedRecording; const selectedRecordingToListen = getListenFromSelectedRecording( selectedRecording ); @@ -96,8 +97,36 @@ export default NiceModal.create(({ listenToMap }: MBIDMappingModalProps) => { handleError(error, "Error while linking listen"); return; } - - resolve(selectedRecording); + try { + // Try to get more metadata for the selected recodring (such as artist mbids) + const response = await APIService.getRecordingMetadata([ + recordingMBID, + ]); + const metadata = response?.[recordingMBID]; + if (metadata) { + resolvedValue = merge(selectedRecording, { + artist_name: metadata?.artist?.name, + additional_info: { + duration_ms: metadata?.recording?.length, + }, + mbid_mapping: { + artist_mbids: metadata?.artist?.artists?.map( + (ar) => ar.artist_mbid + ), + artists: metadata?.artist?.artists, + release_mbid: metadata?.release?.mbid, + caa_id: metadata?.release?.caa_id, + caa_release_mbid: metadata?.release?.caa_release_mbid, + year: metadata?.release?.year, + release_artist_name: metadata?.release?.album_artist_name, + release_group_mbid: metadata?.release?.release_group_mbid, + }, + }); + } + } catch (error) { + // Ignore this failure, it is only cosmetic + } + modal.resolve(resolvedValue); toast.success( { modal.hide(); } }, - [ - listenToMap, - auth_token, - modal, - resolve, - APIService, - selectedRecording, - handleError, - ] + [listenToMap, auth_token, modal, APIService, selectedRecording, handleError] ); const copyTextToSearchField = React.useCallback(() => {