diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 45f80de4cf..2d3fce950d 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1034,6 +1034,7 @@ export enum PlaybackSource { type PlaybackPlay = { eventName: Name.PLAYBACK_PLAY id?: string + isPreview?: boolean source: PlaybackSource } type PlaybackPause = { diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index 7adb405908..998695eb7e 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -200,7 +200,8 @@ export type TrackMetadata = { category: StemCategory } remix_of: Nullable - preview_start_seconds?: number + preview_cid?: Nullable + preview_start_seconds?: Nullable // Added fields dateListened?: string diff --git a/packages/common/src/store/lineup/actions.ts b/packages/common/src/store/lineup/actions.ts index 2a0f811bd9..92da60c48e 100644 --- a/packages/common/src/store/lineup/actions.ts +++ b/packages/common/src/store/lineup/actions.ts @@ -164,10 +164,11 @@ export class LineupActions { } } - play(uid?: UID) { + play(uid?: UID, { isPreview = false }: { isPreview?: boolean } = {}) { return { type: addPrefix(this.prefix, PLAY), - uid + uid, + isPreview } } diff --git a/packages/common/src/store/player/selectors.ts b/packages/common/src/store/player/selectors.ts index f0fa0ac459..129c269450 100644 --- a/packages/common/src/store/player/selectors.ts +++ b/packages/common/src/store/player/selectors.ts @@ -11,6 +11,7 @@ export const getTrackId = (state: CommonState) => state.player.trackId export const getCollectible = (state: CommonState) => state.player.collectible export const getPlaying = (state: CommonState) => state.player.playing +export const getPreviewing = (state: CommonState) => state.player.previewing export const getPaused = (state: CommonState) => !state.player.playing export const getCounter = (state: CommonState) => state.player.counter export const getBuffering = (state: CommonState) => state.player.buffering diff --git a/packages/common/src/store/player/slice.ts b/packages/common/src/store/player/slice.ts index afadbae1fa..1e191f868a 100644 --- a/packages/common/src/store/player/slice.ts +++ b/packages/common/src/store/player/slice.ts @@ -16,6 +16,9 @@ export type PlayerState = { // object to allow components to subscribe to changes. playing: boolean + // Indicates that current playback session is a track preview + previewing: boolean + // Keep 'buffering' in the store separately from the audio // object to allow components to subscribe to changes. buffering: boolean @@ -42,6 +45,7 @@ export const initialState: PlayerState = { collectible: null, playing: false, + previewing: false, buffering: false, counter: 0, playbackRate: '1x', @@ -52,10 +56,12 @@ export const initialState: PlayerState = { type PlayPayload = Maybe<{ uid?: Nullable trackId?: ID + isPreview?: boolean onEnd?: (...args: any) => any }> type PlaySucceededPayload = { + isPreview?: boolean uid?: Nullable trackId?: ID } @@ -122,6 +128,7 @@ const slice = createSlice({ state.trackId = trackId || state.trackId state.collectible = null state.counter = state.counter + 1 + state.previewing = !!action.payload.isPreview }, playCollectible: ( _state, diff --git a/packages/common/src/store/queue/slice.ts b/packages/common/src/store/queue/slice.ts index 654199c00b..6ee1090a81 100644 --- a/packages/common/src/store/queue/slice.ts +++ b/packages/common/src/store/queue/slice.ts @@ -48,6 +48,7 @@ export const initialState: State = { type PlayPayload = { uid?: Nullable + isPreview?: boolean trackId?: Nullable collectible?: Collectible source?: Nullable diff --git a/packages/web/src/common/store/lineup/sagas.js b/packages/web/src/common/store/lineup/sagas.js index 572699bfd9..32ea8e2a60 100644 --- a/packages/web/src/common/store/lineup/sagas.js +++ b/packages/web/src/common/store/lineup/sagas.js @@ -14,7 +14,8 @@ import { queueSelectors, getContext, FeatureFlags, - isPremiumContentUSDCPurchaseGated + isPremiumContentUSDCPurchaseGated, + doesUserHaveTrackAccess } from '@audius/common' import { all, @@ -350,6 +351,14 @@ function* updateQueueLineup(lineupPrefix, source, lineupEntries) { function* play(lineupActions, lineupSelector, prefix, action) { const lineup = yield select(lineupSelector) const requestedPlayTrack = yield select(getTrack, { uid: action.uid }) + let isPreview = !!action.isPreview + + // 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) { + const hasAccess = yield call(doesUserHaveTrackAccess, requestedPlayTrack) + isPreview = !hasAccess && !!requestedPlayTrack.preview_cid + } if (action.uid) { const source = yield select(getSource) @@ -370,6 +379,7 @@ function* play(lineupActions, lineupSelector, prefix, action) { yield put( queueActions.play({ uid: action.uid, + isPreview, trackId: requestedPlayTrack && requestedPlayTrack.track_id, source: prefix }) diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index 33a713cf07..7aa0489528 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -66,11 +66,12 @@ 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') yield* takeLatest(play.type, function* (action: ReturnType) { - const { uid, trackId, onEnd } = action.payload ?? {} + const { uid, trackId, isPreview, onEnd } = action.payload ?? {} const audioPlayer = yield* getContext('audioPlayer') const isNativeMobile = yield getContext('isNativeMobile') @@ -113,6 +114,19 @@ export function* watchPlay() { audiusBackendInstance, premiumContentSignature }) + + let trackDuration = track.duration + + 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 + ) + } + const mp3Url = apiClient.makeUrl( `/tracks/${encodedTrackId}/stream`, queryParams @@ -124,7 +138,7 @@ export function* watchPlay() { const currentUserId = yield* select(getUserId) const endChannel = eventChannel((emitter) => { audioPlayer.load( - track.duration || + trackDuration || track.track_segments.reduce( (duration, segment) => duration + parseFloat(segment.duration), 0 @@ -182,7 +196,7 @@ export function* watchPlay() { ) } else { audioPlayer.play() - yield* put(playSucceeded({ uid, trackId })) + yield* put(playSucceeded({ uid, trackId, isPreview })) yield* put(seek({ seconds: trackPlaybackInfo.playbackPosition })) return } @@ -196,9 +210,9 @@ export function* watchPlay() { // Play if user has access to track. const track = yield* select(getTrack, { id: trackId }) const doesUserHaveAccess = yield* call(doesUserHaveTrackAccess, track) - if (doesUserHaveAccess) { + if (doesUserHaveAccess || isPreview) { audioPlayer.play() - yield* put(playSucceeded({ uid, trackId })) + yield* put(playSucceeded({ uid, trackId, isPreview })) } else { yield* put(queueActions.next({})) } diff --git a/packages/web/src/common/store/queue/sagas.ts b/packages/web/src/common/store/queue/sagas.ts index b331c2a308..eefe702327 100644 --- a/packages/web/src/common/store/queue/sagas.ts +++ b/packages/web/src/common/store/queue/sagas.ts @@ -44,7 +44,11 @@ const { getUndershot } = queueSelectors -const { getTrackId: getPlayerTrackId, getUid: getPlayerUid } = playerSelectors +const { + getTrackId: getPlayerTrackId, + getUid: getPlayerUid, + getPreviewing: getPlayerPreviewing +} = playerSelectors const { add, clear, next, pause, play, queueAutoplay, previous, remove } = queueActions @@ -144,11 +148,13 @@ function* handleQueueAutoplay({ */ export function* watchPlay() { yield* takeLatest(play.type, function* (action: ReturnType) { - const { uid, trackId, collectible } = action.payload + const { uid, trackId, isPreview, collectible } = action.payload // Play a specific uid const playerUid = yield* select(getPlayerUid) const playerTrackId = yield* select(getPlayerTrackId) + const playerIsPreviewing = yield* select(getPlayerPreviewing) + if (uid || trackId) { const playActionTrack = yield* select( getTrack, @@ -181,13 +187,16 @@ export function* watchPlay() { const noTrackPlaying = !playerTrackId const trackIsDifferent = playerTrackId !== playActionTrack.track_id const trackIsSameButDifferentUid = - playerTrackId === playActionTrack.track_id && uid !== playerUid + playerTrackId === playActionTrack.track_id && + (uid !== playerUid || !!isPreview !== playerIsPreviewing) if (noTrackPlaying || trackIsDifferent || trackIsSameButDifferentUid) { yield* put( playerActions.play({ uid, + isPreview, trackId: playActionTrack.track_id, - onEnd: next + // Don't auto-advance after previews + onEnd: isPreview ? playerActions.stop : next }) ) } else { diff --git a/packages/web/src/components/track/GiantTrackTile.tsx b/packages/web/src/components/track/GiantTrackTile.tsx index aaa529912c..cbdb746ab7 100644 --- a/packages/web/src/components/track/GiantTrackTile.tsx +++ b/packages/web/src/components/track/GiantTrackTile.tsx @@ -105,11 +105,13 @@ export type GiantTrackTileProps = { onMakePublic: (trackId: ID) => void onFollow: () => void onPlay: () => void + onPreview: () => void onRepost: () => void onSave: () => void onShare: () => void onUnfollow: () => void playing: boolean + previewing: boolean premiumConditions: Nullable released: string repostCount: number @@ -150,6 +152,7 @@ export const GiantTrackTile = ({ onFollow, onMakePublic, onPlay, + onPreview, onSave, onShare, onRepost, @@ -158,6 +161,7 @@ export const GiantTrackTile = ({ repostCount, saveCount, playing, + previewing, premiumConditions, tags, trackId, @@ -183,11 +187,6 @@ export const GiantTrackTile = ({ // Play button is conditionally hidden for USDC-gated tracks when the user does not have access const showPlay = isUSDCPurchaseGated ? doesUserHaveAccess : true - // TODO: https://linear.app/audius/issue/PAY-1590/[webmobileweb]-add-support-for-playing-previews - const onPreview = useCallback(() => { - console.info('Preview Clicked') - }, []) - const renderCardTitle = (className: string) => { return ( ) : null} {showPreview ? ( setArtworkLoaded(true), label: `${title} by ${name}`, - doesUserHaveAccess + doesUserHaveAccess: doesUserHaveAccess || hasPreview } return } @@ -317,7 +318,7 @@ const ConnectedTrackTile = ({ // Show the locked content modal if gated track and user does not have access. // Also skip toggle play in this case. - if (trackId && !doesUserHaveAccess) { + if (trackId && !doesUserHaveAccess && !hasPreview) { dispatch(setLockedContentId({ id: trackId })) setLockedContentVisibility(true) return @@ -327,6 +328,7 @@ const ConnectedTrackTile = ({ }, [ togglePlay, + hasPreview, uid, trackId, doesUserHaveAccess, diff --git a/packages/web/src/pages/track-page/TrackPageProvider.tsx b/packages/web/src/pages/track-page/TrackPageProvider.tsx index a10f7c5854..f303e46c1f 100644 --- a/packages/web/src/pages/track-page/TrackPageProvider.tsx +++ b/packages/web/src/pages/track-page/TrackPageProvider.tsx @@ -67,7 +67,7 @@ import StemsSEOHint from './components/StemsSEOHint' import { OwnProps as DesktopTrackPageProps } from './components/desktop/TrackPage' import { OwnProps as MobileTrackPageProps } from './components/mobile/TrackPage' const { makeGetCurrent } = queueSelectors -const { getPlaying, getBuffering } = playerSelectors +const { getPlaying, getPreviewing, getBuffering } = playerSelectors const { setFavorite } = favoritesUserListActions const { setRepost } = repostsUserListActions const { requestOpen: requestOpenShareModal } = shareModalUIActions @@ -242,10 +242,17 @@ class TrackPageProvider extends Component< } } - onHeroPlay = (heroPlaying: boolean) => { + onHeroPlay = ({ + isPlaying, + isPreview + }: { + isPlaying: boolean + isPreview?: boolean + }) => { const { play, pause, + previewing, currentQueueItem, moreByArtist: { entries }, record @@ -253,7 +260,7 @@ class TrackPageProvider extends Component< if (!entries || !entries[0]) return const track = entries[0] - if (heroPlaying) { + if (isPlaying && previewing === isPreview) { pause() record( make(Name.PLAYBACK_PAUSE, { @@ -270,14 +277,16 @@ class TrackPageProvider extends Component< record( make(Name.PLAYBACK_PLAY, { id: `${track.id}`, + isPreview, source: PlaybackSource.TRACK_PAGE }) ) } else if (track) { - play(track.uid) + play(track.uid, { isPreview }) record( make(Name.PLAYBACK_PLAY, { id: `${track.id}`, + isPreview, source: PlaybackSource.TRACK_PAGE }) ) @@ -380,6 +389,7 @@ class TrackPageProvider extends Component< moreByArtist, currentQueueItem, playing, + previewing, buffering, userId, pause, @@ -456,6 +466,7 @@ class TrackPageProvider extends Component< heroPlaying, userId, badge, + previewing, onHeroPlay: this.onHeroPlay, goToAllRemixesPage: this.goToAllRemixesPage, goToParentRemixesPage: this.goToParentRemixesPage, @@ -512,6 +523,7 @@ function makeMapStateToProps() { currentQueueItem: getCurrentQueueItem(state), playing: getPlaying(state), + previewing: getPreviewing(state), buffering: getBuffering(state), trackRank: getTrackRank(state), isMobile: isMobile(), @@ -543,7 +555,8 @@ function mapDispatchToProps(dispatch: Dispatch) { goToRoute: (route: string) => dispatch(pushRoute(route)), replaceRoute: (route: string) => dispatch(replace(route)), reset: (source?: string) => dispatch(tracksActions.reset(source)), - play: (uid?: string) => dispatch(tracksActions.play(uid)), + play: (uid?: string, options: { isPreview?: boolean } = {}) => + dispatch(tracksActions.play(uid, options)), recordPlayMoreByArtist: (trackId: ID) => { const trackEvent: TrackEvent = make(Name.TRACK_PAGE_PLAY_MORE, { id: trackId diff --git a/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx b/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx index 8db2fb5a03..863283c52f 100644 --- a/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx +++ b/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx @@ -41,9 +41,16 @@ export type OwnProps = { hasValidRemixParent: boolean user: User | null heroPlaying: boolean + previewing: boolean userId: ID | null badge: string | null - onHeroPlay: (isPlaying: boolean) => void + onHeroPlay: ({ + isPlaying, + isPreview + }: { + isPlaying: boolean + isPreview?: boolean + }) => void goToAllRemixesPage: () => void goToParentRemixesPage: () => void onHeroShare: (trackId: ID) => void @@ -75,6 +82,7 @@ const TrackPage = ({ heroTrack, user, heroPlaying, + previewing, userId, badge, onHeroPlay, @@ -108,7 +116,10 @@ const TrackPage = ({ usePremiumContentAccess(heroTrack) const loading = !heroTrack || isUserAccessTBD - const onPlay = () => onHeroPlay(heroPlaying) + const onPlay = () => onHeroPlay({ isPlaying: heroPlaying }) + const onPreview = () => + onHeroPlay({ isPlaying: heroPlaying, isPreview: true }) + const onSave = isOwner ? () => {} : () => heroTrack && onSaveTrack(isSaved, heroTrack.track_id) @@ -122,6 +133,7 @@ const TrackPage = ({ { type TrackHeaderProps = { isLoading: boolean isPlaying: boolean + isPreviewing: boolean isOwner: boolean isSaved: boolean isReposted: boolean @@ -130,6 +131,7 @@ type TrackHeaderProps = { overflowActions: OverflowAction[] ) => void onPlay: () => void + onPreview: () => void onShare: () => void onSave: () => void onRepost: () => void @@ -150,6 +152,7 @@ const TrackHeader = ({ duration, isLoading, isPlaying, + isPreviewing, isSaved, isReposted, isUnlisted, @@ -168,6 +171,7 @@ const TrackHeader = ({ tags, aiAttributedUserId, onPlay, + onPreview, onShare, onSave, onRepost, @@ -187,9 +191,6 @@ const TrackHeader = ({ const showListenCount = isOwner || (!isPremium && (isUnlisted || fieldVisibility.play_count)) - // TODO: https://linear.app/audius/issue/PAY-1590/[webmobileweb]-add-support-for-playing-previews - const onPreview = useCallback(() => console.info('Preview Clicked'), []) - const image = useTrackCoverArt( trackId, coverArtSizes, @@ -389,7 +390,7 @@ const TrackHeader = ({ {showPlay ? ( ) : null} @@ -407,7 +408,7 @@ const TrackHeader = ({ /> ) : null} {showPreview ? ( - + ) : null} void + onHeroPlay: ({ + isPlaying, + isPreview + }: { + isPlaying: boolean + isPreview?: boolean + }) => void onHeroShare: (trackId: ID) => void goToAllRemixesPage: () => void goToParentRemixesPage: () => void @@ -78,6 +85,7 @@ const TrackPage = ({ heroTrack, user, heroPlaying, + previewing, userId, onHeroPlay, onHeroShare, @@ -120,7 +128,9 @@ const TrackPage = ({ usePremiumContentAccess(heroTrack) const loading = !heroTrack || isUserAccessTBD - const onPlay = () => onHeroPlay(heroPlaying) + const onPlay = () => onHeroPlay({ isPlaying: heroPlaying }) + const onPreview = () => + onHeroPlay({ isPlaying: heroPlaying, isPreview: true }) const onSave = isOwner ? () => {} : () => heroTrack && onSaveTrack(isSaved, heroTrack.track_id) @@ -156,6 +166,7 @@ const TrackPage = ({