ytptube/ui/app/components/VideoPlayer.vue
2025-11-02 19:50:22 +03:00

768 lines
20 KiB
Vue

<style scoped>
.player {
display: flex;
justify-content: center;
align-items: center;
height: auto;
max-height: 80vh;
max-width: 80vw;
position: relative;
}
.keyboard-help {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
color: var(--bulma-white);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
border-radius: 4px;
overflow-y: auto;
padding: 2rem;
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
max-width: 1000px;
width: 100%;
}
.shortcut-section {
text-align: left;
}
.shortcut-section h3 {
color: var(--bulma-primary);
margin-bottom: 1rem;
font-size: 1.1rem;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.shortcut-item span:first-child {
flex: 1;
}
.shortcut-item .kbd-group {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.shortcut-key {
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 3px;
margin-right: 1rem;
font-family: monospace;
font-weight: bold;
min-width: 60px;
text-align: center;
}
.help-close-hint {
margin-top: 2rem;
font-size: 0.9rem;
color: var(--bulma-light);
}
</style>
<template>
<div v-if="infoLoaded">
<div style="position: relative;">
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
crossorigin="anonymous" preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
:srclang="track.lang" :src="track.file" :default="notFirefox && 0 === i" />
</video>
<span class="has-text-white is-pointer" @click="showHelp = !showHelp" title="Keyboard shortcuts (or press ?)">
<span class="icon"><i class="fa-solid fa-question" /></span>
<span>Click here to Keyboard shortcuts or press <kbd>?</kbd> or <kbd>/</kbd></span>
</span>
<div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false">
<h2 style="margin-bottom: 1.5rem;">Keyboard Shortcuts</h2>
<div class="shortcuts-grid">
<div class="shortcut-section">
<h3>Playback Control</h3>
<div class="shortcut-item">
<span>Play/Pause</span>
<div class="kbd-group">
<kbd class="shortcut-key">SPACE</kbd>
<kbd class="shortcut-key">K</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Rewind 10s</span>
<div class="kbd-group">
<kbd class="shortcut-key">J</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Forward 10s</span>
<div class="kbd-group">
<kbd class="shortcut-key">L</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Mute/Unmute</span>
<div class="kbd-group">
<kbd class="shortcut-key">M</kbd>
</div>
</div>
</div>
<div class="shortcut-section">
<h3>Seeking</h3>
<div class="shortcut-item">
<span>Seek Back 5s</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Seek Forward 5s</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Jump to Start</span>
<div class="kbd-group">
<kbd class="shortcut-key">HOME</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Jump to End</span>
<div class="kbd-group">
<kbd class="shortcut-key">END</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Jump to %</span>
<div class="kbd-group">
<kbd class="shortcut-key">0-9</kbd>
</div>
</div>
</div>
<div class="shortcut-section">
<h3>Volume & Speed</h3>
<div class="shortcut-item">
<span>Increase Volume</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Decrease Volume</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Increase Speed</span>
<div class="kbd-group">
<kbd class="shortcut-key">'</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Decrease Speed</span>
<div class="kbd-group">
<kbd class="shortcut-key">;</kbd>
</div>
</div>
</div>
<div class="shortcut-section">
<h3>Display & Other</h3>
<div class="shortcut-item">
<span>Fullscreen</span>
<div class="kbd-group">
<kbd class="shortcut-key">F</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Picture in Picture (PiP)</span>
<div class="kbd-group">
<kbd class="shortcut-key">P</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Toggle Captions</span>
<div class="kbd-group">
<kbd class="shortcut-key">C</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Frame Advance</span>
<div class="kbd-group">
<kbd class="shortcut-key">.</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Frame Rewind</span>
<div class="kbd-group">
<kbd class="shortcut-key">,</kbd>
</div>
</div>
</div>
</div>
<div class="help-close-hint">Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close</div>
</div>
</div>
</div>
<div style="text-align: center;" v-else>
<div class="icon">
<i class="fa-solid fa-spinner fa-spin fa-4x" />
</div>
</div>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { watch } from 'vue'
import Hls from 'hls.js'
import { disableOpacity, enableOpacity } from '~/utils'
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts'
import type { StoreItem } from '~/types/store'
import type { file_info, video_source_element, video_track_element } from '~/types/video'
const config = useConfigStore()
const toast = useNotification()
const props = defineProps({
item: {
type: Object as () => StoreItem,
default: () => ({}),
}
})
const emitter = defineEmits(['closeModel'])
const video = useTemplateRef<HTMLVideoElement>('video')
const tracks = ref<Array<video_track_element>>([])
const sources = ref<Array<video_source_element>>([])
const thumbnail = ref('/images/placeholder.png')
const artist = ref('')
const title = ref('')
const isAudio = ref(false)
const hasVideoStream = ref(false)
const volume = useStorage('player_volume', 1)
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
const infoLoaded = ref(false)
const destroyed = ref(false)
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
const havePoster = ref(false)
const showHelp = ref(false)
let unbindMediaSessionListeners: null | (() => void) = null
let hls: Hls | null = null
let detachDecodeGuard: null | (() => void) = null
let cleanupKeyboardShortcuts: null | (() => void) = null
const handle_event = (e: KeyboardEvent) => {
if ('Escape' !== e.key) {
return
}
emitter('closeModel')
}
const bindMediaSessionListeners = (el: HTMLVideoElement) => {
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onPause = async (e: Event) => {
const target = (e.currentTarget as HTMLVideoElement) ?? null
if (!target || destroyed.value) {
return
}
const dataUrl = await captureFrame(target)
if (dataUrl) {
thumbnail.value = dataUrl
havePoster.value = true
applyMediaSessionMetadata()
}
}
el.addEventListener('loadedmetadata', onLoadedMetadata)
el.addEventListener('timeupdate', onTimeUpdate)
el.addEventListener('ratechange', onRateChange)
el.addEventListener('seeked', onSeeked)
el.addEventListener('pause', onPause)
return () => {
el.removeEventListener('loadedmetadata', onLoadedMetadata)
el.removeEventListener('timeupdate', onTimeUpdate)
el.removeEventListener('ratechange', onRateChange)
el.removeEventListener('seeked', onSeeked)
el.removeEventListener('pause', onPause)
}
}
const updateMediaSessionPosition = (target: EventTarget | null) => {
if (false === ('mediaSession' in navigator)) {
return
}
const el = (target as HTMLVideoElement) ?? null
if (!el || destroyed.value) {
return
}
const d = el.duration
if (false === Number.isFinite(d) || 0 >= d) {
return
}
try {
navigator.mediaSession.setPositionState({
duration: d,
playbackRate: el.playbackRate,
position: el.currentTime,
})
} catch { }
}
const volume_change_handler = () => {
const el = video.value
if (!el) {
return
}
volume.value = el.volume
updateMediaSessionPosition(el)
}
/**
* Unified frame capture helper (merges previous captureCurrentFrame/captureFirstFramePoster logic).
* Returns a JPEG data URL or '' on failure.
*/
const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
if (!el || destroyed.value) {
return ''
}
if (0 === el.videoWidth || 0 === el.videoHeight) {
return ''
}
const w = el.videoWidth
const h = el.videoHeight
try {
if ('OffscreenCanvas' in window) {
const c = new (window as any).OffscreenCanvas(w, h)
const ctx = c.getContext('2d')
if (!ctx) { return '' }
ctx.drawImage(el, 0, 0, w, h)
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 })
return await new Promise<string>(r => {
const fr = new FileReader()
fr.onload = () => r(String(fr.result))
fr.readAsDataURL(blob)
})
} else {
const c = document.createElement('canvas')
c.width = w
c.height = h
const ctx = c.getContext('2d')
if (!ctx) { return '' }
ctx.drawImage(el, 0, 0, w, h)
return c.toDataURL('image/jpeg', 0.86)
}
} catch {
return ''
}
}
const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
if (!el || destroyed.value) {
return
}
if (havePoster.value) {
return
}
if (0 === el.videoWidth || 0 === el.videoHeight) {
return
}
const dataUrl = await captureFrame(el)
if (!dataUrl) {
return
}
thumbnail.value = dataUrl
havePoster.value = true
applyMediaSessionMetadata()
if (detachDecodeGuard) {
detachDecodeGuard()
detachDecodeGuard = null
}
}
onMounted(async () => {
disableOpacity()
const req = await request(makeDownload(config, props.item, 'api/file/info'))
const response: file_info = await req.json()
if (!req.ok) {
toast.error(`Failed to fetch video info. ${response?.error}`)
emitter('closeModel')
return
}
await nextTick()
if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
havePoster.value = true
} else {
if (response.sidecar?.image?.[0]?.file) {
thumbnail.value = makeDownload(config, { 'filename': response.sidecar.image[0].file })
havePoster.value = true
}
}
hasVideoStream.value = Array.isArray(response.ffprobe?.video)
&& response.ffprobe.video.some(s => 'video' === s.codec_type)
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
} else {
if (false === hasVideoStream.value) {
isAudio.value = true
}
}
if (isApple) {
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
const src = makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8')
sources.value.push({
src,
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: (_e: Event) => src_error(),
})
} else {
const src = makeDownload(config, props.item, 'api/download')
sources.value.push({
src,
type: response.mimetype,
onerror: (_e: Event) => src_error(),
})
}
if (props.item.extras?.channel) {
artist.value = props.item.extras.channel
}
if (!artist.value && props.item.extras?.uploader) {
artist.value = props.item.extras.uploader
}
if (props.item?.title) {
title.value = props.item.title
} else {
if (response?.title) {
title.value = response.title
} else {
if (response.ffprobe?.metadata?.tags?.title) {
title.value = response.ffprobe.metadata.tags.title
}
}
}
response.sidecar?.subtitle?.forEach((cap, _) => {
tracks.value.push({
kind: 'captions',
label: cap.name,
lang: cap.lang,
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
})
})
if (isApple) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block')
}
infoLoaded.value = true
await nextTick()
prepareVideoPlayer()
if (video.value) {
unbindMediaSessionListeners = bindMediaSessionListeners(video.value)
}
// Initialize keyboard shortcuts
const keyboardShortcutsResult = useKeyboardShortcuts({
videoElement: video,
enabled: ref(true),
closePlayer: () => emitter('closeModel'),
onHelpToggle: () => {
// Help toggle callback (optional)
}
})
// Attach the keyboard shortcuts listener and store cleanup function
cleanupKeyboardShortcuts = keyboardShortcutsResult.attach()
// Bind the showHelp from the composable
watch(keyboardShortcutsResult.showHelp, (newVal) => {
showHelp.value = newVal
})
document.addEventListener('keydown', handle_event)
})
const applyMediaSessionMetadata = () => {
if (false === ('mediaSession' in navigator)) {
return
}
const meta: MediaMetadataInit = { title: title.value }
if (artist.value) {
meta.artist = artist.value
}
if (thumbnail.value) {
meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
}
try { navigator.mediaSession.metadata = new MediaMetadata(meta) } catch { }
}
onUpdated(() => prepareVideoPlayer())
onBeforeUnmount(() => {
enableOpacity()
if (hls) {
hls.destroy()
}
document.removeEventListener('keydown', handle_event)
if (cleanupKeyboardShortcuts) {
cleanupKeyboardShortcuts()
cleanupKeyboardShortcuts = null
}
if (unbindMediaSessionListeners) {
unbindMediaSessionListeners()
unbindMediaSessionListeners = null
}
if (title.value) {
window.document.title = 'YTPTube'
}
if (!video.value) {
return
}
destroyed.value = true
try {
video.value.pause()
video.value.querySelectorAll('source').forEach(source => source.removeAttribute('src'))
video.value.removeEventListener('volumechange', volume_change_handler)
if (detachDecodeGuard) {
detachDecodeGuard()
detachDecodeGuard = null
}
video.value.load()
}
catch (e) {
console.error(e)
}
})
const prepareVideoPlayer = () => {
if (!infoLoaded.value) {
return
}
applyMediaSessionMetadata()
if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}`
}
if (!video.value) {
return
}
video.value.volume = volume.value
video.value.addEventListener('volumechange', volume_change_handler)
if (detachDecodeGuard) {
detachDecodeGuard()
detachDecodeGuard = null
}
if (hasVideoStream.value) {
if ('requestVideoFrameCallback' in video.value) {
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
} else {
const tryOnce = () => captureFirstFramePoster(video.value!)
; (video.value as any).addEventListener('loadeddata', tryOnce, { once: true })
}
detachDecodeGuard = attachDecodeFailGuard(video.value, () => src_error())
}
}
function attachDecodeFailGuard(el: HTMLVideoElement, onFail: () => void): () => void {
let rvfcId: number | null = null
let timer: number | null = null
let armed = false
let done = false
let playAttempted = false
const clearAll = () => {
if (null !== timer) {
clearTimeout(timer)
timer = null
}
if (null !== rvfcId && 'cancelVideoFrameCallback' in el) {
; (el as any).cancelVideoFrameCallback(rvfcId)
rvfcId = null
}
el.removeEventListener('loadeddata', onLoadedData)
el.removeEventListener('error', onError)
el.removeEventListener('emptied', onError)
el.removeEventListener('play', onPlay)
}
const ok = () => {
if (done) return
done = true
clearAll()
}
const fail = () => {
if (done) return
done = true
clearAll()
onFail()
}
const decodedFrames = (): number => {
try {
const q = 'getVideoPlaybackQuality' in el ? (el as any).getVideoPlaybackQuality() : null
if (q && 'totalVideoFrames' in q) {
return Number(q.totalVideoFrames || 0)
}
const anyEl = el as any
if ('webkitDecodedFrameCount' in anyEl) {
return Number(anyEl.webkitDecodedFrameCount || 0)
}
} catch { }
return 0
}
const onPlay = () => playAttempted = true
const armCheck = () => {
if (armed || done) return
armed = true
if ('requestVideoFrameCallback' in el) {
rvfcId = (el as any).requestVideoFrameCallback(() => ok())
// Extend timeout for network delays and buffering
timer = window.setTimeout(() => fail(), 3000)
return
}
// Give more time for network playback and buffering
timer = window.setTimeout(() => {
// Only fail if we actually tried to play and have no frames decoded after 3 seconds
if (playAttempted && 0 === (el as any).videoWidth && 0 === (el as any).videoHeight && 0 === decodedFrames()) {
fail()
} else if (0 < (el as any).videoWidth || 0 < (el as any).videoHeight || 0 < decodedFrames()) {
// Video dimensions or frames are available, stream is working
ok()
}
// If nothing is available yet but video hasn't been played, keep waiting
}, 3000)
}
const onLoadedData = () => armCheck()
const onError = () => fail()
el.addEventListener('loadeddata', onLoadedData, { once: true })
el.addEventListener('error', onError)
el.addEventListener('emptied', onError)
el.addEventListener('play', onPlay)
if (4 <= el.readyState || 2 <= el.readyState) {
queueMicrotask(armCheck)
}
return clearAll
}
const src_error = async () => {
if (hls) {
return
}
await nextTick()
if (destroyed.value) {
return
}
if (video.value && (notFirefox && video.value.paused)) {
return
}
console.warn('Source failed to load, attempting HLS fallback via hls.js...')
attach_hls(makeDownload(config, props.item, 'm3u8'))
}
const attach_hls = (link: string) => {
if (!video.value) {
return
}
hls = new Hls({
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 120,
fragLoadingTimeOut: 200000,
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
applyMediaSessionMetadata()
})
hls.on(Hls.Events.LEVEL_LOADED, () => {
if (video.value) {
if ('requestVideoFrameCallback' in video.value) {
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
} else {
const once = () => captureFirstFramePoster(video.value!)
; (video.value as any).addEventListener('loadeddata', once, { once: true })
}
}
})
hls.loadSource(link)
hls.attachMedia(video.value)
}
</script>