feat(renderer): video player with HLS.js & YouTube embed (M10.12)

- VideoPlayer.vue: native <video> with custom glass styling
- HLS.js lazy-loaded for adaptive streaming (.m3u8)
- YouTube detection → youtube-nocookie.com embed
- Fullscreen via native controls, playsinline for mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:03:30 +00:00
co-authored by Claude Opus 4.6
parent 7fa06ce500
commit f4270424f8
3 changed files with 105 additions and 0 deletions
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@aiui/core": "workspace:*",
"@tanstack/vue-virtual": "^3.13.19",
"hls.js": "^1.6.15",
"katex": "^0.16.33",
"leaflet": "^1.9.4",
"markdown-it": "^14.1.1",
@@ -0,0 +1,96 @@
<template>
<div class="video-player rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<!-- YouTube embed -->
<div v-if="youtubeId" class="relative w-full" style="aspect-ratio: 16/9">
<iframe
:src="`https://www.youtube-nocookie.com/embed/${youtubeId}`"
class="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="Video"
/>
</div>
<!-- Native video -->
<div v-else class="relative w-full" style="aspect-ratio: 16/9">
<video
ref="videoRef"
class="absolute inset-0 w-full h-full bg-black"
controls
playsinline
preload="metadata"
:poster="poster"
>
<source v-if="!isHls" :src="url" />
</video>
</div>
<!-- Title bar -->
<div v-if="title" class="px-3 py-2 border-t border-white/5">
<p class="text-xs text-white/60 truncate">{{ title }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, shallowRef, watch } from 'vue'
const props = defineProps<{
url: string
title?: string
poster?: string
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
// YouTube detection
const youtubeId = computed(() => {
const ytRe = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
const m = props.url.match(ytRe)
return m ? m[1] : null
})
// HLS detection
const isHls = computed(() =>
/\.m3u8(\?|$)/i.test(props.url) || /\.hls(\?|$)/i.test(props.url)
)
type HlsInstance = import('hls.js').default
const hls = shallowRef<HlsInstance | null>(null)
async function initHls() {
if (!isHls.value || !videoRef.value || youtubeId.value) return
const HlsModule = await import('hls.js')
const Hls = HlsModule.default
if (!Hls.isSupported()) {
// Try native HLS support (Safari)
if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.value.src = props.url
}
return
}
const instance = new Hls()
instance.loadSource(props.url)
instance.attachMedia(videoRef.value)
hls.value = instance
}
onMounted(() => {
if (isHls.value) {
initHls()
}
})
watch(() => props.url, () => {
hls.value?.destroy()
hls.value = null
if (isHls.value) initHls()
})
onBeforeUnmount(() => {
hls.value?.destroy()
})
</script>
+8
View File
@@ -27,6 +27,9 @@ importers:
'@tanstack/vue-virtual':
specifier: ^3.13.19
version: 3.13.19(vue@3.5.29(typescript@5.8.3))
hls.js:
specifier: ^1.6.15
version: 1.6.15
katex:
specifier: ^0.16.33
version: 0.16.33
@@ -2295,6 +2298,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hls.js@1.6.15:
resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -5899,6 +5905,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
hls.js@1.6.15: {}
hookable@5.5.3: {}
html-entities@2.6.0: {}