This commit is contained in:
2026-06-15 22:46:12 +08:00
commit f6508eccdb
38 changed files with 3133 additions and 0 deletions

13
hbuilder_app/App.vue Normal file
View File

@@ -0,0 +1,13 @@
<script>
export default {
onLaunch() {}
}
</script>
<style>
page {
background: #f8fafc;
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
</style>

View File

@@ -0,0 +1,29 @@
export function createApi(baseUrl) {
const root = baseUrl.replace(/\/$/, '')
return {
createRoom() {
return request({ url: `${root}/api/rooms`, method: 'POST' })
},
getRoom(code, deviceId) {
return request({ url: `${root}/api/rooms/${code}?deviceId=${encodeURIComponent(deviceId)}` })
}
}
}
function request(options) {
return new Promise((resolve, reject) => {
uni.request({
...options,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
reject(new Error(typeof res.data === 'string' ? res.data : JSON.stringify(res.data)))
}
},
fail(err) {
reject(err)
}
})
})
}

View File

@@ -0,0 +1,8 @@
export function getDeviceId() {
let id = uni.getStorageSync('deviceId')
if (!id) {
id = 'dev_' + Math.random().toString(16).slice(2) + Date.now().toString(16)
uni.setStorageSync('deviceId', id)
}
return id
}

View File

@@ -0,0 +1,71 @@
export function createPlayer({ onError } = {}) {
let nativePlayer = null
try {
// Available only after the native plugin is added in HBuilderX.
nativePlayer = uni.requireNativePlugin && uni.requireNativePlugin('SyncTV-VLC')
} catch (err) {
nativePlayer = null
}
return {
hasNative: !!nativePlayer,
open(source, server) {
const url = source.url && source.url.startsWith('/api/')
? server.replace(/\/$/, '') + source.url
: source.url
if (nativePlayer) {
nativePlayer.open({
url,
headers: source.headers || {},
username: source.username || '',
password: source.password || '',
isLive: !!source.isLive
})
}
return url
},
play() {
if (nativePlayer) {
nativePlayer.play()
return true
}
return false
},
pause() {
if (nativePlayer) {
nativePlayer.pause()
return true
}
return false
},
seek(positionMs) {
if (nativePlayer) {
nativePlayer.seek({ positionMs })
return true
}
return false
},
getPosition(callback) {
if (nativePlayer) {
nativePlayer.getPosition(callback)
return
}
callback({ positionMs: 0 })
},
release() {
if (nativePlayer) {
nativePlayer.release()
}
},
error(message) {
if (onError) onError(message)
}
}
}

View File

@@ -0,0 +1,52 @@
export class SyncClient {
constructor({ onEvent, onError }) {
this.socket = null
this.onEvent = onEvent
this.onError = onError
}
connect(baseUrl, roomCode, deviceId) {
this.close()
const wsBase = baseUrl.replace(/\/$/, '').replace('http://', 'ws://').replace('https://', 'wss://')
this.socket = uni.connectSocket({
url: `${wsBase}/ws?roomCode=${encodeURIComponent(roomCode)}&deviceId=${encodeURIComponent(deviceId)}`,
complete: () => {}
})
this.socket.onMessage((message) => {
const data = JSON.parse(message.data)
this.onEvent(data.type, data.payload || {})
})
this.socket.onError((err) => this.onError(JSON.stringify(err)))
this.socket.onClose(() => this.onError('websocket closed'))
}
close() {
if (this.socket) {
this.socket.close({})
this.socket = null
}
}
setSource(source) {
this.send('setSource', source)
}
play(positionMs) {
this.send('play', { positionMs })
}
pause(positionMs) {
this.send('pause', { positionMs })
}
syncToLive() {
this.send('syncToLive', { state: 'playing', positionMs: 0, targetLatencyMs: 3000 })
}
send(type, payload) {
if (!this.socket) return
this.socket.send({
data: JSON.stringify({ type, payload })
})
}
}

7
hbuilder_app/main.js Normal file
View File

@@ -0,0 +1,7 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return { app }
}

View File

@@ -0,0 +1,31 @@
{
"name": "SyncTV",
"appid": "__UNI__SYNCTV",
"description": "SyncTV synchronized watching app",
"versionName": "0.1.0",
"versionCode": "1",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": false,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {}
}
},
"quickapp": {},
"mp-weixin": {},
"vueVersion": "3"
}

View File

@@ -0,0 +1,57 @@
# SyncTV VLC Native Plugin
HBuilder/uni-app cannot call VLC directly from Vue/JavaScript. The app needs a native plugin layer:
```text
uni-app UI
-> SyncTV-VLC plugin JS facade
-> Android native module: libVLC
-> iOS native module: MobileVLCKit / VLCKit
```
The Vue page should only call the facade API below. The plugin implementation can then be filled in per platform without changing room/sync UI code.
## JS Facade API
```js
const player = uni.requireNativePlugin('SyncTV-VLC')
player.open({
url,
headers,
username,
password,
isLive
})
player.play()
player.pause()
player.seek({ positionMs: 120000 })
player.getPosition((result) => {
console.log(result.positionMs)
})
player.release()
```
## Android Implementation Target
- Use `org.videolan.android:libvlc-all`.
- Expose methods through a uni-app native plugin module.
- Render into a native view component if possible.
- Support:
- HTTP/HTTPS URL.
- m3u8.
- WebDAV Basic Auth.
- Custom headers.
- Live stream.
- play/pause/seek/getPosition.
## iOS Implementation Target
- Use MobileVLCKit / VLCKit.
- Expose equivalent plugin methods.
- Render through native view component.
## Fallback
The current page keeps a `<video>` fallback for development preview. Production app playback should use this native plugin because the built-in video component still depends on system/WebView media support.

16
hbuilder_app/pages.json Normal file
View File

@@ -0,0 +1,16 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "SyncTV"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "SyncTV",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f8fafc"
}
}

View File

@@ -0,0 +1,305 @@
<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>