项目提交
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