feat(embeds): interactive X/Twitter post embed + Apple Music player
X/Twitter: the static OG card was a snapshot — no video, no galleries/threads. Keep it as the (reliable) facade and add a 'View post' button that loads the official platform.twitter.com interactive embed on click: playable video/GIF, image galleries, quote tweets. The embed self-sizes via a scoped postMessage resize listener (matched to our iframe + the platform.twitter.com origin). Nothing loads from X until the user clicks, and the link still works if X blocks the frame. Apple Music: music.apple.com album/playlist/song links now play inline via the embed.music.apple.com player (compact for a single song, tall for collections), through the existing MediaEmbedCard audio path. Pure parsers/builders unit-tested (13 cases). Desktop CSP frame-src adds platform.twitter.com + embed.music.apple.com (separate commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
getTwitchTarget,
|
||||
getSpotifyEmbedTarget,
|
||||
isSoundCloudTrack,
|
||||
getAppleMusicEmbed,
|
||||
getTweetId,
|
||||
buildVideoEmbedUrl,
|
||||
spotifyEmbedHeight,
|
||||
parseMediaEmbed,
|
||||
@@ -109,6 +111,31 @@ test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
|
||||
assert.equal(parseMediaEmbed('https://example.com/x', HOST), null);
|
||||
});
|
||||
|
||||
test('Apple Music: album vs single song height, embed host swap', () => {
|
||||
const album = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456');
|
||||
assert.equal(album?.embedUrl, 'https://embed.music.apple.com/us/album/some-album/123456');
|
||||
assert.equal(album?.height, 450);
|
||||
const song = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456?i=789');
|
||||
assert.equal(song?.height, 175);
|
||||
assert.ok(song?.embedUrl.includes('?i=789'));
|
||||
assert.equal(getAppleMusicEmbed('https://music.apple.com/us/browse'), null);
|
||||
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
||||
});
|
||||
|
||||
test('getTweetId', () => {
|
||||
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
|
||||
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
|
||||
assert.equal(getTweetId('https://x.com/user'), null);
|
||||
assert.equal(getTweetId('https://youtube.com/watch?v=x'), null);
|
||||
});
|
||||
|
||||
test('parseMediaEmbed: Apple Music → audio', () => {
|
||||
const m = parseMediaEmbed('https://music.apple.com/us/album/x/1?i=2', 'chat.lotusguild.org');
|
||||
assert.equal(m?.provider, 'applemusic');
|
||||
assert.equal(m?.kind, 'audio');
|
||||
assert.equal(m?.height, 175);
|
||||
});
|
||||
|
||||
test('parseMediaEmbed: Twitch embed carries the parent host', () => {
|
||||
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
|
||||
assert.equal(chan?.provider, 'twitch');
|
||||
|
||||
@@ -170,6 +170,37 @@ export function isSoundCloudTrack(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Apple Music ----------------------------------------------------------
|
||||
|
||||
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
|
||||
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
|
||||
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
|
||||
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
|
||||
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
|
||||
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
|
||||
return { embedUrl, height: isSong ? 175 : 450 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- X / Twitter ----------------------------------------------------------
|
||||
|
||||
export function getTweetId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
if (h !== 'twitter.com' && h !== 'x.com' && h !== 'mobile.twitter.com') return null;
|
||||
const m = pathname.match(/\/status(?:es)?\/(\d+)/);
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Embed-URL builders ---------------------------------------------------
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
@@ -254,5 +285,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
height: 166,
|
||||
};
|
||||
|
||||
const apple = getAppleMusicEmbed(url);
|
||||
if (apple)
|
||||
return {
|
||||
provider: 'applemusic',
|
||||
kind: 'audio',
|
||||
embedUrl: apple.embedUrl,
|
||||
height: apple.height,
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user