项目提交
This commit is contained in:
commit
183084b9ed
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
38
README.md
Normal file
38
README.md
Normal 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
BIN
favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
13
index.html
Normal file
13
index.html
Normal 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
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
8
live-player.iml
Normal file
8
live-player.iml
Normal 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
1
logo.svg
Normal 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
1632
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
102
src/App.vue
Normal file
102
src/App.vue
Normal 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
src/components/Controls.vue
Normal file
34
src/components/Controls.vue
Normal 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
141
src/components/Player.vue
Normal 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
src/components/StatusBar.vue
Normal file
14
src/components/StatusBar.vue
Normal 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
6
src/main.ts
Normal 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
2
src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare module 'dplayer'
|
||||
declare module 'hls.js'
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal 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
9
vite.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user