init
This commit is contained in:
13
hbuilder_app/App.vue
Normal file
13
hbuilder_app/App.vue
Normal 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>
|
||||
29
hbuilder_app/common/api.js
Normal file
29
hbuilder_app/common/api.js
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
8
hbuilder_app/common/device.js
Normal file
8
hbuilder_app/common/device.js
Normal 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
|
||||
}
|
||||
71
hbuilder_app/common/player.js
Normal file
71
hbuilder_app/common/player.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
hbuilder_app/common/sync.js
Normal file
52
hbuilder_app/common/sync.js
Normal 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
7
hbuilder_app/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return { app }
|
||||
}
|
||||
31
hbuilder_app/manifest.json
Normal file
31
hbuilder_app/manifest.json
Normal 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"
|
||||
}
|
||||
57
hbuilder_app/nativeplugins/SyncTV-VLC/README.md
Normal file
57
hbuilder_app/nativeplugins/SyncTV-VLC/README.md
Normal 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
16
hbuilder_app/pages.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "SyncTV"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "SyncTV",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
}
|
||||
305
hbuilder_app/pages/index/index.vue
Normal file
305
hbuilder_app/pages/index/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user