youtube embeds

This commit is contained in:
Moon.eth
2026-07-29 11:53:15 +09:00
parent 2f0c8821f3
commit cd77720a4f
7 changed files with 274 additions and 1 deletions
+6 -1
View File
@@ -15,11 +15,13 @@
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile' import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
import { renderDisplayName } from '$lib/util/html' import { renderDisplayName } from '$lib/util/html'
import { isoDate, longDate, stampDate } from '$lib/util/time' import { isoDate, longDate, stampDate } from '$lib/util/time'
import { extractYouTubeVideoIds } from '$lib/util/youtube'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import RichText from '../common/RichText.svelte' import RichText from '../common/RichText.svelte'
import Attachments from './Attachments.svelte' import Attachments from './Attachments.svelte'
import PollView from './PollView.svelte' import PollView from './PollView.svelte'
import PreviewCardView from './PreviewCardView.svelte' import PreviewCardView from './PreviewCardView.svelte'
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
interface Props { interface Props {
status: Status status: Status
@@ -45,6 +47,7 @@
const handle = $derived(fullHandle(author, session.host)) const handle = $derived(fullHandle(author, session.host))
const permalink = $derived(`#/blog/${entry.id}`) const permalink = $derived(`#/blog/${entry.id}`)
const isMine = $derived(session.me?.id === entry.account.id) const isMine = $derived(session.me?.id === entry.account.id)
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content))
let busy = $state(false) let busy = $state(false)
let actionError = $state<string | null>(null) let actionError = $state<string | null>(null)
@@ -188,6 +191,7 @@
{#if entry.media_attachments.length > 0} {#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} /> <Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
{/if} {/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
</details> </details>
{:else} {:else}
<RichText <RichText
@@ -200,13 +204,14 @@
{#if entry.media_attachments.length > 0} {#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} /> <Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
{/if} {/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
{/if} {/if}
{#if entry.poll} {#if entry.poll}
<PollView poll={entry.poll} /> <PollView poll={entry.poll} />
{/if} {/if}
{#if entry.card && entry.media_attachments.length === 0} {#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
<PreviewCardView card={entry.card} /> <PreviewCardView card={entry.card} />
{/if} {/if}
+50
View File
@@ -0,0 +1,50 @@
import { fireEvent, render } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import { status, testServices } from '$test/fixtures'
import BlogEntry from './BlogEntry.svelte'
function renderEntry(overrides: Parameters<typeof status>[0]) {
return render(BlogEntry, {
props: { status: status(overrides) },
context: new Map([[APP_SERVICES, testServices()]]),
})
}
describe('BlogEntry YouTube embeds', () => {
it('turns a YouTube note link into an attachment-style embed', () => {
const view = renderEntry({
content:
'<p>Watch this: <a href="https://www.youtube.com/watch?v=pyNCigSulNs">https://www.youtube.com/watch?v=pyNCigSulNs</a></p>',
card: {
url: 'https://www.youtube.com/watch?v=pyNCigSulNs',
title: 'Server-generated YouTube preview',
description: 'This should be replaced by the player.',
type: 'video',
},
})
const frame = view.getByTitle('YouTube video')
expect(frame).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/pyNCigSulNs',
)
expect(frame.closest('.attachment')).toHaveAttribute('data-type', 'youtube')
expect(view.getByRole('link', { name: 'Watch on YouTube' })).toHaveAttribute(
'href',
'https://www.youtube.com/watch?v=pyNCigSulNs',
)
expect(view.queryByText('Server-generated YouTube preview')).not.toBeInTheDocument()
})
it('does not load a sensitive player before the reader reveals it', async () => {
const view = renderEntry({
content: '<p><a href="https://youtu.be/pyNCigSulNs">video</a></p>',
sensitive: true,
})
expect(view.queryByTitle('YouTube video')).not.toBeInTheDocument()
await fireEvent.click(view.getByRole('button', { name: 'Show sensitive video' }))
expect(view.getByTitle('YouTube video')).toBeInTheDocument()
})
})
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
/** Privacy-enhanced YouTube players presented in the normal attachment grid. */
import { youtubeEmbedUrl, youtubeVideoId, youtubeWatchUrl } from '$lib/util/youtube'
interface Props {
videoIds: string[]
sensitive?: boolean
}
let { videoIds, sensitive = false }: Props = $props()
let revealed = $state<Record<string, boolean>>({})
const validVideoIds = $derived(
[...new Set(videoIds.filter((id) => youtubeVideoId(youtubeWatchUrl(id)) === id))].slice(0, 4),
)
function isRevealed(id: string): boolean {
return !sensitive || revealed[id] === true
}
function toggle(id: string): void {
revealed = { ...revealed, [id]: !revealed[id] }
}
</script>
{#if validVideoIds.length > 0}
<ul class="attachment-list youtube-attachment-list" aria-label="YouTube videos">
{#each validVideoIds as videoId, index (videoId)}
<li
class="attachment youtube-attachment"
data-type="youtube"
data-sensitive={sensitive ? 'true' : 'false'}
data-revealed={isRevealed(videoId) ? 'true' : 'false'}
>
<figure class="attachment-figure">
<div class="youtube-embed">
{#if isRevealed(videoId)}
<iframe
src={youtubeEmbedUrl(videoId)}
title={validVideoIds.length === 1 ? 'YouTube video' : `YouTube video ${index + 1}`}
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
{:else}
<div class="youtube-embed-placeholder">Sensitive YouTube video</div>
{/if}
</div>
<figcaption class="attachment-caption">
<a href={youtubeWatchUrl(videoId)} target="_blank" rel="noopener noreferrer">
Watch on YouTube
</a>
</figcaption>
</figure>
{#if sensitive}
<button type="button" class="button button--small attachment-reveal" onclick={() => toggle(videoId)}>
{isRevealed(videoId) ? 'Hide' : 'Show'} sensitive video
</button>
{/if}
</li>
{/each}
</ul>
{/if}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { extractYouTubeVideoIds, youtubeVideoId } from './youtube'
describe('youtubeVideoId', () => {
it.each([
['https://www.youtube.com/watch?v=pyNCigSulNs', 'pyNCigSulNs'],
['https://youtu.be/pyNCigSulNs?t=42', 'pyNCigSulNs'],
['https://m.youtube.com/shorts/pyNCigSulNs', 'pyNCigSulNs'],
['https://www.youtube.com/live/pyNCigSulNs', 'pyNCigSulNs'],
['https://www.youtube-nocookie.com/embed/pyNCigSulNs', 'pyNCigSulNs'],
])('recognizes %s', (url, expected) => {
expect(youtubeVideoId(url)).toBe(expected)
})
it('rejects lookalike hosts and malformed IDs', () => {
expect(youtubeVideoId('https://youtube.com.example.test/watch?v=pyNCigSulNs')).toBeNull()
expect(youtubeVideoId('https://www.youtube.com/watch?v=too-short')).toBeNull()
expect(youtubeVideoId('https://youtu.be/%invalid-id')).toBeNull()
expect(youtubeVideoId('javascript:alert(1)')).toBeNull()
})
})
describe('extractYouTubeVideoIds', () => {
it('finds and de-duplicates linked and plain-text YouTube URLs', () => {
const html = `
<p>
<a href="https://www.youtube.com/watch?v=pyNCigSulNs&amp;t=12">first</a>
https://youtu.be/pyNCigSulNs
<a href="https://youtu.be/dQw4w9WgXcQ">second</a>
</p>
`
expect(extractYouTubeVideoIds(html)).toEqual(['pyNCigSulNs', 'dQw4w9WgXcQ'])
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* YouTube links in status HTML.
*
* Mastodon and Pleroma normally linkify URLs before returning a status, but
* scanning visible text as well keeps this useful with older or unusual
* servers. Only a validated eleven-character video ID leaves this module.
*/
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'music.youtube.com'])
const EMBED_HOSTS = new Set(['youtube-nocookie.com'])
function decodeAttribute(value: string): string {
return value
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#(?:39|x27);/gi, "'")
}
function validVideoId(value: string | null | undefined): string | null {
if (!value) return null
try {
const decoded = decodeURIComponent(value)
return VIDEO_ID_PATTERN.test(decoded) ? decoded : null
} catch {
return null
}
}
export function youtubeVideoId(value: string): string | null {
let url: URL
try {
url = new URL(decodeAttribute(value).replace(/[),.;!?]+$/, ''))
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
const host = url.hostname.toLowerCase().replace(/^www\./, '')
if (host === 'youtu.be') return validVideoId(url.pathname.split('/')[1])
const segments = url.pathname.split('/').filter(Boolean)
if (YOUTUBE_HOSTS.has(host)) {
if (segments[0] === 'watch') return validVideoId(url.searchParams.get('v'))
if (['embed', 'live', 'shorts'].includes(segments[0])) return validVideoId(segments[1])
}
if (EMBED_HOSTS.has(host) && segments[0] === 'embed') {
return validVideoId(segments[1])
}
return null
}
export function extractYouTubeVideoIds(html: string | null | undefined): string[] {
if (!html) return []
const candidates: string[] = []
const hrefPattern = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
const urlPattern =
/https?:\/\/(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)\/[^\s<>"']+/gi
for (const match of html.matchAll(hrefPattern)) {
candidates.push(match[1] ?? match[2] ?? match[3])
}
candidates.push(...html.match(urlPattern) ?? [])
const ids: string[] = []
const seen = new Set<string>()
for (const candidate of candidates) {
const id = youtubeVideoId(candidate)
if (id && !seen.has(id)) {
seen.add(id)
ids.push(id)
}
}
return ids
}
export function youtubeWatchUrl(videoId: string): string {
return `https://www.youtube.com/watch?v=${videoId}`
}
export function youtubeEmbedUrl(videoId: string): string {
return `https://www.youtube-nocookie.com/embed/${videoId}`
}
+1
View File
@@ -61,6 +61,7 @@
['.blog-entry[data-mine="true"]', 'Entries you wrote'], ['.blog-entry[data-mine="true"]', 'Entries you wrote'],
['.blog-entry[data-visibility="private"]', 'Friends-only entries'], ['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
['.blog-entry[data-boosted="true"]', 'Reposts'], ['.blog-entry[data-boosted="true"]', 'Reposts'],
['.youtube-attachment, .youtube-embed', 'YouTube embeds'],
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons youve activated'], ['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons youve activated'],
['.comment[data-depth="2"]', 'Comments by nesting depth'], ['.comment[data-depth="2"]', 'Comments by nesting depth'],
], ],
+30
View File
@@ -173,6 +173,36 @@
font-size: var(--ms-font-size-small); font-size: var(--ms-font-size-small);
} }
/* YouTube links become privacy-enhanced players in the attachment space. */
.youtube-attachment-list {
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
}
.youtube-embed {
display: grid;
aspect-ratio: 16 / 9;
background: #000;
}
.youtube-embed iframe,
.youtube-embed-placeholder {
width: 100%;
height: 100%;
grid-area: 1 / 1;
border: 0;
}
.youtube-embed-placeholder {
display: grid;
place-items: center;
box-sizing: border-box;
padding: 12px;
color: #fff;
background: #222;
font-weight: 700;
text-align: center;
}
/* ----------------------------------------------------------- link preview */ /* ----------------------------------------------------------- link preview */
.preview-card { .preview-card {