直播:画质可选、只读 /live/info、弹幕 WS 透传;Nginx 弹幕路径
Made-with: Cursor
This commit is contained in:
@@ -3,8 +3,44 @@
|
||||
*/
|
||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||
|
||||
function liveWsURLPublish(token) {
|
||||
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}`
|
||||
export const LIVE_QUALITY_OPTIONS = [
|
||||
{ value: 'source', label: '原画(设备默认)' },
|
||||
{ value: 'high', label: '高清 720p' },
|
||||
{ value: 'mid', label: '标清 480p' },
|
||||
{ value: 'low', label: '流畅 360p' }
|
||||
]
|
||||
|
||||
const QUALITY_MEDIA = {
|
||||
source: { video: true, audio: true },
|
||||
high: {
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: true
|
||||
},
|
||||
mid: {
|
||||
video: {
|
||||
width: { ideal: 854 },
|
||||
height: { ideal: 480 },
|
||||
frameRate: { ideal: 24 }
|
||||
},
|
||||
audio: true
|
||||
},
|
||||
low: {
|
||||
video: {
|
||||
width: { ideal: 640 },
|
||||
height: { ideal: 360 },
|
||||
frameRate: { ideal: 20 }
|
||||
},
|
||||
audio: true
|
||||
}
|
||||
}
|
||||
|
||||
function liveWsURLPublish(token, quality) {
|
||||
const q = QUALITY_MEDIA[quality] ? quality : 'high'
|
||||
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
|
||||
if (apiBase) {
|
||||
const base = apiBase.replace(/\/$/, '')
|
||||
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
||||
@@ -48,15 +84,21 @@ function humanizeGetUserMediaError(err) {
|
||||
* @param {string} opts.token 管理员 JWT
|
||||
* @param {(s: string) => void} [opts.onStatus]
|
||||
* @param {(stream: MediaStream) => void} [opts.onLocalStream]
|
||||
* @param {'source'|'high'|'mid'|'low'} [opts.quality] 推流画质(约束摄像头采集分辨率)
|
||||
*/
|
||||
export function startPublishing(opts = {}) {
|
||||
const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
|
||||
const {
|
||||
token = '',
|
||||
quality = 'high',
|
||||
onStatus = () => {},
|
||||
onLocalStream = () => {}
|
||||
} = opts
|
||||
if (!token) {
|
||||
onStatus('未登录,无法开播')
|
||||
return { stop: () => {} }
|
||||
}
|
||||
|
||||
const wsUrl = liveWsURLPublish(token)
|
||||
const wsUrl = liveWsURLPublish(token, quality)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
const pc = new RTCPeerConnection({ iceServers: defaultIce })
|
||||
let stream = null
|
||||
@@ -75,7 +117,8 @@ export function startPublishing(opts = {}) {
|
||||
onStatus('信令已连接,正在采集摄像头…')
|
||||
try {
|
||||
// 后台多为 PC:不要用 facingMode:'user',部分机器会直接导致 “Could not start video source”
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
||||
const cons = QUALITY_MEDIA[quality] || QUALITY_MEDIA.high
|
||||
stream = await navigator.mediaDevices.getUserMedia(cons)
|
||||
onLocalStream(stream)
|
||||
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
|
||||
const offer = await pc.createOffer()
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
<code>LIVE_PUBLIC_IP</code>(服务器公网 IPv4,与域名一致),并配置 <code>LIVE_ICE_SERVERS</code>(含 TURN)。
|
||||
</p>
|
||||
<p class="status">{{ status }}</p>
|
||||
<div v-if="!session" class="quality-row">
|
||||
<span class="quality-label">推流画质</span>
|
||||
<el-select v-model="quality" style="width: 220px" :disabled="!token">
|
||||
<el-option
|
||||
v-for="o in qualityOptions"
|
||||
:key="o.value"
|
||||
:label="o.label"
|
||||
:value="o.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
||||
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
||||
@@ -23,7 +34,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { startPublishing } from '../../utils/liveWebRTC'
|
||||
import { startPublishing, LIVE_QUALITY_OPTIONS } from '../../utils/liveWebRTC'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const token = computed(() => authStore.getToken() || '')
|
||||
@@ -39,6 +50,7 @@ function start() {
|
||||
status.value = '正在连接…'
|
||||
const { stop } = startPublishing({
|
||||
token: token.value,
|
||||
quality: quality.value,
|
||||
onStatus: (s) => {
|
||||
status.value = s
|
||||
},
|
||||
@@ -96,6 +108,16 @@ onBeforeRouteLeave(() => {
|
||||
margin-bottom: 12px;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
.quality-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.quality-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,18 @@ server {
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/web/live/danmaku/ws {
|
||||
proxy_pass http://api:9527;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:9527;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -49,6 +49,20 @@ server {
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/web/live/danmaku/ws {
|
||||
set $upstream_api api;
|
||||
proxy_pass http://$upstream_api:8088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
set $upstream_api api;
|
||||
proxy_pass http://$upstream_api:8088;
|
||||
|
||||
@@ -37,6 +37,18 @@ server {
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/web/live/danmaku/ws {
|
||||
proxy_pass http://127.0.0.1:8443;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8443;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
96
server/pkg/weblive/danmaku.go
Normal file
96
server/pkg/weblive/danmaku.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package weblive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const maxDanmakuRunes = 120
|
||||
|
||||
var (
|
||||
danmakuClientsMu sync.Mutex
|
||||
danmakuClients = make(map[*websocket.Conn]struct{})
|
||||
)
|
||||
|
||||
// handleDanmakuWS 弹幕:客户端发 JSON {"text":"..."},服务端立刻向所有连接广播 {"type":"dm","text","ts"},不落库
|
||||
func handleDanmakuWS(c *gin.Context) {
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(4096)
|
||||
|
||||
danmakuClientsMu.Lock()
|
||||
danmakuClients[ws] = struct{}{}
|
||||
danmakuClientsMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
danmakuClientsMu.Lock()
|
||||
delete(danmakuClients, ws)
|
||||
danmakuClientsMu.Unlock()
|
||||
_ = ws.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
mt, payload, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mt != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
text := extractDanmakuText(payload)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out, err := json.Marshal(map[string]interface{}{
|
||||
"type": "dm",
|
||||
"text": text,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
danmakuBroadcast(out)
|
||||
}
|
||||
}
|
||||
|
||||
func extractDanmakuText(payload []byte) string {
|
||||
var v struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &v); err != nil {
|
||||
return ""
|
||||
}
|
||||
t := strings.TrimSpace(v.Text)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(t) <= maxDanmakuRunes {
|
||||
return t
|
||||
}
|
||||
runes := []rune(t)
|
||||
return string(runes[:maxDanmakuRunes])
|
||||
}
|
||||
|
||||
func danmakuBroadcast(b []byte) {
|
||||
danmakuClientsMu.Lock()
|
||||
defer danmakuClientsMu.Unlock()
|
||||
dead := make([]*websocket.Conn, 0)
|
||||
for conn := range danmakuClients {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(8 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
|
||||
dead = append(dead, conn)
|
||||
}
|
||||
}
|
||||
for _, conn := range dead {
|
||||
delete(danmakuClients, conn)
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ type Hub struct {
|
||||
|
||||
publishConn *websocket.Conn
|
||||
pubPC *webrtc.PeerConnection
|
||||
// 开播 WebSocket 上 quality= 参数,供 GET /live/info 只读输出
|
||||
publishQuality string
|
||||
forwarders []*trackForwarder
|
||||
|
||||
viewers map[string]*viewerSession
|
||||
@@ -128,6 +130,7 @@ func (h *Hub) clearPublisher() {
|
||||
h.pubPC = nil
|
||||
}
|
||||
h.publishConn = nil
|
||||
h.publishQuality = ""
|
||||
}
|
||||
|
||||
func (h *Hub) removeViewer(id string) {
|
||||
|
||||
50
server/pkg/weblive/info.go
Normal file
50
server/pkg/weblive/info.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package weblive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// liveQualitySet 与前端开播档位一致;非法 query 回落为 high
|
||||
var liveQualitySet = map[string]struct{}{
|
||||
"source": {}, "high": {}, "mid": {}, "low": {},
|
||||
}
|
||||
|
||||
func normalizeQuality(q string) string {
|
||||
q = strings.TrimSpace(strings.ToLower(q))
|
||||
if _, ok := liveQualitySet[q]; ok {
|
||||
return q
|
||||
}
|
||||
return "high"
|
||||
}
|
||||
|
||||
func liveQualityList() []gin.H {
|
||||
return []gin.H{
|
||||
{"id": "source", "label": "原画(设备默认)"},
|
||||
{"id": "high", "label": "高清 720p"},
|
||||
{"id": "mid", "label": "标清 480p"},
|
||||
{"id": "low", "label": "流畅 360p"},
|
||||
}
|
||||
}
|
||||
|
||||
// handleLiveInfo 仅 GET、无请求体、不读 query;只输出直播状态与画质元数据
|
||||
func handleLiveInfo(c *gin.Context) {
|
||||
h, herr := getHub()
|
||||
live := false
|
||||
cq := ""
|
||||
if herr == nil {
|
||||
h.mu.RLock()
|
||||
live = h.publishConn != nil && h.pubPC != nil && len(h.forwarders) > 0
|
||||
cq = h.publishQuality
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"live": live,
|
||||
"qualities": liveQualityList(),
|
||||
"current_quality": cq,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
@@ -32,7 +32,9 @@ type wsEnvelope struct {
|
||||
|
||||
func RegisterRoutes(r gin.IRoutes) {
|
||||
r.GET("/live/status", handleLiveStatus)
|
||||
r.GET("/live/info", handleLiveInfo)
|
||||
r.GET("/live/ws", handleLiveWS)
|
||||
r.GET("/live/danmaku/ws", handleDanmakuWS)
|
||||
}
|
||||
|
||||
func handleLiveStatus(c *gin.Context) {
|
||||
@@ -90,12 +92,14 @@ func handlePublisherWS(c *gin.Context, h *Hub) {
|
||||
return
|
||||
}
|
||||
h.publishConn = ws
|
||||
h.publishQuality = normalizeQuality(c.Query("quality"))
|
||||
h.mu.Unlock()
|
||||
|
||||
pc, err := h.api.NewPeerConnection(h.cfg)
|
||||
if err != nil {
|
||||
h.mu.Lock()
|
||||
h.publishConn = nil
|
||||
h.publishQuality = ""
|
||||
h.mu.Unlock()
|
||||
_ = ws.Close()
|
||||
return
|
||||
|
||||
@@ -12,6 +12,23 @@ 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) {
|
||||
const base = apiBase.replace(/\/$/, '')
|
||||
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
||||
return `${wsOrigin}${path}`
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}${path}`
|
||||
}
|
||||
|
||||
export async function fetchLiveStatus() {
|
||||
const url = apiBase ? `${apiBase}/api/web/live/status` : '/api/web/live/status'
|
||||
try {
|
||||
|
||||
@@ -17,11 +17,33 @@
|
||||
playsinline
|
||||
autoplay
|
||||
></video>
|
||||
<div class="live-dm-layer" aria-hidden="true">
|
||||
<div
|
||||
v-for="d in dmItems"
|
||||
:key="d.id"
|
||||
class="live-dm-line"
|
||||
:style="{ top: d.top + '%' }"
|
||||
>
|
||||
{{ d.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-video-toolbar" role="toolbar" aria-label="播放控制">
|
||||
<button type="button" class="live-video-toolbtn" @click="unmuteAndPlay">开声音</button>
|
||||
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-dm-bar">
|
||||
<input
|
||||
v-model="dmDraft"
|
||||
class="live-dm-input"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="发条弹幕…"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="sendDm"
|
||||
/>
|
||||
<button type="button" class="live-dm-send" @click="sendDm">发送</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="live-block live-block--divider" aria-label="外链直播间">
|
||||
@@ -44,12 +66,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { apiBase } from '../config'
|
||||
import { startViewing } from '../utils/liveWebRTC'
|
||||
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
||||
|
||||
const watchVideoRef = ref(null)
|
||||
const rawLiveUrl = ref('')
|
||||
const pageTitle = ref('视频直播')
|
||||
const watchStatus = ref('正在检测本站直播…')
|
||||
const dmDraft = ref('')
|
||||
const dmItems = ref([])
|
||||
let dmIdSeq = 0
|
||||
let dmWs = null
|
||||
|
||||
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
||||
|
||||
@@ -108,9 +134,48 @@ function toggleVideoFullscreen() {
|
||||
el.requestFullscreen?.() || el.webkitRequestFullscreen?.()
|
||||
}
|
||||
|
||||
function pushDmLine(text) {
|
||||
const id = ++dmIdSeq
|
||||
const top = 8 + Math.floor(Math.random() * 55)
|
||||
dmItems.value = [...dmItems.value, { id, text, top }]
|
||||
setTimeout(() => {
|
||||
dmItems.value = dmItems.value.filter((x) => x.id !== id)
|
||||
}, 12000)
|
||||
}
|
||||
|
||||
function connectDanmaku() {
|
||||
try {
|
||||
dmWs?.close()
|
||||
} catch (_) {}
|
||||
dmWs = new WebSocket(liveDanmakuWsURL())
|
||||
dmWs.onmessage = (ev) => {
|
||||
let j
|
||||
try {
|
||||
j = JSON.parse(ev.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (j.type === 'dm' && typeof j.text === 'string' && j.text) {
|
||||
pushDmLine(j.text)
|
||||
}
|
||||
}
|
||||
dmWs.onclose = () => {
|
||||
dmWs = null
|
||||
}
|
||||
}
|
||||
|
||||
function sendDm() {
|
||||
const t = dmDraft.value.trim()
|
||||
if (!t) return
|
||||
if (!dmWs || dmWs.readyState !== WebSocket.OPEN) return
|
||||
dmWs.send(JSON.stringify({ text: t }))
|
||||
dmDraft.value = ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadHomepage()
|
||||
await nextTick()
|
||||
connectDanmaku()
|
||||
viewSession = startViewing(watchVideoRef.value, {
|
||||
muted: false,
|
||||
onStatus: (s) => {
|
||||
@@ -121,6 +186,10 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
viewSession?.stop()
|
||||
try {
|
||||
dmWs?.close()
|
||||
} catch (_) {}
|
||||
dmWs = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -187,6 +256,73 @@ onUnmounted(() => {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.live-dm-layer {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
.live-dm-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 6px #000, 0 0 2px #000;
|
||||
animation: live-dm-marquee 12s linear forwards;
|
||||
}
|
||||
@keyframes live-dm-marquee {
|
||||
from {
|
||||
transform: translateX(105%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-105%);
|
||||
}
|
||||
}
|
||||
.live-dm-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 480px;
|
||||
margin: 14px auto 0;
|
||||
}
|
||||
.live-dm-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.live-dm-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.live-dm-send {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.45);
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
color: #00d4ff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.live-dm-send:hover {
|
||||
background: rgba(0, 212, 255, 0.25);
|
||||
}
|
||||
.live-video-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user