diff --git a/packages/common/src/store/player/slice.ts b/packages/common/src/store/player/slice.ts index 1e191f868a..d39e48b238 100644 --- a/packages/common/src/store/player/slice.ts +++ b/packages/common/src/store/player/slice.ts @@ -91,6 +91,7 @@ type SetBufferingPayload = { type SetPayload = { uid: UID trackId: ID + previewing?: boolean } type SeekPayload = { @@ -139,6 +140,7 @@ const slice = createSlice({ action: PayloadAction ) => { const { collectible } = action.payload + state.previewing = false state.playing = true state.uid = null state.trackId = null @@ -158,15 +160,17 @@ const slice = createSlice({ state.counter = state.counter + 1 }, set: (state, action: PayloadAction) => { - const { uid, trackId } = action.payload + const { previewing, uid, trackId } = action.payload state.uid = uid state.trackId = trackId + state.previewing = !!previewing }, reset: (_state, _action: PayloadAction) => {}, resetSucceeded: (state, action: PayloadAction) => { const { shouldAutoplay } = action.payload state.playing = shouldAutoplay state.counter = state.counter + 1 + state.previewing = false }, seek: (state, action: PayloadAction) => { const { seconds } = action.payload diff --git a/packages/common/src/store/queue/types.ts b/packages/common/src/store/queue/types.ts index 0d2bc11adf..4e3b935e4c 100644 --- a/packages/common/src/store/queue/types.ts +++ b/packages/common/src/store/queue/types.ts @@ -27,6 +27,7 @@ export type Queueable = { uid: UID artistId?: ID collectible?: Collectible + isPreview?: boolean source: QueueSource } diff --git a/packages/common/src/utils/streaming.ts b/packages/common/src/utils/streaming.ts index 8072550e9f..e6c9a83545 100644 --- a/packages/common/src/utils/streaming.ts +++ b/packages/common/src/utils/streaming.ts @@ -8,6 +8,8 @@ import { Nullable } from './typeUtils' const { getPremiumTrackSignatureMap } = premiumContentSelectors +const PREVIEW_LENGTH_SECONDS = 30 + export async function generateUserSignature( audiusBackendInstance: AudiusBackend ) { @@ -50,3 +52,8 @@ export function* doesUserHaveTrackAccess(track: Nullable) { return !isPremium || hasPremiumContentSignature } + +export function getTrackPreviewDuration(track: Track) { + const previewStartSeconds = track.preview_start_seconds || 0 + return Math.min(track.duration - previewStartSeconds, PREVIEW_LENGTH_SECONDS) +} diff --git a/packages/mobile/src/components/audio/Audio.tsx b/packages/mobile/src/components/audio/Audio.tsx index dc8c5e42ec..967195165b 100644 --- a/packages/mobile/src/components/audio/Audio.tsx +++ b/packages/mobile/src/components/audio/Audio.tsx @@ -5,6 +5,7 @@ import type { ID, Nullable, QueryParams, + Queueable, Track } from '@audius/common' import { @@ -30,7 +31,8 @@ import { SquareSizes, shallowCompare, savedPageTracksLineupActions, - useAppContext + useAppContext, + getTrackPreviewDuration } from '@audius/common' import { isEqual } from 'lodash' import TrackPlayer, { @@ -149,6 +151,10 @@ const unlistedTrackFallbackTrackData = { duration: 0 } +type QueueableTrack = { + track: Nullable +} & Pick + export const Audio = () => { const { isEnabled: isNewPodcastControlsEnabled } = useFeatureFlag( FeatureFlags.PODCAST_CONTROL_UPDATES_ENABLED, @@ -184,11 +190,12 @@ export const Audio = () => { (state) => getTracks(state, { uids: queueTrackUids }), shallowCompare ) - const queueTracks = queueOrder.map( - (trackData) => queueTrackMap[trackData.id] as Nullable - ) + const queueTracks: QueueableTrack[] = queueOrder.map(({ id, isPreview }) => ({ + track: queueTrackMap[id] as Nullable, + isPreview + })) const queueTrackOwnerIds = queueTracks - .map((track) => track?.owner_id) + .map(({ track }) => track?.owner_id) .filter(removeNullable) const queueTrackOwnersMap = useSelector( @@ -244,8 +251,16 @@ export const Audio = () => { [dispatch] ) const updatePlayerInfo = useCallback( - ({ trackId, uid }: { trackId: number; uid: string }) => { - dispatch(playerActions.set({ trackId, uid })) + ({ + previewing, + trackId, + uid + }: { + previewing: boolean + trackId: number + uid: string + }) => { + dispatch(playerActions.set({ previewing, trackId, uid })) }, [dispatch] ) @@ -278,10 +293,10 @@ export const Audio = () => { }>({}) const handleGatedQueryParams = useCallback( - async (tracks: Nullable[]) => { + async (tracks: QueueableTrack[]) => { const queryParamsMap: { [trackId: ID]: QueryParams } = {} - for (const track of tracks) { + for (const { track } of tracks) { if (!track) { continue } @@ -355,7 +370,7 @@ export const Audio = () => { // Figure out how to call next earlier next() } else { - const track = queueTracks[playerIndex] + const { track, isPreview } = queueTracks[playerIndex] ?? {} // Skip track if user does not have access i.e. for an unlocked premium track const doesUserHaveAccess = (() => { @@ -381,6 +396,7 @@ export const Audio = () => { // Update queue info and handle playback position updates updateQueueIndex(playerIndex) updatePlayerInfo({ + previewing: !!isPreview, trackId: track.track_id, uid: queueTrackUids[playerIndex] }) @@ -410,8 +426,8 @@ export const Audio = () => { } const isLongFormContent = - queueTracks[playerIndex]?.genre === Genre.PODCASTS || - queueTracks[playerIndex]?.genre === Genre.AUDIOBOOKS + queueTracks[playerIndex]?.track?.genre === Genre.PODCASTS || + queueTracks[playerIndex]?.track?.genre === Genre.AUDIOBOOKS if (isLongFormContent !== isLongFormContentRef.current) { isLongFormContentRef.current = isLongFormContent // Update playback rate based on if the track is a podcast or not @@ -426,10 +442,10 @@ export const Audio = () => { // Handle track end event if ( isNewPodcastControlsEnabled && - event?.position !== null && - event?.track !== null + event?.position != null && + event?.track != null ) { - const track = queueTracks[event.track] + const { track } = queueTracks[event.track] ?? {} const isLongFormContent = track?.genre === Genre.PODCASTS || track?.genre === Genre.AUDIOBOOKS const isAtEndOfTrack = @@ -574,7 +590,7 @@ export const Audio = () => { ? await handleGatedQueryParams(newQueueTracks) : null - const newTrackData = newQueueTracks.map((track) => { + const newTrackData = newQueueTracks.map(({ track, isPreview }) => { if (!track) { return unlistedTrackFallbackTrackData } @@ -589,17 +605,14 @@ export const Audio = () => { const audioFilePath = getLocalAudioPath(trackId) url = `file://${audioFilePath}` } else { - const queryParams = queryParamsMap?.[track.track_id] - if (queryParams) { - url = apiClient.makeUrl( - `/tracks/${encodeHashId(track.track_id)}/stream`, - queryParams - ) - } else { - url = apiClient.makeUrl( - `/tracks/${encodeHashId(track.track_id)}/stream` - ) + const queryParams = { + ...queryParamsMap?.[track.track_id], + preview: isPreview } + url = apiClient.makeUrl( + `/tracks/${encodeHashId(track.track_id)}/stream`, + queryParams + ) } const localTrackImageSource = @@ -627,7 +640,7 @@ export const Audio = () => { genre: track.genre, date: track.created_at, artwork: imageUrl, - duration: track?.duration + duration: isPreview ? getTrackPreviewDuration(track) : track.duration } }) diff --git a/packages/mobile/src/components/details-tile/DetailsTile.tsx b/packages/mobile/src/components/details-tile/DetailsTile.tsx index 31ba526535..08345978ff 100644 --- a/packages/mobile/src/components/details-tile/DetailsTile.tsx +++ b/packages/mobile/src/components/details-tile/DetailsTile.tsx @@ -161,6 +161,7 @@ export const DetailsTile = ({ hideRepostCount, hideShare, isPlaying, + isPreviewing, isPlayable = true, isPlaylist = false, isPublished = true, @@ -169,6 +170,7 @@ export const DetailsTile = ({ onPressFavorites, onPressOverflow, onPressPlay, + onPressPreview, onPressPublish, onPressRepost, onPressReposts, @@ -213,6 +215,12 @@ export const DetailsTile = ({ const isUSDCPurchaseGated = isPremiumContentUSDCPurchaseGated(premiumConditions) + const isPlayingPreview = isPreviewing && isPlaying + const isPlayingFullAccess = isPlaying && !isPreviewing + + const showPreviewButton = + isUSDCPurchaseGated && (isOwner || !doesUserHaveAccess) && onPressPreview + const handlePressArtistName = useCallback(() => { if (!user) { return @@ -229,6 +237,11 @@ export const DetailsTile = ({ onPressPlay() }, [onPressPlay]) + const handlePressPreview = useCallback(() => { + light() + onPressPreview?.() + }, [onPressPreview]) + const renderDogEar = () => { const dogEarType = getDogEarType({ isOwner, @@ -298,13 +311,11 @@ export const DetailsTile = ({ text: styles.playButtonText, icon: styles.playButtonIcon }} - title={isPlaying ? messages.pause : messages.preview} + title={isPlayingPreview ? messages.pause : messages.preview} size='large' iconPosition='left' - icon={isPlaying ? IconPause : PlayIcon} - onPress={() => { - console.info('Preview button pressed') - }} + icon={isPlayingPreview ? IconPause : PlayIcon} + onPress={handlePressPreview} disabled={!isPlayable} fullWidth /> @@ -365,10 +376,10 @@ export const DetailsTile = ({ text: styles.playButtonText, icon: styles.playButtonIcon }} - title={isPlaying ? messages.pause : playText} + title={isPlayingFullAccess ? messages.pause : playText} size='large' iconPosition='left' - icon={isPlaying ? IconPause : PlayIcon} + icon={isPlayingFullAccess ? IconPause : PlayIcon} onPress={handlePressPlay} disabled={!isPlayable} fullWidth @@ -381,9 +392,7 @@ export const DetailsTile = ({ trackArtist={user} /> ) : null} - {isUSDCPurchaseGated && (isOwner || !doesUserHaveAccess) ? ( - - ) : null} + {showPreviewButton ? : null} getUser(state, track ? { id: track.owner_id } : {}) @@ -306,6 +307,7 @@ export const NowPlayingDrawer = memo(function NowPlayingDrawer( ({ type PlayBarProps = { track: Nullable + duration: number user: Nullable onPress: () => void translationAnim: Animated.Value @@ -93,7 +94,7 @@ type PlayBarProps = { } export const PlayBar = (props: PlayBarProps) => { - const { track, user, onPress, translationAnim, mediaKey } = props + const { duration, track, user, onPress, translationAnim, mediaKey } = props const styles = useStyles() const dispatch = useDispatch() const currentUser = useSelector(getAccountUser) @@ -136,7 +137,7 @@ export const PlayBar = (props: PlayBarProps) => { return ( diff --git a/packages/mobile/src/components/now-playing-drawer/useCurrentTrackDuration.ts b/packages/mobile/src/components/now-playing-drawer/useCurrentTrackDuration.ts new file mode 100644 index 0000000000..30fcf0b218 --- /dev/null +++ b/packages/mobile/src/components/now-playing-drawer/useCurrentTrackDuration.ts @@ -0,0 +1,15 @@ +import { getTrackPreviewDuration, playerSelectors } from '@audius/common' +import { useSelector } from 'react-redux' + +const { getCurrentTrack, getPreviewing } = playerSelectors + +export const useCurrentTrackDuration = () => { + const track = useSelector(getCurrentTrack) + const isPreviewing = useSelector(getPreviewing) + + return !track + ? 0 + : isPreviewing + ? getTrackPreviewDuration(track) + : track.duration +} diff --git a/packages/mobile/src/screens/collection-screen/CollectionScreenDetailsTile.tsx b/packages/mobile/src/screens/collection-screen/CollectionScreenDetailsTile.tsx index fbd393c09a..88acaa0215 100644 --- a/packages/mobile/src/screens/collection-screen/CollectionScreenDetailsTile.tsx +++ b/packages/mobile/src/screens/collection-screen/CollectionScreenDetailsTile.tsx @@ -128,6 +128,7 @@ type CollectionScreenDetailsTileProps = { | 'details' | 'headerText' | 'onPressPlay' + | 'onPressPreview' | 'collectionId' > diff --git a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx index 32c2ceed66..3ea8da055b 100644 --- a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx +++ b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx @@ -59,7 +59,7 @@ import { moodMap } from 'app/utils/moods' import { useThemeColors } from 'app/utils/theme' import { TrackScreenDownloadButtons } from './TrackScreenDownloadButtons' -const { getPlaying, getTrackId } = playerSelectors +const { getPlaying, getTrackId, getPreviewing } = playerSelectors const { setFavorite } = favoritesUserListActions const { setRepost } = repostsUserListActions const { requestOpen: requestOpenShareModal } = shareModalUIActions @@ -220,6 +220,7 @@ export const TrackScreenDetailsTile = ({ const dispatch = useDispatch() const playingId = useSelector(getTrackId) const isPlaying = useSelector(getPlaying) + const isPreviewing = useSelector(getPreviewing) const isPlayingId = playingId === track.track_id const { @@ -289,20 +290,37 @@ export const TrackScreenDetailsTile = ({ [track] ) - const handlePressPlay = useCallback(() => { - if (isLineupLoading) return - - if (isPlaying && isPlayingId) { - dispatch(tracksActions.pause()) - recordPlay(track_id, false) - } else if (!isPlayingId) { - dispatch(tracksActions.play(uid)) - recordPlay(track_id) - } else { - dispatch(tracksActions.play()) - recordPlay(track_id) - } - }, [track_id, uid, isPlayingId, dispatch, isPlaying, isLineupLoading]) + const play = useCallback( + ({ isPreview = false } = {}) => { + if (isLineupLoading) return + + if (isPlaying && isPlayingId && isPreviewing === isPreview) { + dispatch(tracksActions.pause()) + recordPlay(track_id, false) + } else if (!isPlayingId) { + dispatch(tracksActions.play(uid, { isPreview })) + recordPlay(track_id) + } else { + dispatch(tracksActions.play()) + recordPlay(track_id) + } + }, + [ + track_id, + uid, + isPlayingId, + dispatch, + isPlaying, + isPreviewing, + isLineupLoading + ] + ) + + const handlePressPlay = useCallback(() => play(), [play]) + const handlePressPreview = useCallback( + () => play({ isPreview: true }), + [play] + ) const handlePressFavorites = useCallback(() => { dispatch(setFavorite(track_id, FavoriteType.TRACK)) @@ -538,10 +556,12 @@ export const TrackScreenDetailsTile = ({ hideListenCount={is_unlisted && !field_visibility?.play_count} hideRepostCount={is_unlisted} isPlaying={isPlaying && isPlayingId} + isPreviewing={isPreviewing} isUnlisted={is_unlisted} onPressFavorites={handlePressFavorites} onPressOverflow={handlePressOverflow} onPressPlay={handlePressPlay} + onPressPreview={handlePressPreview} onPressRepost={handlePressRepost} onPressReposts={handlePressReposts} onPressSave={handlePressSave} diff --git a/packages/web/src/common/store/lineup/sagas.js b/packages/web/src/common/store/lineup/sagas.js index 32ea8e2a60..538174f725 100644 --- a/packages/web/src/common/store/lineup/sagas.js +++ b/packages/web/src/common/store/lineup/sagas.js @@ -355,7 +355,7 @@ function* play(lineupActions, lineupSelector, prefix, action) { // If preview isn't forced, check for track acccess and switch to preview // if the user doesn't have access but the track is previewable - if (!isPreview && requestedPlayTrack.is_premium) { + if (!isPreview && requestedPlayTrack?.is_premium) { const hasAccess = yield call(doesUserHaveTrackAccess, requestedPlayTrack) isPreview = !hasAccess && !!requestedPlayTrack.preview_cid } @@ -369,7 +369,15 @@ function* play(lineupActions, lineupSelector, prefix, action) { source !== lineup.prefix ) { const toQueue = yield all( - lineup.entries.map((e) => call(getToQueue, lineup.prefix, e)) + lineup.entries.map(function* (e) { + const queueable = yield call(getToQueue, lineup.prefix, e) + // If the entry is the one we're playing, set isPreview to incoming + // value + if (queueable.uid === action.uid) { + queueable.isPreview = isPreview + } + return queueable + }) ) const flattenedQueue = flatten(toQueue).filter((e) => Boolean(e)) yield put(queueActions.clear({})) diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index 7aa0489528..b3dc50f405 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -19,7 +19,8 @@ import { QueryParams, Genre, doesUserHaveTrackAccess, - getQueryParams + getQueryParams, + getTrackPreviewDuration } from '@audius/common' import { eventChannel } from 'redux-saga' import { @@ -66,7 +67,6 @@ const { getIsReachable } = reachabilitySelectors const PLAYER_SUBSCRIBER_NAME = 'PLAYER' const RECORD_LISTEN_SECONDS = 1 const RECORD_LISTEN_INTERVAL = 1000 -const PREVIEW_LENGTH_SECONDS = 30 export function* watchPlay() { const getFeatureEnabled = yield* getContext('getFeatureEnabled') @@ -119,12 +119,8 @@ export function* watchPlay() { if (isPreview) { // Add preview query string and calculate preview duration for use later - const previewStartSeconds = track.preview_start_seconds || 0 queryParams.preview = true - trackDuration = Math.min( - track.duration - previewStartSeconds, - PREVIEW_LENGTH_SECONDS - ) + trackDuration = getTrackPreviewDuration(track) } const mp3Url = apiClient.makeUrl(