Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/common/src/services/remote-config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export enum FeatureFlags {
FAN_CLUB_TEXT_POST_POSTING = 'fan_club_text_post_posting',
QUEUE_NEW_FEATURE_BADGE = 'queue_new_feature_badge',
WEEKLY_ROTATION = 'weekly_rotation',
TRACK_ROWS = 'track_rows'
TRACK_ROWS = 'track_rows',
NOW_PLAYING_COMMENTS = 'now_playing_comments'
}

type FlagDefaults = Record<FeatureFlags, boolean>
Expand Down Expand Up @@ -55,5 +56,8 @@ export const flagDefaults: FlagDefaults = {
[FeatureFlags.WEEKLY_ROTATION]: true,
// Dense track rows instead of tiles. Default on so pages do not swap from
// tiles to rows when the datafile loads; the remote flag is the kill switch.
[FeatureFlags.TRACK_ROWS]: true
[FeatureFlags.TRACK_ROWS]: true,
// Timestamped comments as markers and bubbles on the Now Playing scrubber.
// Default on; the remote flag is the kill switch.
[FeatureFlags.NOW_PLAYING_COMMENTS]: true
}
187 changes: 187 additions & 0 deletions packages/mobile/src/components/now-playing-drawer/CommentBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useMemo, useState } from 'react'

import { useUser } from '@audius/common/api'
import type { ID } from '@audius/common/models'
import { playbackSelectors } from '@audius/common/store'
import { Dimensions, Pressable, View } from 'react-native'
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'
import TrackPlayer from 'react-native-track-player'
import { useSelector } from 'react-redux'

import { Flex, Text, useTheme } from '@audius/harmony-native'
import { useCommentDrawer } from 'app/components/comments/CommentDrawerContext'
import { ProfilePicture } from 'app/components/core'
import { useNavigation } from 'app/hooks/useNavigation'

import type { TimestampedComment } from './useTimestampedComments'

const { getSeek, getSeekCounter } = playbackSelectors

const messages = {
openComment: (name: string) => `Open comment by ${name}`
}

// How long a comment stays up after its timestamp plays.
const BUBBLE_WINDOW_S = 6
const POLL_MS = 500
const BUBBLE_MAX_WIDTH = Dimensions.get('window').width * 0.85

/**
* Whole-second playback position, polled while active. Cheap enough to run
* twice a second, and it follows seeks and the native player directly instead
* of the scrubber's own timer.
*/
const usePlaybackPosition = (isActive: boolean, mediaKey: string) => {
const [position, setPosition] = useState(0)
const seek = useSelector(getSeek)
const seekCounter = useSelector(getSeekCounter)

useEffect(() => {
setPosition(0)
}, [mediaKey])

useEffect(() => {
if (seek !== null) setPosition(Math.floor(seek))
}, [seek, seekCounter])

useEffect(() => {
if (!isActive) return
let cancelled = false
const tick = async () => {
const { position } = await TrackPlayer.getProgress()
if (cancelled) return
const whole = Math.floor(position)
setPosition((current) => (current === whole ? current : whole))
}
tick()
const interval = setInterval(tick, POLL_MS)
return () => {
cancelled = true
clearInterval(interval)
}
}, [isActive])

return position
}

type CommentBubbleProps = {
trackId: ID
comments: TimestampedComment[]
/** Poll playback only while the player is open and playing. */
isActive: boolean
mediaKey: string
}

/**
* Shows the comment whose timestamp just played, SoundCloud style, in the gap
* under the scrubber. Zero height in the layout; the bubble floats.
*/
export const CommentBubble = (props: CommentBubbleProps) => {
const { trackId, comments, isActive, mediaKey } = props
const { spacing } = useTheme()
const position = usePlaybackPosition(isActive, mediaKey)

// Most recent comment inside the window; most reacted on a tie.
const comment = useMemo(() => {
let best: TimestampedComment | null = null
for (const candidate of comments) {
const t = candidate.trackTimestampS
if (t > position) break
if (position - t >= BUBBLE_WINDOW_S) continue
if (
!best ||
t > best.trackTimestampS ||
(t === best.trackTimestampS && candidate.reactCount > best.reactCount)
) {
best = candidate
}
}
return best
}, [comments, position])

return (
<View style={{ height: 0, zIndex: 1 }}>
<View
pointerEvents='box-none'
style={{
position: 'absolute',
top: spacing.xs,
left: 0,
right: 0,
alignItems: 'center'
}}
>
{comment ? (
<Animated.View
key={comment.id}
entering={FadeInDown.duration(180)}
exiting={FadeOutDown.duration(180)}
>
<Bubble trackId={trackId} comment={comment} />
</Animated.View>
) : null}
</View>
</View>
)
}

const Bubble = ({
trackId,
comment
}: {
trackId: ID
comment: TimestampedComment
}) => {
const navigation = useNavigation()
const { open } = useCommentDrawer()
const { data: name } = useUser(comment.userId, {
select: (user) => user.name
})

const handlePress = useCallback(() => {
open({
entityId: trackId,
navigation,
playbackSource: 'comments',
highlightedComment: comment
})
}, [open, trackId, navigation, comment])

if (!name) return null

return (
<Pressable
onPress={handlePress}
accessibilityRole='button'
accessibilityLabel={messages.openComment(name)}
>
<Flex
row
alignItems='center'
gap='s'
pl='xs'
pr='m'
backgroundColor='surface2'
// 28px tall: clears the play button that overhangs the controls row
style={{
paddingVertical: 2,
borderRadius: 999,
maxWidth: BUBBLE_MAX_WIDTH
}}
>
<ProfilePicture userId={comment.userId} size='small' />
<Text
variant='body'
size='s'
numberOfLines={1}
style={{ flexShrink: 1 }}
>
<Text variant='body' size='s' strength='strong'>
{name}
</Text>{' '}
{comment.message}
</Text>
</Flex>
</Pressable>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'

import { useUser } from '@audius/common/api'
import { useCurrentTrack } from '@audius/common/hooks'
import { useCurrentTrack, useFeatureFlag } from '@audius/common/hooks'
import { FeatureFlags } from '@audius/common/services'
import { playbackActions, playbackSelectors } from '@audius/common/store'
import { Genre } from '@audius/common/utils'
import { useNavigationState } from '@react-navigation/native'
Expand Down Expand Up @@ -48,12 +50,14 @@ import { zIndex } from 'app/utils/zIndex'
import { ActionsBar } from './ActionsBar'
import { Artwork } from './Artwork'
import { AudioControls } from './AudioControls'
import { CommentBubble } from './CommentBubble'
import { Logo } from './Logo'
import { PlayBar } from './PlayBar'
import { TitleBar } from './TitleBar'
import { TrackInfo } from './TrackInfo'
import { PLAY_BAR_HEIGHT } from './constants'
import { useCurrentTrackDuration } from './useCurrentTrackDuration'
import { useTimestampedComments } from './useTimestampedComments'
const { seekTo: seek, reset, next, previous } = playbackActions

const { getPlaying, getTrackId, getCounter, getBuffering } = playbackSelectors
Expand Down Expand Up @@ -256,6 +260,23 @@ export const NowPlayingDrawer = memo(function NowPlayingDrawer(
const { data: user } = useUser(track?.owner_id)
const [mediaKey, setMediaKey] = useState(0)

const { isEnabled: isNowPlayingCommentsEnabled } = useFeatureFlag(
FeatureFlags.NOW_PLAYING_COMMENTS
)
const timestampedComments = useTimestampedComments(
trackId,
isNowPlayingCommentsEnabled && isOpen
)
const commentMarkers = useMemo(
() =>
trackDuration > 0 && timestampedComments.length > 0
? timestampedComments.map((comment) =>
Math.min(1, comment.trackTimestampS / trackDuration)
)
: undefined,
[timestampedComments, trackDuration]
)

useEffect(() => {
setMediaKey((mediaKey) => mediaKey + 1)
}, [playCounter, currentTrackId])
Expand Down Expand Up @@ -380,8 +401,17 @@ export const NowPlayingDrawer = memo(function NowPlayingDrawer(
onPressIn={onPressScrubberIn}
onPressOut={onPressScrubberOut}
duration={trackDuration}
markers={commentMarkers}
/>
</View>
{isNowPlayingCommentsEnabled && trackId ? (
<CommentBubble
trackId={trackId}
comments={timestampedComments}
isActive={isOpen && isPlaying && !isBuffering}
mediaKey={`${mediaKey}`}
/>
) : null}
<View style={styles.controlsContainer}>
<AudioControls
onNext={onNext}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useMemo } from 'react'

import { useTrackComments } from '@audius/common/api'
import type { Comment, ID } from '@audius/common/models'

const PAGE_SIZE = 50
// GetTrackCommentsSortMethodEnum.Timestamp
const SORT_BY_TIMESTAMP = 'timestamp'

export type TimestampedComment = Comment & { trackTimestampS: number }

/**
* The track's comments that carry a timestamp, oldest timestamp first.
* Capped at one page; the list is for markers and bubbles, not the thread.
*/
export const useTimestampedComments = (
trackId: ID | undefined,
enabled: boolean
) => {
const { data: comments } = useTrackComments(
{
trackId: trackId as ID,
sortMethod: SORT_BY_TIMESTAMP,
pageSize: PAGE_SIZE
},
{ enabled: enabled && !!trackId }
)

return useMemo(
() =>
(comments ?? [])
.filter(
(comment): comment is TimestampedComment =>
typeof comment.trackTimestampS === 'number' &&
comment.trackTimestampS >= 0 &&
!(comment as Comment).isTombstone
)
.sort((a, b) => a.trackTimestampS - b.trackTimestampS),
[comments]
)
}
6 changes: 5 additions & 1 deletion packages/mobile/src/components/scrubber/Scrubber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type ScrubberProps = {
* Callback invoked when focus is lost on the scrubber
*/
onPressOut: () => void
/** Rail positions, 0 to 1, marked with a dot. */
markers?: number[]
}

/**
Expand All @@ -59,7 +61,8 @@ export const Scrubber = ({
isPlaying,
duration,
onPressIn,
onPressOut
onPressOut,
markers
}: ScrubberProps) => {
const styles = useStyles()
const dispatch = useDispatch()
Expand Down Expand Up @@ -120,6 +123,7 @@ export const Scrubber = ({
onDrag={handleDrag}
onDragRelease={handleDragRelease}
duration={duration}
markers={markers}
/>
<Text style={styles.timestamp} weight='regular' numberOfLines={1}>
{formatSeconds(duration)}
Expand Down
Loading
Loading