feat(shared-music): add support for Vimeo, SoundCloud, Spotify, Dailymotion, Twitch

- Add source type detection for Vimeo, SoundCloud, Spotify, Dailymotion, Twitch
- Create EmbedPlayerManager for iframe-based media players
- Update extractMusicUrl to extract IDs from various platforms
- Update SharedMusicPlayer to route to appropriate player manager
- Update SharedMusicTile to handle video vs audio sources
- Video sources (YouTube, Vimeo, Dailymotion, Twitch) show embedded player
- Audio sources (SoundCloud, Spotify) show embedded widget
- Direct audio files show HTML5 audio player with custom controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-05 16:43:22 +00:00
parent 751a04b950
commit f56986818b
6 changed files with 494 additions and 33 deletions

View File

@ -26,7 +26,8 @@
width: 100%;
height: 100%;
.youtube-player-container {
.youtube-player-container,
.embed-player-container {
width: 100%;
height: 100%;

View File

@ -0,0 +1,218 @@
import React from 'react';
import { connect } from 'react-redux';
import { PLAYBACK_STATUSES, SOURCE_TYPES } from '../../constants';
import AbstractMusicManager, {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from './AbstractMusicManager';
/**
* Generates the embed URL for various platforms.
*
* @param {string} url - The original URL.
* @param {string} sourceType - The source type.
* @returns {string} The embed URL.
*/
function getEmbedUrl(url: string, sourceType: string): string {
switch (sourceType) {
case SOURCE_TYPES.VIMEO: {
// Extract Vimeo ID and create embed URL
const vimeoMatch = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
if (vimeoMatch) {
return `https://player.vimeo.com/video/${vimeoMatch[1]}?autoplay=1&autopause=0`;
}
return url;
}
case SOURCE_TYPES.SOUNDCLOUD: {
// SoundCloud requires URL encoding
const encodedUrl = encodeURIComponent(url);
return `https://w.soundcloud.com/player/?url=${encodedUrl}&auto_play=true&visual=true`;
}
case SOURCE_TYPES.SPOTIFY: {
// Extract Spotify type and ID
const spotifyMatch = url.match(/spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/);
if (spotifyMatch) {
return `https://open.spotify.com/embed/${spotifyMatch[1]}/${spotifyMatch[2]}?utm_source=generator&theme=0`;
}
return url;
}
case SOURCE_TYPES.DAILYMOTION: {
// Extract Dailymotion ID
const dmMatch = url.match(/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/);
if (dmMatch) {
return `https://www.dailymotion.com/embed/video/${dmMatch[1]}?autoplay=1`;
}
return url;
}
case SOURCE_TYPES.TWITCH: {
const parent = window.location.hostname;
// Check for video or channel
const videoMatch = url.match(/twitch\.tv\/videos\/(\d+)/);
const channelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\?|$)/);
if (videoMatch) {
return `https://player.twitch.tv/?video=v${videoMatch[1]}&parent=${parent}&autoplay=true`;
}
if (channelMatch && channelMatch[1] !== 'videos') {
return `https://player.twitch.tv/?channel=${channelMatch[1]}&parent=${parent}&autoplay=true`;
}
return url;
}
default:
return url;
}
}
/**
* Manager for embedded media players (Vimeo, SoundCloud, Spotify, Dailymotion, Twitch).
* Uses iframe embeds for playback.
*/
class EmbedPlayerManager extends AbstractMusicManager {
iframeRef: React.RefObject<HTMLIFrameElement>;
_isPlaying: boolean;
_isMuted: boolean;
_currentTime: number;
/**
* Initializes a new EmbedPlayerManager instance.
*
* @param {Object} props - This component's props.
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.iframeRef = React.createRef();
this._isPlaying = true; // Assume playing since autoplay is enabled
this._isMuted = false;
this._currentTime = 0;
}
/**
* Indicates the playback state of the music.
* Note: Most embed players don't provide reliable state feedback.
*
* @returns {string}
*/
override getPlaybackStatus() {
return this._isPlaying ? PLAYBACK_STATUSES.PLAYING : PLAYBACK_STATUSES.PAUSED;
}
/**
* Indicates whether the music is muted.
*
* @returns {boolean}
*/
override isMuted() {
return this._isMuted;
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
override getVolume() {
return this._isMuted ? 0 : 1;
}
/**
* Retrieves current time.
* Note: Embedded players don't provide time reliably.
*
* @returns {number}
*/
override getTime() {
return this._currentTime;
}
/**
* Seeks music to provided time.
* Note: Most embedded players don't support external seek control.
*
* @param {number} time - The time to seek to.
* @returns {void}
*/
override seek(time: number) {
this._currentTime = time;
}
/**
* Plays music.
* Note: Embedded players are controlled by user interaction within the iframe.
*
* @returns {void}
*/
override play() {
this._isPlaying = true;
}
/**
* Pauses music.
* Note: Embedded players are controlled by user interaction within the iframe.
*
* @returns {void}
*/
override pause() {
this._isPlaying = false;
}
/**
* Mutes music.
*
* @returns {void}
*/
override mute() {
this._isMuted = true;
}
/**
* Unmutes music.
*
* @returns {void}
*/
override unMute() {
this._isMuted = false;
}
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
const { musicId, _sourceType } = this.props;
const embedUrl = getEmbedUrl(musicId, _sourceType ?? '');
return (
<div className = 'embed-player-container'>
<iframe
allow = 'autoplay; encrypted-media; fullscreen; picture-in-picture'
allowFullScreen = { true }
frameBorder = '0'
height = '100%'
id = 'sharedMusicPlayer'
ref = { this.iframeRef }
src = { embedUrl }
title = 'Shared Media Player'
width = '100%' />
</div>
);
}
}
export default connect(_mapStateToProps, _mapDispatchToProps)(EmbedPlayerManager);

View File

@ -8,6 +8,7 @@ import { isSharingStatus } from '../../functions';
import { SourceType } from '../../types';
import DirectAudioManager from './DirectAudioManager';
import EmbedPlayerManager from './EmbedPlayerManager';
import YouTubeMusicManager from './YouTubeMusicManager';
interface IProps {
@ -33,6 +34,17 @@ interface IProps {
sourceType?: SourceType;
}
/**
* Embedded source types that use iframe players.
*/
const EMBED_SOURCE_TYPES: readonly string[] = [
SOURCE_TYPES.VIMEO,
SOURCE_TYPES.SOUNDCLOUD,
SOURCE_TYPES.SPOTIFY,
SOURCE_TYPES.DAILYMOTION,
SOURCE_TYPES.TWITCH
];
/**
* Component that manages and renders the appropriate music player.
*/
@ -50,10 +62,17 @@ class SharedMusicPlayer extends Component<IProps> {
return null;
}
// YouTube has its own dedicated player
if (sourceType === SOURCE_TYPES.YOUTUBE) {
return <YouTubeMusicManager musicId = { musicUrl } />;
}
// Vimeo, SoundCloud, Spotify, Dailymotion, Twitch use iframe embeds
if (sourceType && EMBED_SOURCE_TYPES.includes(sourceType)) {
return <EmbedPlayerManager musicId = { musicUrl } />;
}
// Direct audio/video files use HTML5 audio element
return <DirectAudioManager musicId = { musicUrl } />;
}

View File

@ -10,6 +10,28 @@ import { isSharingStatus } from '../../functions';
import SharedMusicPlayer from './SharedMusicPlayer';
/**
* Source types that render video content (should show iframe player).
*/
const VIDEO_SOURCE_TYPES: readonly string[] = [
SOURCE_TYPES.YOUTUBE,
SOURCE_TYPES.VIMEO,
SOURCE_TYPES.DAILYMOTION,
SOURCE_TYPES.TWITCH
];
/**
* Source types that have embedded controls (user interacts with iframe).
*/
const EMBEDDED_CONTROL_TYPES: readonly string[] = [
SOURCE_TYPES.YOUTUBE,
SOURCE_TYPES.VIMEO,
SOURCE_TYPES.SOUNDCLOUD,
SOURCE_TYPES.SPOTIFY,
SOURCE_TYPES.DAILYMOTION,
SOURCE_TYPES.TWITCH
];
interface IProps {
/**
* The participant ID (music URL).
@ -35,7 +57,12 @@ const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
const isOwner = ownerId === localParticipant?.id;
const isMusicShared = isSharingStatus(status ?? '');
const isPlaying = status === PLAYBACK_STATUSES.PLAYING;
const isYouTube = sourceType === SOURCE_TYPES.YOUTUBE;
// Determine if this source type shows video content
const isVideoSource = sourceType && VIDEO_SOURCE_TYPES.includes(sourceType);
// Determine if this source type has embedded controls (so we don't show our own)
const hasEmbeddedControls = sourceType && EMBEDDED_CONTROL_TYPES.includes(sourceType);
const handlePlayPause = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); // Prevent thumbnail click from pinning
@ -61,27 +88,27 @@ const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
return (
<div className = 'shared-music-tile'>
{/* Render the actual player for YouTube videos */}
{isYouTube ? (
{/* Render the actual player for video sources */}
{isVideoSource || hasEmbeddedControls ? (
<div className = 'shared-music-player-wrapper'>
<SharedMusicPlayer />
</div>
) : (
/* For audio-only, show a background with controls */
/* For direct audio files, show a background with controls */
<div className = 'shared-music-audio-bg'>
<SharedMusicPlayer />
</div>
)}
{/* Overlay with title and controls for non-owners */}
{/* Overlay with title and controls */}
<div className = 'shared-music-controls-overlay'>
{/* Title */}
<div className = 'shared-music-title'>
{title || 'Shared Music'}
{title || 'Shared Media'}
</div>
{/* Play/Pause button (owner only, shown when YouTube controls are hidden) */}
{isOwner && !isYouTube && (
{/* Play/Pause button (owner only, shown for direct audio without embedded controls) */}
{isOwner && !hasEmbeddedControls && (
<button
aria-label = { isPlaying ? 'Pause' : 'Play' }
className = 'shared-music-control-button'
@ -95,8 +122,8 @@ const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
</button>
)}
{/* Status indicator for non-owners when not YouTube */}
{!isOwner && !isYouTube && (
{/* Status indicator for non-owners when no embedded controls */}
{!isOwner && !hasEmbeddedControls && (
<div className = 'shared-music-status'>
{isPlaying ? 'Playing' : 'Paused'}
</div>

View File

@ -31,6 +31,11 @@ export const PLAYBACK_START = 'start';
*/
export const SOURCE_TYPES = {
YOUTUBE: 'youtube',
VIMEO: 'vimeo',
SOUNDCLOUD: 'soundcloud',
SPOTIFY: 'spotify',
DAILYMOTION: 'dailymotion',
TWITCH: 'twitch',
DIRECT: 'direct'
} as const;
@ -43,3 +48,28 @@ export const YOUTUBE_URL_DOMAIN = 'youtube.com';
* YouTube Music domain.
*/
export const YOUTUBE_MUSIC_URL_DOMAIN = 'music.youtube.com';
/**
* Vimeo domain.
*/
export const VIMEO_URL_DOMAIN = 'vimeo.com';
/**
* SoundCloud domain.
*/
export const SOUNDCLOUD_URL_DOMAIN = 'soundcloud.com';
/**
* Spotify domain.
*/
export const SPOTIFY_URL_DOMAIN = 'spotify.com';
/**
* Dailymotion domain.
*/
export const DAILYMOTION_URL_DOMAIN = 'dailymotion.com';
/**
* Twitch domain.
*/
export const TWITCH_URL_DOMAIN = 'twitch.tv';

View File

@ -3,10 +3,15 @@ import { IJitsiConference } from '../base/conference/reducer';
import { toState } from '../base/redux/functions';
import {
DAILYMOTION_URL_DOMAIN,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_MUSIC,
SOUNDCLOUD_URL_DOMAIN,
SOURCE_TYPES,
SPOTIFY_URL_DOMAIN,
TWITCH_URL_DOMAIN,
VIMEO_URL_DOMAIN,
YOUTUBE_MUSIC_URL_DOMAIN,
YOUTUBE_URL_DOMAIN
} from './constants';
@ -30,6 +35,86 @@ function getYoutubeId(url: string): string | null {
return result ? result[1] : null;
}
/**
* Extracts Vimeo video ID from URL.
*
* @param {string} url - The entered URL.
* @returns {string | null} The Vimeo video id if matched.
*/
function getVimeoId(url: string): string | null {
if (!url) {
return null;
}
// Matches vimeo.com/123456789 or player.vimeo.com/video/123456789
const p = /(?:https?:\/\/)?(?:www\.|player\.)?vimeo\.com\/(?:video\/)?(\d+)/;
const result = url.match(p);
return result ? result[1] : null;
}
/**
* Extracts Dailymotion video ID from URL.
*
* @param {string} url - The entered URL.
* @returns {string | null} The Dailymotion video id if matched.
*/
function getDailymotionId(url: string): string | null {
if (!url) {
return null;
}
// Matches dailymotion.com/video/x123abc or dai.ly/x123abc
const p = /(?:https?:\/\/)?(?:www\.)?(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/;
const result = url.match(p);
return result ? result[1] : null;
}
/**
* Extracts Twitch channel or video from URL.
*
* @param {string} url - The entered URL.
* @returns {Object|null} The Twitch info if matched.
*/
function getTwitchInfo(url: string): { id: string; type: string; } | null {
if (!url) {
return null;
}
// Matches twitch.tv/channel or twitch.tv/videos/123456
const channelMatch = url.match(/(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([a-zA-Z0-9_]+)(?:\?|$)/);
const videoMatch = url.match(/(?:https?:\/\/)?(?:www\.)?twitch\.tv\/videos\/(\d+)/);
if (videoMatch) {
return { type: 'video', id: videoMatch[1] };
}
if (channelMatch && channelMatch[1] !== 'videos') {
return { type: 'channel', id: channelMatch[1] };
}
return null;
}
/**
* Extracts Spotify track/album/playlist from URL.
*
* @param {string} url - The entered URL.
* @returns {Object|null} The Spotify info if matched.
*/
function getSpotifyInfo(url: string): { id: string; type: string; } | null {
if (!url) {
return null;
}
// Matches open.spotify.com/track/123, /album/123, /playlist/123
const p = /(?:https?:\/\/)?open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
const result = url.match(p);
return result ? { type: result[1], id: result[2] } : null;
}
/**
* Checks if the status is one that is actually sharing music - playing, pause or start.
*
@ -47,19 +132,54 @@ export function isSharingStatus(status: string): boolean {
* @returns {SourceType} The source type.
*/
export function getSourceType(url: string): SourceType {
const youtubeId = getYoutubeId(url);
if (youtubeId) {
if (getYoutubeId(url)) {
return SOURCE_TYPES.YOUTUBE;
}
if (getVimeoId(url)) {
return SOURCE_TYPES.VIMEO;
}
if (getDailymotionId(url)) {
return SOURCE_TYPES.DAILYMOTION;
}
if (getTwitchInfo(url)) {
return SOURCE_TYPES.TWITCH;
}
if (getSpotifyInfo(url)) {
return SOURCE_TYPES.SPOTIFY;
}
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
if (urlObj.hostname.includes(YOUTUBE_URL_DOMAIN)
|| urlObj.hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
if (hostname.includes(YOUTUBE_URL_DOMAIN)
|| hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
return SOURCE_TYPES.YOUTUBE;
}
if (hostname.includes(VIMEO_URL_DOMAIN)) {
return SOURCE_TYPES.VIMEO;
}
if (hostname.includes(SOUNDCLOUD_URL_DOMAIN)) {
return SOURCE_TYPES.SOUNDCLOUD;
}
if (hostname.includes(SPOTIFY_URL_DOMAIN)) {
return SOURCE_TYPES.SPOTIFY;
}
if (hostname.includes(DAILYMOTION_URL_DOMAIN)) {
return SOURCE_TYPES.DAILYMOTION;
}
if (hostname.includes(TWITCH_URL_DOMAIN)) {
return SOURCE_TYPES.TWITCH;
}
} catch (_) {
// Not a valid URL
}
@ -68,12 +188,16 @@ export function getSourceType(url: string): SourceType {
}
/**
* Extracts a YouTube ID or validates a direct URL.
* Extracts media info from URL based on the source type.
*
* @param {string} input - The user input.
* @returns {Object | undefined} An object with url and sourceType, or undefined.
* @returns {Object | undefined} An object with url, sourceType, and optional embedInfo.
*/
export function extractMusicUrl(input: string): { sourceType: SourceType; url: string; } | undefined {
export function extractMusicUrl(input: string): {
embedInfo?: { id: string; type?: string; };
sourceType: SourceType;
url: string;
} | undefined {
if (!input) {
return;
}
@ -84,33 +208,75 @@ export function extractMusicUrl(input: string): { sourceType: SourceType; url: s
return;
}
// YouTube
const youtubeId = getYoutubeId(trimmedLink);
if (youtubeId) {
return {
url: youtubeId,
sourceType: SOURCE_TYPES.YOUTUBE
sourceType: SOURCE_TYPES.YOUTUBE,
embedInfo: { id: youtubeId }
};
}
// Check if the URL is valid
// Vimeo
const vimeoId = getVimeoId(trimmedLink);
if (vimeoId) {
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.VIMEO,
embedInfo: { id: vimeoId }
};
}
// Dailymotion
const dailymotionId = getDailymotionId(trimmedLink);
if (dailymotionId) {
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.DAILYMOTION,
embedInfo: { id: dailymotionId }
};
}
// Twitch
const twitchInfo = getTwitchInfo(trimmedLink);
if (twitchInfo) {
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.TWITCH,
embedInfo: twitchInfo
};
}
// Spotify
const spotifyInfo = getSpotifyInfo(trimmedLink);
if (spotifyInfo) {
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.SPOTIFY,
embedInfo: spotifyInfo
};
}
// Check if the URL is valid for other sources
try {
const urlObj = new URL(trimmedLink);
const hostname = urlObj.hostname.toLowerCase();
// Check if it's a YouTube URL
if (urlObj.hostname.includes(YOUTUBE_URL_DOMAIN)
|| urlObj.hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
const videoId = getYoutubeId(trimmedLink);
if (videoId) {
return {
url: videoId,
sourceType: SOURCE_TYPES.YOUTUBE
};
}
// SoundCloud - we use the full URL for embedding
if (hostname.includes(SOUNDCLOUD_URL_DOMAIN)) {
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.SOUNDCLOUD
};
}
// It's a direct URL
// It's a direct URL (audio/video file)
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.DIRECT