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:
parent
751a04b950
commit
f56986818b
|
|
@ -26,7 +26,8 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.youtube-player-container {
|
.youtube-player-container,
|
||||||
|
.embed-player-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -8,6 +8,7 @@ import { isSharingStatus } from '../../functions';
|
||||||
import { SourceType } from '../../types';
|
import { SourceType } from '../../types';
|
||||||
|
|
||||||
import DirectAudioManager from './DirectAudioManager';
|
import DirectAudioManager from './DirectAudioManager';
|
||||||
|
import EmbedPlayerManager from './EmbedPlayerManager';
|
||||||
import YouTubeMusicManager from './YouTubeMusicManager';
|
import YouTubeMusicManager from './YouTubeMusicManager';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
|
@ -33,6 +34,17 @@ interface IProps {
|
||||||
sourceType?: SourceType;
|
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.
|
* Component that manages and renders the appropriate music player.
|
||||||
*/
|
*/
|
||||||
|
|
@ -50,10 +62,17 @@ class SharedMusicPlayer extends Component<IProps> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YouTube has its own dedicated player
|
||||||
if (sourceType === SOURCE_TYPES.YOUTUBE) {
|
if (sourceType === SOURCE_TYPES.YOUTUBE) {
|
||||||
return <YouTubeMusicManager musicId = { musicUrl } />;
|
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 } />;
|
return <DirectAudioManager musicId = { musicUrl } />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,28 @@ import { isSharingStatus } from '../../functions';
|
||||||
|
|
||||||
import SharedMusicPlayer from './SharedMusicPlayer';
|
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 {
|
interface IProps {
|
||||||
/**
|
/**
|
||||||
* The participant ID (music URL).
|
* The participant ID (music URL).
|
||||||
|
|
@ -35,7 +57,12 @@ const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
|
||||||
const isOwner = ownerId === localParticipant?.id;
|
const isOwner = ownerId === localParticipant?.id;
|
||||||
const isMusicShared = isSharingStatus(status ?? '');
|
const isMusicShared = isSharingStatus(status ?? '');
|
||||||
const isPlaying = status === PLAYBACK_STATUSES.PLAYING;
|
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) => {
|
const handlePlayPause = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation(); // Prevent thumbnail click from pinning
|
e.stopPropagation(); // Prevent thumbnail click from pinning
|
||||||
|
|
@ -61,27 +88,27 @@ const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className = 'shared-music-tile'>
|
<div className = 'shared-music-tile'>
|
||||||
{/* Render the actual player for YouTube videos */}
|
{/* Render the actual player for video sources */}
|
||||||
{isYouTube ? (
|
{isVideoSource || hasEmbeddedControls ? (
|
||||||
<div className = 'shared-music-player-wrapper'>
|
<div className = 'shared-music-player-wrapper'>
|
||||||
<SharedMusicPlayer />
|
<SharedMusicPlayer />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* For audio-only, show a background with controls */
|
/* For direct audio files, show a background with controls */
|
||||||
<div className = 'shared-music-audio-bg'>
|
<div className = 'shared-music-audio-bg'>
|
||||||
<SharedMusicPlayer />
|
<SharedMusicPlayer />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overlay with title and controls for non-owners */}
|
{/* Overlay with title and controls */}
|
||||||
<div className = 'shared-music-controls-overlay'>
|
<div className = 'shared-music-controls-overlay'>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className = 'shared-music-title'>
|
<div className = 'shared-music-title'>
|
||||||
{title || 'Shared Music'}
|
{title || 'Shared Media'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Play/Pause button (owner only, shown when YouTube controls are hidden) */}
|
{/* Play/Pause button (owner only, shown for direct audio without embedded controls) */}
|
||||||
{isOwner && !isYouTube && (
|
{isOwner && !hasEmbeddedControls && (
|
||||||
<button
|
<button
|
||||||
aria-label = { isPlaying ? 'Pause' : 'Play' }
|
aria-label = { isPlaying ? 'Pause' : 'Play' }
|
||||||
className = 'shared-music-control-button'
|
className = 'shared-music-control-button'
|
||||||
|
|
@ -95,8 +122,8 @@ const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status indicator for non-owners when not YouTube */}
|
{/* Status indicator for non-owners when no embedded controls */}
|
||||||
{!isOwner && !isYouTube && (
|
{!isOwner && !hasEmbeddedControls && (
|
||||||
<div className = 'shared-music-status'>
|
<div className = 'shared-music-status'>
|
||||||
{isPlaying ? 'Playing' : 'Paused'}
|
{isPlaying ? 'Playing' : 'Paused'}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export const PLAYBACK_START = 'start';
|
||||||
*/
|
*/
|
||||||
export const SOURCE_TYPES = {
|
export const SOURCE_TYPES = {
|
||||||
YOUTUBE: 'youtube',
|
YOUTUBE: 'youtube',
|
||||||
|
VIMEO: 'vimeo',
|
||||||
|
SOUNDCLOUD: 'soundcloud',
|
||||||
|
SPOTIFY: 'spotify',
|
||||||
|
DAILYMOTION: 'dailymotion',
|
||||||
|
TWITCH: 'twitch',
|
||||||
DIRECT: 'direct'
|
DIRECT: 'direct'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -43,3 +48,28 @@ export const YOUTUBE_URL_DOMAIN = 'youtube.com';
|
||||||
* YouTube Music domain.
|
* YouTube Music domain.
|
||||||
*/
|
*/
|
||||||
export const YOUTUBE_MUSIC_URL_DOMAIN = 'music.youtube.com';
|
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';
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,15 @@ import { IJitsiConference } from '../base/conference/reducer';
|
||||||
import { toState } from '../base/redux/functions';
|
import { toState } from '../base/redux/functions';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DAILYMOTION_URL_DOMAIN,
|
||||||
PLAYBACK_START,
|
PLAYBACK_START,
|
||||||
PLAYBACK_STATUSES,
|
PLAYBACK_STATUSES,
|
||||||
SHARED_MUSIC,
|
SHARED_MUSIC,
|
||||||
|
SOUNDCLOUD_URL_DOMAIN,
|
||||||
SOURCE_TYPES,
|
SOURCE_TYPES,
|
||||||
|
SPOTIFY_URL_DOMAIN,
|
||||||
|
TWITCH_URL_DOMAIN,
|
||||||
|
VIMEO_URL_DOMAIN,
|
||||||
YOUTUBE_MUSIC_URL_DOMAIN,
|
YOUTUBE_MUSIC_URL_DOMAIN,
|
||||||
YOUTUBE_URL_DOMAIN
|
YOUTUBE_URL_DOMAIN
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
|
@ -30,6 +35,86 @@ function getYoutubeId(url: string): string | null {
|
||||||
return result ? result[1] : 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.
|
* 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.
|
* @returns {SourceType} The source type.
|
||||||
*/
|
*/
|
||||||
export function getSourceType(url: string): SourceType {
|
export function getSourceType(url: string): SourceType {
|
||||||
const youtubeId = getYoutubeId(url);
|
if (getYoutubeId(url)) {
|
||||||
|
|
||||||
if (youtubeId) {
|
|
||||||
return SOURCE_TYPES.YOUTUBE;
|
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 {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
|
const hostname = urlObj.hostname.toLowerCase();
|
||||||
|
|
||||||
if (urlObj.hostname.includes(YOUTUBE_URL_DOMAIN)
|
if (hostname.includes(YOUTUBE_URL_DOMAIN)
|
||||||
|| urlObj.hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
|
|| hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
|
||||||
return SOURCE_TYPES.YOUTUBE;
|
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 (_) {
|
} catch (_) {
|
||||||
// Not a valid URL
|
// 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.
|
* @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) {
|
if (!input) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -84,33 +208,75 @@ export function extractMusicUrl(input: string): { sourceType: SourceType; url: s
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YouTube
|
||||||
const youtubeId = getYoutubeId(trimmedLink);
|
const youtubeId = getYoutubeId(trimmedLink);
|
||||||
|
|
||||||
if (youtubeId) {
|
if (youtubeId) {
|
||||||
return {
|
return {
|
||||||
url: youtubeId,
|
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 {
|
try {
|
||||||
const urlObj = new URL(trimmedLink);
|
const urlObj = new URL(trimmedLink);
|
||||||
|
const hostname = urlObj.hostname.toLowerCase();
|
||||||
|
|
||||||
// Check if it's a YouTube URL
|
// SoundCloud - we use the full URL for embedding
|
||||||
if (urlObj.hostname.includes(YOUTUBE_URL_DOMAIN)
|
if (hostname.includes(SOUNDCLOUD_URL_DOMAIN)) {
|
||||||
|| urlObj.hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
|
return {
|
||||||
const videoId = getYoutubeId(trimmedLink);
|
url: trimmedLink,
|
||||||
|
sourceType: SOURCE_TYPES.SOUNDCLOUD
|
||||||
if (videoId) {
|
};
|
||||||
return {
|
|
||||||
url: videoId,
|
|
||||||
sourceType: SOURCE_TYPES.YOUTUBE
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// It's a direct URL
|
// It's a direct URL (audio/video file)
|
||||||
return {
|
return {
|
||||||
url: trimmedLink,
|
url: trimmedLink,
|
||||||
sourceType: SOURCE_TYPES.DIRECT
|
sourceType: SOURCE_TYPES.DIRECT
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue