feat: Youtube embed support

This commit is contained in:
2026-04-04 03:30:21 +02:00
parent b9df9c92f2
commit 35352923a5
4 changed files with 71 additions and 2 deletions

View File

@@ -32,4 +32,19 @@
}
</div>
</ng-template>
<ng-template
[remarkTemplate]="'link'"
let-node
>
<a
[href]="node.url"
[title]="node.title ?? ''"
[remarkNode]="node"
></a>
@if (isYoutubeUrl(node.url)) {
<div class="block">
<app-chat-youtube-embed [url]="node.url" />
</div>
}
</ng-template>
</remark>

View File

@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
@@ -38,7 +39,8 @@ const REMARK_PROCESSOR = unified()
CommonModule,
RemarkModule,
MermaidComponent,
ChatImageProxyFallbackDirective
ChatImageProxyFallbackDirective,
ChatYoutubeEmbedComponent
],
templateUrl: './chat-message-markdown.component.html'
})
@@ -57,6 +59,10 @@ export class ChatMessageMarkdownComponent {
return KLIPY_MEDIA_URL_PATTERN.test(url);
}
isYoutubeUrl(url?: string): boolean {
return isYoutubeUrl(url);
}
isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid';
}

View File

@@ -0,0 +1,48 @@
import { Component, computed, input } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
@Component({
selector: 'app-chat-youtube-embed',
standalone: true,
template: `
@if (videoId()) {
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
<iframe
[src]="embedUrl()"
class="aspect-video w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
></iframe>
</div>
}
`
})
export class ChatYoutubeEmbedComponent {
readonly url = input.required<string>();
readonly videoId = computed(() => {
const match = this.url().match(YOUTUBE_URL_PATTERN);
return match?.[1] ?? null;
});
readonly embedUrl = computed(() => {
const id = this.videoId();
if (!id)
return '';
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
);
});
constructor(private readonly sanitizer: DomSanitizer) {}
}
export function isYoutubeUrl(url?: string): boolean {
return !!url && YOUTUBE_URL_PATTERN.test(url);
}

View File

@@ -10,7 +10,7 @@
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:;"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com;"
/>
<link
rel="icon"