-
Notifications
You must be signed in to change notification settings - Fork 13.9k
feat(audio): persistent audio player across room navigation #41120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
b2a4fca
feat(audio): persistent audio player across room navigation
ggazzo 3f95184
fix(audio): mount Now playing card in new navigation sidebar too
ggazzo 80c05fd
refactor(audio): keep original message player, persist via hand-off
ggazzo 5493f8e
fix(audio): hide slider value output in persistent player
ggazzo 02eeefa
fix(audio): keep playback in persistent card on room return instead o…
ggazzo a93535f
fix(audio): match play/pause icons with native message player
ggazzo b1e81f6
refactor(audio): single shared audio element for seamless persistence
ggazzo 6ce18a1
fix(audio): close card on ended and hide it while source room is open
ggazzo df19b7a
fix(audio): match in-message player to original fuselage layout; neut…
ggazzo 118e08f
feat(audio): jump to the actual message from the now-playing card
ggazzo 82393b7
fix(audio): move card to sidebar bottom, drop now-playing header, neu…
ggazzo 381fe1f
feat(audio): show file size next to filename in the card
ggazzo ab01ff4
fix(audio): respect UI_Use_Real_Name setting for sender name in card
ggazzo 053ef44
refactor(sidebar): extract reusable SidebarCard wrapper
ggazzo 0b4e1c9
refactor(sidebar): SidebarCard takes only children, no prop spread
ggazzo 3eaa929
refactor(sidebar): use fuselage border props and radius token in Side…
ggazzo cdc625a
fix(audio): show sidebar card whenever a track is loaded (incl. after…
ggazzo e8c3bdc
refactor(audio): single shared AudioPlayerControls layout for message…
ggazzo ee8dc86
chore(audio): TODO to replace local controls with fuselage AudioPlaye…
ggazzo 062fc7a
fix(audio): reset recovery flag on early exits + re-check track after…
ggazzo 2467c4c
refactor(audio): use AudioPlayerControls from @rocket.chat/fuselage, …
ggazzo b5b4244
refactor(audio): reuse useReloadOnError in provider; split AudioAttac…
ggazzo 9cd3a89
chore: extract type from FileAttachment.tsx fn signature
gabriellsh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@rocket.chat/meteor': minor | ||
| '@rocket.chat/i18n': minor | ||
| --- | ||
|
|
||
| Added a persistent audio player. Playing an audio attachment now continues across room navigation: the audio keeps playing when you switch or close the conversation, and a "Now playing" card appears at the top of the sidebar with play/pause, seek, playback speed (1x/1.5x/2x), and a shortcut back to the originating conversation. |
8 changes: 6 additions & 2 deletions
8
apps/meteor/client/components/message/content/Attachments.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,18 @@ | ||
| import type { MessageAttachmentBase } from '@rocket.chat/core-typings'; | ||
|
|
||
| import AttachmentsItem from './attachments/AttachmentsItem'; | ||
| import type { AudioAttachmentSource } from './attachments/file/AudioAttachment'; | ||
|
|
||
| type AttachmentsProps = { | ||
| attachments: MessageAttachmentBase[]; | ||
| id?: string | undefined; | ||
| source?: AudioAttachmentSource; | ||
| }; | ||
|
|
||
| const Attachments = ({ attachments, id }: AttachmentsProps) => { | ||
| return <>{attachments?.map((attachment, index) => <AttachmentsItem key={index} id={id} attachment={{ ...attachment }} />)}</>; | ||
| const Attachments = ({ attachments, id, source }: AttachmentsProps) => { | ||
| return ( | ||
| <>{attachments?.map((attachment, index) => <AttachmentsItem key={index} id={id} attachment={{ ...attachment }} source={source} />)}</> | ||
| ); | ||
| }; | ||
|
|
||
| export default Attachments; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 7 additions & 2 deletions
9
apps/meteor/client/components/message/content/attachments/FileAttachment.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { createContext, useContext } from 'react'; | ||
|
|
||
| /** | ||
| * Describes a single audio track owned by the shared player. | ||
| * The descriptor is self-contained so the player keeps working after the message | ||
| * (and the room) that originated it has been unmounted — the underlying `<audio>` | ||
| * element lives in the provider and is never recreated on navigation. | ||
| */ | ||
| export type PersistentAudioTrack = { | ||
| /** Stable identity of the track, e.g. `${mid}:${url}`. */ | ||
| id: string; | ||
| /** Resolved, ready-to-play media URL. */ | ||
| url: string; | ||
| /** MIME type of the media, when known. */ | ||
| mediaType?: string; | ||
| /** File name shown in the player. */ | ||
| title: string; | ||
| /** File size in bytes, when known. */ | ||
| size?: number; | ||
| /** Room the audio was sent in (enables "jump back to conversation"). */ | ||
| rid?: string; | ||
| /** Message the audio belongs to (enables jump-to-message). */ | ||
| mid?: string; | ||
| /** Username of the sender (drives the avatar). */ | ||
| username?: string; | ||
| /** Display name of the sender. */ | ||
| name?: string; | ||
| }; | ||
|
|
||
| export type MediaPlayerContextValue = { | ||
| track: PersistentAudioTrack | null; | ||
| playing: boolean; | ||
| currentTime: number; | ||
| duration: number; | ||
| playbackRate: number; | ||
| /** Loads (only if a different track) and plays the given track in the shared element. */ | ||
| play: (track: PersistentAudioTrack) => void; | ||
| /** Toggles play/pause for the active track. No-op when no track is active. */ | ||
| toggle: () => void; | ||
| /** Seeks the active track to `time` seconds. */ | ||
| seek: (time: number) => void; | ||
| /** Cycles the playback rate 1x → 1.5x → 2x → 1x. */ | ||
| cyclePlaybackRate: () => void; | ||
| /** Stops playback and clears the active track. */ | ||
| close: () => void; | ||
| /** Whether the given track id is the one currently owned by the shared element. */ | ||
| isActive: (id: string) => boolean; | ||
| }; | ||
|
|
||
| const noop = () => undefined; | ||
|
|
||
| export const MediaPlayerContext = createContext<MediaPlayerContextValue>({ | ||
| track: null, | ||
| playing: false, | ||
| currentTime: 0, | ||
| duration: 0, | ||
| playbackRate: 1, | ||
| play: noop, | ||
| toggle: noop, | ||
| seek: noop, | ||
| cyclePlaybackRate: noop, | ||
| close: noop, | ||
| isActive: () => false, | ||
| }); | ||
|
|
||
| export const useMediaPlayer = (): MediaPlayerContextValue => useContext(MediaPlayerContext); |
131 changes: 131 additions & 0 deletions
131
apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { useMergedRefs, useStableCallback } from '@rocket.chat/fuselage-hooks'; | ||
| import type { ReactNode } from 'react'; | ||
| import { useCallback, useMemo, useRef, useState } from 'react'; | ||
|
|
||
| import type { MediaPlayerContextValue, PersistentAudioTrack } from './MediaPlayerContext'; | ||
| import { MediaPlayerContext } from './MediaPlayerContext'; | ||
| import { useReloadOnError } from '../../components/message/content/attachments/file/hooks/useReloadOnError'; | ||
|
|
||
| const PLAYBACK_RATES = [1, 1.5, 2] as const; | ||
|
|
||
| type MediaPlayerProviderProps = { | ||
| children?: ReactNode; | ||
| }; | ||
|
|
||
| /** | ||
| * Owns the single, app-wide `<audio>` element used to play message audio | ||
| * attachments. Because the element lives above the room layout and is never | ||
| * recreated, both the in-message controls and the sidebar card drive the very | ||
| * same element: switching or closing the room only swaps which UI is shown — the | ||
| * element keeps playing with no reload, seek, or gap. | ||
| */ | ||
| const MediaPlayerProvider = ({ children }: MediaPlayerProviderProps) => { | ||
| const audioRef = useRef<HTMLAudioElement | null>(null); | ||
|
|
||
| const [track, setTrack] = useState<PersistentAudioTrack | null>(null); | ||
| const [playing, setPlaying] = useState(false); | ||
| const [currentTime, setCurrentTime] = useState(0); | ||
| const [duration, setDuration] = useState(0); | ||
| const [playbackRate, setPlaybackRate] = useState<number>(1); | ||
|
|
||
| const trackRef = useRef<PersistentAudioTrack | null>(null); | ||
| trackRef.current = track; | ||
|
|
||
| // Reuse the message player's signed-URL recovery on the shared element. | ||
| const { mediaRef } = useReloadOnError(track?.url ?? '', 'audio'); | ||
| const audioCallback = useCallback((node: HTMLAudioElement | null) => { | ||
| audioRef.current = node; | ||
| }, []); | ||
| const setAudioRef = useMergedRefs(audioCallback, mediaRef); | ||
|
|
||
| const play = useStableCallback((next: PersistentAudioTrack) => { | ||
| const audio = audioRef.current; | ||
| if (!audio) { | ||
| return; | ||
| } | ||
|
|
||
| if (trackRef.current?.id !== next.id) { | ||
| setTrack(next); | ||
| setCurrentTime(0); | ||
| setDuration(0); | ||
| audio.src = next.url; | ||
| audio.load(); | ||
| } | ||
|
|
||
| audio.playbackRate = playbackRate; | ||
| audio.play().catch((err) => console.warn('Failed to start audio playback:', err)); | ||
| }); | ||
|
|
||
| const toggle = useStableCallback(() => { | ||
| const audio = audioRef.current; | ||
| if (!audio || !trackRef.current) { | ||
| return; | ||
| } | ||
| if (audio.paused) { | ||
| audio.play().catch((err) => console.warn('Failed to resume audio playback:', err)); | ||
| } else { | ||
| audio.pause(); | ||
| } | ||
| }); | ||
|
|
||
| const seek = useStableCallback((time: number) => { | ||
| const audio = audioRef.current; | ||
| if (!audio) { | ||
| return; | ||
| } | ||
| audio.currentTime = Math.max(0, Math.min(time, audio.duration || time)); | ||
| }); | ||
|
|
||
| const cyclePlaybackRate = useStableCallback(() => { | ||
| setPlaybackRate((rate) => { | ||
| const idx = PLAYBACK_RATES.indexOf(rate as (typeof PLAYBACK_RATES)[number]); | ||
| const nextRate = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length]; | ||
| if (audioRef.current) { | ||
| audioRef.current.playbackRate = nextRate; | ||
| } | ||
| return nextRate; | ||
| }); | ||
| }); | ||
|
|
||
| const close = useStableCallback(() => { | ||
| const audio = audioRef.current; | ||
| if (audio) { | ||
| audio.pause(); | ||
| audio.removeAttribute('src'); | ||
| audio.load(); | ||
| } | ||
| setTrack(null); | ||
| setPlaying(false); | ||
| setCurrentTime(0); | ||
| setDuration(0); | ||
| }); | ||
|
|
||
| const isActive = useCallback((id: string) => trackRef.current?.id === id, []); | ||
|
|
||
| const value = useMemo<MediaPlayerContextValue>( | ||
| () => ({ track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, isActive }), | ||
| [track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, isActive], | ||
| ); | ||
|
|
||
| return ( | ||
| <MediaPlayerContext.Provider value={value}> | ||
| {children} | ||
| <audio | ||
| ref={setAudioRef} | ||
| hidden | ||
| preload='metadata' | ||
| onPlay={() => setPlaying(true)} | ||
| onPause={() => setPlaying(false)} | ||
| onEnded={() => close()} | ||
| onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)} | ||
| onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)} | ||
| onDurationChange={(e) => setDuration(e.currentTarget.duration || 0)} | ||
| onRateChange={(e) => setPlaybackRate(e.currentTarget.playbackRate)} | ||
| > | ||
| <track kind='captions' /> | ||
| </audio> | ||
| </MediaPlayerContext.Provider> | ||
| ); | ||
| }; | ||
|
|
||
| export default MediaPlayerProvider; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export { default } from './MediaPlayerProvider'; | ||
| export { MediaPlayerContext, useMediaPlayer } from './MediaPlayerContext'; | ||
| export type { MediaPlayerContextValue, PersistentAudioTrack } from './MediaPlayerContext'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.