feat(admin): 清晰度产物支持 HLS 预览播放
This commit is contained in:
1 parent
208b9f29d8
commit
1d18b7b52b
5 files changed
+150
-30
No files matched your search
@@ -38,6 +38,7 @@
|
|||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
"echarts-wordcloud": "^2.1.0",
|
"echarts-wordcloud": "^2.1.0",
|
||||||
"element-plus": "2.5.5",
|
"element-plus": "2.5.5",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
|||||||
@@ -559,6 +559,9 @@ export default {
|
|||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
copyUrl: 'Copy playback URL',
|
copyUrl: 'Copy playback URL',
|
||||||
openUrl: 'Open in new window',
|
openUrl: 'Open in new window',
|
||||||
|
play: 'Preview playback',
|
||||||
|
playerFailed: 'Playback failed, please check the rendition output',
|
||||||
|
playerUnsupported: 'This browser does not support HLS playback',
|
||||||
copySuccess: 'Playback URL copied',
|
copySuccess: 'Playback URL copied',
|
||||||
copyFailed: 'Failed to copy playback URL',
|
copyFailed: 'Failed to copy playback URL',
|
||||||
openFailed: 'Unable to open playback URL',
|
openFailed: 'Unable to open playback URL',
|
||||||
|
|||||||
@@ -553,6 +553,9 @@ export default {
|
|||||||
actions: '操作',
|
actions: '操作',
|
||||||
copyUrl: '复制播放地址',
|
copyUrl: '复制播放地址',
|
||||||
openUrl: '新窗口打开',
|
openUrl: '新窗口打开',
|
||||||
|
play: '预览播放',
|
||||||
|
playerFailed: '播放失败,请检查产物是否完整',
|
||||||
|
playerUnsupported: '当前浏览器不支持 HLS 播放',
|
||||||
copySuccess: '播放地址已复制',
|
copySuccess: '播放地址已复制',
|
||||||
copyFailed: '复制播放地址失败',
|
copyFailed: '复制播放地址失败',
|
||||||
openFailed: '无法打开播放地址',
|
openFailed: '无法打开播放地址',
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
<!-- 剧集转码清晰度表格与播放地址操作 -->
|
<!-- 剧集转码清晰度表格与播放地址操作 -->
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import type { MediaRenditionItem } from '@/api/admin/types'
|
import type { MediaRenditionItem } from '@/api/admin/types'
|
||||||
import { AppIcon } from '@/components/AppIcon'
|
import { AppIcon } from '@/components/AppIcon'
|
||||||
import { AppIcons } from '@/constants/icons'
|
import { AppIcons } from '@/constants/icons'
|
||||||
import { useI18n } from '@/hooks/web/useI18n'
|
import { useI18n } from '@/hooks/web/useI18n'
|
||||||
|
import RenditionPlayerDialog from './RenditionPlayerDialog.vue'
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -20,30 +20,20 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { copy, isSupported } = useClipboard()
|
|
||||||
|
const playerVisible = ref(false)
|
||||||
|
const playerUrl = ref('')
|
||||||
|
const playerTitle = ref('')
|
||||||
|
|
||||||
function statusLabel(rendition: MediaRenditionItem) {
|
function statusLabel(rendition: MediaRenditionItem) {
|
||||||
return t(`episodeTranscode.status.${renditionStatusLabelKey(rendition.status)}`)
|
return t(`episodeTranscode.status.${renditionStatusLabelKey(rendition.status)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyPlayUrl(url: string) {
|
function playRendition(rendition: MediaRenditionItem) {
|
||||||
if (!url) return
|
if (!rendition.hlsUrl) return
|
||||||
if (!isSupported.value) {
|
playerUrl.value = rendition.hlsUrl
|
||||||
ElMessage.error(t('episodeTranscode.copyFailed'))
|
playerTitle.value = `${rendition.qualityName} · ${rendition.resolution}`
|
||||||
return
|
playerVisible.value = true
|
||||||
}
|
|
||||||
try {
|
|
||||||
await copy(url)
|
|
||||||
ElMessage.success(t('episodeTranscode.copySuccess'))
|
|
||||||
} catch {
|
|
||||||
ElMessage.error(t('episodeTranscode.copyFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPlayUrl(url: string) {
|
|
||||||
if (!url || !window.open(url, '_blank', 'noopener,noreferrer')) {
|
|
||||||
ElMessage.error(t('episodeTranscode.openFailed'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -82,21 +72,18 @@ function openPlayUrl(url: string) {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('episodeTranscode.actions')" width="92" fixed="right" align="center">
|
<el-table-column :label="t('episodeTranscode.actions')" width="76" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :content="t('episodeTranscode.copyUrl')" placement="top">
|
<el-tooltip :content="t('episodeTranscode.play')" placement="top">
|
||||||
<el-button text circle @click="copyPlayUrl(row.hlsUrl)">
|
<el-button text circle @click="playRendition(row)">
|
||||||
<AppIcon :icon="AppIcons.copy" :size="17" />
|
<AppIcon :icon="AppIcons.videoPlay" :size="17" />
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip :content="t('episodeTranscode.openUrl')" placement="top">
|
|
||||||
<el-button text circle @click="openPlayUrl(row.hlsUrl)">
|
|
||||||
<AppIcon :icon="AppIcons.openInNew" :size="17" />
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<RenditionPlayerDialog v-model="playerVisible" :url="playerUrl" :title="playerTitle" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
<!-- 清晰度产物 HLS 预览播放器:Chrome/Edge 用 hls.js,Safari 走原生 -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import type Hls from 'hls.js'
|
||||||
|
import { useI18n } from '@/hooks/web/useI18n'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const videoRef = ref<HTMLVideoElement>()
|
||||||
|
const errorMsg = ref('')
|
||||||
|
let hls: Hls | null = null
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
hls?.destroy()
|
||||||
|
hls = null
|
||||||
|
const video = videoRef.value
|
||||||
|
if (video) {
|
||||||
|
video.pause()
|
||||||
|
video.removeAttribute('src')
|
||||||
|
video.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(url: string) {
|
||||||
|
await nextTick()
|
||||||
|
const video = videoRef.value
|
||||||
|
if (!video || !url) return
|
||||||
|
errorMsg.value = ''
|
||||||
|
destroy()
|
||||||
|
|
||||||
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = url
|
||||||
|
video.play().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { default: HlsPlayer } = await import('hls.js')
|
||||||
|
if (!HlsPlayer.isSupported()) {
|
||||||
|
errorMsg.value = t('episodeTranscode.playerUnsupported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const instance = new HlsPlayer()
|
||||||
|
hls = instance
|
||||||
|
instance.on(HlsPlayer.Events.MANIFEST_PARSED, () => {
|
||||||
|
video.play().catch(() => {})
|
||||||
|
})
|
||||||
|
instance.on(HlsPlayer.Events.ERROR, (_event, data) => {
|
||||||
|
if (data?.fatal) {
|
||||||
|
errorMsg.value = t('episodeTranscode.playerFailed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
instance.loadSource(url)
|
||||||
|
instance.attachMedia(video)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
start(props.url)
|
||||||
|
} else {
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(destroy)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="title"
|
||||||
|
width="720px"
|
||||||
|
destroy-on-close
|
||||||
|
class="rendition-player-dialog"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
@closed="destroy"
|
||||||
|
>
|
||||||
|
<div class="rendition-player">
|
||||||
|
<video ref="videoRef" class="rendition-player__video" controls playsinline />
|
||||||
|
<p v-if="errorMsg" class="rendition-player__error">{{ errorMsg }}</p>
|
||||||
|
<p class="rendition-player__url" :title="url">{{ url }}</p>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rendition-player {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rendition-player__video {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 62vh;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rendition-player__error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rendition-player__url {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user