diff --git a/css/_base.scss b/css/_base.scss
index ea5a83d..744292e 100644
--- a/css/_base.scss
+++ b/css/_base.scss
@@ -106,7 +106,7 @@ form {
.watermark {
display: block;
position: absolute;
- top: 15;
+ top: 15px;
width: $watermarkWidth;
height: $watermarkHeight;
background-size: contain;
diff --git a/css/_meeting_intelligence.scss b/css/_meeting_intelligence.scss
index c95b948..2444c65 100644
--- a/css/_meeting_intelligence.scss
+++ b/css/_meeting_intelligence.scss
@@ -7,6 +7,7 @@
right: 0;
top: 0;
width: 400px;
+ max-width: 100%;
height: 100%;
background: rgba(28, 32, 37, 0.98);
border-left: 1px solid rgba(255, 255, 255, 0.1);
@@ -14,6 +15,7 @@
flex-direction: column;
z-index: 350;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
+ padding-bottom: env(safe-area-inset-bottom, 0);
}
.meeting-intelligence-header {
@@ -42,8 +44,8 @@
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
- padding: 4px;
- border-radius: 4px;
+ padding: 10px;
+ border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
diff --git a/css/_toolbars.scss b/css/_toolbars.scss
index d1327a9..84864a8 100644
--- a/css/_toolbars.scss
+++ b/css/_toolbars.scss
@@ -93,6 +93,7 @@
.toolbox-content-wrapper::after {
content: '';
+ display: block;
background: $newToolbarBackgroundColor;
padding-bottom: env(safe-area-inset-bottom, 0);
}
diff --git a/css/_videolayout_default.scss b/css/_videolayout_default.scss
index 9113b24..795c4e5 100644
--- a/css/_videolayout_default.scss
+++ b/css/_videolayout_default.scss
@@ -29,7 +29,7 @@
}
.layout-indicator {
- bottom: 80px;
+ bottom: calc(100px + env(safe-area-inset-bottom, 0px));
display: flex;
gap: 6px;
justify-content: center;
@@ -37,13 +37,13 @@
pointer-events: none;
position: absolute;
right: 0;
- z-index: 2;
+ z-index: 251;
}
.layout-indicator-dot {
border-radius: 50%;
- height: 6px;
- width: 6px;
+ height: 8px;
+ width: 8px;
}
.layout-indicator-dot--active {
@@ -74,7 +74,7 @@
#localVideoWrapper video,
#localVideoWrapper object {
border-radius: $borderRadius !important;
- cursor: hand;
+ cursor: pointer;
object-fit: cover;
}
diff --git a/css/filmstrip/_small_video.scss b/css/filmstrip/_small_video.scss
index 4671a06..036e399 100644
--- a/css/filmstrip/_small_video.scss
+++ b/css/filmstrip/_small_video.scss
@@ -7,11 +7,11 @@
margin: 0 2px;
&:hover {
- cursor: hand;
+ cursor: pointer;
}
& > video {
- cursor: hand;
+ cursor: pointer;
border-radius: $borderRadius;
object-fit: cover;
overflow: hidden;
diff --git a/index.html b/index.html
index c624ca3..939e3cd 100644
--- a/index.html
+++ b/index.html
@@ -3,8 +3,8 @@
-
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 64c15b6..bfb0596 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -55,6 +55,7 @@
"dayjs": "1.11.13",
"dompurify": "3.2.6",
"dropbox": "10.7.0",
+ "emoji-mart": "^5.6.0",
"focus-visible": "5.1.0",
"glob": "11.0.3",
"grapheme-splitter": "1.0.4",
@@ -132,6 +133,7 @@
"@babel/preset-env": "7.25.9",
"@babel/preset-react": "7.25.9",
"@jitsi/eslint-config": "6.0.4",
+ "@playwright/test": "^1.58.2",
"@react-native-community/cli": "15.0.1",
"@react-native-community/cli-platform-android": "15.0.1",
"@react-native-community/cli-platform-ios": "15.0.1",
@@ -5247,6 +5249,22 @@
"url": "https://opencollective.com/unts"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
@@ -13088,6 +13106,12 @@
"integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==",
"license": "ISC"
},
+ "node_modules/emoji-mart": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
+ "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
+ "license": "MIT"
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -21198,6 +21222,53 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
@@ -30480,6 +30551,15 @@
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"dev": true
},
+ "@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "requires": {
+ "playwright": "1.58.2"
+ }
+ },
"@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
@@ -36018,6 +36098,11 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz",
"integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ=="
},
+ "emoji-mart": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
+ "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="
+ },
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -41782,6 +41867,31 @@
"find-up": "^4.0.0"
}
},
+ "playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "requires": {
+ "fsevents": "2.3.2",
+ "playwright-core": "1.58.2"
+ },
+ "dependencies": {
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ }
+ }
+ },
+ "playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true
+ },
"pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
diff --git a/package.json b/package.json
index 83014f9..6abf2a1 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"dayjs": "1.11.13",
"dompurify": "3.2.6",
"dropbox": "10.7.0",
+ "emoji-mart": "^5.6.0",
"focus-visible": "5.1.0",
"glob": "11.0.3",
"grapheme-splitter": "1.0.4",
@@ -138,6 +139,7 @@
"@babel/preset-env": "7.25.9",
"@babel/preset-react": "7.25.9",
"@jitsi/eslint-config": "6.0.4",
+ "@playwright/test": "^1.58.2",
"@react-native-community/cli": "15.0.1",
"@react-native-community/cli-platform-android": "15.0.1",
"@react-native-community/cli-platform-ios": "15.0.1",
diff --git a/react/features/app/components/App.web.tsx b/react/features/app/components/App.web.tsx
index ca38235..5891176 100644
--- a/react/features/app/components/App.web.tsx
+++ b/react/features/app/components/App.web.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import GlobalStyles from '../../base/ui/components/GlobalStyles.web';
import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web';
import DialogContainer from '../../base/ui/components/web/DialogContainer';
+import DialogErrorBoundary from '../../base/ui/components/web/DialogErrorBoundary';
import ChromeExtensionBanner from '../../chrome-extension-banner/components/ChromeExtensionBanner.web';
import OverlayContainer from '../../overlay/components/web/OverlayContainer';
import PiP from '../../pip/components/PiP';
@@ -62,7 +63,9 @@ export class App extends AbstractApp {
override _renderDialogContainer() {
return (
-
+
+
+
);
}
diff --git a/react/features/shared-music/components/web/EmbedPlayerManager.tsx b/react/features/shared-music/components/web/EmbedPlayerManager.tsx
index 22c9dec..c15bfee 100644
--- a/react/features/shared-music/components/web/EmbedPlayerManager.tsx
+++ b/react/features/shared-music/components/web/EmbedPlayerManager.tsx
@@ -72,6 +72,35 @@ function getEmbedUrl(url: string, sourceType: string): string {
return url;
}
+ case SOURCE_TYPES.APPLE_MUSIC: {
+ // Replace music.apple.com with embed.music.apple.com
+ return url.replace('music.apple.com', 'embed.music.apple.com');
+ }
+ case SOURCE_TYPES.DEEZER: {
+ // Extract type and ID: deezer.com/{country}/track/123
+ const deezerMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?(track|album|playlist)\/(\d+)/);
+
+ if (deezerMatch) {
+ return `https://widget.deezer.com/widget/dark/${deezerMatch[1]}/${deezerMatch[2]}`;
+ }
+
+ return url;
+ }
+ case SOURCE_TYPES.TIDAL: {
+ // Extract type and ID: tidal.com/browse/track/123
+ const tidalMatch = url.match(/tidal\.com\/(?:browse\/)?(track|album|playlist|mix)\/([a-zA-Z0-9-]+)/);
+
+ if (tidalMatch) {
+ return `https://embed.tidal.com/${tidalMatch[1]}s/${tidalMatch[2]}`;
+ }
+
+ return url;
+ }
+ case SOURCE_TYPES.BANDCAMP: {
+ // Bandcamp doesn't have a simple embed URL transformation
+ // The full page URL works in an iframe for playback
+ return url;
+ }
default:
return url;
}
diff --git a/react/features/shared-music/components/web/SharedMusicPlayer.tsx b/react/features/shared-music/components/web/SharedMusicPlayer.tsx
index 004d798..fb839fb 100644
--- a/react/features/shared-music/components/web/SharedMusicPlayer.tsx
+++ b/react/features/shared-music/components/web/SharedMusicPlayer.tsx
@@ -42,7 +42,11 @@ const EMBED_SOURCE_TYPES: readonly string[] = [
SOURCE_TYPES.SOUNDCLOUD,
SOURCE_TYPES.SPOTIFY,
SOURCE_TYPES.DAILYMOTION,
- SOURCE_TYPES.TWITCH
+ SOURCE_TYPES.TWITCH,
+ SOURCE_TYPES.APPLE_MUSIC,
+ SOURCE_TYPES.DEEZER,
+ SOURCE_TYPES.TIDAL,
+ SOURCE_TYPES.BANDCAMP
];
/**
diff --git a/react/features/shared-music/components/web/SharedMusicTile.tsx b/react/features/shared-music/components/web/SharedMusicTile.tsx
index 6d5c205..3f96e61 100644
--- a/react/features/shared-music/components/web/SharedMusicTile.tsx
+++ b/react/features/shared-music/components/web/SharedMusicTile.tsx
@@ -29,7 +29,11 @@ const EMBEDDED_CONTROL_TYPES: readonly string[] = [
SOURCE_TYPES.SOUNDCLOUD,
SOURCE_TYPES.SPOTIFY,
SOURCE_TYPES.DAILYMOTION,
- SOURCE_TYPES.TWITCH
+ SOURCE_TYPES.TWITCH,
+ SOURCE_TYPES.APPLE_MUSIC,
+ SOURCE_TYPES.DEEZER,
+ SOURCE_TYPES.TIDAL,
+ SOURCE_TYPES.BANDCAMP
];
interface IProps {
diff --git a/react/features/shared-music/constants.ts b/react/features/shared-music/constants.ts
index 803c310..9a48a12 100644
--- a/react/features/shared-music/constants.ts
+++ b/react/features/shared-music/constants.ts
@@ -36,6 +36,10 @@ export const SOURCE_TYPES = {
SPOTIFY: 'spotify',
DAILYMOTION: 'dailymotion',
TWITCH: 'twitch',
+ APPLE_MUSIC: 'apple_music',
+ DEEZER: 'deezer',
+ TIDAL: 'tidal',
+ BANDCAMP: 'bandcamp',
DIRECT: 'direct'
} as const;
@@ -73,3 +77,23 @@ export const DAILYMOTION_URL_DOMAIN = 'dailymotion.com';
* Twitch domain.
*/
export const TWITCH_URL_DOMAIN = 'twitch.tv';
+
+/**
+ * Apple Music domain.
+ */
+export const APPLE_MUSIC_URL_DOMAIN = 'music.apple.com';
+
+/**
+ * Deezer domain.
+ */
+export const DEEZER_URL_DOMAIN = 'deezer.com';
+
+/**
+ * Tidal domain.
+ */
+export const TIDAL_URL_DOMAIN = 'tidal.com';
+
+/**
+ * Bandcamp domain.
+ */
+export const BANDCAMP_URL_DOMAIN = 'bandcamp.com';
diff --git a/react/features/shared-music/functions.ts b/react/features/shared-music/functions.ts
index 021fce5..4ad5e85 100644
--- a/react/features/shared-music/functions.ts
+++ b/react/features/shared-music/functions.ts
@@ -3,13 +3,17 @@ import { IJitsiConference } from '../base/conference/reducer';
import { toState } from '../base/redux/functions';
import {
+ APPLE_MUSIC_URL_DOMAIN,
+ BANDCAMP_URL_DOMAIN,
DAILYMOTION_URL_DOMAIN,
+ DEEZER_URL_DOMAIN,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_MUSIC,
SOUNDCLOUD_URL_DOMAIN,
SOURCE_TYPES,
SPOTIFY_URL_DOMAIN,
+ TIDAL_URL_DOMAIN,
TWITCH_URL_DOMAIN,
VIMEO_URL_DOMAIN,
YOUTUBE_MUSIC_URL_DOMAIN,
@@ -115,6 +119,64 @@ function getSpotifyInfo(url: string): { id: string; type: string; } | null {
return result ? { type: result[1], id: result[2] } : null;
}
+/**
+ * Extracts Apple Music info from URL.
+ *
+ * @param {string} url - The entered URL.
+ * @returns {Object|null} The Apple Music info if matched.
+ */
+function getAppleMusicInfo(url: string): { id: string; path: string; } | null {
+ if (!url) {
+ return null;
+ }
+
+ // Matches music.apple.com/{country}/album/{name}/{id} or /playlist/{name}/{id}
+ const p = /(?:https?:\/\/)?music\.apple\.com\/([a-z]{2})\/(album|playlist|song)\/([^/]+)\/([a-zA-Z0-9.]+)/;
+ const result = url.match(p);
+
+ if (result) {
+ return { path: `${result[1]}/${result[2]}/${result[3]}/${result[4]}`, id: result[4] };
+ }
+
+ return null;
+}
+
+/**
+ * Extracts Deezer info from URL.
+ *
+ * @param {string} url - The entered URL.
+ * @returns {Object|null} The Deezer info if matched.
+ */
+function getDeezerInfo(url: string): { id: string; type: string; } | null {
+ if (!url) {
+ return null;
+ }
+
+ // Matches deezer.com/{country}/track/123, /album/123, /playlist/123
+ const p = /(?:https?:\/\/)?(?:www\.)?deezer\.com\/(?:[a-z]{2}\/)?(track|album|playlist)\/(\d+)/;
+ const result = url.match(p);
+
+ return result ? { type: result[1], id: result[2] } : null;
+}
+
+/**
+ * Extracts Tidal info from URL.
+ *
+ * @param {string} url - The entered URL.
+ * @returns {Object|null} The Tidal info if matched.
+ */
+function getTidalInfo(url: string): { id: string; type: string; } | null {
+ if (!url) {
+ return null;
+ }
+
+ // Matches tidal.com/browse/track/123, /album/123, /playlist/{uuid}
+ const p = /(?:https?:\/\/)?(?:www\.)?(?:listen\.)?tidal\.com\/(?:browse\/)?(track|album|playlist|mix)\/([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.
*
@@ -180,6 +242,22 @@ export function getSourceType(url: string): SourceType {
if (hostname.includes(TWITCH_URL_DOMAIN)) {
return SOURCE_TYPES.TWITCH;
}
+
+ if (hostname.includes(APPLE_MUSIC_URL_DOMAIN)) {
+ return SOURCE_TYPES.APPLE_MUSIC;
+ }
+
+ if (hostname.includes(DEEZER_URL_DOMAIN)) {
+ return SOURCE_TYPES.DEEZER;
+ }
+
+ if (hostname.includes(TIDAL_URL_DOMAIN)) {
+ return SOURCE_TYPES.TIDAL;
+ }
+
+ if (hostname.includes(BANDCAMP_URL_DOMAIN)) {
+ return SOURCE_TYPES.BANDCAMP;
+ }
} catch (_) {
// Not a valid URL
}
@@ -276,6 +354,47 @@ export function extractMusicUrl(input: string): {
};
}
+ // Apple Music
+ const appleMusicInfo = getAppleMusicInfo(trimmedLink);
+
+ if (appleMusicInfo) {
+ return {
+ url: trimmedLink,
+ sourceType: SOURCE_TYPES.APPLE_MUSIC,
+ embedInfo: { id: appleMusicInfo.id, type: appleMusicInfo.path }
+ };
+ }
+
+ // Deezer
+ const deezerInfo = getDeezerInfo(trimmedLink);
+
+ if (deezerInfo) {
+ return {
+ url: trimmedLink,
+ sourceType: SOURCE_TYPES.DEEZER,
+ embedInfo: deezerInfo
+ };
+ }
+
+ // Tidal
+ const tidalInfo = getTidalInfo(trimmedLink);
+
+ if (tidalInfo) {
+ return {
+ url: trimmedLink,
+ sourceType: SOURCE_TYPES.TIDAL,
+ embedInfo: tidalInfo
+ };
+ }
+
+ // Bandcamp - use full URL for embedding
+ if (hostname.includes(BANDCAMP_URL_DOMAIN)) {
+ return {
+ url: trimmedLink,
+ sourceType: SOURCE_TYPES.BANDCAMP
+ };
+ }
+
// It's a direct URL (audio/video file)
return {
url: trimmedLink,
diff --git a/react/features/shared-video/constants.ts b/react/features/shared-video/constants.ts
index 1a07a2c..66afb3f 100644
--- a/react/features/shared-video/constants.ts
+++ b/react/features/shared-video/constants.ts
@@ -34,6 +34,17 @@ export const PLAYBACK_STATUSES = {
*/
export const PLAYBACK_START = 'start';
+/**
+ * Video source types.
+ */
+export const VIDEO_SOURCE_TYPES = {
+ YOUTUBE: 'youtube',
+ VIMEO: 'vimeo',
+ DAILYMOTION: 'dailymotion',
+ TWITCH: 'twitch',
+ DIRECT: 'direct'
+} as const;
+
/**
* The domain for youtube URLs.
*/
@@ -46,5 +57,6 @@ export const ALLOW_ALL_URL_DOMAINS = '*';
/**
* The default white listed domains for shared video.
+ * Allow all domains so Vimeo, Dailymotion, Twitch etc. work out of the box.
*/
-export const DEFAULT_ALLOWED_URL_DOMAINS = [ YOUTUBE_URL_DOMAIN ];
+export const DEFAULT_ALLOWED_URL_DOMAINS = [ ALLOW_ALL_URL_DOMAINS ];
diff --git a/react/features/shared-video/functions.ts b/react/features/shared-video/functions.ts
index b96c7ec..a49f253 100644
--- a/react/features/shared-video/functions.ts
+++ b/react/features/shared-video/functions.ts
@@ -9,6 +9,7 @@ import {
PLAYBACK_STATUSES,
SHARED_VIDEO,
VIDEO_PLAYER_PARTICIPANT_NAME,
+ VIDEO_SOURCE_TYPES,
YOUTUBE_PLAYER_PARTICIPANT_NAME,
YOUTUBE_URL_DOMAIN
} from './constants';
@@ -139,6 +140,95 @@ export function isURLAllowedForSharedVideo(url: string,
return false;
}
+/**
+ * Determines the video source type from a URL or YouTube ID.
+ *
+ * @param {string} url - The video URL or YouTube ID.
+ * @returns {string} The source type.
+ */
+export function getVideoSourceType(url: string): string {
+ if (!url) {
+ return VIDEO_SOURCE_TYPES.DIRECT;
+ }
+
+ // If it doesn't look like a URL, it's a YouTube ID
+ if (!url.match(/^https?:\/\//)) {
+ return VIDEO_SOURCE_TYPES.YOUTUBE;
+ }
+
+ try {
+ const urlObj = new URL(url);
+ const hostname = urlObj.hostname.toLowerCase();
+
+ if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
+ return VIDEO_SOURCE_TYPES.YOUTUBE;
+ }
+
+ if (hostname.includes('vimeo.com')) {
+ return VIDEO_SOURCE_TYPES.VIMEO;
+ }
+
+ if (hostname.includes('dailymotion.com') || hostname.includes('dai.ly')) {
+ return VIDEO_SOURCE_TYPES.DAILYMOTION;
+ }
+
+ if (hostname.includes('twitch.tv')) {
+ return VIDEO_SOURCE_TYPES.TWITCH;
+ }
+ } catch (_) {
+ // Not a valid URL
+ }
+
+ return VIDEO_SOURCE_TYPES.DIRECT;
+}
+
+/**
+ * Generates an embed URL for video platforms.
+ *
+ * @param {string} url - The original URL.
+ * @param {string} sourceType - The source type.
+ * @returns {string} The embed URL.
+ */
+export function getVideoEmbedUrl(url: string, sourceType: string): string {
+ switch (sourceType) {
+ case VIDEO_SOURCE_TYPES.VIMEO: {
+ 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 VIDEO_SOURCE_TYPES.DAILYMOTION: {
+ 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 VIDEO_SOURCE_TYPES.TWITCH: {
+ const parent = window.location.hostname;
+ 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;
+ }
+}
+
/**
* Sends SHARED_VIDEO command.
*
diff --git a/react/features/toolbox/constants.ts b/react/features/toolbox/constants.ts
index b8c2624..d739dcf 100644
--- a/react/features/toolbox/constants.ts
+++ b/react/features/toolbox/constants.ts
@@ -56,7 +56,7 @@ export const THRESHOLDS = [
},
{
width: 200,
- order: [ 'microphone', 'camera' ]
+ order: [ 'microphone', 'camera', 'hangup' ]
}
];
@@ -162,6 +162,7 @@ export const TOOLBAR_BUTTONS: ToolbarButton[] = [
'invite',
'linktosalesforce',
'livestreaming',
+ 'meetingintelligence',
'microphone',
'mute-everyone',
'mute-video-everyone',
diff --git a/static/close2.html b/static/close2.html
index 8a25b45..38b5622 100644
--- a/static/close2.html
+++ b/static/close2.html
@@ -17,9 +17,11 @@
color: #fff;
font-size: 16px;
font-weight: 500;
- padding: 12px 24px;
+ padding: 14px 28px;
text-decoration: none;
transition: opacity 0.2s;
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: transparent;
}
.meeting-intelligence-link a:hover {
opacity: 0.9;