Skip to content
Merged
Show file tree
Hide file tree
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 Jun 30, 2026
3f95184
fix(audio): mount Now playing card in new navigation sidebar too
ggazzo Jun 30, 2026
80c05fd
refactor(audio): keep original message player, persist via hand-off
ggazzo Jun 30, 2026
5493f8e
fix(audio): hide slider value output in persistent player
ggazzo Jun 30, 2026
02eeefa
fix(audio): keep playback in persistent card on room return instead o…
ggazzo Jun 30, 2026
a93535f
fix(audio): match play/pause icons with native message player
ggazzo Jun 30, 2026
b1e81f6
refactor(audio): single shared audio element for seamless persistence
ggazzo Jun 30, 2026
6ce18a1
fix(audio): close card on ended and hide it while source room is open
ggazzo Jun 30, 2026
df19b7a
fix(audio): match in-message player to original fuselage layout; neut…
ggazzo Jun 30, 2026
118e08f
feat(audio): jump to the actual message from the now-playing card
ggazzo Jun 30, 2026
82393b7
fix(audio): move card to sidebar bottom, drop now-playing header, neu…
ggazzo Jun 30, 2026
381fe1f
feat(audio): show file size next to filename in the card
ggazzo Jun 30, 2026
ab01ff4
fix(audio): respect UI_Use_Real_Name setting for sender name in card
ggazzo Jun 30, 2026
053ef44
refactor(sidebar): extract reusable SidebarCard wrapper
ggazzo Jul 1, 2026
0b4e1c9
refactor(sidebar): SidebarCard takes only children, no prop spread
ggazzo Jul 1, 2026
3eaa929
refactor(sidebar): use fuselage border props and radius token in Side…
ggazzo Jul 1, 2026
cdc625a
fix(audio): show sidebar card whenever a track is loaded (incl. after…
ggazzo Jul 1, 2026
e8c3bdc
refactor(audio): single shared AudioPlayerControls layout for message…
ggazzo Jul 1, 2026
ee8dc86
chore(audio): TODO to replace local controls with fuselage AudioPlaye…
ggazzo Jul 1, 2026
062fc7a
fix(audio): reset recovery flag on early exits + re-check track after…
ggazzo Jul 2, 2026
2467c4c
refactor(audio): use AudioPlayerControls from @rocket.chat/fuselage, …
ggazzo Jul 2, 2026
b5b4244
refactor(audio): reuse useReloadOnError in provider; split AudioAttac…
ggazzo Jul 3, 2026
9cd3a89
chore: extract type from FileAttachment.tsx fn signature
gabriellsh Jul 3, 2026
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
6 changes: 6 additions & 0 deletions .changeset/persistent-audio-player.md
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.
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;
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import { memo } from 'react';
import DefaultAttachment from './DefaultAttachment';
import FileAttachment from './FileAttachment';
import { QuoteAttachment } from './QuoteAttachment';
import type { AudioAttachmentSource } from './file/AudioAttachment';

type AttachmentsItemProps = {
attachment: MessageAttachmentBase;
id: string | undefined;
source?: AudioAttachmentSource;
};

const AttachmentsItem = ({ attachment, id }: AttachmentsItemProps) => {
const AttachmentsItem = ({ attachment, id, source }: AttachmentsItemProps) => {
if (isFileAttachment(attachment)) {
return <FileAttachment id={id} {...attachment} />;
return <FileAttachment id={id} source={source} {...attachment} />;
}

if (isQuoteAttachment(attachment)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { type FileAttachmentProps, isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings';

import AudioAttachment from './file/AudioAttachment';
import type { AudioAttachmentSource } from './file/AudioAttachment';
import GenericFileAttachment from './file/GenericFileAttachment';
import ImageAttachment from './file/ImageAttachment';
import VideoAttachment from './file/VideoAttachment';

const FileAttachment = (attachment: FileAttachmentProps) => {
type FileAttachmentComponentProps = FileAttachmentProps & {
source?: AudioAttachmentSource;
};

const FileAttachment = ({ source, ...attachment }: FileAttachmentComponentProps) => {
if (isFileImageAttachment(attachment)) {
return <ImageAttachment {...attachment} />;
}

if (isFileAudioAttachment(attachment)) {
return <AudioAttachment {...attachment} />;
return <AudioAttachment {...attachment} source={source} />;
}

if (isFileVideoAttachment(attachment)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import type { AudioAttachmentProps } from '@rocket.chat/core-typings';
import { AudioPlayer } from '@rocket.chat/fuselage';
import { AudioPlayerControls, Box } from '@rocket.chat/fuselage';
import { useMediaUrl } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';

import { useReloadOnError } from './hooks/useReloadOnError';
import { useMediaPlayer } from '../../../../../providers/MediaPlayerProvider';
import type { PersistentAudioTrack } from '../../../../../providers/MediaPlayerProvider';
import MarkdownText from '../../../../MarkdownText';
import MessageCollapsible from '../../../MessageCollapsible';
import MessageContentBody from '../../../MessageContentBody';

/** Extra context about the message that owns this audio, used by the shared player. */
export type AudioAttachmentSource = {
rid?: string;
mid?: string;
username?: string;
name?: string;
};

type AudioAttachmentComponentProps = AudioAttachmentProps & {
source?: AudioAttachmentSource;
};

const AudioAttachment = ({
title,
audio_url: url,
Expand All @@ -18,16 +31,56 @@ const AudioAttachment = ({
title_link: link,
title_link_download: hasDownload,
collapsed,
}: AudioAttachmentProps) => {
source,
}: AudioAttachmentComponentProps) => {
const getURL = useMediaUrl();
const src = useMemo(() => getURL(url), [getURL, url]);
const { mediaRef } = useReloadOnError(src, 'audio');

const { play, toggle, seek, cyclePlaybackRate, isActive, playing, currentTime, duration, playbackRate } = useMediaPlayer();

const track = useMemo<PersistentAudioTrack>(
() => ({
id: `${source?.mid ?? ''}:${url}`,
url: src,
mediaType: type,
title: title || url,
size,
rid: source?.rid,
mid: source?.mid,
username: source?.username,
name: source?.name,
}),
[source?.mid, source?.rid, source?.username, source?.name, url, src, type, title, size],
);

const active = isActive(track.id);

return (
<>
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
<AudioPlayer src={src} type={type} ref={mediaRef} />
<Box
borderWidth='default'
borderStyle='solid'
borderColor='extra-light'
bg='tint'
pb={12}
pie={8}
pis={16}
borderRadius='x4'
w='100%'
maxWidth='x300'
>
<AudioPlayerControls
isPlaying={active && playing}
currentTime={active ? currentTime : 0}
durationTime={active ? duration : 0}
playbackSpeed={playbackRate}
onTogglePlay={() => (active ? toggle() : play(track))}
onSeek={(time) => (active ? seek(time) : play(track))}
onChangePlaybackSpeed={cyclePlaybackRate}
/>
</Box>
</MessageCollapsible>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
</>
)}

{!!attachments && <Attachments id={message.files?.[0]?._id} attachments={attachments} />}
{!!attachments && (
<Attachments
id={message.files?.[0]?._id}
attachments={attachments}
source={{ rid: message.rid, mid: message._id, username: message.u.username, name: message.u.name }}
/>
)}

{normalizedMessage.blocks && (
<UiKitMessageBlock rid={normalizedMessage.rid} mid={normalizedMessage._id} blocks={normalizedMessage.blocks} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => {
<UiKitMessageBlock rid={normalizedMessage.rid} mid={normalizedMessage._id} blocks={normalizedMessage.blocks} />
)}

{!!attachments && <Attachments id={message.files?.[0]?._id} attachments={attachments} />}
{!!attachments && (
<Attachments
id={message.files?.[0]?._id}
attachments={attachments}
source={{ rid: message.rid, mid: message._id, username: message.u.username, name: message.u.name }}
/>
)}

{oembedEnabled && !!normalizedMessage.urls?.length && <UrlPreviews urls={normalizedMessage.urls} />}

Expand Down
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);
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');
Comment thread
ggazzo marked this conversation as resolved.
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;
3 changes: 3 additions & 0 deletions apps/meteor/client/providers/MediaPlayerProvider/index.ts
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';
Loading
Loading