feat(metronome): 升级到 BackgroundAudioManager,iOS 真后台播放
iOS 微信小程序硬限制: InnerAudioContext 切到后台/锁屏一定被挂起, 即使配置 requiredBackgroundModes 也无效。 唯一支持 iOS 真后台的音频 API 是 BackgroundAudioManager。 新增预合成档位音轨(ffmpeg 拼接 click + click-wood): - loop_80bpm_2.mp3 慢走 6.0s 4 周期循环 (72 KB) - loop_110bpm_2.mp3 健走 5.45s 5 周期循环 (66 KB) - loop_130bpm_2.mp3 快走 5.54s 6 周期循环 (67 KB) - cover.jpg 200×200 emerald 占位封面 (474B) 新 hook training/hooks/useMetronomeBg.ts: - 封装 uni.getBackgroundAudioManager() 单例 - 必填 metadata: title/coverImgUrl/singer/epname/webUrl - onEnded 重赋 src 实现循环 (BgAudio 无原生 loop 属性) - onPause 同步 isPlaying,用户从锁屏控制条点暂停 UI 自动同步 - 暴露 playLoop(id)/pause/resume/stop API metronome.vue 改造: - 切换为 useMetronomeBg,移除右下角微调和拍号选择 (预合成 mp3 改不了 BPM/拍号,要么砍要么扩 9 个 mp3) - 中央圆按钮逻辑: · 未选档位 → 默认开"健走" · 选了档位且在播 → 暂停 · 选了档位且暂停 → 恢复 - 视频 playbackRate 跟当前档位 BPM 同步(80→0.65× / 110→0.89× / 130→1.05×) - 加底部提示"🔒 锁屏可继续播放,放兜里走也能听到" - onHide 不 stop,onUnmounted 才 stop(避免锁屏控制条挂死) 副作用: - 锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(老人友好) - 切档位有 200~500ms 延迟 (用户主动操作可接受) - 不再支持任意 BPM 微调 (3 档已覆盖大部分场景) 旧的 useMetronome.ts 暂时保留,如 BgAudio 真机验证 OK 再清理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 节拍器后台播放专版 - 基于 BackgroundAudioManager
|
||||
*
|
||||
* 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制:
|
||||
* - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效
|
||||
* - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API
|
||||
*
|
||||
* 取舍:
|
||||
* - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复
|
||||
* - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍)
|
||||
* 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环
|
||||
*
|
||||
* 副作用:
|
||||
* - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停)
|
||||
* - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages)
|
||||
* - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受
|
||||
*/
|
||||
|
||||
export type LoopId = 'slow' | 'normal' | 'brisk'
|
||||
|
||||
export interface LoopPreset {
|
||||
id: LoopId
|
||||
bpm: number
|
||||
accent: number
|
||||
src: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||
{
|
||||
id: 'slow',
|
||||
bpm: 80,
|
||||
accent: 2,
|
||||
src: '/training/static/audio/loop_80bpm_2.mp3',
|
||||
label: '慢走 80 BPM',
|
||||
},
|
||||
{
|
||||
id: 'normal',
|
||||
bpm: 110,
|
||||
accent: 2,
|
||||
src: '/training/static/audio/loop_110bpm_2.mp3',
|
||||
label: '健走 110 BPM',
|
||||
},
|
||||
{
|
||||
id: 'brisk',
|
||||
bpm: 130,
|
||||
accent: 2,
|
||||
src: '/training/static/audio/loop_130bpm_2.mp3',
|
||||
label: '快走 130 BPM',
|
||||
},
|
||||
]
|
||||
|
||||
const COVER_URL = '/training/static/audio/cover.jpg'
|
||||
|
||||
export function useMetronomeBg() {
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const currentLoop = ref<LoopId | null>(null)
|
||||
|
||||
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
|
||||
let bgm: any = null
|
||||
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
|
||||
let desiredSrc = ''
|
||||
|
||||
/* 懒初始化 BackgroundAudioManager + 绑定事件
|
||||
BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */
|
||||
const ensureBgm = () => {
|
||||
if (bgm) return bgm
|
||||
|
||||
const m = uni.getBackgroundAudioManager()
|
||||
|
||||
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
|
||||
m.title = '节拍器'
|
||||
m.epname = '甄养堂 · 健走'
|
||||
m.singer = '健走配速'
|
||||
m.coverImgUrl = COVER_URL
|
||||
m.webUrl = ''
|
||||
|
||||
m.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
m.onPause(() => {
|
||||
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
|
||||
isPlaying.value = false
|
||||
})
|
||||
m.onStop(() => {
|
||||
isPlaying.value = false
|
||||
currentLoop.value = null
|
||||
})
|
||||
|
||||
/* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放
|
||||
BgAudio 没有原生 loop 属性,只能用这招 */
|
||||
m.onEnded(() => {
|
||||
if (desiredSrc && isPlaying.value) {
|
||||
try {
|
||||
m.src = desiredSrc
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
m.onError((err) => {
|
||||
console.error('[BgAudio] error:', err)
|
||||
isPlaying.value = false
|
||||
uni.showToast({
|
||||
title: '音频播放失败,请重试',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
bgm = m
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* 切到指定档位并开始播放
|
||||
* 如果已经在播同一档位 → 切到 pause/play 状态
|
||||
* 如果在播别的档位 → 切换 src(有 200~500ms 延迟)
|
||||
*/
|
||||
const playLoop = (id: LoopId) => {
|
||||
const preset = LOOP_PRESETS.find((p) => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
const m = ensureBgm()
|
||||
|
||||
/* 同档位再点一下 = 暂停 */
|
||||
if (currentLoop.value === id && isPlaying.value) {
|
||||
m.pause()
|
||||
return
|
||||
}
|
||||
|
||||
/* 切换档位或从暂停恢复 */
|
||||
currentLoop.value = id
|
||||
desiredSrc = preset.src
|
||||
|
||||
/* 重设 title 让锁屏控制条显示当前档位 */
|
||||
m.title = `节拍器 · ${preset.label}`
|
||||
|
||||
/* 赋值 src 会自动播放(微信 API 设计如此) */
|
||||
m.src = preset.src
|
||||
/* isPlaying 由 onPlay 回调置 true */
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
if (bgm && isPlaying.value) {
|
||||
bgm.pause()
|
||||
}
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
if (bgm && !isPlaying.value && desiredSrc) {
|
||||
/* 从暂停态恢复:直接 play 即可 */
|
||||
try {
|
||||
bgm.play()
|
||||
} catch (_) {
|
||||
/* 部分基础库 play() 不可用时,重赋 src */
|
||||
bgm.src = desiredSrc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完全停止 + 清掉锁屏控制条
|
||||
* 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例
|
||||
*/
|
||||
const stop = () => {
|
||||
if (bgm) {
|
||||
try {
|
||||
bgm.stop()
|
||||
} catch (_) {}
|
||||
}
|
||||
currentLoop.value = null
|
||||
desiredSrc = ''
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
/* hook 卸载时不主动 stop,因为用户离开节拍器页时
|
||||
仍希望音乐持续(走在路上拿出手机切别的页面应该不停)
|
||||
真正停止的责任在 metronome.vue 的退出按钮里 */
|
||||
onUnmounted(() => {
|
||||
/* 仅解绑回调? BgAudio 是全局单例,我们的回调还在,
|
||||
不解会导致内存中保留无用引用,但回调里都判断了 isPlaying,
|
||||
且新页面再 ensureBgm 时会覆盖回调,可接受 */
|
||||
})
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentLoop,
|
||||
playLoop,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
LOOP_PRESETS,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<view class="page" :style="rootStyle">
|
||||
<!-- ===== 主舞台:节拍器圆 ===== -->
|
||||
<view class="stage" @click="togglePlay">
|
||||
<view class="stage" @click="onCenterTap">
|
||||
<view class="ring r1" :class="{ playing: isPlaying }" />
|
||||
<view class="ring r2" :class="{ playing: isPlaying }" />
|
||||
<view class="core" :class="{ playing: isPlaying, accent: isAccent }">
|
||||
<text class="bpm-num">{{ bpm }}</text>
|
||||
<view class="core" :class="{ playing: isPlaying }">
|
||||
<text class="bpm-num">{{ currentBpm }}</text>
|
||||
<text class="bpm-label">BPM</text>
|
||||
<view class="ctrl">
|
||||
<view v-if="isPlaying" class="icon-pause">
|
||||
@@ -38,10 +38,8 @@
|
||||
@error="onVideoError"
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
/>
|
||||
<!-- 微信原生 video 右上角的 PIP/小窗按钮无法靠属性彻底关掉,
|
||||
用 cover-view 覆盖一个跟背景同色的小色块盖住它 -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- <cover-view class="pip-mask"> </cover-view> -->
|
||||
<cover-view class="pip-mask"> </cover-view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
@@ -49,39 +47,22 @@
|
||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||
<view class="presets">
|
||||
<view
|
||||
v-for="p in PRESETS"
|
||||
v-for="p in LOOP_PRESETS"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:class="{ active: activePreset === p.id }"
|
||||
@click="applyPreset(p)"
|
||||
:class="{ active: currentLoop === p.id }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ p.icon }}</text>
|
||||
<text class="preset-name">{{ p.name }}</text>
|
||||
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
|
||||
<text class="preset-name">{{ presetName(p.id) }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 右下角微调(不显眼) ===== -->
|
||||
<view class="quick-adjust">
|
||||
<view class="qa-group">
|
||||
<view class="qa-btn" @click.stop="adjustBpm(-1)">−</view>
|
||||
<text class="qa-label">微调</text>
|
||||
<view class="qa-btn" @click.stop="adjustBpm(1)">+</view>
|
||||
</view>
|
||||
<view class="qa-divider" />
|
||||
<view class="qa-group">
|
||||
<view
|
||||
v-for="m in METER_OPTIONS"
|
||||
:key="m.value"
|
||||
class="qa-meter"
|
||||
:class="{ active: accentEvery === m.value }"
|
||||
@click.stop="onMeterChange(m.value)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</view>
|
||||
<text class="qa-label">拍</text>
|
||||
</view>
|
||||
<!-- 提示信息(锁屏可继续播放) -->
|
||||
<view class="hint">
|
||||
<text>🔒 锁屏可继续播放,放兜里走也能听到</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -89,7 +70,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
||||
|
||||
/**
|
||||
* 跑步动画视频(腾讯云 COS CDN)
|
||||
@@ -99,104 +80,63 @@ import { useMetronome } from '../hooks/useMetronome'
|
||||
const RUNNER_VIDEO_URL =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
||||
|
||||
/**
|
||||
* 视频原速对应的 BPM 基准(参见 hooks 实现注释)
|
||||
* playback-rate 范围 [0.5, 2.0]
|
||||
*/
|
||||
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
||||
const VIDEO_BASE_BPM = 124
|
||||
|
||||
/**
|
||||
* 推荐档位:覆盖大多数老人健走场景,一键切换 BPM + 拍号
|
||||
* 不在档位的中间数值用右下角微调即可
|
||||
*/
|
||||
const PRESETS = [
|
||||
{
|
||||
id: 'slow',
|
||||
icon: '🚶',
|
||||
name: '慢走',
|
||||
bpm: 80,
|
||||
accent: 2,
|
||||
desc: '热身 · 恢复',
|
||||
},
|
||||
{
|
||||
id: 'normal',
|
||||
icon: '🏃♀️',
|
||||
name: '健走',
|
||||
bpm: 110,
|
||||
accent: 2,
|
||||
desc: '日常 · 通勤',
|
||||
},
|
||||
{
|
||||
id: 'brisk',
|
||||
icon: '🏃',
|
||||
name: '快走',
|
||||
bpm: 130,
|
||||
accent: 2,
|
||||
desc: '提速 · 燃脂',
|
||||
},
|
||||
] as const
|
||||
const { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS } =
|
||||
useMetronomeBg()
|
||||
|
||||
const METER_OPTIONS = [
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '3', value: 3 },
|
||||
{ label: '4', value: 4 },
|
||||
]
|
||||
/* 档位元数据(图标/名称/描述) */
|
||||
const presetIcon = (id: LoopId) =>
|
||||
id === 'slow' ? '🚶' : id === 'normal' ? '🏃♀️' : '🏃'
|
||||
const presetName = (id: LoopId) =>
|
||||
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
|
||||
const presetDesc = (id: LoopId) =>
|
||||
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
|
||||
|
||||
const metronome = useMetronome({
|
||||
initialBpm: 80,
|
||||
accentEvery: 4,
|
||||
clickSrc: '/training/static/audio/click.mp3',
|
||||
accentSrc: '/training/static/audio/click-wood.mp3',
|
||||
})
|
||||
const {
|
||||
bpm,
|
||||
isPlaying,
|
||||
isAccent,
|
||||
accentEvery,
|
||||
intervalMs,
|
||||
start,
|
||||
stop,
|
||||
setBpm,
|
||||
setAccentEvery,
|
||||
preload,
|
||||
} = metronome
|
||||
const currentPreset = computed(() =>
|
||||
currentLoop.value
|
||||
? LOOP_PRESETS.find((p) => p.id === currentLoop.value) ?? null
|
||||
: null,
|
||||
)
|
||||
|
||||
const adjustBpm = (delta: number) => {
|
||||
setBpm(bpm.value + delta)
|
||||
}
|
||||
const currentBpm = computed(() => currentPreset.value?.bpm ?? 110)
|
||||
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||
|
||||
const onMeterChange = (val: number) => {
|
||||
setAccentEvery(val)
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
/* 中央圆按钮:
|
||||
- 没选档位 → 默认开"健走"
|
||||
- 选了档位且在播 → 暂停
|
||||
- 选了档位且暂停 → 恢复 */
|
||||
const onCenterTap = () => {
|
||||
if (!currentLoop.value) {
|
||||
playLoop('normal')
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
return
|
||||
}
|
||||
if (isPlaying.value) {
|
||||
stop()
|
||||
pause()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
start()
|
||||
resume()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
|
||||
/* 当前命中的档位(BPM + 拍号都匹配才算选中) */
|
||||
const activePreset = computed(() => {
|
||||
const hit = PRESETS.find(
|
||||
(p) => p.bpm === bpm.value && p.accent === accentEvery.value,
|
||||
)
|
||||
return hit?.id ?? null
|
||||
})
|
||||
|
||||
const applyPreset = (p: (typeof PRESETS)[number]) => {
|
||||
setBpm(p.bpm)
|
||||
setAccentEvery(p.accent)
|
||||
/* 档位卡片:
|
||||
- 点未选档位 → 切到该档位并播放
|
||||
- 点当前档位 → 切到暂停/恢复 */
|
||||
const onPresetTap = (id: LoopId) => {
|
||||
playLoop(id)
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
|
||||
/* 视频实例与 BPM 同步 */
|
||||
/* ============================================================
|
||||
* 视频实例与 BPM 同步
|
||||
* ============================================================ */
|
||||
let videoCtx: UniApp.VideoContext | null = null
|
||||
|
||||
const playbackRate = computed(() => {
|
||||
const rate = bpm.value / VIDEO_BASE_BPM
|
||||
const rate = currentBpm.value / VIDEO_BASE_BPM
|
||||
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
||||
})
|
||||
|
||||
@@ -207,11 +147,6 @@ onMounted(() => {
|
||||
|
||||
const onVideoError = (e: any) => {
|
||||
console.error('[runner-video] error:', e?.detail || e)
|
||||
uni.showToast({
|
||||
title: '视频加载失败,请用真机预览',
|
||||
icon: 'none',
|
||||
duration: 2500,
|
||||
})
|
||||
}
|
||||
|
||||
const onVideoLoaded = (e: any) => {
|
||||
@@ -237,27 +172,25 @@ watch(playbackRate, (rate) => {
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--beat-duration': `${intervalMs.value}ms`,
|
||||
'--step-duration': `${intervalMs.value}ms`,
|
||||
}))
|
||||
|
||||
onShow(() => {
|
||||
preload()
|
||||
/* BgAudio 全局单例,无需特殊预热,首次 setSrc 时自动初始化 */
|
||||
})
|
||||
|
||||
/**
|
||||
* onHide 故意不 stop:
|
||||
* - 切到后台 / 微信"小窗" 都会触发 onHide
|
||||
* - 配合 pages.json 的 requiredBackgroundModes:["audio"],
|
||||
* 小程序退到后台后节拍声仍然可以继续(健走时把手机放兜里也能听到响)
|
||||
* - 真正离开节拍器页(navigateBack/重启) 会 onUnload+onUnmounted,
|
||||
* 在那里清理屏幕常亮
|
||||
* - 切到后台/锁屏/小窗 都会触发 onHide
|
||||
* - 配合 BackgroundAudioManager,音频会通过系统层继续播放
|
||||
* - 用户能从锁屏控制条主动暂停(通过 onPause 回调同步 UI)
|
||||
*/
|
||||
onHide(() => {
|
||||
/* 不再主动 stop, 让节拍器在后台继续工作 */
|
||||
/* 不停止,让节拍声后台继续 */
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
/* 页面卸载才真正停 + 释放屏幕常亮 */
|
||||
/* 页面被销毁(navigateBack/重启)时才真正停止
|
||||
否则锁屏控制条会一直挂着无主的"节拍器" */
|
||||
if (isPlaying.value) stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
@@ -276,12 +209,8 @@ $text-3: #94a3b8;
|
||||
$brand: #10b981;
|
||||
$brand-soft: #d1fae5;
|
||||
$brand-deep: #047857;
|
||||
$accent: #f59e0b;
|
||||
$accent-soft: #fef3c7;
|
||||
$radius-card: 24rpx;
|
||||
$radius-btn: 18rpx;
|
||||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
$shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
||||
|
||||
/* ============================================================
|
||||
* 页面容器
|
||||
@@ -365,14 +294,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
||||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.playing.accent {
|
||||
background: linear-gradient(140deg, #fbbf24 0%, #d97706 100%);
|
||||
box-shadow:
|
||||
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.22),
|
||||
0 16rpx 40rpx rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.bpm-num {
|
||||
font-size: 168rpx;
|
||||
font-weight: 800;
|
||||
@@ -434,7 +355,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 跑步小人(无边框,跟白底融合)
|
||||
* 跑步小人
|
||||
* ============================================================ */
|
||||
.walker {
|
||||
width: 100%;
|
||||
@@ -445,9 +366,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.walker-box {
|
||||
/* 1:1 矩形,跟 video 内容比例一致,消除左右黑边
|
||||
background 设成 video 内容右上角的浅灰白色,
|
||||
万一还有 1rpx 缝隙也不会出现刺眼黑边 */
|
||||
position: relative;
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
@@ -460,14 +378,9 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
||||
.runner-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 微信 native video 自身默认是 #000 黑底,父容器 bg 在 web 看不到,
|
||||
只能让 video 占满 + 让父 bg 跟 video 内容色融合 */
|
||||
background: #e8ecf2;
|
||||
}
|
||||
|
||||
/* 覆盖在 video 右上角原生 PIP/小窗按钮上的色块
|
||||
颜色取 video 内容右上角的浅灰白渐变(#dde2eb 左右),让色块融入视频画面
|
||||
仅小程序端有效(cover-view 是小程序唯一能盖住 native 组件的元素) */
|
||||
.pip-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -549,84 +462,16 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 右下角微调(不显眼)
|
||||
* 底部提示
|
||||
* ============================================================ */
|
||||
.quick-adjust {
|
||||
position: absolute;
|
||||
right: 28rpx;
|
||||
bottom: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding: 10rpx 18rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1rpx solid rgba(15, 23, 42, 0.05);
|
||||
border-radius: 999rpx;
|
||||
backdrop-filter: blur(10rpx);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.qa-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.qa-label {
|
||||
font-size: 20rpx;
|
||||
color: $text-3;
|
||||
margin: 0 4rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.qa-btn {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
color: $text-2;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
|
||||
&:active {
|
||||
background: rgba(16, 185, 129, 0.18);
|
||||
color: $brand-deep;
|
||||
}
|
||||
}
|
||||
|
||||
.qa-meter {
|
||||
min-width: 36rpx;
|
||||
height: 36rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: 999rpx;
|
||||
.hint {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
&.active {
|
||||
background: $brand;
|
||||
color: #fff;
|
||||
box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4);
|
||||
text {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.qa-divider {
|
||||
width: 1rpx;
|
||||
height: 22rpx;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 474 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user