feat: playroom sync finished

This commit is contained in:
2026-04-04 17:48:34 +08:00
parent 970aae1c5f
commit 122971200f
4 changed files with 973 additions and 655 deletions

View File

@@ -11,6 +11,8 @@ const videoRef = ref(null);
const player = ref(null);
const hlsInstance = ref(null);
const emit = defineEmits(['canplay', 'play', 'pause', 'remote-play-failed']);
const props = defineProps({
autoplay: { type: Boolean, default: false },
videoUrl: { type: String, required: true },
@@ -121,10 +123,12 @@ onMounted(() => {
player.value.on('play', () => {
console.log('播放:', props.videoUrl);
emit('play', { time: getCurrentTime() });
});
player.value.on('pause', () => {
console.log('暂停');
emit('pause', { time: getCurrentTime() });
});
player.value.on('ended', () => {
@@ -140,13 +144,82 @@ onMounted(() => {
});
player.value.on('canplay', () => {
console.log('视频可以播放');
// 缓冲、seek 后常会多次触发;不在此刷屏打印,由父组件用 pending* 决定是否执行同步
emit('canplay');
});
} catch (error) {
console.error('初始化播放器失败:', error);
}
});
const getCurrentTime = () => {
const video = player.value?.video;
if (!video || typeof video.currentTime !== 'number' || Number.isNaN(video.currentTime)) {
return 0;
}
return video.currentTime;
};
const seekTo = (timeSec) => {
const t = Number(timeSec);
if (!Number.isFinite(t) || t < 0) return;
if (player.value?.seek) {
try {
player.value.seek(t);
return;
} catch (e) {}
}
const video = player.value?.video;
if (video && typeof video.currentTime === 'number') {
try {
video.currentTime = t;
} catch (e) {}
}
};
/**
* @param {{ remote?: boolean }} options
* remote=true来自 WebSocket 同步,无用户手势;需静音才能通过多数浏览器的自动播放策略
*/
const play = async (options = {}) => {
const remote = options?.remote === true;
const video = player.value?.video;
if (!video?.play) return false;
if (remote) {
try {
video.muted = true;
video.setAttribute?.('playsinline', 'true');
} catch (e) {}
}
try {
await video.play();
return true;
} catch (e) {
console.warn('[videoPlayer] play() 被拒绝(多为自动播放策略):', e?.name, e?.message);
if (remote) {
emit('remote-play-failed', { error: e });
}
return false;
}
};
const pause = () => {
if (player.value?.pause) {
try {
player.value.pause();
return;
} catch (e) {}
}
const video = player.value?.video;
if (video?.pause) {
try {
video.pause();
} catch (e) {}
}
};
defineExpose({ getCurrentTime, seekTo, play, pause });
watch(() => props.videoUrl, (newUrl) => {
if (player.value && newUrl) {
console.log('切换视频到:', newUrl);

View File

@@ -59,6 +59,7 @@ export const PlayroomStore = defineStore("PlayroomStore",
return {
currentPlayroom,
currentUrl,
getCurrentId,
setCurrentPlayroom,
clearPlayroom,
addmember,

View File

@@ -37,7 +37,15 @@
</el-row>
</el-col>
<el-col :span="20">
<video-player :autoplay="false" :videoUrl="currentURL" />
<video-player
ref="videoPlayerRef"
:autoplay="false"
:videoUrl="currentURL"
@canplay="handlePlayerCanplay"
@play="handleLocalPlay"
@pause="handleLocalPause"
@remote-play-failed="handleRemotePlayFailed"
/>
</el-col>
</el-row>
</div>
@@ -120,19 +128,56 @@
</template>
<script setup>
import { onMounted, ref, watchEffect } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
import { ElMessage } from 'element-plus';
import { userInfoStore } from '@/store/user';
import videoPlayer from '@/components/videoPlayer.vue';
import { PlayroomStore } from '@/store/playroom';
import { getPlayroomDetails } from '@/api/playroom';
import { connectWebSocket } from '@/websocket/roomSocket';
import { getUserInfo } from '@/api/user';
import {
connectWebSocket,
ROOM_SOCKET_VIDEO_PAUSE_EVENT,
ROOM_SOCKET_VIDEO_PLAY_EVENT,
ROOM_SOCKET_VIDEO_SYNC_EVENT,
sendMessage
} from '@/websocket/roomSocket';
//import audioPlayer from '@/components/audioPlayer.vue'; // 假设你有一个音频播放组件
const curruentRoomInfo = PlayroomStore();
const userinfo = userInfoStore();
const videoPlayerRef = ref(null);
const pendingSyncSeekSec = ref(null);
const pendingSyncUrl = ref(null);
const isApplyingRemoteControl = ref(false);
const pendingRemoteAction = ref(null); // 'play' | 'pause' | null
const getSelfFrom = () => {
// u_id 在 JSON 里经常是 number若只判断 typeof === 'string' 会漏掉,最后错误落到 id===0 → from 变成 0
const rawId = userinfo.user?.id;
if (typeof rawId === 'number' && rawId > 0) return rawId;
if (typeof rawId === 'string' && rawId.trim() !== '') {
const n = Number(rawId);
if (Number.isFinite(n) && n > 0) return n;
}
const rawUid = userinfo.user?.u_id;
if (typeof rawUid === 'number' && Number.isFinite(rawUid) && rawUid !== 0) return rawUid;
if (typeof rawUid === 'string' && rawUid.trim() !== '') return rawUid.trim();
return 0;
};
const isSelfMessage = (from) => {
// 后端可能用数字 id也可能用 u_id任一对上即视为自己避免只比 getSelfFrom() 一种形态
const f = String(from);
const id = userinfo.user?.id;
const uid = userinfo.user?.u_id;
if (id != null && String(id) === f) return true;
if (uid != null && String(uid) === f) return true;
return false;
};
const dialogVisibleCode = ref(false)
const dialogVisibleVideo = ref(false)
@@ -283,10 +328,150 @@ const getInvitingCode = () => {
}
const replaceURL = () => {
const playbackTimeSec = videoPlayerRef.value?.getCurrentTime?.() ?? 0;
currentURL.value = changingVideoUrl.value;
dialogVisibleVideo.value = false;
}
const msg = {
cmd: "VIDEO_SYNC",
from: getSelfFrom(),
url: changingVideoUrl.value,
timestamp: Number(playbackTimeSec.toFixed(3)),
playroom: curruentRoomInfo.getCurrentId()
}
console.log(msg);
sendMessage(msg);
dialogVisibleVideo.value = false;
};
const handlePlayerCanplay = () => {
const hasWork =
pendingSyncSeekSec.value != null ||
pendingRemoteAction.value != null ||
pendingSyncUrl.value != null;
if (!hasWork) return;
if (pendingSyncSeekSec.value != null) {
const t = Number(pendingSyncSeekSec.value);
if (Number.isFinite(t) && t >= 0) {
videoPlayerRef.value?.seekTo?.(t);
}
}
if (pendingRemoteAction.value === 'play') {
void videoPlayerRef.value?.play?.({ remote: true });
} else if (pendingRemoteAction.value === 'pause') {
videoPlayerRef.value?.pause?.();
}
pendingSyncSeekSec.value = null;
pendingSyncUrl.value = null;
pendingRemoteAction.value = null;
if (isApplyingRemoteControl.value) {
setTimeout(() => {
isApplyingRemoteControl.value = false;
}, 200);
}
};
const handleRemotePlayFailed = () => {
ElMessage.info('同步播放被浏览器拦截:请在本页点击一次播放器上的播放(需用户手势后才能出声)');
};
const handleVideoSync = (payload) => {
if (!payload) return;
const from = payload.from;
// 如果同步消息来自自己,直接跳过
if (isSelfMessage(from)) return;
const url = payload.url;
if (typeof url !== 'string' || url.trim() === '') return;
const ts = Number(payload.timestamp ?? 0);
pendingSyncSeekSec.value = Number.isFinite(ts) && ts >= 0 ? ts : 0;
pendingSyncUrl.value = url;
// 先切 URL等 canplay 后再 seek
currentURL.value = url;
};
const handleLocalPlay = () => {
if (isApplyingRemoteControl.value) return;
const t = videoPlayerRef.value?.getCurrentTime?.() ?? 0;
const msg = {
cmd: "VIDEO_PLAY",
from: getSelfFrom(),
playroom: curruentRoomInfo.getCurrentId(),
timestamp: Number(Number(t).toFixed(3)),
url: currentURL.value,
};
console.log("[ws send]", msg);
sendMessage(msg);
};
const handleLocalPause = () => {
if (isApplyingRemoteControl.value) return;
const t = videoPlayerRef.value?.getCurrentTime?.() ?? 0;
const msg = {
cmd: "VIDEO_PAUSE",
from: getSelfFrom(),
playroom: curruentRoomInfo.getCurrentId(),
timestamp: Number(Number(t).toFixed(3)),
url: currentURL.value,
};
console.log("[ws send]", msg);
sendMessage(msg);
};
const applyRemotePlayPause = async (payload, action) => {
if (!payload) return;
const from = payload.from;
if (isSelfMessage(from)) return;
console.log("[ws recv]", action, payload);
const url = payload.url;
if (typeof url === 'string' && url.trim() !== '' && url !== currentURL.value) {
// 远端可能切了新 URL但你本地还没收到/处理到 LINK/VIDEO_SYNC先对齐
pendingSyncUrl.value = url;
currentURL.value = url;
}
const ts = Number(payload.timestamp ?? 0);
pendingSyncSeekSec.value = Number.isFinite(ts) && ts >= 0 ? ts : 0;
isApplyingRemoteControl.value = true;
pendingRemoteAction.value = action;
// 尽量立刻执行一次(同 URL 时不用等 canplay
// 用 nextTick + setTimeout(0) 确保 URL/DOM 更新完成再触发 play/pause
await nextTick();
setTimeout(async () => {
const seekSec = pendingSyncSeekSec.value;
try {
videoPlayerRef.value?.seekTo?.(seekSec);
// 先清 pending避免 await play() 期间多次 canplay 又走一遍 handlePlayerCanplay
pendingSyncSeekSec.value = null;
pendingSyncUrl.value = null;
pendingRemoteAction.value = null;
if (action === 'play') {
console.log("[apply]", "play", seekSec);
await videoPlayerRef.value?.play?.({ remote: true });
} else {
console.log("[apply]", "pause", seekSec);
videoPlayerRef.value?.pause?.();
}
} catch (e) {
console.warn("[apply failed]", e);
} finally {
setTimeout(() => {
isApplyingRemoteControl.value = false;
}, 250);
}
}, 0);
};
const joinVoiceRoom = () => {
membersInVoice.value.push({
@@ -309,14 +494,44 @@ const goback = () => {
}
const syncListener = (e) => handleVideoSync(e?.detail);
const playListener = (e) => applyRemotePlayPause(e?.detail, 'play');
const pauseListener = (e) => applyRemotePlayPause(e?.detail, 'pause');
onMounted(async () => {
const r_id = window.location.search.split('=')[1];
// 新窗口只进房间时不会经过 homegetUserInfo 可能从未执行,导致 id/u_id 仍是默认值
if (
userinfo.token &&
(!String(userinfo.user?.u_id || '').trim() || userinfo.user?.id === 0)
) {
const ok = await getUserInfo();
if (!ok && getSelfFrom() === 0) {
ElMessage.warning('用户信息未就绪,播放同步里的 from 可能为 0请重新登录或从首页进入房间');
}
}
console.log("[playroom-debug][room onMounted 即将连 WS]", {
"user.id": userinfo.user?.id,
"id 非 0": typeof userinfo.user?.id === "number" && userinfo.user.id > 0,
"user.u_id": userinfo.user?.u_id,
getSelfFrom: getSelfFrom(),
hasToken: Boolean(userinfo.token),
note: "房间 WebSocket 仅用 r_id + token 子协议,不校验 idfrom 为 0 是发消息时 store 里 id/u_id 仍为空",
});
await getRoomInfo(r_id)
connectWebSocket(r_id)
window.addEventListener(ROOM_SOCKET_VIDEO_SYNC_EVENT, syncListener);
window.addEventListener(ROOM_SOCKET_VIDEO_PLAY_EVENT, playListener);
window.addEventListener(ROOM_SOCKET_VIDEO_PAUSE_EVENT, pauseListener);
})
onBeforeUnmount(() => {
window.removeEventListener(ROOM_SOCKET_VIDEO_SYNC_EVENT, syncListener);
window.removeEventListener(ROOM_SOCKET_VIDEO_PLAY_EVENT, playListener);
window.removeEventListener(ROOM_SOCKET_VIDEO_PAUSE_EVENT, pauseListener);
});
</script>

View File

@@ -1,6 +1,9 @@
import { ref } from "vue";
import { userInfoStore } from "@/store/user";
export const ROOM_SOCKET_VIDEO_SYNC_EVENT = "room:video_sync";
export const ROOM_SOCKET_VIDEO_PLAY_EVENT = "room:video_play";
export const ROOM_SOCKET_VIDEO_PAUSE_EVENT = "room:video_pause";
// userinfo 实例
const userinfo = userInfoStore();
@@ -62,9 +65,19 @@ export const connectWebSocket = (r_id: number) => {
return;
}
console.log(retrytime);
// 调试:房间 WS 不依赖 user.idURL 只有 r_id鉴权在子协议 tokenid 只影响你主动发的消息里的 from
console.log("[playroom-debug][connectWebSocket]", {
r_id,
"user.id": userinfo.user?.id,
"user.id > 0": typeof userinfo.user?.id === "number" && userinfo.user.id > 0,
"user.u_id": userinfo.user?.u_id,
hasToken: Boolean(userinfo.token),
socketUrl,
});
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
socket.value.onopen = (event: any) => {
console.log("[playroom-debug][ws open] 连接已建立,此时 user.id =", userinfo.user?.id, "u_id =", userinfo.user?.u_id);
console.log("WebSocket for video 连接已建立", event);
setReconnectScheduled(false);
setIsManualClose(false);
@@ -89,7 +102,23 @@ export const connectWebSocket = (r_id: number) => {
sendMessage(msg);
break;
case "VIDEO_SYNC":
// console.log("视频同步消息", MessageData);
console.log("视频同步消息", MessageData);
// 通过事件分发给页面(避免 websocket 层直接依赖具体视图)
window.dispatchEvent(
new CustomEvent(ROOM_SOCKET_VIDEO_SYNC_EVENT, { detail: MessageData })
);
break;
case "VIDEO_PLAY":
console.log("视频播放");
window.dispatchEvent(
new CustomEvent(ROOM_SOCKET_VIDEO_PLAY_EVENT, { detail: MessageData })
);
break;
case "VIDEO_PAUSE":
console.log("视频暂停");
window.dispatchEvent(
new CustomEvent(ROOM_SOCKET_VIDEO_PAUSE_EVENT, { detail: MessageData })
);
break;
}
}catch(error){