项目提交

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

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# live-player
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>拉流</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

8
live-player.iml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

1
logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24"><path fill="#fff" d="M9.5 9.325v5.35q0 .575.525.875t1.025-.05l4.15-2.65q.475-.3.475-.85t-.475-.85L11.05 8.5q-.5-.35-1.025-.05t-.525.875ZM12 22q-2.075 0-3.9-.788t-3.175-2.137q-1.35-1.35-2.137-3.175T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12q0 2.075-.788 3.9t-2.137 3.175q-1.35 1.35-3.175 2.138T12 22Z"></path></svg>

After

Width:  |  Height:  |  Size: 470 B

1632
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "live-player",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "latest",
"element-plus": "latest",
"artplayer": "latest",
"hls.js": "latest"
},
"devDependencies": {
"vite": "latest",
"@vitejs/plugin-vue": "latest",
"typescript": "latest",
"@types/hls.js": "latest"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

102
src/App.vue Normal file
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>

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
src/components/Player.vue Normal file
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>

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
src/main.ts Normal file
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
src/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module 'dplayer'
declare module 'hls.js'

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

9
vite.config.js Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173
}
})