diff --git a/.changeset/persistent-audio-player.md b/.changeset/persistent-audio-player.md
new file mode 100644
index 0000000000000..15d68178c43ac
--- /dev/null
+++ b/.changeset/persistent-audio-player.md
@@ -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.
diff --git a/apps/meteor/client/components/message/content/Attachments.tsx b/apps/meteor/client/components/message/content/Attachments.tsx
index d97270b01cf0b..7ea0fa7609f5a 100644
--- a/apps/meteor/client/components/message/content/Attachments.tsx
+++ b/apps/meteor/client/components/message/content/Attachments.tsx
@@ -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) => )}>;
+const Attachments = ({ attachments, id, source }: AttachmentsProps) => {
+ return (
+ <>{attachments?.map((attachment, index) => )}>
+ );
};
export default Attachments;
diff --git a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx
index f71afab03b05a..a1f45d868ad82 100644
--- a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx
+++ b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx
@@ -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 ;
+ return ;
}
if (isQuoteAttachment(attachment)) {
diff --git a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx
index 6553dc7aa315d..7146cc4e18919 100644
--- a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx
+++ b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx
@@ -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 ;
}
if (isFileAudioAttachment(attachment)) {
- return ;
+ return ;
}
if (isFileVideoAttachment(attachment)) {
diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx
index 227874cfb8eaf..54bf0a4968ebb 100644
--- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx
+++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx
@@ -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,
@@ -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(
+ () => ({
+ 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 ? : }
-
+
+ (active ? toggle() : play(track))}
+ onSeek={(time) => (active ? seek(time) : play(track))}
+ onChangePlaybackSpeed={cyclePlaybackRate}
+ />
+
>
);
diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
index afc482c40955e..e73f2e45c649f 100644
--- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
+++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
@@ -74,7 +74,13 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
>
)}
- {!!attachments && }
+ {!!attachments && (
+
+ )}
{normalizedMessage.blocks && (
diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
index 79081bb5a4cc9..0149b164376f0 100644
--- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
+++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
@@ -70,7 +70,13 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => {
)}
- {!!attachments && }
+ {!!attachments && (
+
+ )}
{oembedEnabled && !!normalizedMessage.urls?.length && }
diff --git a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts
new file mode 100644
index 0000000000000..50607808b6177
--- /dev/null
+++ b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts
@@ -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 `