项目提交
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<el-container
|
||||
style="height:100vh; display:flex; flex-direction:column; justify-content:center; align-items:center; background:#ffffff; color:lightseagreen; gap:16px;">
|
||||
|
||||
<!-- 控制栏 -->
|
||||
<Controls
|
||||
v-model:streamId="streamId"
|
||||
:isPlaying="isPlaying"
|
||||
@play="startPlay"
|
||||
@stop="stopPlay"
|
||||
@refresh="refreshPlayer"
|
||||
@export="exportUrl"
|
||||
/>
|
||||
|
||||
<!-- 外层圆角卡片 -->
|
||||
<div class="player-outer">
|
||||
<!-- 中间层控制比例并居中 -->
|
||||
<div class="player-middle">
|
||||
<Player :url="url" :playing="isPlaying" @statusUpdate="statusMessage = $event"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<StatusBar :status="statusMessage"/>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import Controls from './components/Controls.vue'
|
||||
import Player from './components/Player.vue'
|
||||
import StatusBar from './components/StatusBar.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: { Controls, Player, StatusBar },
|
||||
setup() {
|
||||
const streamId = ref('')
|
||||
const url = ref('')
|
||||
const isPlaying = ref(false)
|
||||
const statusMessage = ref('未播放')
|
||||
|
||||
const startPlay = () => {
|
||||
if (!streamId.value) return alert('请输入房间ID')
|
||||
url.value = `https://live.sansenhoshi.top/live/${encodeURIComponent(streamId.value)}/index.m3u8`
|
||||
isPlaying.value = true
|
||||
statusMessage.value = '正在播放'
|
||||
}
|
||||
|
||||
const stopPlay = () => {
|
||||
isPlaying.value = false
|
||||
statusMessage.value = '已停止'
|
||||
}
|
||||
|
||||
const refreshPlayer = () => {
|
||||
isPlaying.value = false
|
||||
setTimeout(() => {
|
||||
isPlaying.value = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const exportUrl = () => {
|
||||
if (!url.value) return alert('请先播放')
|
||||
navigator.clipboard.writeText(url.value).then(() => alert('链接已复制'))
|
||||
}
|
||||
|
||||
return { streamId, url, isPlaying, statusMessage, startPlay, stopPlay, refreshPlayer, exportUrl }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 最外层卡片:圆角 + 阴影 + 居中 */
|
||||
.player-outer {
|
||||
width: 80%;
|
||||
max-width: 1200px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px; /* 可选 */
|
||||
}
|
||||
|
||||
/* 中间层:控制比例并居中 */
|
||||
.player-middle {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Player 填充中间层 */
|
||||
.player-middle > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<el-header style="display:flex; gap:10px; align-items:center; justify-content:center;">
|
||||
<el-input v-model="localStreamId" placeholder="房间ID" style="width:300px"/>
|
||||
<el-button type="primary" @click="togglePlay">{{ isPlaying ? '停止播放' : '播放' }}</el-button>
|
||||
<el-button @click="$emit('refresh')" :disabled="!isPlaying">刷新</el-button>
|
||||
<el-button @click="$emit('export')">导出链接</el-button>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Controls',
|
||||
props: {
|
||||
streamId: { type: String, required: false },
|
||||
isPlaying: { type: Boolean, required: true }
|
||||
},
|
||||
emits: ['update:streamId', 'play', 'stop', 'refresh', 'export'],
|
||||
setup(props, { emit }) {
|
||||
const localStreamId = ref(props.streamId || '')
|
||||
|
||||
watch(localStreamId, val => emit('update:streamId', val))
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!localStreamId.value) return alert('请输入房间ID')
|
||||
if (props.isPlaying) emit('stop')
|
||||
else emit('play')
|
||||
}
|
||||
|
||||
return { localStreamId, togglePlay }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div ref="playerContainer" style="width:98%; height:98%; background:black;"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, watch, onBeforeUnmount} from 'vue'
|
||||
import Artplayer from 'artplayer'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Player',
|
||||
props: {
|
||||
url: {type: String, required: true},
|
||||
playing: {type: Boolean, required: true}
|
||||
},
|
||||
emits: ['statusUpdate'],
|
||||
setup(props, {emit}) {
|
||||
const playerContainer = ref<HTMLDivElement | null>(null)
|
||||
let player: Artplayer | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let watchdogTimer: ReturnType<typeof setInterval> | null = null
|
||||
let reconnectCount = 0
|
||||
let lastFragTime = Date.now()
|
||||
|
||||
const FRAG_DURATION = 4.16667 * 1000
|
||||
const STALL_LIMIT = FRAG_DURATION * 6
|
||||
const MAX_RECONNECT = 10
|
||||
|
||||
const initPlayer = () => {
|
||||
if (!props.url || !props.playing) return
|
||||
|
||||
if (player) player.destroy()
|
||||
clearTimers()
|
||||
|
||||
emit('statusUpdate', '正在连接…')
|
||||
|
||||
player = new Artplayer({
|
||||
container: playerContainer.value as HTMLDivElement,
|
||||
autoplay: true,
|
||||
isLive: true,
|
||||
volume: 0.3,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
url: props.url,
|
||||
customType: {
|
||||
m3u8(video, url, art) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({liveDurationInfinity: true})
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
;(art as any).hls = hls
|
||||
|
||||
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||
lastFragTime = Date.now()
|
||||
})
|
||||
|
||||
art.on('destroy', () => hls.destroy())
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url
|
||||
} else {
|
||||
art.notice.show = 'Unsupported playback format: m3u8'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
player.on('ready', () => {
|
||||
emit('statusUpdate', '正在播放')
|
||||
reconnectCount = 0
|
||||
startWatchdog()
|
||||
})
|
||||
|
||||
player.on('waiting', () => {
|
||||
emit('statusUpdate', '缓冲中…')
|
||||
})
|
||||
|
||||
player.on('error', () => {
|
||||
emit('statusUpdate', `播放出错,重连中(第 ${++reconnectCount} 次)`)
|
||||
reconnect()
|
||||
})
|
||||
}
|
||||
|
||||
const startWatchdog = () => {
|
||||
clearInterval(watchdogTimer!)
|
||||
watchdogTimer = setInterval(() => {
|
||||
if (!player || !props.playing) return
|
||||
const delta = Date.now() - lastFragTime
|
||||
if (delta > STALL_LIMIT) {
|
||||
emit('statusUpdate', `检测到卡顿,尝试软重连(第 ${++reconnectCount} 次)`)
|
||||
softReconnect()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const softReconnect = () => {
|
||||
if (!player) return
|
||||
// 使用 load + play 更可靠
|
||||
player.video.load()
|
||||
setTimeout(() => {
|
||||
player.play().catch(() => {
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const reconnect = () => {
|
||||
if (reconnectCount > MAX_RECONNECT) {
|
||||
emit('statusUpdate', '重连失败,请手动刷新')
|
||||
return
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (!props.playing) return
|
||||
initPlayer()
|
||||
}, 3000 + reconnectCount * 2000)
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
if (watchdogTimer) clearInterval(watchdogTimer)
|
||||
}
|
||||
|
||||
watch([() => props.url, () => props.playing], () => {
|
||||
if (!props.playing) {
|
||||
if (player) player.destroy()
|
||||
clearTimers()
|
||||
player = null
|
||||
emit('statusUpdate', '已停止播放')
|
||||
} else {
|
||||
initPlayer()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (player) player.destroy()
|
||||
clearTimers()
|
||||
})
|
||||
|
||||
return {playerContainer}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<el-footer style="text-align:center;">状态:{{ status }}</el-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StatusBar',
|
||||
props: {
|
||||
status: { type: String, required: true }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
createApp(App).use(ElementPlus).mount('#app')
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare module 'dplayer'
|
||||
declare module 'hls.js'
|
||||
Reference in New Issue
Block a user