PlayerListComponent.vue
15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
<template>
<!-- 内容区域 -->
<div class="grid-container" :style="containerStyle">
<div v-for="(item, i) in items"
:key="i"
class="grid-item"
:class="{ 'is-selected': selectedPlayerIndex === i }"
:style="item.gridStyle"
@click="playerClick(item, i, items.length)">
<!-- 播放器组件 -->
<easyPlayer
:class="`video${i}`"
:ref="`player${i}`"
:initial-play-url="videoUrl[i]"
:initial-buffer-time="0.1"
:show-custom-mask="false"
:has-audio="true"
style="width: 100%;height: 100%;"
@click="playerClick(item, i, items.length)"
></easyPlayer>
<!-- 选中/悬停的高亮框 (使用绝对定位覆盖,不影响布局) -->
<div class="highlight-border"></div>
</div>
</div>
</template>
<script>
//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等),
//例如:import 《组件名称》 from '《组件路径》,
import EasyPlayer from "./EasyPlayer.vue";
export default {
name: "PlayerListComponent",
//import引入的组件需要注入到对象中才能使用"
components: { EasyPlayer },
props: {
value: {
type: String,
default: '9'
},
videoUrl: {
type: Array,
default: []
},
videoDataList: {
type: Array,
default: []
}
},
data() {
//这里存放数据"
return {
items: [],
selectedPlayerIndex: -1, // 用于跟踪当前选中的播放器索引
containerStyle: {}, // 容器的 Grid 样式
}
},
//计算属性 类似于data概念",
computed: {
},
//监控data中的数据变化",
watch: {
/**
* 监听value值变化
* @param val
*/
value(val) {
this.updateGridTemplate(val)
},
/**
* 监听videoUrl变化
* @param newVal
*/
videoUrl: {
handler(newVal) {
// 如果需要,可以在这里进行额外的处理
},
deep: true
},
videoDataList: {
handler(newVal) {
// 如果需要,可以在这里进行额外的处理
},
deep: true
}
},
//方法集合",
methods: {
/**
* 循环赋值 items 并且返回items
*/
setItems(num, gridStyle) {
let items = []
for (let i = 0; i < num; i++) {
items.push({ name: `Item ${Number(i) + 1}`, gridStyle: gridStyle })
}
this.items = items
},
/**
* 核心:生成布局策略
* 这里定义了不同模式下的 行列数 和 特殊格子的跨度
*/
getLayoutStrategy(mode) {
const strategies = {
// --- 标准模式 ---
'1': { rows: 1, cols: 1, count: 1, spans: [] },
'4': { rows: 2, cols: 2, count: 4, spans: [] },
'9': { rows: 3, cols: 3, count: 9, spans: [] },
'16': { rows: 4, cols: 4, count: 16, spans: [] },
// 新增:25分屏 (5x5)
'25': { rows: 5, cols: 5, count: 25, spans: [] },
// 新增:36分屏 (6x6)
'36': { rows: 6, cols: 6, count: 36, spans: [] },
// --- 异形模式 ---
// 1+5 (基于3x3,主窗口2x2)
'1+5': {
rows: 3, cols: 3, count: 6,
spans: [{ index: 0, rowSpan: 2, colSpan: 2 }]
},
// 1+7 (基于4x4,主窗口3x3)
'1+7': {
rows: 4, cols: 4, count: 8,
spans: [{ index: 0, rowSpan: 3, colSpan: 3 }]
},
// 新增:1+9 (基于5x5,主窗口4x4,剩余9个)
// 计算逻辑:5x5=25格,4x4=16格,25-16=9格,合计 1+9=10个窗口
'1+9': {
rows: 5, cols: 5, count: 10,
spans: [{ index: 0, rowSpan: 4, colSpan: 4 }]
},
// 新增:1+11 (基于6x6,主窗口5x5,剩余11个)
// 计算逻辑:6x6=36格,5x5=25格,36-25=11格,合计 1+11=12个窗口
'1+11': {
rows: 6, cols: 6, count: 12,
spans: [{ index: 0, rowSpan: 5, colSpan: 5 }]
}
};
return strategies[mode] || strategies['4'];
},
/**
* 改变播放窗口数量
*/
updateGridTemplate(val) {
const layout = this.getLayoutStrategy(val);
// 1. 设置容器的 Grid 样式
this.containerStyle = {
display: 'grid',
width: '100%',
height: '100%',
// 设置行列数量
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
// 【修改核心】将 gap 改为 '2px'
// 原因:在 16 分屏下,1px 的间隙极易因为浏览器渲染的子像素取整问题而被“吞掉”导致消失。
// 2px 可以确保分割线始终可见。
gap: '2px',
backgroundColor: '#333' // 灰色背景作为分割线颜色
};
// 2. 生成 Items (保持不变)
let newItems = [];
for (let i = 0; i < layout.count; i++) {
let style = {};
const spanConfig = layout.spans.find(s => s.index === i);
if (spanConfig) {
style = {
gridColumn: `span ${spanConfig.colSpan}`,
gridRow: `span ${spanConfig.rowSpan}`
};
}
newItems.push({
index: i,
gridStyle: style
});
}
this.items = newItems;
},
/**
* 播放窗口点击事件
* 修改:只改变 selectedPlayerIndex,样式由 CSS 类控制,彻底解决抖动
*/
playerClick(data, index, len) {
this.selectedPlayerIndex = index;
// 通知父组件,注意:如果布局变了(比如切到1+5),len可能变了
this.$emit('playerClick', data, index, this.items.length);
},
setDivStyle(idx, len) {
// 保持兼容,如果 len 变化了才更新布局
if (String(len) !== this.value) {
// 这里的逻辑可能需要父组件配合修改 v-model,或者只更新内部
// 建议父组件统一通过 prop: value 来控制布局
}
this.selectedPlayerIndex = idx;
let data = this.items[idx];
this.$emit('playerClick', data, idx, this.items.length);
},
/**
* 鼠标悬停事件处理
*/
handleMouseEnter(data, index) {
if (this.selectedPlayerIndex !== index) {
const newGridStyle = { ...data.gridStyle, border: '1px solid #36a3f7' } // 悬停时边框颜色变为蓝色
this.$set(this.items, index, { ...data, gridStyle: newGridStyle })
}
},
/**
* 鼠标离开事件处理
*/
handleMouseLeave(data, index) {
if (this.selectedPlayerIndex !== index) {
const newGridStyle = { ...data.gridStyle, border: '1px solid black' } // 离开时边框颜色恢复为黑色
this.$set(this.items, index, { ...data, gridStyle: newGridStyle })
}
},
destroy(idx) {
this.clear(idx.substring(idx.length - 1))
},
closeVideo() {
// 这里的逻辑很奇怪,this.windowClickIndex 并不是组件的 data
// 假设是想关闭当前选中的
const indexToClose = this.selectedPlayerIndex;
if (indexToClose >= 0 && this.videoUrl[indexToClose]) {
// 通知父组件关闭
this.$emit('close-video', indexToClose);
}
},
/**
* 【重要】播放流 (已修改)
* @param data
*/
getPlayStream(data) {
let stream = data.code.replace('-', '_');
let windowClickIndex = this.windowClickIndex;
this.$axios({
method: 'get',
url: '/api/jt1078/query/send/request/io/' + arr[1] + '/' + arr[2]
}).then(res => {
if (res.code === 200) {
const url = res.data.data.ws_flv;
const indexToUpdate = windowClickIndex - 1;
// 直接修改父组件的 videoUrl 数组
this.$set(this.videoUrl, indexToUpdate, url);
// 直接修改父组件的 videoDataList 数组
data['videoUrl'] = url;
this.$set(this.videoDataList, indexToUpdate, data);
// 更新下一个要播放的窗口索引
windowClickIndex++;
if (windowClickIndex > this.windowNum) {
windowClickIndex = 1;
}
this.windowClickIndex = windowClickIndex;
this.$message.success(`[${data.parentCode}] ${data.name} 开始播放`);
} else {
this.$message.error(res.data.msg);
}
});
},
},
//生命周期 - 创建完成(可以访问当前this实例)",
created() {
},
//生命周期 - 挂载完成(可以访问DOM元素)",
mounted() {
this.updateGridTemplate(this.value)
},
beforeCreate() {
}, //生命周期 - 创建之前",
beforeMount() {
}, //生命周期 - 挂载之前",
beforeUpdate() {
}, //生命周期 - 更新之前",
updated() {
}, //生命周期 - 更新之后",
beforeDestroy() {
}, //生命周期 - 销毁之前",
destroyed() {
}, //生命周期 - 销毁完成",
activated() {
} //如果页面有keep-alive缓存功能,这个函数会触发",
}
</script>
<style scoped>
/* 容器样式 */
.grid-container {
box-sizing: border-box;
overflow: hidden;
}
/* 每一个视频格子的样式 */
.grid-item {
position: relative;
background-color: #000;
box-sizing: border-box;
overflow: hidden;
outline: none;
-webkit-tap-highlight-color: transparent;
/* 1. 开启 GPU 硬件加速,将每个视频格子提升为独立图层 */
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
/* 2. 布局隔离:告诉浏览器这个格子的内部布局变化不会影响外部 */
/* 这能极大减少重排(Reflow)的计算量 */
contain: layout paint style;
/* 3. 提前告知浏览器该元素的大小可能会变化 */
will-change: width, height;
}
/*
高亮边框层
使用 pointer-events: none 让鼠标直接穿透它点到视频上
*/
.highlight-border {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
box-sizing: border-box;
border: 2px solid transparent;
z-index: 99;
transition: border-color 0.1s ease-in-out;
}
/* =========================================
CSS 状态管理 (优先级非常重要)
========================================= */
/* 1. 悬停状态 (Hover) - 蓝色 */
/* 只有当鼠标悬停且该元素没有被选中时,才显示蓝色?
或者简单的逻辑:悬停显示蓝,选中显示红。如果又选中又悬停,显示红。
*/
.grid-item:hover .highlight-border {
border-color: #36a3f7; /* 蓝色悬停 */
}
/* 2. 选中状态 (Selected) - 红色 */
/* 这里的 CSS 优先级高于上面的 :hover,所以选中时红色会覆盖蓝色 */
.grid-item.is-selected .highlight-border {
border-color: red !important; /* 强制红色 */
box-shadow: inset 0 0 0 1px red; /* 加粗效果 */
}
/* 3. 选中时的悬停状态 */
/* 确保选中后,即使鼠标再移上去,也依然是红色,不要变回蓝色 */
.grid-item.is-selected:hover .highlight-border {
border-color: red !important;
}
::v-deep .el-card__header {
padding: 0;
}
/* 右键菜单样式 - 增加宽度和高度以适应所有复选框 */
.context-menu {
position: fixed;
z-index: 1999; /* 调整z-index值,使其低于Element UI确认框(默认2000+) */
user-select: none;
width: 550px; /* 增加宽度 */
max-height: 600px; /* 增加最大高度 */
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
animation: fadeIn 0.2s ease-out;
}
.context-menu-card {
border: none;
border-radius: 8px;
box-shadow: none;
}
/* 可拖拽头部样式 - 极简紧凑 */
.draggable-header {
cursor: move;
user-select: none;
position: relative;
padding: 4px 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 500;
font-size: 15px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
line-height: 1;
min-height: 40px;
}
.draggable-header span {
flex: 1;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 4px;
}
.close-icon {
position: relative;
right: 0;
top: 0;
transform: none;
cursor: pointer;
font-size: 12px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s ease;
flex-shrink: 0;
}
.close-icon:hover {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
/* 表单样式优化 */
.el-form {
padding: 16px;
}
.el-form-item {
margin-bottom: 16px;
}
.el-form-item__label {
color: #333;
font-weight: 500;
font-size: 13px;
}
/* 水平排列的复选框组 - 自适应文字长度,内容左对齐 */
.checkbox-group-horizontal {
display: grid;
grid-template-columns: repeat(2, 1fr); /* 每行2列,给更多空间 */
gap: 8px;
width: 100%;
max-height: 350px;
overflow-y: auto;
}
.checkbox-group-horizontal .el-checkbox {
margin-right: 0;
margin-bottom: 0;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkbox-group-horizontal .el-checkbox.is-bordered {
margin-left: 0;
margin-right: 0;
width: 100%;
min-height: 36px; /* 最小高度适应文字 */
display: flex;
align-items: center;
box-sizing: border-box;
padding: 0 10px; /* 添加内边距 */
}
.checkbox-group-horizontal .el-checkbox.is-bordered .el-checkbox__input {
display: none;
}
.checkbox-group-horizontal .el-checkbox.is-bordered .el-checkbox__label {
padding: 0;
text-align: left; /* 左对齐 */
overflow: hidden;
text-overflow: ellipsis;
white-space: normal; /* 允许换行 */
word-break: break-all; /* 强制换行 */
line-height: 1.3;
}
/* 选中状态样式 */
.checkbox-group-horizontal .el-checkbox.is-bordered.is-checked {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.1);
}
/* 文本域样式优化 */
.el-textarea__inner {
border-radius: 6px;
border: 1px solid #dcdfe6;
transition: border-color 0.3s ease;
font-size: 13px;
}
.el-textarea__inner:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
/* 按钮样式优化 */
.el-form-item:last-child {
margin-bottom: 0;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.el-button {
border-radius: 6px;
padding: 6px 16px;
font-weight: 500;
font-size: 13px;
transition: all 0.3s ease;
}
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.el-button--primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.el-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.context-menu {
width: 450px;
max-height: 500px;
}
.draggable-header {
padding: 6px 10px;
font-size: 13px;
}
.close-icon {
font-size: 14px;
width: 18px;
height: 18px;
}
.el-form {
padding: 12px;
}
.checkbox-group-horizontal {
grid-template-columns: repeat(1, 1fr); /* 小屏幕每行1列 */
max-height: 300px;
}
.checkbox-group-horizontal .el-checkbox.is-bordered {
min-height: 32px;
font-size: 11px;
}
}
</style>