Merge pull request #382 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled
Added better fallback to hls.
This commit is contained in:
commit
8473618726
7 changed files with 764 additions and 448 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -95,6 +95,7 @@
|
||||||
"rejecttitle",
|
"rejecttitle",
|
||||||
"remux",
|
"remux",
|
||||||
"rtime",
|
"rtime",
|
||||||
|
"rvfc",
|
||||||
"SIGUSR",
|
"SIGUSR",
|
||||||
"smhd",
|
"smhd",
|
||||||
"socketio",
|
"socketio",
|
||||||
|
|
|
||||||
|
|
@ -5,35 +5,19 @@ Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import operator
|
import operator
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess # qa: ignore
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.library.cache import Cache
|
||||||
|
|
||||||
# parameter-less decorator
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
def async_lru_cache_decorator(async_function):
|
|
||||||
@functools.lru_cache
|
|
||||||
def cached_async_function(*args, **kwargs):
|
|
||||||
coroutine = async_function(*args, **kwargs)
|
|
||||||
return asyncio.ensure_future(coroutine)
|
|
||||||
|
|
||||||
return cached_async_function
|
|
||||||
|
|
||||||
|
|
||||||
# decorator with options
|
|
||||||
def async_lru_cache(*lru_cache_args, **lru_cache_kwargs):
|
|
||||||
def async_lru_cache_decorator(async_function):
|
|
||||||
@functools.lru_cache(*lru_cache_args, **lru_cache_kwargs)
|
|
||||||
def cached_async_function(*args, **kwargs):
|
|
||||||
coroutine = async_function(*args, **kwargs)
|
|
||||||
return asyncio.ensure_future(coroutine)
|
|
||||||
|
|
||||||
return cached_async_function
|
|
||||||
|
|
||||||
return async_lru_cache_decorator
|
|
||||||
|
|
||||||
|
|
||||||
class FFProbeError(Exception):
|
class FFProbeError(Exception):
|
||||||
|
|
@ -254,7 +238,6 @@ class FFProbeResult:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@async_lru_cache(maxsize=512)
|
|
||||||
async def ffprobe(file: str) -> FFProbeResult:
|
async def ffprobe(file: str) -> FFProbeResult:
|
||||||
"""
|
"""
|
||||||
Run ffprobe on a file and return the parsed data as a dictionary.
|
Run ffprobe on a file and return the parsed data as a dictionary.
|
||||||
|
|
@ -266,6 +249,21 @@ async def ffprobe(file: str) -> FFProbeResult:
|
||||||
dict: A dictionary containing the parsed data.
|
dict: A dictionary containing the parsed data.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from app.library.Services import Services
|
||||||
|
|
||||||
|
f = Path(file)
|
||||||
|
|
||||||
|
if not f.exists():
|
||||||
|
msg = f"No such media file '{file}'."
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
cache: Cache | None = Services.get_instance().get("cache")
|
||||||
|
cache_key: str = f"ffprobe:{f!s}:{f.stat().st_size}"
|
||||||
|
|
||||||
|
if cache and (cached := cache.get(cache_key)):
|
||||||
|
LOG.debug(f"ffprobe cache hit for '{cache_key}'")
|
||||||
|
return cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with await anyio.open_file(os.devnull, "w") as tempf:
|
async with await anyio.open_file(os.devnull, "w") as tempf:
|
||||||
await asyncio.create_subprocess_exec(
|
await asyncio.create_subprocess_exec(
|
||||||
|
|
@ -279,11 +277,7 @@ async def ffprobe(file: str) -> FFProbeResult:
|
||||||
msg = "ffprobe not found."
|
msg = "ffprobe not found."
|
||||||
raise OSError(msg) from e
|
raise OSError(msg) from e
|
||||||
|
|
||||||
if not Path(file).exists():
|
args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)]
|
||||||
msg = f"No such media file '{file}'."
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", file]
|
|
||||||
|
|
||||||
p = await asyncio.create_subprocess_exec(
|
p = await asyncio.create_subprocess_exec(
|
||||||
"ffprobe",
|
"ffprobe",
|
||||||
|
|
@ -316,4 +310,7 @@ async def ffprobe(file: str) -> FFProbeResult:
|
||||||
elif stream.is_attachment():
|
elif stream.is_attachment():
|
||||||
result.attachment.append(stream)
|
result.attachment.append(stream)
|
||||||
|
|
||||||
|
if cache:
|
||||||
|
cache.set(cache_key, result, ttl=300)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
max-width: 80vw;
|
max-width: 80vw;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="infoLoaded">
|
<div v-if="infoLoaded">
|
||||||
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
|
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
|
||||||
|
|
@ -28,32 +29,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import type { StoreItem } from '~/types/store'
|
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils'
|
||||||
|
|
||||||
type video_track_element = {
|
import type { StoreItem } from '~/types/store'
|
||||||
file: string,
|
import type { file_info, video_source_element, video_track_element } from '~/types/video'
|
||||||
kind: string,
|
|
||||||
label: string,
|
|
||||||
lang: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
type video_source_element = {
|
|
||||||
src: string,
|
|
||||||
type: string,
|
|
||||||
onerror: (e: Event) => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
type file_info = {
|
|
||||||
title: string,
|
|
||||||
mimetype: string,
|
|
||||||
sidecar?: {
|
|
||||||
image?: Array<{ file: string }>,
|
|
||||||
text?: Array<{ file: string }>,
|
|
||||||
subtitle?: Array<{ name: string, lang: string, file: string }>,
|
|
||||||
}
|
|
||||||
error?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -67,7 +46,7 @@ const props = defineProps({
|
||||||
|
|
||||||
const emitter = defineEmits(['closeModel'])
|
const emitter = defineEmits(['closeModel'])
|
||||||
|
|
||||||
const video = useTemplateRef<HTMLMediaElement>('video')
|
const video = useTemplateRef<HTMLVideoElement>('video')
|
||||||
const tracks = ref<Array<video_track_element>>([])
|
const tracks = ref<Array<video_track_element>>([])
|
||||||
const sources = ref<Array<video_source_element>>([])
|
const sources = ref<Array<video_source_element>>([])
|
||||||
|
|
||||||
|
|
@ -75,43 +54,153 @@ const thumbnail = ref('/images/placeholder.png')
|
||||||
const artist = ref('')
|
const artist = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const isAudio = ref(false)
|
const isAudio = ref(false)
|
||||||
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
|
const hasVideoStream = ref(false)
|
||||||
const volume = useStorage('player_volume', 1)
|
const volume = useStorage('player_volume', 1)
|
||||||
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
|
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
|
||||||
const infoLoaded = ref(false)
|
const infoLoaded = ref(false)
|
||||||
const destroyed = ref(false)
|
const destroyed = ref(false)
|
||||||
|
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
|
||||||
|
const havePoster = ref(false)
|
||||||
|
|
||||||
|
let unbindMediaSessionListeners: null | (() => void) = null
|
||||||
let hls: Hls | null = null
|
let hls: Hls | null = null
|
||||||
|
let detachDecodeGuard: null | (() => void) = null
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent) => {
|
const handle_event = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') {
|
if ('Escape' !== e.key) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emitter('closeModel')
|
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 volume_change_handler = () => {
|
||||||
if (!video.value) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
volume.value = video.value.volume
|
const dataUrl = await captureFrame(el)
|
||||||
|
if (!dataUrl) {
|
||||||
if (false === ("mediaSession" in navigator)) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.mediaSession.setPositionState({
|
thumbnail.value = dataUrl
|
||||||
duration: video.value.duration,
|
havePoster.value = true
|
||||||
playbackRate: video.value.playbackRate,
|
applyMediaSessionMetadata()
|
||||||
position: video.value.currentTime,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
disableOpacity()
|
disableOpacity()
|
||||||
const req = await request(makeDownload(config, props.item, 'api/file/info'))
|
const req = await request(makeDownload(config, props.item, 'api/file/info'))
|
||||||
|
|
||||||
const response: file_info = await req.json()
|
const response: file_info = await req.json()
|
||||||
|
|
||||||
if (!req.ok) {
|
if (!req.ok) {
|
||||||
|
|
@ -124,24 +213,37 @@ onMounted(async () => {
|
||||||
|
|
||||||
if (props.item.extras?.thumbnail) {
|
if (props.item.extras?.thumbnail) {
|
||||||
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
|
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
|
||||||
|
havePoster.value = true
|
||||||
} else {
|
} else {
|
||||||
if (response.sidecar?.image?.[0]?.file) {
|
if (response.sidecar?.image?.[0]?.file) {
|
||||||
thumbnail.value = makeDownload(config, { "filename": 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- check if mimetype is video/mp4 and device is apple
|
|
||||||
// -- as always apple, apple like to be snowflakes.
|
|
||||||
if (isApple) {
|
if (isApple) {
|
||||||
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
|
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
|
||||||
|
const src = makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8')
|
||||||
sources.value.push({
|
sources.value.push({
|
||||||
src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'),
|
src,
|
||||||
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
||||||
onerror: (e: Event) => src_error(),
|
onerror: (e: Event) => src_error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
const src = makeDownload(config, props.item, 'api/download')
|
||||||
sources.value.push({
|
sources.value.push({
|
||||||
src: makeDownload(config, props.item, 'api/download'),
|
src,
|
||||||
type: response.mimetype,
|
type: response.mimetype,
|
||||||
onerror: e => src_error(),
|
onerror: e => src_error(),
|
||||||
})
|
})
|
||||||
|
|
@ -157,20 +259,19 @@ onMounted(async () => {
|
||||||
|
|
||||||
if (props.item?.title) {
|
if (props.item?.title) {
|
||||||
title.value = props.item.title
|
title.value = props.item.title
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
if (response?.title) {
|
if (response?.title) {
|
||||||
title.value = response.title
|
title.value = response.title
|
||||||
|
} else {
|
||||||
|
if (response.ffprobe?.metadata?.tags?.title) {
|
||||||
|
title.value = response.ffprobe.metadata.tags.title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
|
|
||||||
isAudio.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
response.sidecar?.subtitle?.forEach((cap, _) => {
|
response.sidecar?.subtitle?.forEach((cap, _) => {
|
||||||
tracks.value.push({
|
tracks.value.push({
|
||||||
kind: "captions",
|
kind: 'captions',
|
||||||
label: cap.name,
|
label: cap.name,
|
||||||
lang: cap.lang,
|
lang: cap.lang,
|
||||||
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
|
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
|
||||||
|
|
@ -185,9 +286,28 @@ onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
prepareVideoPlayer()
|
prepareVideoPlayer()
|
||||||
|
|
||||||
|
if (video.value) {
|
||||||
|
unbindMediaSessionListeners = bindMediaSessionListeners(video.value)
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handle_event)
|
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())
|
onUpdated(() => prepareVideoPlayer())
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|
@ -198,6 +318,11 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
document.removeEventListener('keydown', handle_event)
|
document.removeEventListener('keydown', handle_event)
|
||||||
|
|
||||||
|
if (unbindMediaSessionListeners) {
|
||||||
|
unbindMediaSessionListeners()
|
||||||
|
unbindMediaSessionListeners = null
|
||||||
|
}
|
||||||
|
|
||||||
if (title.value) {
|
if (title.value) {
|
||||||
window.document.title = 'YTPTube'
|
window.document.title = 'YTPTube'
|
||||||
}
|
}
|
||||||
|
|
@ -209,8 +334,12 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
video.value.pause()
|
video.value.pause()
|
||||||
video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src"))
|
video.value.querySelectorAll('source').forEach(source => source.removeAttribute('src'))
|
||||||
video.value.removeEventListener('volumechange', volume_change_handler)
|
video.value.removeEventListener('volumechange', volume_change_handler)
|
||||||
|
if (detachDecodeGuard) {
|
||||||
|
detachDecodeGuard()
|
||||||
|
detachDecodeGuard = null
|
||||||
|
}
|
||||||
video.value.load()
|
video.value.load()
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|
@ -223,21 +352,8 @@ const prepareVideoPlayer = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false === ("mediaSession" in navigator)) {
|
applyMediaSessionMetadata()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let mediaMetadata: MediaMetadataInit = { title: title.value }
|
|
||||||
|
|
||||||
if (thumbnail.value) {
|
|
||||||
mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artist.value) {
|
|
||||||
mediaMetadata.artist = artist.value
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata(mediaMetadata)
|
|
||||||
if (title.value) {
|
if (title.value) {
|
||||||
window.document.title = `YTPTube - Playing: ${title.value}`
|
window.document.title = `YTPTube - Playing: ${title.value}`
|
||||||
}
|
}
|
||||||
|
|
@ -248,6 +364,102 @@ const prepareVideoPlayer = () => {
|
||||||
|
|
||||||
video.value.volume = volume.value
|
video.value.volume = volume.value
|
||||||
video.value.addEventListener('volumechange', volume_change_handler)
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 armCheck = () => {
|
||||||
|
if (armed || done) return
|
||||||
|
armed = true
|
||||||
|
|
||||||
|
if ('requestVideoFrameCallback' in el) {
|
||||||
|
rvfcId = (el as any).requestVideoFrameCallback(() => ok())
|
||||||
|
timer = window.setTimeout(() => fail(), 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
if (0 === (el as any).videoWidth || 0 === (el as any).videoHeight || 0 === decodedFrames()) {
|
||||||
|
fail()
|
||||||
|
} else {
|
||||||
|
ok()
|
||||||
|
}
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadedData = () => armCheck()
|
||||||
|
const onError = () => fail()
|
||||||
|
|
||||||
|
el.addEventListener('loadeddata', onLoadedData, { once: true })
|
||||||
|
el.addEventListener('error', onError)
|
||||||
|
el.addEventListener('emptied', onError)
|
||||||
|
|
||||||
|
if (4 <= el.readyState || 2 <= el.readyState) {
|
||||||
|
queueMicrotask(armCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clearAll
|
||||||
}
|
}
|
||||||
|
|
||||||
const src_error = async () => {
|
const src_error = async () => {
|
||||||
|
|
@ -258,7 +470,8 @@ const src_error = async () => {
|
||||||
if (destroyed.value) {
|
if (destroyed.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.warn('Direct play failed, trying HLS.')
|
|
||||||
|
console.warn('Source failed to load, attempting HLS fallback...')
|
||||||
attach_hls(makeDownload(config, props.item, 'm3u8'))
|
attach_hls(makeDownload(config, props.item, 'm3u8'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,6 +488,21 @@ const attach_hls = (link: string) => {
|
||||||
fragLoadingTimeOut: 200000,
|
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.loadSource(link)
|
||||||
hls.attachMedia(video.value)
|
hls.attachMedia(video.value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
ui/app/types/video.d.ts
vendored
Normal file
85
ui/app/types/video.d.ts
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
|
||||||
|
type video_track_element = {
|
||||||
|
file: string,
|
||||||
|
kind: string,
|
||||||
|
label: string,
|
||||||
|
lang: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type video_source_element = {
|
||||||
|
src: string,
|
||||||
|
type: string,
|
||||||
|
onerror: (e: Event) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FFProbeTags = {
|
||||||
|
major_brand?: string,
|
||||||
|
minor_version?: string,
|
||||||
|
compatible_brands?: string,
|
||||||
|
encoder?: string,
|
||||||
|
title?: string,
|
||||||
|
artist?: string,
|
||||||
|
album_artist?: string,
|
||||||
|
album?: string,
|
||||||
|
date?: string,
|
||||||
|
track?: string,
|
||||||
|
genre?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FFProbeMetadata = {
|
||||||
|
filename: string,
|
||||||
|
nb_streams: number,
|
||||||
|
nb_programs: number,
|
||||||
|
format_name: string,
|
||||||
|
format_long_name: string,
|
||||||
|
start_time: string,
|
||||||
|
duration: string,
|
||||||
|
size: string,
|
||||||
|
bit_rate: string,
|
||||||
|
tags: FFProbeTags
|
||||||
|
}
|
||||||
|
|
||||||
|
type FFProbeStream = {
|
||||||
|
index: number,
|
||||||
|
codec_name: string,
|
||||||
|
codec_type: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
tags?: {
|
||||||
|
language?: string,
|
||||||
|
title?: string,
|
||||||
|
handler_name?: string,
|
||||||
|
},
|
||||||
|
channels?: number,
|
||||||
|
sample_rate?: string,
|
||||||
|
bit_rate?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FFProbeResult = {
|
||||||
|
metadata: FFProbeMetadata,
|
||||||
|
video: Array<FFProbeStream>,
|
||||||
|
audio: Array<FFProbeStream>,
|
||||||
|
subtitle: Array<FFProbeStream>,
|
||||||
|
attachment: Array<FFProbeStream>,
|
||||||
|
is_video: boolean,
|
||||||
|
is_audio: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
type file_info = {
|
||||||
|
title: string,
|
||||||
|
ffprobe: FFProbeResult,
|
||||||
|
mimetype: string,
|
||||||
|
sidecar?: {
|
||||||
|
image?: Array<{ file: string }>,
|
||||||
|
text?: Array<{ file: string }>,
|
||||||
|
subtitle?: Array<{ name: string, lang: string, file: string }>,
|
||||||
|
},
|
||||||
|
error?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
video_track_element,
|
||||||
|
video_source_element,
|
||||||
|
FFProbeResult,
|
||||||
|
file_info,
|
||||||
|
};
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"nuxt": "^4.0.3",
|
"nuxt": "^4.0.3",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.19",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue-toastification": "2.0.0-rc.5"
|
"vue-toastification": "2.0.0-rc.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
6
uv.lock
6
uv.lock
|
|
@ -1572,11 +1572,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2025.8.20"
|
version = "2025.8.22"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/af/d3c81af35ae2aef148d0ff78f001650ce5a7ca73fbd3b271eb9aab4c56ee/yt_dlp-2025.8.20.tar.gz", hash = "sha256:da873bcf424177ab5c3b701fa94ea4cdac17bf3aec5ef37b91f530c90def7bcf", size = 3037484 }
|
sdist = { url = "https://files.pythonhosted.org/packages/40/98/b077bebdc5c759a3f7af3ed3a2a5345ad1145c61963b476469b840ac84ce/yt_dlp-2025.8.22.tar.gz", hash = "sha256:d1846bbb7edbcd2a0d4a2d76c7a2124868de9ea3b3959a8cb8219e3f7cb5c335", size = 3037631 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/e8/ebd888100684c10799897296e3061c19ba5559b641f8da218bf48229a815/yt_dlp-2025.8.20-py3-none-any.whl", hash = "sha256:073c97e2a3f9cd0fa6a76142c4ef46ca62b2575c37eaf80d8c3718fd6f3277eb", size = 3266841 },
|
{ url = "https://files.pythonhosted.org/packages/f4/06/e9e5e85969bd85142b004577915f33356ee0d5e20b79af8dd40b8bbfc96f/yt_dlp-2025.8.22-py3-none-any.whl", hash = "sha256:b8c71fe4516170dea60c6e5c54e2d45654693b8dc273cad060f22199476ec979", size = 3266783 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue