import { IStateful } from '../base/app/types'; 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'; import { SourceType } from './types'; /** * Validates the entered URL and extracts YouTube ID if applicable. * * @param {string} url - The entered URL. * @returns {string | null} The youtube video id if matched. */ function getYoutubeId(url: string): string | null { if (!url) { return null; } // eslint-disable-next-line max-len const p = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|(?:m\.)?(?:music\.)?youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; const result = url.match(p); 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. * * @param {string} status - The shared music status. * @returns {boolean} */ export function isSharingStatus(status: string): boolean { return [ PLAYBACK_STATUSES.PLAYING, PLAYBACK_STATUSES.PAUSED, PLAYBACK_START ].includes(status); } /** * Determines the source type of the given URL. * * @param {string} url - The URL to check. * @returns {SourceType} The source type. */ export function getSourceType(url: string): SourceType { 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 (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 } return SOURCE_TYPES.DIRECT; } /** * Extracts media info from URL based on the source type. * * @param {string} input - The user input. * @returns {Object | undefined} An object with url, sourceType, and optional embedInfo. */ export function extractMusicUrl(input: string): { embedInfo?: { id: string; type?: string; }; sourceType: SourceType; url: string; } | undefined { if (!input) { return; } const trimmedLink = input.trim(); if (!trimmedLink) { return; } // YouTube const youtubeId = getYoutubeId(trimmedLink); if (youtubeId) { return { url: youtubeId, sourceType: SOURCE_TYPES.YOUTUBE, embedInfo: { id: youtubeId } }; } // 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(); // 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 (audio/video file) return { url: trimmedLink, sourceType: SOURCE_TYPES.DIRECT }; } catch (_) { return; } } /** * Returns true if shared music functionality is enabled. * * @param {IStateful} stateful - The redux store or getState function. * @returns {boolean} */ export function isSharedMusicEnabled(stateful: IStateful): boolean { const state = toState(stateful); const { disableThirdPartyRequests = false } = state['features/base/config']; return !disableThirdPartyRequests; } /** * Returns true if music is currently playing. * * @param {IStateful} stateful - The redux store or getState function. * @returns {boolean} */ export function isMusicPlaying(stateful: IStateful): boolean { const state = toState(stateful); const { status } = state['features/shared-music']; return isSharingStatus(status ?? ''); } /** * Sends SHARED_MUSIC command. * * @param {Object} options - The command options. * @param {string} options.id - The id of the music. * @param {string} options.status - The status of the shared music. * @param {IJitsiConference} options.conference - The current conference. * @param {string} options.localParticipantId - The id of the local participant. * @param {number} options.time - The seek position of the music. * @param {boolean} options.muted - Whether the music is muted. * @param {number} options.volume - The volume level. * @param {string} options.sourceType - The source type. * @param {string} options.title - The track title. * @returns {void} */ export function sendShareMusicCommand({ id, status, conference, localParticipantId = '', time, muted, volume, sourceType, title }: { conference?: IJitsiConference; id: string; localParticipantId?: string; muted?: boolean; sourceType?: string; status: string; time: number; title?: string; volume?: number; }): void { conference?.sendCommandOnce(SHARED_MUSIC, { value: id, attributes: { from: localParticipantId, muted, sourceType, state: status, time, title, volume } }); }