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 `