diff --git a/admin/src/utils/liveWebRTC.js b/admin/src/utils/liveWebRTC.js
index 96ba5e2..b32deff 100644
--- a/admin/src/utils/liveWebRTC.js
+++ b/admin/src/utils/liveWebRTC.js
@@ -1,7 +1,7 @@
/**
- * 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
- * 画质由官网 /live 写入 localStorage(yh_live_capture_quality),同浏览器开播时生效;默认原画约束最少。
- * 信令异常断开时自动重连(复用已采集的 MediaStream,不重复弹权限)。
+ * 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=)
+ * 画质:官网 /live 写入 localStorage(yh_live_capture_quality)
+ * 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream)
*/
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
@@ -57,14 +57,29 @@ function liveWsURLPublish(token) {
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
+function buildCameraConstraints(publishKey, videoDeviceId) {
+ const preset = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
+ const dev = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : {}
+ if (preset.video === true) {
+ return {
+ audio: true,
+ video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true
+ }
+ }
+ return {
+ audio: true,
+ video: { ...preset.video, ...dev }
+ }
+}
+
function humanizeGetUserMediaError(err) {
const name = err && err.name
const raw = ((err && err.message) || '').toLowerCase()
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
- return '已拒绝摄像头或麦克风权限:在浏览器地址栏左侧允许摄像头与麦克风,并确认本页为 HTTPS。'
+ return '已拒绝摄像头或麦克风权限。'
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
- return '未检测到摄像头,请检查是否已接入设备或被系统禁用。'
+ return '未检测到摄像头。'
}
if (
name === 'NotReadableError' ||
@@ -72,25 +87,34 @@ function humanizeGetUserMediaError(err) {
raw.includes('failed to start video source') ||
raw.includes('video source')
) {
- return '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。'
+ return '摄像头被占用或不可用,请关闭其它使用相机的程序后重试。'
}
if (name === 'OverconstrainedError') {
- return '摄像头不满足当前参数约束:请到官网「直播」页换一档画质(或选原画)后再开播。'
+ return '当前摄像头不满足画质约束,请在官网「直播」页改为「原画」或换摄像头。'
}
if (name === 'AbortError') {
- return '打开摄像头被系统中断,请重试。'
+ return '采集被中断,请重试。'
}
return (err && err.message) || '无法打开摄像头'
}
/**
* @param {object} opts
- * @param {string} opts.token 管理员 JWT
+ * @param {string} opts.token
+ * @param {'camera'|'screen_pip'} [opts.captureMode]
+ * @param {string} [opts.videoDeviceId] videoinput deviceId,空则默认设备
* @param {(s: string) => void} [opts.onStatus]
- * @param {(stream: MediaStream) => void} [opts.onLocalStream]
+ * @param {(p: { main: MediaStream, pip: MediaStream|null }) => void} [opts.onLocalStream]
*/
export function startPublishing(opts = {}) {
- const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
+ const {
+ token = '',
+ captureMode = 'camera',
+ videoDeviceId = '',
+ onStatus = () => {},
+ onLocalStream = () => {}
+ } = opts
+
if (!token) {
onStatus('未登录,无法开播')
return { stop: () => {} }
@@ -105,7 +129,6 @@ export function startPublishing(opts = {}) {
let pc = null
let reconnectTimer = null
let reconnectAttempt = 0
- /** 先于关闭旧 WebSocket 递增,避免旧 onclose 误触发重连 */
let wsGen = 0
function clearReconnectTimer() {
@@ -119,8 +142,48 @@ export function startPublishing(opts = {}) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
}
+ async function acquireMedia() {
+ if (captureMode === 'screen_pip') {
+ let display
+ try {
+ display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
+ } catch (e) {
+ const name = e && e.name
+ throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
+ }
+ const screenVideo = display.getVideoTracks()[0]
+ if (!screenVideo) {
+ display.getTracks().forEach((t) => t.stop())
+ throw new Error('未获得屏幕画面')
+ }
+ screenVideo.addEventListener('ended', () => {
+ if (!closedByLocal) {
+ onStatus('屏幕共享已结束')
+ stop()
+ }
+ })
+ let cam
+ try {
+ cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
+ } catch (e) {
+ screenVideo.stop()
+ display.getTracks().forEach((t) => t.stop())
+ throw e
+ }
+ const camVideo = cam.getVideoTracks()[0]
+ const mic = cam.getAudioTracks()
+ const publish = new MediaStream([screenVideo, camVideo, ...mic])
+ const mainPrev = new MediaStream([screenVideo])
+ const pipPrev = new MediaStream([camVideo])
+ onLocalStream({ main: mainPrev, pip: pipPrev })
+ return publish
+ }
+ const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
+ onLocalStream({ main: s, pip: null })
+ return s
+ }
+
async function ensureStreamAndAttach() {
- const cons = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
const needNew =
!stream ||
!stream.getTracks().length ||
@@ -131,8 +194,16 @@ export function startPublishing(opts = {}) {
t.stop()
} catch (_) {}
})
- stream = await navigator.mediaDevices.getUserMedia(cons)
- onLocalStream(stream)
+ try {
+ stream = await acquireMedia()
+ } catch (e) {
+ const msg =
+ e && typeof e.message === 'string' && e.message
+ ? e.message
+ : humanizeGetUserMediaError(e)
+ onStatus(msg)
+ throw e
+ }
}
if (pc) {
try {
@@ -150,7 +221,7 @@ export function startPublishing(opts = {}) {
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
- onStatus('已发起推流协商,等待服务端应答…')
+ onStatus('协商中…')
}
function onSocketMessage(ev) {
@@ -165,10 +236,10 @@ export function startPublishing(opts = {}) {
() => {
reconnectAttempt = 0
clearReconnectTimer()
- onStatus('直播中(信令断开会自动重连;结束请点「结束直播」)')
+ onStatus('直播中')
},
(e) => {
- onStatus(e.message || '设置远端描述失败')
+ onStatus(e.message || '协商失败')
}
)
}
@@ -187,7 +258,7 @@ export function startPublishing(opts = {}) {
clearReconnectTimer()
const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt), 28000)
reconnectAttempt += 1
- onStatus(`信令断开,${Math.round(delay / 1000)} 秒后自动重连(第 ${reconnectAttempt} 次)…`)
+ onStatus(`信令断开,${Math.round(delay / 1000)} 秒后重连(${reconnectAttempt})…`)
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null
if (closedByLocal) return
@@ -214,25 +285,25 @@ export function startPublishing(opts = {}) {
try {
ws = new WebSocket(wsUrl)
} catch (_) {
- onStatus('无法创建信令连接')
+ onStatus('无法连接信令')
scheduleReconnect()
return
}
ws.onopen = async () => {
if (closedByLocal || myGen !== wsGen) return
- onStatus('信令已连接,正在采集摄像头…')
+ onStatus('采集中…')
try {
await ensureStreamAndAttach()
} catch (err) {
- onStatus(humanizeGetUserMediaError(err))
- stop()
+ if (!closedByLocal) {
+ onStatus(err?.message || humanizeGetUserMediaError(err))
+ stop()
+ }
}
}
ws.onmessage = onSocketMessage
ws.onerror = () => {
- if (!closedByLocal) {
- onStatus('信令 WebSocket 异常(请查 Nginx Upgrade 与网关)')
- }
+ if (!closedByLocal) onStatus('信令异常')
}
ws.onclose = () => {
if (myGen !== wsGen) return
@@ -248,8 +319,6 @@ export function startPublishing(opts = {}) {
}
}
- openSignalingSocket()
-
function stop() {
closedByLocal = true
wsGen += 1
@@ -274,5 +343,7 @@ export function startPublishing(opts = {}) {
stream = null
}
+ openSignalingSocket()
+
return { stop }
}
diff --git a/admin/src/views/sites/LiveBroadcast.vue b/admin/src/views/sites/LiveBroadcast.vue
index 017f933..5387cae 100644
--- a/admin/src/views/sites/LiveBroadcast.vue
+++ b/admin/src/views/sites/LiveBroadcast.vue
@@ -4,20 +4,51 @@
官网视频直播(WebRTC)
-
- 仅后台可开播:推流后,官网首页左上角以画中画自动展示,用户也可打开官网「直播」全屏页观看。需站点使用
- HTTPS。公网若观众端黑屏但信令正常,请在 API 服务环境变量中设置
- LIVE_PUBLIC_IP(服务器公网 IPv4,与域名一致),并配置 LIVE_ICE_SERVERS(含 TURN)。
-
{{ status }}
-
- 画质请在官网「直播」页选择(写入本机);与后台开播使用同一浏览器时,开始直播将按该档位采集。
-
+
开始直播
结束直播
-
+
+
+
+
@@ -30,9 +61,31 @@ import { startPublishing } from '../../utils/liveWebRTC'
const authStore = useAuthStore()
const token = computed(() => authStore.getToken() || '')
-const previewRef = ref(null)
+const previewMainRef = ref(null)
+const previewPipRef = ref(null)
const status = ref('就绪')
const session = ref(null)
+const captureMode = ref('camera')
+const selectedCameraId = ref('')
+const videoInputs = ref([])
+
+async function refreshVideoDevices() {
+ try {
+ if (!navigator.mediaDevices?.enumerateDevices) return
+ const list = await navigator.mediaDevices.enumerateDevices()
+ videoInputs.value = list.filter((d) => d.kind === 'videoinput')
+ } catch (_) {}
+}
+
+function applyPreview({ main, pip }) {
+ if (previewMainRef.value) previewMainRef.value.srcObject = main || null
+ if (previewPipRef.value) previewPipRef.value.srcObject = pip || null
+}
+
+function clearPreview() {
+ if (previewMainRef.value) previewMainRef.value.srcObject = null
+ if (previewPipRef.value) previewPipRef.value.srcObject = null
+}
function start() {
if (!token.value) {
@@ -42,12 +95,12 @@ function start() {
status.value = '正在连接…'
const { stop } = startPublishing({
token: token.value,
+ captureMode: captureMode.value,
+ videoDeviceId: selectedCameraId.value || '',
onStatus: (s) => {
status.value = s
},
- onLocalStream: (stream) => {
- if (previewRef.value) previewRef.value.srcObject = stream
- }
+ onLocalStream: applyPreview
})
session.value = { stop }
}
@@ -55,7 +108,7 @@ function start() {
function stop() {
session.value?.stop()
session.value = null
- if (previewRef.value) previewRef.value.srcObject = null
+ clearPreview()
status.value = '已停止'
}
@@ -66,13 +119,13 @@ function onBeforeUnload() {
onMounted(() => {
document.title = '视频直播开播 - 管理后台'
window.addEventListener('beforeunload', onBeforeUnload)
+ refreshVideoDevices()
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
-/** 离开本页时再结束推流;勿在 onUnmounted 里 stop,避免 Vue 开发严格模式双挂载误关 WebSocket */
onBeforeRouteLeave(() => {
stop()
})
@@ -82,39 +135,51 @@ onBeforeRouteLeave(() => {
.live-broadcast {
max-width: 720px;
}
-.tip {
- font-size: 13px;
- line-height: 1.7;
- color: #606266;
- margin-bottom: 16px;
-}
-.tip code {
- font-size: 12px;
- background: #f4f4f5;
- padding: 2px 6px;
- border-radius: 4px;
-}
.status {
color: #409eff;
- margin-bottom: 12px;
+ margin-bottom: 14px;
min-height: 1.5em;
}
-.quality-hint {
- font-size: 13px;
- line-height: 1.6;
- color: #909399;
- margin: 0 0 14px;
+.form-block {
+ margin-bottom: 16px;
+}
+.field-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+.field-label {
+ font-size: 14px;
+ color: #606266;
+ min-width: 72px;
}
.actions {
margin-bottom: 16px;
}
-.preview {
+.preview-wrap {
+ position: relative;
+ max-width: 720px;
+}
+.preview-main {
+ display: block;
width: 100%;
- max-width: 480px;
+ max-height: 70vh;
border-radius: 8px;
background: #000;
+ object-fit: contain;
+}
+.preview-pip {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ width: min(28%, 200px);
aspect-ratio: 4 / 3;
+ border-radius: 8px;
+ border: 2px solid #409eff;
+ background: #000;
object-fit: cover;
- display: block;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js
index e285761..52b5969 100644
--- a/web/src/utils/liveWebRTC.js
+++ b/web/src/utils/liveWebRTC.js
@@ -12,12 +12,10 @@ export function liveWsURLView() {
return `${proto}//${window.location.host}${path}`
}
-/** 只读:直播元信息(GET,无请求体) */
export function liveInfoURL() {
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
}
-/** 弹幕 WebSocket:发 {"text":"..."},收 {"type":"dm","text","ts"} */
export function liveDanmakuWsURL() {
const path = '/api/web/live/danmaku/ws'
if (apiBase) {
@@ -43,19 +41,15 @@ export async function fetchLiveStatus() {
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
-function newPeer(onTrack) {
- const pc = new RTCPeerConnection({ iceServers: defaultIce })
- pc.ontrack = onTrack
- return pc
-}
-
/**
- * 观众端:轮询 live 后拉流;结束后自动恢复轮询以便下一场
- * @param {HTMLVideoElement | null} videoEl
- * @param {{ onStatus?: (s: string) => void, onLive?: () => void, onEnded?: () => void, pollMs?: number, muted?: boolean }} [opts]
+ * 观众端:轮询 live 后拉流;支持双路视频(第一路主画面、第二路摄像头小窗)
+ * @param {HTMLVideoElement | null} videoEl 主画面
+ * @param {{ pipVideoEl?: HTMLVideoElement | null, onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts]
*/
export function startViewing(videoEl, opts = {}) {
const {
+ pipVideoEl = null,
+ onPipActive = () => {},
onStatus = () => {},
onLive = () => {},
onEnded = () => {},
@@ -63,18 +57,55 @@ export function startViewing(videoEl, opts = {}) {
muted = true
} = opts
- function attachStreamToVideo(stream) {
- if (!videoEl || !stream) return
- videoEl.srcObject = stream
+ let mainRecv = new MediaStream()
+ let videoIdx = 0
+
+ function syncMain() {
+ if (!videoEl) return
+ videoEl.srcObject = mainRecv
videoEl.muted = !!muted
videoEl.play().catch(() => {})
}
- let pc = newPeer((e) => {
- if (videoEl && e.streams[0]) {
- attachStreamToVideo(e.streams[0])
+ function clearPip() {
+ if (pipVideoEl) {
+ pipVideoEl.srcObject = null
}
- })
+ try {
+ onPipActive(false)
+ } catch (_) {}
+ }
+
+ function handleTrack(e) {
+ if (e.track.kind === 'audio') {
+ mainRecv.addTrack(e.track)
+ syncMain()
+ return
+ }
+ if (e.track.kind === 'video') {
+ const i = videoIdx++
+ if (i === 0) {
+ mainRecv.addTrack(e.track)
+ syncMain()
+ } else if (pipVideoEl) {
+ const pipMs = new MediaStream([e.track])
+ pipVideoEl.srcObject = pipMs
+ pipVideoEl.muted = true
+ pipVideoEl.play().catch(() => {})
+ try {
+ onPipActive(true)
+ } catch (_) {}
+ }
+ }
+ }
+
+ function newPeer() {
+ const pc = new RTCPeerConnection({ iceServers: defaultIce })
+ pc.ontrack = handleTrack
+ return pc
+ }
+
+ let pc = newPeer()
let ws = null
let pollTimer = null
let currentPollMs = pollMs
@@ -101,9 +132,7 @@ export function startViewing(videoEl, opts = {}) {
peer.onconnectionstatechange = () => {
if (stopped) return
if (peer.connectionState === 'failed') {
- onStatus(
- '媒体连接失败(ICE):服务器若在 Docker/内网,请在环境变量中设置 LIVE_PUBLIC_IP=公网IPv4,并在 LIVE_ICE_SERVERS 配置 TURN。'
- )
+ onStatus('播放连接失败')
}
}
}
@@ -116,9 +145,7 @@ export function startViewing(videoEl, opts = {}) {
const hasStream = Boolean(videoEl.srcObject)
const noFrame = videoEl.videoWidth === 0
if (hasStream && noFrame && peer.connectionState === 'connected') {
- onStatus(
- '已连接仍无画面:请确认后台正在推流;公网部署请配置服务端 LIVE_PUBLIC_IP 或 TURN(LIVE_ICE_SERVERS)。'
- )
+ onStatus('暂无画面')
}
}, 6000)
}
@@ -127,24 +154,23 @@ export function startViewing(videoEl, opts = {}) {
try {
pc.close()
} catch (_) {}
- pc = newPeer((e) => {
- if (videoEl && e.streams[0]) {
- attachStreamToVideo(e.streams[0])
- }
- })
+ mainRecv = new MediaStream()
+ videoIdx = 0
+ clearPip()
+ pc = newPeer()
}
function resumePollingAfterDisconnect(tip) {
if (stopped) return
clearBlackFrameTimer()
if (videoEl) videoEl.srcObject = null
+ clearPip()
if (tip) onStatus(tip)
if (pollTimer) return
rebuildPeer()
currentPollMs = pollMs
upstreamErrorStreak = 0
schedulePollLoop()
- // 下一轮再 poll,避免从 poll() 内同步调用时 pollInFlight 仍为 true 导致被跳过
setTimeout(() => {
if (!stopped) poll()
}, 0)
@@ -160,6 +186,7 @@ export function startViewing(videoEl, opts = {}) {
else icePending.push(msg)
}
pc.addTransceiver('video', { direction: 'recvonly' })
+ pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
@@ -218,10 +245,8 @@ export function startViewing(videoEl, opts = {}) {
if (upstreamError) {
upstreamErrorStreak += 1
const extra =
- upstreamErrorStreak >= 6
- ? ' 若长时间如此,多为服务端未启动或网关异常,请刷新页面或联系管理员。'
- : ''
- onStatus(`无法连接服务器(网关 502/离线),将放慢重试…${extra}`)
+ upstreamErrorStreak >= 6 ? ' 请稍后再试或刷新页面。' : ''
+ onStatus(`无法连接服务器,将放慢重试…${extra}`)
const next = Math.min(Math.round(currentPollMs * 1.5), 30000)
if (next !== currentPollMs) {
currentPollMs = next
@@ -240,20 +265,16 @@ export function startViewing(videoEl, opts = {}) {
pollTimer = null
}
onLive()
- onStatus('检测到直播,正在连接…')
+ onStatus('连接中…')
try {
await negotiate()
} catch (err) {
- onStatus(
- err?.message
- ? `拉流连接失败:${err.message}`
- : '拉流连接失败,将恢复轮询…'
- )
+ onStatus(err?.message ? `连接失败:${err.message}` : '连接失败,将恢复轮询…')
resumePollingAfterDisconnect('')
}
return
}
- onStatus('等待主播开播…')
+ onStatus('等待开播…')
} catch {
onStatus('无法获取直播状态')
} finally {
@@ -276,6 +297,7 @@ export function startViewing(videoEl, opts = {}) {
ws = null
pc.close()
if (videoEl) videoEl.srcObject = null
+ clearPip()
}
return { stop }
diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue
index 5fee133..20cefb0 100644
--- a/web/src/views/LiveRoom.vue
+++ b/web/src/views/LiveRoom.vue
@@ -17,15 +17,23 @@
- {{ liveInfoLine }}
{{ watchStatus }}
+
(rawLiveUrl.value || '').trim())
let viewSession = null
-let liveInfoTimer = null
function loadCaptureQualityPref() {
try {
@@ -120,17 +128,6 @@ watch(captureQualityPref, (v) => {
} catch (_) {}
})
-async function refreshLiveInfoLine() {
- try {
- const r = await fetch(liveInfoURL())
- if (!r.ok) return
- const j = await r.json()
- const id = typeof j.current_quality === 'string' && j.current_quality ? j.current_quality : 'source'
- const label = qualityOptions.find((o) => o.value === id)?.label || id
- liveInfoLine.value = j.live ? `当前在播 · 采集档位:${label}` : ''
- } catch (_) {}
-}
-
function normalizeOutboundUrl(u) {
const s = (u || '').trim()
if (!s) return ''
@@ -166,6 +163,11 @@ function unmuteAndPlay() {
if (!v) return
v.muted = false
v.play().catch(() => {})
+ const p = pipVideoRef.value
+ if (p) {
+ p.muted = true
+ p.play().catch(() => {})
+ }
}
function toggleVideoFullscreen() {
@@ -305,9 +307,11 @@ onMounted(async () => {
loadHomepage()
await nextTick()
connectDanmaku()
- refreshLiveInfoLine()
- liveInfoTimer = window.setInterval(refreshLiveInfoLine, 8000)
viewSession = startViewing(watchVideoRef.value, {
+ pipVideoEl: pipVideoRef.value,
+ onPipActive: (on) => {
+ pipVisible.value = on
+ },
muted: false,
onStatus: (s) => {
watchStatus.value = s
@@ -322,11 +326,8 @@ onUnmounted(() => {
clearTimeout(dmReconnectTimer)
dmReconnectTimer = null
}
- if (liveInfoTimer) {
- clearInterval(liveInfoTimer)
- liveInfoTimer = null
- }
viewSession?.stop()
+ pipVisible.value = false
try {
dmWs?.close()
} catch (_) {}
@@ -406,11 +407,6 @@ onUnmounted(() => {
color: #fff;
font-size: 14px;
}
-.live-info-quality {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.45);
- margin: 0 0 8px;
-}
.live-watch-status {
font-size: 13px;
color: #00d4ff;
@@ -562,4 +558,22 @@ onUnmounted(() => {
.live-room-video--watch {
margin-top: 4px;
}
+.live-room-video--contain {
+ object-fit: contain;
+ aspect-ratio: 16 / 9;
+}
+.live-room-pip-video {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ width: 30%;
+ max-width: 200px;
+ aspect-ratio: 4 / 3;
+ border-radius: 10px;
+ border: 2px solid rgba(0, 212, 255, 0.65);
+ background: #000;
+ object-fit: cover;
+ z-index: 4;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
+}