From 8761a2ba8888dbc7dca097f745347eaecf068ff4 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 22 Oct 2026 14:36:04 -0700 Subject: [PATCH] feat(mobile): comment markers and bubbles on the Now Playing scrubber Timestamped comments show up in the player without opening the drawer, the way SoundCloud does it. Each one is a dot above the scrubber rail, and when playback reaches it the comment appears as a pill under the scrubber for six seconds: avatar, name, first line. Tapping the pill opens the comment drawer with that comment highlighted. Behind now_playing_comments (default on). Comments load with the timestamp sort, one page of 50, only while the drawer is open. Position is polled from the native player twice a second while playing. Co-Authored-By: Claude Fable 5.1 --- .../services/remote-config/feature-flags.ts | 8 +- .../now-playing-drawer/CommentBubble.tsx | 187 ++++++++++++++++++ .../now-playing-drawer/NowPlayingDrawer.tsx | 32 ++- .../useTimestampedComments.ts | 41 ++++ .../src/components/scrubber/Scrubber.tsx | 6 +- .../mobile/src/components/scrubber/Slider.tsx | 30 ++- 6 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 packages/mobile/src/components/now-playing-drawer/CommentBubble.tsx create mode 100644 packages/mobile/src/components/now-playing-drawer/useTimestampedComments.ts diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 8619bdde269..26d86158fc7 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -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 @@ -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 } diff --git a/packages/mobile/src/components/now-playing-drawer/CommentBubble.tsx b/packages/mobile/src/components/now-playing-drawer/CommentBubble.tsx new file mode 100644 index 00000000000..f9547c4aa14 --- /dev/null +++ b/packages/mobile/src/components/now-playing-drawer/CommentBubble.tsx @@ -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 ( + + + {comment ? ( + + + + ) : null} + + + ) +} + +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 ( + + + + + + {name} + {' '} + {comment.message} + + + + ) +} diff --git a/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx b/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx index 6cae3a23f58..c60778dd1c9 100644 --- a/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx +++ b/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx @@ -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' @@ -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 @@ -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]) @@ -380,8 +401,17 @@ export const NowPlayingDrawer = memo(function NowPlayingDrawer( onPressIn={onPressScrubberIn} onPressOut={onPressScrubberOut} duration={trackDuration} + markers={commentMarkers} /> + {isNowPlayingCommentsEnabled && trackId ? ( + + ) : null} { + 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] + ) +} diff --git a/packages/mobile/src/components/scrubber/Scrubber.tsx b/packages/mobile/src/components/scrubber/Scrubber.tsx index 4da976f6096..4d02f8462cb 100644 --- a/packages/mobile/src/components/scrubber/Scrubber.tsx +++ b/packages/mobile/src/components/scrubber/Scrubber.tsx @@ -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[] } /** @@ -59,7 +61,8 @@ export const Scrubber = ({ isPlaying, duration, onPressIn, - onPressOut + onPressOut, + markers }: ScrubberProps) => { const styles = useStyles() const dispatch = useDispatch() @@ -120,6 +123,7 @@ export const Scrubber = ({ onDrag={handleDrag} onDragRelease={handleDragRelease} duration={duration} + markers={markers} /> {formatSeconds(duration)} diff --git a/packages/mobile/src/components/scrubber/Slider.tsx b/packages/mobile/src/components/scrubber/Slider.tsx index b439d53d48e..32a574473e9 100644 --- a/packages/mobile/src/components/scrubber/Slider.tsx +++ b/packages/mobile/src/components/scrubber/Slider.tsx @@ -21,6 +21,8 @@ const { getPlaybackRate, getSeek, getSeekCounter } = playbackSelectors // How much the handle "grows" when pressing const HANDLE_GROW_SCALE = 1.1 +// Comment marker dot above the rail +const MARKER_SIZE = 4 const useStyles = makeStyles(({ palette, spacing }) => ({ root: { @@ -70,6 +72,14 @@ const useStyles = makeStyles(({ palette, spacing }) => ({ shadowOpacity: 0.5, shadowRadius: 2, elevation: 3 + }, + marker: { + position: 'absolute', + top: -8, + width: MARKER_SIZE, + height: MARKER_SIZE, + borderRadius: MARKER_SIZE / 2, + backgroundColor: palette.secondary } })) @@ -103,6 +113,11 @@ type SliderProps = { */ onDrag: (percentComplete: number) => void onDragRelease: () => void + /** + * Positions along the rail, 0 to 1, that get a small dot above them + * (timestamped comments). + */ + markers?: number[] } /** @@ -120,7 +135,8 @@ export const Slider = memo(function Slider(props: SliderProps) { onPressOut, onNewPosition, onDrag, - onDragRelease + onDragRelease, + markers } = props const trackGenre = useCurrentTrack({ select: (track) => track?.genre @@ -408,6 +424,18 @@ export const Slider = memo(function Slider(props: SliderProps) { + {railWidth > 0 + ? markers?.map((fraction, index) => ( + + )) + : null}