项目提交

This commit is contained in:
ccc_dw
2026-02-03 16:54:02 +08:00
commit 183084b9ed
19 changed files with 2082 additions and 0 deletions
+102
View File
@@ -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>
+34
View File
@@ -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>
+141
View File
@@ -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>
+14
View File
@@ -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>
+6
View File
@@ -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')
+2
View File
@@ -0,0 +1,2 @@
declare module 'dplayer'
declare module 'hls.js'