浏览代码

Embed Spotify player for links to Spotify tracks

New config option: `embed_3rd_party_media_players`
JC Brand 2 年之前
父节点
当前提交
cff07d779c

+ 1 - 0
CHANGES.md

@@ -49,6 +49,7 @@
 - Fix: renaming getEmojisByAtrribute to getEmojisByAttribute.
 
 ### Changes and features
+- Embed the Spotify player for links to Spotify tracks. New config option [embed_3rd_party_media_players](https://conversejs.org/docs/html/configuration.html#embed-3rd-party-media-players).
 - Add support for XEP-0191 Blocking Command
 - Upgrade to Bootstrap 5
 - Add an occupants filter to the MUC sidebar

+ 8 - 0
docs/source/configuration.rst

@@ -809,6 +809,14 @@ domain_placeholder
 
 The placeholder text shown in the domain input on the registration form.
 
+embed_3rd_party_media_players
+-----------------------------
+
+* Default: ``true``
+
+If ``true``, links to 3rd party media sites, such as Spotify will be turned
+into embedded media players from those sites (if supported by Converse).
+
 
 emoji_categories
 ----------------

+ 1 - 0
src/headless/shared/settings/constants.js

@@ -45,6 +45,7 @@ export const DEFAULT_SETTINGS = {
     credentials_url: null, // URL from where login credentials can be fetched
     disable_effects: false, // Disabled UI transition effects. Mainly used for tests.
     discover_connection_methods: true,
+    embed_3rd_party_media_players: true,
     geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g,
     geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2',
     i18n: undefined,

+ 1 - 0
src/headless/types/shared/settings/constants.d.ts

@@ -11,6 +11,7 @@ export namespace DEFAULT_SETTINGS {
     let credentials_url: any;
     let disable_effects: boolean;
     let discover_connection_methods: boolean;
+    let embed_3rd_party_media_players: boolean;
     let geouri_regex: RegExp;
     let geouri_replacement: string;
     let i18n: any;

+ 20 - 3
src/plugins/chatview/tests/message-audio.js

@@ -8,7 +8,7 @@ describe("A Chat Message", function () {
             mock.initConverse(['chatBoxesFetched'],
             { fetch_url_headers: true },
             async function (_converse) {
-        await mock.waitForRoster(_converse, 'current');
+        await mock.waitForRoster(_converse, 'current', 1);
         const base_url = 'https://conversejs.org';
         const message = base_url+"/logo/audio.mp3";
 
@@ -35,7 +35,7 @@ describe("A Chat Message", function () {
             });
         });
 
-        await mock.waitForRoster(_converse, 'current');
+        await mock.waitForRoster(_converse, 'current', 1);
         const message = 'http://foo.bar/stream';
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await mock.openChatBoxFor(_converse, contact_jid);
@@ -51,7 +51,7 @@ describe("A Chat Message", function () {
             { fetch_url_headers: true },
             async function (_converse) {
 
-        await mock.waitForRoster(_converse, 'current');
+        await mock.waitForRoster(_converse, 'current', 1);
         const message = 'https://differentdrumz.radioca.st/stream/1/';
 
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
@@ -64,4 +64,21 @@ describe("A Chat Message", function () {
             `<audio controls="" src="${message}"></audio>`+
             `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
     }));
+
+    it("will render Spotify player for Spotify URLs",
+            mock.initConverse(['chatBoxesFetched'],
+            { embed_3rd_party_media_players: true, view_mode: 'fullscreen' },
+            async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        const message = 'https://open.spotify.com/track/6rqhFgbbKwnb9MLmUQDhG6';
+
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        await mock.sendMessage(view, message);
+        await u.waitUntil(() => view.querySelectorAll('.chat-content iframe').length, 1000)
+        const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+        expect(msg.querySelector('iframe').src).toContain('https://open.spotify.com/embed/track/6rqhFgbbKwnb9MLmUQDhG6');
+    }));
 });

+ 1 - 1
src/shared/styles/messages.scss

@@ -108,7 +108,7 @@
             display: inline-flex;
             width: 100%;
             flex-direction: row;
-            padding: 0.25em 1rem;
+            padding: 0 1rem;
 
             &.onload {
                 animation: colorchange-chatmessage 1s;

+ 1 - 0
src/shared/tests/mock.js

@@ -761,6 +761,7 @@ async function _initConverse (settings) {
         bosh_service_url: 'montague.lit/http-bind',
         disable_effects: true,
         discover_connection_methods: false,
+        embed_3rd_party_media_players: false,
         enable_smacks: false,
         fetch_url_headers: false,
         i18n: 'en',

+ 49 - 36
src/shared/texture/texture.js

@@ -6,6 +6,7 @@ import tplAudio from 'templates/audio.js';
 import tplGif from 'templates/gif.js';
 import tplImage from 'templates/image.js';
 import tplVideo from 'templates/video.js';
+import tplSpotify from 'templates/spotify.js';
 import { getEmojiMarkup } from '../chat/utils.js';
 import { getHyperlinkTemplate } from '../../utils/html.js';
 import { shouldRenderMediaFromURL } from 'utils/url.js';
@@ -15,6 +16,7 @@ import {
     getDirectiveAndLength,
     getHeaders,
     isQuoteDirective,
+    isSpotifyTrack,
     isString,
     tplMention,
     tplMentionWithNick,
@@ -122,51 +124,62 @@ export class Texture extends String {
         return shouldRenderMediaFromURL(url, type);
     }
 
+    /**
+     * Look for `http` URIs and return templates that render them as URL links
+     * @param {import('utils/url').MediaURLData} url_obj
+     * @returns {Promise<string|import('lit').TemplateResult>}
+     */
+    async addHyperlinkTemplate(url_obj) {
+        const url_text = url_obj.url;
+        const filtered_url = filterQueryParamsFromURL(url_text);
+        let template;
+        if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) {
+            template = tplGif(filtered_url, this.hide_media_urls);
+        } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) {
+            template = tplImage({
+                src: filtered_url,
+                // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here
+                href: this.hide_media_urls ? null : filtered_url,
+                onClick: this.onImgClick,
+                onLoad: this.onImgLoad,
+            });
+        } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) {
+            template = tplVideo(filtered_url, this.hide_media_urls);
+        } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) {
+            template = tplAudio(filtered_url, this.hide_media_urls);
+        } else if (api.settings.get('embed_3rd_party_media_players') && isSpotifyTrack(url_text)) {
+            const song_id = url_text.split('/track/')[1];
+            template = tplSpotify(song_id, url_text, this.hide_media_urls);
+        } else {
+            if (this.shouldRenderMedia(url_text, 'audio') && api.settings.get('fetch_url_headers')) {
+                const headers = await getHeaders(url_text);
+                if (headers.get('content-type')?.startsWith('audio')) {
+                    template = tplAudio(filtered_url, this.hide_media_urls, headers.get('Icy-Name'));
+                }
+            }
+        }
+        return template || getHyperlinkTemplate(filtered_url);
+    }
+
     /**
      * Look for `http` URIs and return templates that render them as URL links
      * @param {string} text
      * @param {number} local_offset - The index of the passed in text relative to
      *  the start of this Texture instance (which is not necessarily the same as the
      *  offset from the start of the original message stanza's body text).
-     *
-     * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData
      */
     async addHyperlinks(text, local_offset) {
         const full_offset = local_offset + this.offset;
         const urls_meta = this.media_urls || getMediaURLsMetadata(text, local_offset).media_urls || [];
-        const media_urls = /** @type {MediaURLData[]} */ (getMediaURLs(urls_meta, text, full_offset));
-
-        await Promise.all(media_urls
-            .filter((o) => !o.is_encrypted)
-            .map(async (url_obj) => {
-                const url_text = url_obj.url;
-                const filtered_url = filterQueryParamsFromURL(url_text);
-                let template;
-                if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) {
-                    template = tplGif(filtered_url, this.hide_media_urls);
-                } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) {
-                    template = tplImage({
-                        src: filtered_url,
-                        // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here
-                        href: this.hide_media_urls ? null : filtered_url,
-                        onClick: this.onImgClick,
-                        onLoad: this.onImgLoad,
-                    });
-                } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) {
-                    template = tplVideo(filtered_url, this.hide_media_urls);
-                } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) {
-                    template = tplAudio(filtered_url, this.hide_media_urls);
-                } else {
-                    if (this.shouldRenderMedia(url_text, 'audio') && api.settings.get('fetch_url_headers')) {
-                        const headers = await getHeaders(url_text);
-                        if (headers.get('content-type')?.startsWith('audio')) {
-                            template = tplAudio(filtered_url, this.hide_media_urls, headers.get('Icy-Name'));
-                        }
-                    }
-                }
-                template = template || getHyperlinkTemplate(filtered_url);
-                this.addTemplateResult(url_obj.start + local_offset, url_obj.end + local_offset, template);
-            }));
+        const media_urls = getMediaURLs(urls_meta, text, full_offset);
+        await Promise.all(
+            media_urls
+                .filter((o) => !o.is_encrypted)
+                .map(async (o) => {
+                    const template = await this.addHyperlinkTemplate(o);
+                    this.addTemplateResult(o.start + local_offset, o.end + local_offset, template);
+                })
+        );
     }
 
     /**
@@ -311,7 +324,7 @@ export class Texture extends String {
          * Synchronous event which provides a hook for transforming a chat message's body text
          * before the default transformations have been applied.
          * @event _converse#beforeMessageBodyTransformed
-         * @param { Texture } text - A {@link Texture } instance. You
+         * @param {Texture} text - A {@link Texture } instance. You
          *  can call {@link Texture#addTemplateResult } on it in order to
          *  add TemplateResult objects meant to render rich parts of the message.
          * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });

+ 14 - 0
src/shared/texture/utils.js

@@ -9,6 +9,20 @@ export function isString(s) {
     return typeof s === 'string';
 }
 
+/**
+ * @param {string} url
+ * @returns {boolean}
+ */
+export function isSpotifyTrack(url) {
+    try {
+        const { hostname, pathname } = new URL(url);
+        return hostname === 'open.spotify.com' && pathname.startsWith('/track/');
+    } catch (e) {
+        console.warn(`Could not create URL object from ${url}`);
+        return false;
+    }
+}
+
 /**
  * @param {string} url
  * @returns {Promise<Headers>}

+ 22 - 0
src/templates/spotify.js

@@ -0,0 +1,22 @@
+import { html } from 'lit';
+
+/**
+ * @param {string} song_id - The ID of the song to embed.
+ * @param {string} url - The URL to link to (if not hidden).
+ * @param {boolean} hide_url - Flag to determine if the URL should be hidden.
+ * @returns {import('lit').TemplateResult}
+ */
+export default (song_id, url, hide_url) => {
+    const { hostname } = new URL(url);
+    return html`<figure>
+        <iframe
+            style="border-radius:12px"
+            src="https://open.spotify.com/embed/track/${song_id}"
+            width="100%"
+            height="352"
+            frameBorder="0"
+            allowfullscreen=""
+            allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
+            ${hide_url ? '' : html`<a target="_blank" rel="noopener" title="${url}" href="${url}">${hostname}</a>`}
+        </figure>`;
+}

+ 2 - 2
src/types/shared/components/gif.d.ts

@@ -24,8 +24,8 @@ export default class ConverseGIFElement extends CustomElement {
     initGIF(): void;
     supergif: ConverseGif;
     updated(changed: any): void;
-    render(): string | import("lit").TemplateResult<1>;
-    renderErrorFallback(): string | import("lit").TemplateResult<1>;
+    render(): string | import("utils/html.js").TemplateResult;
+    renderErrorFallback(): string | import("utils/html.js").TemplateResult;
     setHover(): void;
     hover_timeout: NodeJS.Timeout;
     unsetHover(): void;

+ 9 - 5
src/types/shared/texture/texture.d.ts

@@ -100,16 +100,20 @@ export class Texture extends String {
      * @param {'audio'|'image'|'video'} type - The type of media
      */
     shouldRenderMedia(url: string, type: "audio" | "image" | "video"): any;
+    /**
+     * Look for `http` URIs and return templates that render them as URL links
+     * @param {import('utils/url').MediaURLData} url_obj
+     * @returns {Promise<string|import('lit').TemplateResult>}
+     */
+    addHyperlinkTemplate(url_obj: import("utils/url").MediaURLData): Promise<string | import("lit").TemplateResult>;
     /**
      * Look for `http` URIs and return templates that render them as URL links
      * @param {string} text
      * @param {number} local_offset - The index of the passed in text relative to
      *  the start of this Texture instance (which is not necessarily the same as the
      *  offset from the start of the original message stanza's body text).
-     *
-     * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData
      */
-    addHyperlinks(text: string, local_offset: number): void;
+    addHyperlinks(text: string, local_offset: number): Promise<void>;
     /**
      * Look for `geo` URIs and return templates that render them as URL links
      * @param {String} text
@@ -141,9 +145,9 @@ export class Texture extends String {
     /**
      * Look for plaintext (i.e. non-templated) sections of this Texture
      * instance and add references via the passed in function.
-     * @param { Function } func
+     * @param {Function} func
      */
-    addAnnotations(func: Function): void;
+    addAnnotations(func: Function): Promise<void>;
     /**
      * Parse the text and add template references for rendering the "rich" parts.
      **/

+ 10 - 0
src/types/shared/texture/utils.d.ts

@@ -3,6 +3,16 @@
  * @returns {boolean} - Returns true if the input is a string, otherwise false.
  */
 export function isString(s: any): boolean;
+/**
+ * @param {string} url
+ * @returns {boolean}
+ */
+export function isSpotifyTrack(url: string): boolean;
+/**
+ * @param {string} url
+ * @returns {Promise<Headers>}
+ */
+export function getHeaders(url: string): Promise<Headers>;
 /**
  * We don't render more than two line-breaks, replace extra line-breaks with
  * the zero-width whitespace character

+ 1 - 1
src/types/templates/audio.d.ts

@@ -1,3 +1,3 @@
-declare function _default(url: string, hide_url?: boolean): import("lit").TemplateResult<1>;
+declare function _default(url: string, hide_url?: boolean, title?: string): import("lit").TemplateResult<1>;
 export default _default;
 //# sourceMappingURL=audio.d.ts.map

+ 3 - 0
src/types/templates/spotify.d.ts

@@ -0,0 +1,3 @@
+declare function _default(song_id: string, url: string, hide_url: boolean): import("lit").TemplateResult;
+export default _default;
+//# sourceMappingURL=spotify.d.ts.map

+ 2 - 1
src/types/utils/html.d.ts

@@ -49,8 +49,9 @@ export function removeElement(el: Element): Element;
 export function ancestor(el: HTMLElement, selector: string): HTMLElement;
 /**
  * @param {string} url
+ * @returns {TemplateResult|string}
  */
-export function getHyperlinkTemplate(url: string): string | import("lit").TemplateResult<1>;
+export function getHyperlinkTemplate(url: string): TemplateResult | string;
 /**
  * Shows/expands an element by sliding it out of itself
  * @method slideOut

+ 1 - 0
src/utils/html.js

@@ -321,6 +321,7 @@ function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOL
 
 /**
  * @param {string} url
+ * @returns {TemplateResult|string}
  */
 export function getHyperlinkTemplate (url) {
     const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;