Files
2026-06-15 22:46:12 +08:00

306 lines
7.3 KiB
Vue

<template>
<view class="page">
<view class="panel">
<text class="title">SyncTV</text>
<text class="status">{{ status }}</text>
<view class="field">
<text class="label">Server</text>
<input v-model="server" class="input" />
</view>
<view class="field">
<text class="label">Room Code</text>
<input v-model="roomCode" class="input" />
</view>
<view class="row">
<button class="primary" @click="createRoom">Create</button>
<button @click="joinRoom">Join</button>
</view>
<view class="divider" />
<text class="section">Source</text>
<picker :range="sourceTypes" :value="sourceTypeIndex" @change="sourceTypeIndex = Number($event.detail.value)">
<view class="select">Type: {{ sourceTypes[sourceTypeIndex] }}</view>
</picker>
<picker :range="sourceModes" :value="sourceModeIndex" @change="sourceModeIndex = Number($event.detail.value)">
<view class="select">Connection: {{ sourceModes[sourceModeIndex] }}</view>
</picker>
<view class="field">
<text class="label">URL</text>
<input v-model="sourceUrl" class="input" />
</view>
<view class="field">
<text class="label">Username</text>
<input v-model="username" class="input" />
</view>
<view class="field">
<text class="label">Password / Token</text>
<input v-model="password" password class="input" />
</view>
<label class="checkbox">
<checkbox :checked="isLive" @click="isLive = !isLive" />
<text>Live stream</text>
</label>
<button :disabled="!isOwner" class="primary" @click="applySource">Apply Source</button>
</view>
<view v-if="player.hasNative" class="native-player">
<text class="native-player-text">VLC native player active</text>
</view>
<video
v-else
id="player"
class="player"
:src="playerUrl"
controls
autoplay="false"
@timeupdate="onTimeUpdate"
/>
<view class="controls">
<button @click="play">Play</button>
<button @click="pause">Pause</button>
<button @click="syncToLive">Back To Live</button>
</view>
<text class="error">{{ error }}</text>
</view>
</template>
<script setup>
import { onBeforeUnmount, ref } from 'vue'
import { createApi } from '../../common/api'
import { getDeviceId } from '../../common/device'
import { createPlayer } from '../../common/player'
import { SyncClient } from '../../common/sync'
const server = ref('http://yuyun-us1.stormrain.cn:8088')
const roomCode = ref('')
const status = ref('Not connected')
const error = ref('')
const isOwner = ref(false)
const deviceId = getDeviceId()
const sourceTypes = ['url', 'oss', 'webdav', 'live']
const sourceModes = ['direct', 'proxy']
const sourceTypeIndex = ref(0)
const sourceModeIndex = ref(0)
const sourceUrl = ref('')
const username = ref('')
const password = ref('')
const isLive = ref(false)
const playerUrl = ref('')
const currentTimeMs = ref(0)
const player = createPlayer({
onError(message) {
error.value = message
}
})
const sync = new SyncClient({
onEvent: handleEvent,
onError(message) {
error.value = message
}
})
function api() {
return createApi(server.value)
}
async function createRoom() {
try {
error.value = ''
const data = await api().createRoom()
roomCode.value = data.room.code
await joinRoom()
} catch (err) {
error.value = err.message
}
}
async function joinRoom() {
try {
error.value = ''
const snapshot = await api().getRoom(roomCode.value.toUpperCase(), deviceId)
applySnapshot(snapshot)
sync.connect(server.value, roomCode.value.toUpperCase(), deviceId)
} catch (err) {
error.value = err.message
}
}
function applySource() {
sync.setSource({
type: sourceTypes[sourceTypeIndex.value],
mode: sourceModes[sourceModeIndex.value],
url: sourceUrl.value,
isLive: isLive.value || sourceTypes[sourceTypeIndex.value] === 'live',
username: username.value,
password: password.value
})
}
function play() {
sync.play(currentTimeMs.value)
if (!player.play()) {
uni.createVideoContext('player').play()
}
}
function pause() {
sync.pause(currentTimeMs.value)
if (!player.pause()) {
uni.createVideoContext('player').pause()
}
}
function syncToLive() {
sync.syncToLive()
}
function onTimeUpdate(event) {
currentTimeMs.value = Math.floor(event.detail.currentTime * 1000)
}
function handleEvent(type, payload) {
if (type === 'roomSnapshot' || type === 'sourceChanged') {
applySnapshot(payload)
} else if (type === 'playbackChanged') {
applyPlayback(payload.playback, payload.serverNow)
} else if (type === 'presenceChanged') {
status.value = `${roomCode.value} · ${isOwner.value ? 'Owner' : 'Member'} · ${payload.onlineCount} online`
} else if (type === 'error') {
error.value = payload.message || 'unknown error'
}
}
function applySnapshot(snapshot) {
roomCode.value = snapshot.room.code
isOwner.value = snapshot.isOwner
status.value = `${snapshot.room.code} · ${snapshot.isOwner ? 'Owner' : 'Member'} · ${snapshot.room.onlineCount} online`
if (snapshot.room.source) {
const source = snapshot.room.source
sourceUrl.value = source.url
isLive.value = !!source.isLive
sourceTypeIndex.value = Math.max(0, sourceTypes.indexOf(source.type))
sourceModeIndex.value = Math.max(0, sourceModes.indexOf(source.mode))
playerUrl.value = player.open(source, server.value)
}
if (snapshot.room.playback) {
applyPlayback(snapshot.room.playback, snapshot.serverNow)
}
}
function applyPlayback(playback, serverNow) {
const ctx = uni.createVideoContext('player')
if (playback.state === 'playing') {
const target = playback.positionMs + Math.max(0, serverNow - playback.serverUpdatedAt)
if (target > 0) {
if (!player.seek(target)) ctx.seek(target / 1000)
}
if (!player.play()) ctx.play()
} else if (playback.state === 'paused') {
if (playback.positionMs > 0) {
if (!player.seek(playback.positionMs)) ctx.seek(playback.positionMs / 1000)
}
if (!player.pause()) ctx.pause()
}
}
onBeforeUnmount(() => {
sync.close()
player.release()
})
</script>
<style>
.page {
padding: 24rpx;
}
.panel {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
}
.title {
display: block;
font-size: 44rpx;
font-weight: 700;
}
.status {
display: block;
color: #64748b;
margin: 8rpx 0 24rpx;
}
.field {
margin-bottom: 18rpx;
}
.label {
display: block;
font-weight: 600;
margin-bottom: 8rpx;
}
.input,
.select {
border: 1px solid #cbd5e1;
border-radius: 8rpx;
padding: 16rpx;
background: #ffffff;
}
.row {
display: flex;
gap: 16rpx;
}
.primary {
background: #2563eb;
color: #ffffff;
}
.divider {
height: 1px;
background: #e2e8f0;
margin: 24rpx 0;
}
.section {
display: block;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.checkbox {
display: flex;
align-items: center;
margin: 14rpx 0;
}
.player {
width: 100%;
height: 420rpx;
margin-top: 24rpx;
background: #000000;
}
.native-player {
height: 420rpx;
margin-top: 24rpx;
background: #000000;
display: flex;
align-items: center;
justify-content: center;
}
.native-player-text {
color: #ffffff;
}
.controls {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
}
.error {
display: block;
color: #dc2626;
margin-top: 18rpx;
}
</style>