From b2a4fca93f4b38886962158ae55982a64f5239ae Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 30 Jun 2026 15:24:41 -0300 Subject: [PATCH 01/23] feat(audio): persistent audio player across room navigation Audio message playback no longer stops when you switch or close the conversation. A single shared audio element is hoisted above the room layout into a new MediaPlayerProvider, so it survives message-list unmounting and keeps playing while the UI re-attaches to it. A "Now playing" card pinned to the top of the sidebar reflects the shared player: play/pause, seek, playback speed (1x/1.5x/2x), and a shortcut back to the originating conversation. In-message audio controls drive the same shared element. --- .changeset/persistent-audio-player.md | 6 + .../AudioPlayer/AudioPlayerControls.tsx | 85 +++++++ .../AudioPlayer/formatPlaybackTime.ts | 6 + .../message/content/Attachments.tsx | 8 +- .../content/attachments/AttachmentsItem.tsx | 6 +- .../content/attachments/FileAttachment.tsx | 5 +- .../attachments/file/AudioAttachment.tsx | 47 +++- .../variants/room/RoomMessageContent.tsx | 8 +- .../variants/thread/ThreadMessageContent.tsx | 8 +- .../MediaPlayerProvider/MediaPlayerContext.ts | 71 ++++++ .../MediaPlayerProvider.tsx | 226 ++++++++++++++++++ .../providers/MediaPlayerProvider/index.ts | 3 + .../client/providers/MeteorProvider.tsx | 5 +- apps/meteor/client/sidebar/Sidebar.tsx | 2 + .../sidebar/sections/NowPlayingSection.tsx | 65 +++++ packages/i18n/src/locales/en.i18n.json | 3 + 16 files changed, 540 insertions(+), 14 deletions(-) create mode 100644 .changeset/persistent-audio-player.md create mode 100644 apps/meteor/client/components/AudioPlayer/AudioPlayerControls.tsx create mode 100644 apps/meteor/client/components/AudioPlayer/formatPlaybackTime.ts create mode 100644 apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts create mode 100644 apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx create mode 100644 apps/meteor/client/providers/MediaPlayerProvider/index.ts create mode 100644 apps/meteor/client/sidebar/sections/NowPlayingSection.tsx 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/AudioPlayer/AudioPlayerControls.tsx b/apps/meteor/client/components/AudioPlayer/AudioPlayerControls.tsx new file mode 100644 index 0000000000000..c339985d30bde --- /dev/null +++ b/apps/meteor/client/components/AudioPlayer/AudioPlayerControls.tsx @@ -0,0 +1,85 @@ +import { Box, IconButton, Slider } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import { formatPlaybackTime } from './formatPlaybackTime'; + +type AudioPlayerControlsProps = { + playing: boolean; + currentTime: number; + duration: number; + playbackRate: number; + onToggle: () => void; + onSeek: (time: number) => void; + onCyclePlaybackRate: () => void; + /** Compact spacing/sizing for tight docks such as the sidebar card. */ + compact?: boolean; +}; + +const AudioPlayerControls = ({ + playing, + currentTime, + duration, + playbackRate, + onToggle, + onSeek, + onCyclePlaybackRate, + compact = false, +}: AudioPlayerControlsProps) => { + const { t } = useTranslation(); + + const maxValue = duration > 0 ? Math.floor(duration) : 0; + const value = Math.min(Math.floor(currentTime), maxValue); + + return ( + + + {!compact && ( + + {formatPlaybackTime(currentTime)} + + )} + + onSeek(Array.isArray(next) ? next[0] : next)} + /> + + + {compact ? `${formatPlaybackTime(currentTime)} / ${formatPlaybackTime(duration)}` : formatPlaybackTime(duration)} + + + {`${playbackRate}x`} + + + ); +}; + +export default AudioPlayerControls; diff --git a/apps/meteor/client/components/AudioPlayer/formatPlaybackTime.ts b/apps/meteor/client/components/AudioPlayer/formatPlaybackTime.ts new file mode 100644 index 0000000000000..2ad1a5e72c5fb --- /dev/null +++ b/apps/meteor/client/components/AudioPlayer/formatPlaybackTime.ts @@ -0,0 +1,6 @@ +/** Formats a number of seconds as `m:ss` (e.g. 86 → "1:26"). */ +export const formatPlaybackTime = (seconds: number): string => { + const total = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0)); + const minutes = Math.floor(total / 60); + return `${minutes}:${String(total % 60).padStart(2, '0')}`; +}; 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..2f18009ba0105 100644 --- a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx @@ -1,17 +1,18 @@ 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) => { +const FileAttachment = ({ source, ...attachment }: FileAttachmentProps & { source?: AudioAttachmentSource }) => { 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..b36698b57f291 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,22 @@ import type { AudioAttachmentProps } from '@rocket.chat/core-typings'; -import { AudioPlayer } 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 AudioPlayerControls from '../../../../AudioPlayer/AudioPlayerControls'; import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; +/** Extra context about the message that owns this audio, used by the persistent player. */ +export type AudioAttachmentSource = { + rid?: string; + mid?: string; + username?: string; + name?: string; +}; + const AudioAttachment = ({ title, audio_url: url, @@ -18,16 +27,44 @@ const AudioAttachment = ({ title_link: link, title_link_download: hasDownload, collapsed, -}: AudioAttachmentProps) => { + source, +}: AudioAttachmentProps & { source?: AudioAttachmentSource }) => { 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, + resolveUrl: () => getURL(url), + }), + [source?.mid, source?.rid, source?.username, source?.name, url, src, type, title, size, getURL], + ); + + const active = isActive(track.id); return ( <> {descriptionMd ? : } - + (active ? toggle() : play(track))} + onSeek={(time) => (active ? seek(time) : play(track))} + onCyclePlaybackRate={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..0d512cdbf9583 --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts @@ -0,0 +1,71 @@ +import { createContext, useContext } from 'react'; + +/** + * Describes a single audio track that the persistent player can own. + * The descriptor is fully self-contained so the player keeps working after the + * message (and the room) that originated it has been unmounted. + */ +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; + /** + * Re-resolves a fresh media URL. Used to recover from signed-URL expiry while + * the track keeps playing in the persistent player. Returns `undefined` to + * fall back to {@link PersistentAudioTrack.url}. + */ + resolveUrl?: () => string | undefined; +}; + +export type MediaPlayerContextValue = { + track: PersistentAudioTrack | null; + playing: boolean; + currentTime: number; + duration: number; + playbackRate: number; + /** Loads (if needed) and plays the given track in the shared audio 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 player. */ + isActive: (id: string) => boolean; +}; + +const noop = () => undefined; + +export const MediaPlayerContext = createContext({ + 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); diff --git a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx new file mode 100644 index 0000000000000..611ab532c253d --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx @@ -0,0 +1,226 @@ +import { 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'; + +const PLAYBACK_RATES = [1, 1.5, 2] as const; + +function toURL(urlString: string): URL { + try { + return new URL(urlString); + } catch { + return new URL(urlString, window.location.href); + } +} + +const getRedirectURLInfo = async (url: string): Promise<{ redirectUrl: string | false; expires: number | null }> => { + const _url = toURL(url); + _url.searchParams.set('replyWithRedirectUrl', 'true'); + const response = await fetch(_url, { credentials: 'same-origin' }); + + if (!response.ok) { + throw new Error(`Failed to fetch URL info: ${response.statusText}`); + } + + const data = await response.json(); + + return { + redirectUrl: data.redirectUrl, + expires: data.expires ? new Date(data.expires).getTime() : null, + }; +}; + +type MediaPlayerProviderProps = { + children?: ReactNode; +}; + +/** + * Owns the single, app-wide `