306 lines
7.3 KiB
Vue
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>
|