feat: 添加直播房间页面及相关样式和功能

- 新增直播房间页面,支持竖屏直播模式
- 实现直播播放器和消息展示功能
- 增加购物车按钮和商品推荐展示
- 更新样式以适应新页面布局
- 移除不再使用的 MQTT 相关文件和 API 调用
This commit is contained in:
Yer11214
2026-07-07 17:10:44 +08:00
parent 4e2fcbc1fe
commit fc3cad18e6
16 changed files with 2028 additions and 508 deletions

View File

@@ -1,379 +0,0 @@
import api from "@/config/api.js";
function normalizeSocketUrl(url = "") {
return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
}
const mqttEndpoint = normalizeSocketUrl(api.mqtt);
function stringToUtf8Bytes(str = "") {
const encoded = encodeURIComponent(String(str));
const bytes = [];
for (let i = 0; i < encoded.length; i++) {
if (encoded[i] === "%") {
bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16));
i += 2;
} else {
bytes.push(encoded.charCodeAt(i));
}
}
return bytes;
}
function utf8BytesToString(bytes) {
let encoded = "";
bytes.forEach((byte) => {
if (byte < 0x80) {
encoded += String.fromCharCode(byte);
} else {
encoded += `%${byte.toString(16).padStart(2, "0")}`;
}
});
try {
return decodeURIComponent(encoded);
} catch (e) {
return "";
}
}
function encodeString(str) {
const bytes = stringToUtf8Bytes(str);
return [(bytes.length >> 8) & 0xff, bytes.length & 0xff, ...bytes];
}
function encodeRemainingLength(length) {
const bytes = [];
do {
let digit = length % 128;
length = Math.floor(length / 128);
if (length > 0) digit |= 0x80;
bytes.push(digit);
} while (length > 0);
return bytes;
}
function toArrayBuffer(bytes) {
return new Uint8Array(bytes).buffer;
}
function decodeRemainingLength(bytes, offset = 1) {
let multiplier = 1;
let value = 0;
let index = offset;
let digit = 0;
do {
digit = bytes[index++];
value += (digit & 127) * multiplier;
multiplier *= 128;
} while ((digit & 128) !== 0 && index < bytes.length);
return { value, bytesUsed: index - offset, nextIndex: index };
}
class MqttClient {
constructor(options = {}) {
const defaultOptions = {
endpoint: mqttEndpoint,
username: "admin",
password: "hivemq",
keepalive: 30,
clean: true,
connectTimeout: 20000,
topicHandlers: [],
};
this.options = { ...defaultOptions, ...options };
this.socketTask = null;
this.connected = false;
this.connecting = false;
this.manualDisconnect = false;
this.packetId = 1;
this.topics = [];
this.pingTimer = null;
this.connectTimeoutTimer = null;
this.onConnectCallback = null;
this.onErrorCallback = null;
this.onCloseCallback = null;
this.onOfflineCallback = null;
this.onMessageCallback = null;
}
connect() {
if (!this.options.endpoint) {
this.onError(new Error("MQTT 地址未配置,请检查 config/api.js 的 mqtt 字段"));
return this;
}
this.disconnect();
this.manualDisconnect = false;
this.connecting = true;
console.log("[MQTT-MP] 开始连接:", this.options.endpoint);
this.socketTask = uni.connectSocket({
url: this.options.endpoint,
protocols: ["mqtt"],
success: () => {},
fail: (error) => this.onError(error),
});
this.socketTask.onOpen(() => {
this.sendConnectPacket();
this.connectTimeoutTimer = setTimeout(() => {
if (!this.connected) {
this.onError(new Error("连接超时"));
this.disconnect();
}
}, this.options.connectTimeout);
});
this.socketTask.onMessage((event) => {
this.handlePacket(event.data);
});
this.socketTask.onError((error) => {
this.onError(error);
});
this.socketTask.onClose(() => {
this.clearTimers();
this.connected = false;
this.connecting = false;
if (!this.manualDisconnect) {
this.onCloseCallback?.();
}
});
return this;
}
sendConnectPacket() {
const variableHeader = [
...encodeString("MQTT"),
0x04,
(this.options.username ? 0x80 : 0) |
(this.options.password ? 0x40 : 0) |
(this.options.clean ? 0x02 : 0),
(this.options.keepalive >> 8) & 0xff,
this.options.keepalive & 0xff,
];
const payload = [
...encodeString(this.options.clientId),
...(this.options.username ? encodeString(this.options.username) : []),
...(this.options.password ? encodeString(this.options.password) : []),
];
const body = [...variableHeader, ...payload];
this.sendBytes([0x10, ...encodeRemainingLength(body.length), ...body]);
}
subscribeToTopics() {
this.options.topicHandlers.forEach(({ topic, qos = 0, handler }) => {
this.subscribe(topic, { qos }, handler);
});
}
subscribe(topic, options = { qos: 0 }, handler) {
if (!this.isClientValid()) {
console.warn("[MQTT-MP] 客户端无效,无法订阅");
return false;
}
const qos = options.qos || 0;
const packetId = this.nextPacketId();
const payload = [...encodeString(topic), qos];
const variableHeader = [(packetId >> 8) & 0xff, packetId & 0xff];
const body = [...variableHeader, ...payload];
this.sendBytes([0x82, ...encodeRemainingLength(body.length), ...body]);
const idx = this.topics.findIndex((item) => item.topic === topic);
if (idx >= 0) {
this.topics[idx] = { topic, qos, handler };
} else {
this.topics.push({ topic, qos, handler });
}
console.log(`[MQTT-MP] 订阅发送: ${topic}`);
return true;
}
sendBytes(bytes) {
if (!this.socketTask) return;
this.socketTask.send({
data: toArrayBuffer(bytes),
fail: (error) => this.onError(error),
});
}
handlePacket(data) {
const bytes = new Uint8Array(data);
const packetType = bytes[0] >> 4;
const flags = bytes[0] & 0x0f;
const remaining = decodeRemainingLength(bytes);
let index = remaining.nextIndex;
if (packetType === 2) {
const returnCode = bytes[index + 1];
if (returnCode === 0) {
this.connected = true;
this.connecting = false;
this.clearConnectTimeout();
this.startPing();
this.subscribeToTopics();
this.onConnectCallback?.();
} else {
this.onError(new Error(`CONNACK 失败: ${returnCode}`));
}
return;
}
if (packetType === 3) {
const topicLength = (bytes[index] << 8) + bytes[index + 1];
index += 2;
const topic = utf8BytesToString(Array.from(bytes.slice(index, index + topicLength)));
index += topicLength;
const qos = (flags >> 1) & 0x03;
let packetId = 0;
if (qos > 0) {
packetId = (bytes[index] << 8) + bytes[index + 1];
index += 2;
}
const payloadEnd = remaining.nextIndex + remaining.value;
const message = utf8BytesToString(Array.from(bytes.slice(index, payloadEnd)));
this.dispatchMessage(topic, message);
if (qos === 1 && packetId) {
this.sendBytes([0x40, 0x02, (packetId >> 8) & 0xff, packetId & 0xff]);
}
return;
}
if (packetType === 9) {
console.log("[MQTT-MP] 订阅确认");
}
}
dispatchMessage(topic, message) {
this.options.topicHandlers
.filter((handler) => handler.topic === topic || this.matchTopicPattern(handler.topic, topic))
.forEach((handler) => {
try {
handler.handler(message, topic);
} catch (error) {
console.error(`[MQTT-MP] 处理主题 ${topic} 消息失败:`, error);
}
});
this.onMessageCallback?.(topic, message);
}
matchTopicPattern(pattern, topic) {
const regex = new RegExp(
`^${pattern
.replace(/\+/g, "[^/]+")
.replace(/#$/, ".*")
.replace(/\//g, "\\/")}$`
);
return regex.test(topic);
}
nextPacketId() {
this.packetId += 1;
if (this.packetId > 65535) this.packetId = 1;
return this.packetId;
}
startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.connected) {
this.sendBytes([0xc0, 0x00]);
}
}, Math.max(10000, (this.options.keepalive * 1000) / 2));
}
stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
clearConnectTimeout() {
if (this.connectTimeoutTimer) {
clearTimeout(this.connectTimeoutTimer);
this.connectTimeoutTimer = null;
}
}
clearTimers() {
this.stopPing();
this.clearConnectTimeout();
}
disconnect() {
this.manualDisconnect = true;
this.clearTimers();
if (this.socketTask) {
try {
if (this.connected) {
this.sendBytes([0xe0, 0x00]);
}
this.socketTask.close({});
} catch (error) {
console.warn("[MQTT-MP] 断开连接异常:", error);
}
}
this.socketTask = null;
this.connected = false;
this.connecting = false;
this.topics = [];
return this;
}
onError(error) {
console.error("[MQTT-MP] 错误:", error?.message || error);
this.connecting = false;
this.onErrorCallback?.(error);
}
isClientValid() {
return this.socketTask && this.connected;
}
onConnected(callback) {
this.onConnectCallback = callback;
return this;
}
onErrorOccurred(callback) {
this.onErrorCallback = callback;
return this;
}
onClosed(callback) {
this.onCloseCallback = callback;
return this;
}
onClientOffline(callback) {
this.onOfflineCallback = callback;
return this;
}
isConnected() {
return this.connected;
}
}
let mqttInstance = null;
export function createMqttClient(options = {}) {
if (mqttInstance && mqttInstance.isConnected()) {
mqttInstance.disconnect();
}
mqttInstance = new MqttClient(options);
return mqttInstance;
}
export { MqttClient };

View File

@@ -1,34 +0,0 @@
import { getLiveSetting } from "@/api/live.js";
let cachedSetting = null;
let fetchingPromise = null;
export async function fetchLiveSetting(force = false) {
if (!force && cachedSetting) return cachedSetting;
if (!force && fetchingPromise) return fetchingPromise;
fetchingPromise = (async () => {
try {
const res = await getLiveSetting();
if (res.data?.success && res.data?.result) {
cachedSetting = res.data.result;
return cachedSetting;
}
console.warn("获取直播配置失败", res.data);
return null;
} catch (error) {
console.error("获取直播配置异常:", error);
return null;
} finally {
fetchingPromise = null;
}
})();
return fetchingPromise;
}
export function resolveSdkAppId(setting) {
if (!setting?.imSdkAppid) return 0;
const id = Number(setting.imSdkAppid);
return Number.isNaN(id) ? 0 : id;
}

View File

@@ -1,6 +1,7 @@
.live-room-page {
width: 100%;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.live-detail-page-vertical {
@@ -51,11 +52,10 @@
.bottom-container-vertical {
position: absolute;
bottom: 0;
bottom: calc(104rpx + env(safe-area-inset-bottom));
left: 0;
right: 0;
z-index: 10;
padding-bottom: env(safe-area-inset-bottom);
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
transition: transform 0.3s ease, opacity 0.3s ease;
@@ -67,7 +67,14 @@
}
.bottom-container-vertical .message-container {
height: 36vh;
overflow: hidden;
padding: 0 24rpx;
box-sizing: border-box;
}
.bottom-container-vertical .message-scroll {
height: 100%;
}
.bottom-container-vertical .message-item {
@@ -90,6 +97,12 @@
z-index: 20;
}
.bottom-container-vertical .cart-btn-inner {
box-shadow:
0 8rpx 24rpx rgba(0, 0, 0, 0.35),
0 0 0 2rpx rgba(255, 255, 255, 0.12);
}
.product-showcase {
margin: 0 24rpx 16rpx;
padding: 16rpx;
@@ -166,9 +179,22 @@
}
}
.bottom-container-vertical .bottom-actions {
background: transparent;
.bottom-container-vertical .bottom-actions,
.bottom-actions-vertical {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
background: rgba(0, 0, 0, 0.45);
border-top: none;
transition: transform 0.3s ease, opacity 0.3s ease;
&.hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
}
.bottom-container-vertical .input-box {

View File

@@ -17,7 +17,8 @@ $border-color: #eee;
.live-detail-page {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: #fff;
}
@@ -121,6 +122,12 @@ $border-color: #eee;
flex-direction: column;
position: relative;
background: #fff;
min-height: 0;
overflow: hidden;
&.has-chat-bar {
padding-bottom: calc(104rpx + env(safe-area-inset-bottom));
}
}
.tab-container {
@@ -162,6 +169,8 @@ $border-color: #eee;
.message-container {
flex: 1;
min-height: 0;
height: 0;
overflow: hidden;
}
@@ -280,20 +289,32 @@ $border-color: #eee;
.cart-btn {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.cart-btn-inner {
width: 106rpx;
height: 116rpx;
width: 96rpx;
height: 96rpx;
background: linear-gradient(135deg, #ff6b35, #ff9f28);
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:active {
transform: scale(0.92);
}
}
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
display: flex;
align-items: center;
padding: 16rpx 24rpx;
@@ -398,6 +419,11 @@ $border-color: #eee;
flex-direction: column;
}
.coupon-list-container {
max-height: 80vh;
box-shadow: 0 -10rpx 40rpx rgba(0, 0, 0, 0.08);
}
.goods-list-header,
.coupon-list-header {
display: flex;
@@ -408,6 +434,12 @@ $border-color: #eee;
border-radius: 32rpx 32rpx 0 0;
}
.coupon-list-header {
padding: 40rpx 40rpx 32rpx;
background: #f7f8fa;
border-bottom: 1rpx solid #eceef2;
}
.goods-list-title,
.coupon-list-title {
font-size: 34rpx;
@@ -415,6 +447,13 @@ $border-color: #eee;
color: #333;
}
.coupon-list-title {
font-size: 36rpx;
font-weight: 800;
color: #111;
letter-spacing: 1rpx;
}
.goods-list-close,
.coupon-list-close {
font-size: 36rpx;
@@ -422,6 +461,25 @@ $border-color: #eee;
padding: 8rpx;
}
.coupon-list-close {
width: 56rpx;
height: 56rpx;
padding: 0;
background: #e5e6eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
font-weight: bold;
line-height: 1;
&:active {
background: #d5d6db;
}
}
.goods-list-scroll,
.coupon-list-scroll {
flex: 1;
@@ -535,8 +593,18 @@ $border-color: #eee;
color: #999;
}
.coupon-empty {
padding: 120rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
color: #8a8f99;
font-weight: 500;
}
.coupon-list-inner {
padding: 0 32rpx 48rpx;
padding: 24rpx 32rpx 48rpx;
}
.coupon-card {
@@ -546,48 +614,124 @@ $border-color: #eee;
border-radius: 24rpx;
margin-bottom: 24rpx;
position: relative;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.03);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
border: 1rpx solid #f0f1f5;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:active {
transform: scale(0.98);
}
&::before,
&::after {
content: "";
position: absolute;
width: 32rpx;
height: 32rpx;
background: #f7f8fa;
border-radius: 50%;
left: 204rpx;
z-index: 2;
box-shadow: inset 0 0 0 1rpx #eceef2;
}
&::before {
top: -16rpx;
}
&::after {
bottom: -16rpx;
}
}
.coupon-card-left {
width: 220rpx;
min-height: 160rpx;
min-height: 180rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ff3b30;
flex-shrink: 0;
color: #ff3b30;
position: relative;
background: linear-gradient(135deg, #fff5f5 0%, #ffecec 100%);
}
.coupon-price-symbol {
font-size: 32rpx;
font-weight: 700;
margin-right: 4rpx;
}
.coupon-price-value {
font-size: 56rpx;
font-weight: 900;
line-height: 1;
letter-spacing: -1rpx;
}
.coupon-divider {
position: absolute;
left: 220rpx;
top: 24rpx;
bottom: 24rpx;
width: 0;
border-left: 2rpx dashed #e5e6eb;
z-index: 1;
}
.coupon-card-right {
flex: 1;
padding: 32rpx;
padding: 32rpx 32rpx 32rpx 40rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
background: #fff;
}
.coupon-info {
display: flex;
flex-direction: column;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.coupon-card-name {
font-size: 30rpx;
color: #111;
font-weight: 700;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.coupon-card-desc {
font-size: 24rpx;
color: #8a8f99;
font-weight: 500;
}
.coupon-claim-btn {
padding: 12rpx 28rpx;
flex-shrink: 0;
padding: 16rpx 32rpx;
border-radius: 40rpx;
font-size: 26rpx;
font-weight: 700;
color: #fff;
background: #111;
background: linear-gradient(135deg, #ff6b35, #ff4b2b);
box-shadow: 0 8rpx 16rpx rgba(255, 75, 43, 0.25);
&.disabled {
background: #f0f0f0;
color: #999;
background: #eceef2;
color: #8a8f99;
box-shadow: none;
border: 1rpx solid #dfe1e6;
}
}
@@ -736,16 +880,19 @@ $border-color: #eee;
display: flex;
height: 180rpx;
overflow: hidden;
position: relative;
}
.rc-ticket-left {
width: 180rpx;
flex-shrink: 0;
display: flex;
align-items: baseline;
justify-content: center;
color: #ff4b2b;
background: linear-gradient(135deg, #fff5f5 0%, #ffecec 100%);
border-right: 2rpx dashed #ffcaca;
flex-shrink: 0;
position: relative;
}
.rc-symbol {
@@ -765,12 +912,13 @@ $border-color: #eee;
padding: 0 24rpx;
display: flex;
align-items: center;
background: #fff;
}
.rc-name {
font-size: 30rpx;
color: #333;
font-weight: 600;
color: #111;
font-weight: 700;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -796,8 +944,9 @@ $border-color: #eee;
box-shadow: 0 8rpx 24rpx rgba(255, 65, 108, 0.25);
&.disabled {
background: #f0f0f0;
color: #999;
background: #eceef2;
color: #8a8f99;
box-shadow: none;
border: 1rpx solid #dfe1e6;
}
}

View File

@@ -33,10 +33,20 @@
autoplay
mode="live"
object-fit="fillCrop"
:min-cache="1"
:max-cache="3"
@error="onPlayerError"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- #ifdef H5 -->
<div
v-if="isLiving && roomInfo.pullStreamUrl"
ref="hlsContainer"
class="stream-player"
style="width: 100%; height: 100%; background: #000"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<video
v-if="isLiving && roomInfo.pullStreamUrl"
class="stream-player"
@@ -58,7 +68,7 @@
</view>
<view class="bottom-container-vertical" :class="{ hidden: !isUIVisible }" @click.stop>
<view class="message-container" :style="{ height: scrollViewHeight + 'px' }">
<view class="message-container">
<scroll-view class="message-scroll" scroll-y :scroll-into-view="scrollToView" scroll-with-animation>
<view
v-for="(item, index) in messageList"
@@ -104,22 +114,22 @@
</view>
</view>
</view>
</view>
<view v-if="showChatBar" class="bottom-actions">
<view class="input-box" @tap="onChatInputTap">
<input
v-model="inputMessage"
class="input-content"
type="text"
:placeholder="chatPlaceholder"
:disabled="isLogin('auth') && liveUser?.muteFlag"
confirm-type="send"
@focus="onChatFocus"
@confirm="sendMessage"
/>
</view>
<view class="send-btn-small" :class="{ disabled: isLogin('auth') && (!canSend || sending) }" @click="sendMessage">发送</view>
<view v-if="showChatBar" class="bottom-actions bottom-actions-vertical" :class="{ hidden: !isUIVisible }">
<view class="input-box" @tap="onChatInputTap">
<input
v-model="inputMessage"
class="input-content"
type="text"
:placeholder="chatPlaceholder"
:disabled="isLogin('auth') && liveUser?.muteFlag"
confirm-type="send"
@focus="onChatFocus"
@confirm="sendMessage"
/>
</view>
<view class="send-btn-small" :class="{ disabled: isLogin('auth') && (!canSend || sending) }" @click="sendMessage">发送</view>
</view>
<!-- 竖屏底部弹窗 -->
@@ -215,10 +225,20 @@
autoplay
mode="live"
:object-fit="objectFit"
:min-cache="1"
:max-cache="3"
@error="onPlayerError"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- #ifdef H5 -->
<div
v-if="isLiving && roomInfo.pullStreamUrl"
ref="hlsContainer"
class="stream-player"
style="width: 100%; height: 100%; background: #000"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<video
v-if="isLiving && roomInfo.pullStreamUrl"
class="stream-player"
@@ -234,7 +254,7 @@
</view>
</view>
<view class="bottom-container">
<view class="bottom-container" :class="{ 'has-chat-bar': currentTab === 0 && showChatBar }">
<view class="tab-container">
<view class="tab-items">
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
@@ -246,7 +266,7 @@
</view>
</view>
<view class="message-container" :style="{ height: scrollViewHeight + 'px' }">
<view class="message-container">
<scroll-view
v-if="currentTab === 0"
class="message-scroll"
@@ -421,10 +441,16 @@
<view class="coupon-list-inner">
<view v-for="item in visibleCouponList" :key="item.id || item.couponId" class="coupon-card">
<view class="coupon-card-left">
<text class="coupon-price-value"><text style="font-size:28rpx">¥</text>{{ unitPrice(item.couponPrice) }}</text>
<text class="coupon-price-value">
<text class="coupon-price-symbol">¥</text>{{ unitPrice(item.couponPrice) }}
</text>
</view>
<view class="coupon-divider" />
<view class="coupon-card-right">
<text class="coupon-card-name">{{ item.couponName }}</text>
<view class="coupon-info">
<text class="coupon-card-name">{{ item.couponName }}</text>
<text class="coupon-card-desc">限时专享优惠</text>
</view>
<view
class="coupon-claim-btn"
:class="{ disabled: isCouponReceived(item.couponId) || claimingCouponId === item.couponId }"
@@ -448,10 +474,14 @@
<script>
import config from "@/config/config";
import { getLivePollingData, registerLiveViewUser, receiveLiveCoupon, sendLiveMessage } from "@/api/live.js";
import { getLiveRoomById, getLivePollingData, registerLiveViewUser, receiveLiveCoupon, sendLiveMessage } from "@/api/live.js";
import { LiveMqttService } from "./utils/liveMqtt.js";
import { LiveChatService } from "./utils/liveChat.js";
import { seedLiveSetting } from "./utils/liveSetting.js";
import { parseRecommendMessageArray, findRecommendPayload } from "./utils/liveRecommend.js";
// #ifdef H5
import Hls from "hls.js";
// #endif
const POLL_INTERVAL = 5000;
@@ -485,7 +515,6 @@ export default {
liveCoupon: [],
liveCouponReceives: [],
currentTab: 0,
scrollViewHeight: 400,
showGoodsModal: false,
showCouponModal: false,
isUIVisible: true,
@@ -499,6 +528,8 @@ export default {
shownRecommendCouponIds: [],
liveMqttService: null,
liveChatService: null,
hlsHttpFallbackUsed: false,
lastHlsPullUrl: "",
};
},
computed: {
@@ -559,10 +590,27 @@ export default {
return this.visibleCouponList.some((item) => !this.isCouponReceived(item.couponId));
},
},
created() {
// hls.js Vue
this.hlsInstance = null;
this.hlsVideoElement = null;
this._hlsVideoClickHandler = null;
},
watch: {
"roomInfo.pullStreamUrl"() {
this.syncH5Player();
},
isLiving() {
this.syncH5Player();
},
isVerticalMode() {
// DOM
this.lastHlsPullUrl = "";
this.$nextTick(() => this.syncH5Player());
},
},
onLoad(options) {
this.liveId = this.parseLiveId(options);
const systemInfo = uni.getSystemInfoSync();
this.scrollViewHeight = Math.max(systemInfo.windowHeight - (this.isVerticalMode ? 320 : 480), 240);
if (!this.liveId) {
uni.showToast({ title: "无效的直播链接", icon: "none" });
@@ -583,18 +631,19 @@ export default {
if (this.liveId && !this.pollTimer) {
this.startPolling();
}
if (this.liveId && !this.loading && this.roomInfo?.liveStatus !== LIVE_STATUS.ENDED) {
if (this.liveId && this.liveMqttService && !this.liveMqttService.isConnected()) {
this.initLiveMqtt();
}
this.syncH5Player();
},
onHide() {
this.stopPolling();
this.cleanupLiveMqtt();
},
onUnload() {
this.stopPolling();
this.cleanupLiveMqtt();
this.cleanupLiveChat();
this.destroyH5HlsPlayer();
},
// #ifdef MP-WEIXIN
onShareTimeline() {
@@ -752,22 +801,19 @@ export default {
// #endif
},
async initLiveRoom() {
const ok = await this.fetchPollingData(true);
const ok = await this.fetchRoomDetail(true);
if (!ok) return;
await this.fetchPollingData(false);
this.setupShare();
if (this.roomInfo.title) {
uni.setNavigationBarTitle({ title: this.roomInfo.title });
}
const systemInfo = uni.getSystemInfoSync();
this.scrollViewHeight = Math.max(
systemInfo.windowHeight - (this.isVerticalMode ? 320 : 480),
240
);
if (this.isLogin("auth")) {
await this.registerLiveUser();
}
this.startPolling();
this.initLiveMqtt();
this.syncH5Player();
},
async initLiveChat() {
if (!this.isLogin("auth")) return;
@@ -886,6 +932,7 @@ export default {
if (updates.liveStatus === LIVE_STATUS.ENDED) {
this.stopPolling();
}
this.syncH5Player();
} catch (error) {
console.error("处理直播 MQTT 消息失败:", error);
}
@@ -921,6 +968,7 @@ export default {
if (this.roomInfo.liveStatus !== LIVE_STATUS.LIVING) {
this.roomInfo = { ...this.roomInfo, liveStatus: LIVE_STATUS.LIVING };
}
this.syncH5Player();
},
startPolling() {
this.stopPolling();
@@ -958,22 +1006,20 @@ export default {
if (this.roomInfo.liveStatus === LIVE_STATUS.ENDED) {
this.stopPolling();
}
this.syncH5Player();
},
async fetchPollingData(isInit = false) {
async fetchRoomDetail(isInit = false) {
if (isInit) this.loading = true;
try {
const res = await getLivePollingData(this.liveId);
if (res.data.success && res.data.result) {
this.applyPollingData(res.data.result);
if (isInit && !res.data.result.liveDetail) {
uni.showToast({ title: "直播间不存在", icon: "none" });
setTimeout(() => this.goBackOrHome(), 1500);
return false;
}
const res = await getLiveRoomById(this.liveId);
if (res.data?.success && res.data?.result) {
this.roomInfo = res.data.result;
seedLiveSetting(this.liveId, res.data.result);
this.syncH5Player();
return true;
}
if (isInit) {
uni.showToast({ title: res.data.message || "直播间不存在", icon: "none" });
uni.showToast({ title: res.data?.message || "直播间不存在", icon: "none" });
setTimeout(() => this.goBackOrHome(), 1500);
}
return false;
@@ -986,6 +1032,21 @@ export default {
if (isInit) this.loading = false;
}
},
async fetchPollingData(isInit = false) {
if (isInit) this.loading = true;
try {
const res = await getLivePollingData(this.liveId);
if (res.data.success && res.data.result) {
this.applyPollingData(res.data.result);
return true;
}
return false;
} catch (e) {
return false;
} finally {
if (isInit) this.loading = false;
}
},
goGoods(item) {
if (this.isGoodsSoldOut(item)) return;
if (!item.canBuyFlag) {
@@ -1017,6 +1078,164 @@ export default {
console.error("live player error:", e.detail);
uni.showToast({ title: "直播加载失败", icon: "none" });
},
/** 根据当前直播状态与拉流地址,按需(重新)挂载或销毁 H5 播放器 */
syncH5Player(retry = 0) {
// #ifdef H5
const shouldPlay = this.isLiving && !!this.roomInfo.pullStreamUrl;
if (!shouldPlay) {
this.destroyH5HlsPlayer();
this.lastHlsPullUrl = "";
return;
}
if (
this.roomInfo.pullStreamUrl === this.lastHlsPullUrl &&
this.hlsInstance &&
this.hlsVideoElement &&
!this.hlsVideoElement.paused
) {
return;
}
if (this.roomInfo.pullStreamUrl === this.lastHlsPullUrl && this.hlsInstance) {
this.ensureH5VideoPlaying(this.hlsVideoElement);
return;
}
this.lastHlsPullUrl = this.roomInfo.pullStreamUrl;
this.hlsHttpFallbackUsed = false;
this.$nextTick(() => {
const container = this.$refs.hlsContainer;
if (!container) {
if (retry < 8) {
setTimeout(() => this.syncH5Player(retry + 1), 80);
}
return;
}
this.initH5HlsPlayer();
});
// #endif
},
/** 尝试自动播放;浏览器策略拦截时先静音播放,点击后再恢复声音 */
ensureH5VideoPlaying(video) {
// #ifdef H5
if (!video) return Promise.resolve();
const tryPlay = (muted) => {
video.muted = muted;
return video.play().catch(() => Promise.reject());
};
return tryPlay(false)
.catch(() => tryPlay(true))
.catch(() => {
console.warn("[HLS] 自动播放被阻止,请点击画面播放");
});
// #endif
},
/** 初始化 H5 HLS 播放器,支持 HTTPS 证书异常时自动降级为 HTTP 重试 */
initH5HlsPlayer(useHttpFallback = false) {
// #ifdef H5
const originalUrl = this.roomInfo.pullStreamUrl;
const container = this.$refs.hlsContainer;
if (!originalUrl || !container) return;
const httpUrl =
useHttpFallback && originalUrl.startsWith("https://")
? originalUrl.replace("https://", "http://")
: originalUrl;
const url = httpUrl.replace(".flv", ".m3u8");
this.destroyH5HlsPlayer();
const video = document.createElement("video");
video.style.cssText = "width:100%;height:100%;object-fit:cover;background:#000;";
video.autoplay = true;
video.muted = true;
video.defaultMuted = true;
video.playsInline = true;
video.setAttribute("autoplay", "");
video.setAttribute("muted", "");
video.setAttribute("playsinline", "");
video.setAttribute("webkit-playsinline", "");
video.setAttribute("x5-playsinline", "");
video.setAttribute("x5-video-player-type", "h5");
video.setAttribute("x5-video-player-fullscreen", "true");
video.controls = false;
const onVideoClick = () => {
if (video.muted) {
video.muted = false;
}
video.play().catch(() => {});
};
video.addEventListener("click", onVideoClick);
container.appendChild(video);
this.hlsVideoElement = video;
this._hlsVideoClickHandler = onVideoClick;
const onReadyPlay = () => {
this.ensureH5VideoPlaying(video);
};
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
this.hlsInstance = hls;
hls.on(Hls.Events.MEDIA_ATTACHED, onReadyPlay);
hls.on(Hls.Events.MANIFEST_PARSED, onReadyPlay);
hls.attachMedia(video);
hls.loadSource(url);
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (!this.hlsHttpFallbackUsed && originalUrl.startsWith("https://")) {
console.warn("[HLS] HTTPS 加载失败,尝试 HTTP 降级...");
this.hlsHttpFallbackUsed = true;
this.destroyH5HlsPlayer();
this.$nextTick(() => this.initH5HlsPlayer(true));
} else {
console.error("[HLS] 网络错误,尝试重连...");
hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error("[HLS] 媒体错误,尝试恢复...");
hls.recoverMediaError();
break;
default:
console.error("[HLS] 致命错误:", data);
this.destroyH5HlsPlayer();
break;
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
video.addEventListener("loadedmetadata", onReadyPlay);
video.addEventListener("canplay", onReadyPlay);
} else {
console.error("[HLS] 当前浏览器不支持 HLS");
}
// #endif
},
/** 销毁 H5 HLS 播放器 */
destroyH5HlsPlayer() {
// #ifdef H5
if (this.hlsInstance) {
this.hlsInstance.destroy();
this.hlsInstance = null;
}
if (this.hlsVideoElement) {
if (this._hlsVideoClickHandler) {
this.hlsVideoElement.removeEventListener("click", this._hlsVideoClickHandler);
this._hlsVideoClickHandler = null;
}
this.hlsVideoElement.remove();
this.hlsVideoElement = null;
}
// #endif
},
buildMessagePayload(content) {
const userInfo = this.isLogin() || {};
return {

View File

@@ -41,7 +41,7 @@ export class LiveChatService {
return;
}
const setting = await fetchLiveSetting();
const setting = await fetchLiveSetting(this.liveId);
if (!setting?.imSdkAppid) {
console.warn("IM SDKAppID 未配置,跳过 IM 初始化");
return;

View File

@@ -1,12 +1,7 @@
/**
* 直播 MQTT 连接管理 saas-uni-v3 useMqtt 迁移
*/
// #ifndef MP-WEIXIN
import { createMqttClient } from "../mqtt/mqtt.js";
// #endif
// #ifdef MP-WEIXIN
import { createMqttClient } from "../mqtt/mqtt-weixin.js";
// #endif
export class LiveMqttService {
constructor(liveId, userId) {

View File

@@ -0,0 +1,41 @@
import { getLiveRoomById } from "@/api/live.js";
const cachedSettings = {};
const fetchingPromises = {};
export function seedLiveSetting(liveId, setting) {
if (liveId && setting) {
cachedSettings[liveId] = setting;
}
}
export async function fetchLiveSetting(liveId, force = false) {
if (!liveId) return null;
if (!force && cachedSettings[liveId]) return cachedSettings[liveId];
if (!force && fetchingPromises[liveId]) return fetchingPromises[liveId];
fetchingPromises[liveId] = (async () => {
try {
const res = await getLiveRoomById(liveId);
if (res.data?.success && res.data?.result) {
cachedSettings[liveId] = res.data.result;
return cachedSettings[liveId];
}
console.warn("获取直播配置失败", res.data);
return null;
} catch (error) {
console.error("获取直播配置异常:", error);
return null;
} finally {
fetchingPromises[liveId] = null;
}
})();
return fetchingPromises[liveId];
}
export function resolveSdkAppId(setting) {
if (!setting?.imSdkAppid) return 0;
const id = Number(setting.imSdkAppid);
return Number.isNaN(id) ? 0 : id;
}