feature: combined-video player.

This commit is contained in:
2024-04-11 17:29:26 +08:00
parent b6fe3e34f5
commit 5cbbcf8d81
15 changed files with 8591 additions and 873 deletions

View File

@ -65,4 +65,219 @@
.ui-media-video {
max-width: 200px;
max-height: 200px;
}
.ui-media-video-container {
position: relative;
background-color: #000;
min-height: 200px;
height: 100%;
user-select: none;
--icon-size: 16px;
>.ui-video-wrapper {
position: relative;
display: inline-block;
>video {
width: 100%;
height: 100%;
}
>.ui-video-waiting {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, .3);
display: flex;
justify-content: center;
align-items: center;
transition: opacity .2s;
>svg {
fill: #eee;
width: 30px;
height: 30px;
top: calc(50% - 15px);
left: calc(50% - 15px);
animation: spinner 1.2s infinite linear;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
}
}
>.ui-video-control {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(transparent calc(100% - 100px), rgba(50, 50, 50, .7) calc(100% - 65px), #000);
transition: opacity .5s;
&:hover,
&.active {
opacity: 1;
}
.ui-video-icon {
position: absolute;
width: var(--icon-size);
height: var(--icon-size);
cursor: pointer;
>svg {
width: var(--icon-size);
height: var(--icon-size);
display: block;
fill: #eee;
}
&.disabled {
cursor: default;
opacity: .5;
}
}
.ui-video-bar {
position: absolute;
height: 4px;
padding: 4px 0;
cursor: pointer;
>.ui-video-duration,
>.ui-video-progress {
position: absolute;
border-radius: 2px;
height: 4px;
cursor: pointer;
}
>.ui-video-duration {
width: 100%;
background-color: #bbb;
}
>.ui-video-progress {
width: 0;
background-color: #fff;
&::after {
content: '\a0';
position: absolute;
right: -6px;
top: -6px;
width: 13px;
height: 13px;
border-radius: 6px;
background-color: #fff;
}
}
}
>.play-icon {
left: 20px;
bottom: 41px;
>svg {
position: absolute;
&:last-child {
display: none;
}
}
&.pause {
>svg:first-child {
display: none;
}
>svg:last-child {
display: initial;
}
}
}
>.ui-video-time-label {
position: absolute;
font-size: 14px;
font-weight: bold;
left: 48px;
bottom: 40px;
color: #eee;
}
>.fullscreen-icon {
right: 20px;
bottom: 41px;
}
>.ui-video-volume-container {
position: absolute;
bottom: 40px;
right: 50px;
>.volume-icon {
right: 0;
bottom: 1px;
>svg {
position: absolute;
&:last-child {
display: none;
}
}
&.muted {
>svg:first-child {
display: none;
}
>svg:last-child {
display: initial;
}
}
}
>.volume-bar {
width: 60px;
right: 30px;
bottom: 2px;
}
}
&.no-fullscreen>.ui-video-volume-container {
right: 20px;
}
>.seek-bar {
left: 16px;
right: 16px;
bottom: 16px;
>.seek-progress::after {
opacity: 0;
transition: opacity .2s;
}
}
&.active>.seek-bar>.seek-progress::after,
>.seek-bar:hover>.seek-progress::after {
opacity: 1;
}
}
}

5
lib/ui/date.d.ts vendored
View File

@ -30,8 +30,9 @@ export function formatDate(date: Date | number | string, formatter?: string): st
* 设置显示日期
* @param element 要设置显示日期的元素
* @param val 日期值,支持格式参见 {@linkcode formatDate}
* @param formatter 日期格式化字符串(仅设置显示元素时调用)
*/
export function setDateValue(element: HTMLElement, val: Date | number | string): void;
export function setDateValue(element: HTMLElement, val: Date | number | string, formatter?: string): void;
/**
* 从日期选择框获取日期值
@ -39,7 +40,7 @@ export function setDateValue(element: HTMLElement, val: Date | number | string):
* @param formatter 自定义格式化函数,传入参数为 `Date` 类型
* @returns 默认返回日期 `ticks` 的字符串
*/
export function getDateValue(element: HTMLInputElement, formatter?: (date: Date) => string): string;
export function getDateValue(element: HTMLInputElement, formatter?: string | ((date: Date) => string)): string;
/** 日期选择框类 */
export class DateSelector {

View File

@ -207,6 +207,25 @@ function getFormatter(date, utc) {
*/
export function formatDate(date, formatter) {
formatter ??= 'm/d/Y';
if (date === '') {
return '';
}
if (isNaN(date)) {
let e = /^(\d{4})-(\d{2})-(\d{2})/.exec(date);
if (e == null) {
e = /^(\d{4})\/(\d{2})\/(\d{2})/.exec(date);
}
if (e != null) {
date = new Date(e[1], parseInt(e[2]) - 1, e[3]);
} else {
e = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(date);
if (e != null) {
date = new Date(e[3], parseInt(e[1]) - 1, e[2]);
} else {
return date;
}
}
}
if (date instanceof Date) {
const f = getFormatter(date, false);
return formatter.replace(/\\?(.?)/gi, (k, v) => f[k] ? f[k]() : v);
@ -224,14 +243,17 @@ export function formatDate(date, formatter) {
* 设置显示日期
* @param {HTMLElement} element - 要设置显示日期的元素
* @param {Date | number | string} val - 日期值,支持格式参见 {@linkcode formatDate}
* @param {string} [formatter] - 日期格式化字符串(仅设置显示元素时调用)
*/
export function setDateValue(element, val) {
export function setDateValue(element, val, formatter) {
if (element.tagName === 'INPUT') {
if (val === '') {
element.value = '';
} else if (isNaN(val)) {
if (/^\d{4}-\d{2}-\d{2}/.test(val)) {
element.value = String(val).substring(0, 10);
} else if (/^\d{4}\/\d{2}\/\d{2}/.test(val)) {
element.value = String(val).replace('/', '-').substring(0, 10);
} else {
const e = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(val);
if (e != null) {
@ -245,7 +267,7 @@ export function setDateValue(element, val) {
}
} else {
if (val instanceof Date) {
element.value = toDateValue(val);
element.value = toDateValue(val, true);
} else {
const ticks = Number(val);
if (!isNaN(ticks) && ticks > 0) {
@ -256,7 +278,7 @@ export function setDateValue(element, val) {
}
}
} else {
element.innerText = formatDate(val);
element.innerText = formatDate(val, formatter);
}
}
@ -270,7 +292,7 @@ export function setDateValue(element, val) {
/**
* 从日期选择框获取日期值
* @param {HTMLInputElement} element - 要获取的日期选择框
* @param {DateFormatterCallback} [formatter] - 自定义格式化函数,传入参数为 `Date` 类型
* @param {string | DateFormatterCallback} [formatter] - 自定义格式化字符串或函数,传入参数为 `Date` 类型
* @returns {string | any} 默认返回日期 `ticks` 的字符串
*/
export function getDateValue(element, formatter) {
@ -280,11 +302,15 @@ export function getDateValue(element, formatter) {
if (year < 1900 || year > 9999) {
return '';
}
if (typeof formatter === 'function') {
if (formatter != null) {
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
// 使外部 formatter 不需要再处理 `getUTCDate` 亦或是 `getDate`
return formatter(new Date(`${year}-${month}-${day}T00:00:00`));
const localDate = new Date(`${year}-${month}-${day}T00:00:00`);
if (typeof formatter === 'function') {
return formatter(localDate);
}
return formatDate(localDate, formatter);
}
return String(date.getTime() * 1e4 + 621355968e9);
}

View File

@ -780,9 +780,11 @@ export class GridDateColumn extends GridColumn {
* @ignore
* @param {HTMLElement} element
* @param {(string | number | Date)} val
* @param {GridItemWrapper} _wrapper
* @param {GridColumnDefinition} col
*/
static setValue(element, val) {
setDateValue(element, val);
static setValue(element, val, _wrapper, col) {
setDateValue(element, val, col.dateDisplayFormatter);
}
/**
@ -793,7 +795,10 @@ export class GridDateColumn extends GridColumn {
*/
static getValue(e, col) {
if (e.target.tagName === 'INPUT') {
return getDateValue(e.target, col.dateValueFormatter);
return {
value: getDateValue(e.target, col.dateValueFormatter),
text: getDateValue(e.target, col.dateDisplayFormatter)
};
}
return e.target.innerText;
}

View File

@ -264,7 +264,8 @@ let r = lang;
* @property {("fa-light" | "fa-regular" | "fa-solid")} [iconType=fa-light] - 列为图标类型时以该值设置图标样式
* @property {string} [dateMin] - 列为日期类型时以该值作为最小可选日期值
* @property {string} [dateMax] - 列为日期类型时以该值作为最大可选日期值
* @property {DateFormatterCallback} [dateValueFormatter] - 列为日期类型时自定义日期格式化函数
* @property {string} [dateDisplayFormatter] - 列为日期类型时日期显示的格式化字符串
* @property {(string | DateFormatterCallback)} [dateValueFormatter] - 列为日期类型时自定义日期格式化字符串或函数
* @property {(string | GridItemStringCallback)} [tooltip] - 额外设置单元格的 tooltip支持直接使用字符串或者使用函数返回的字符串
* @property {Function} [onAllChecked] - 列头复选框改变时触发事件
* @property {Function} [onChanged] - 单元格变化时触发事件
@ -2834,6 +2835,9 @@ export class Grid {
if (type.editing) {
val = type.getValue({ target: cell.children[0] }, col);
this._onRowChanged(null, i, col, val, cell, oldValue);
if (Object.prototype.hasOwnProperty.call(val, 'value')) {
val = val.value;
}
}
}
if (stateChanged) {
@ -4205,7 +4209,7 @@ export class Grid {
});
vals.__changed = true;
if (typeof col.onChanged === 'function') {
col.onChanged.call(this, item, value, oldValue, e);
col.onChanged.call(this, item, v, oldValue, e);
}
}
}

8
lib/ui/media.d.ts vendored
View File

@ -23,4 +23,10 @@ export function createVideo(url: string): HTMLVideoElement
* @param url 文件 url
* @param icon 图标,默认为 `file-alt`
*/
export function createFile(url: string, icon?: string): HTMLDivElement
export function createFile(url: string, icon?: string): HTMLDivElement
/**
* 创建联动视频元素
* @param urls 视频 url 数组
*/
export function createVideoList(urls: string[]): HTMLDivElement

View File

@ -62,14 +62,24 @@ function playPcm(samples, ended) {
}
function getTimeLabel(time) {
time = Math.round(time);
return String(Math.floor(time / 60)).padStart(2, '0') + ':' + String(time % 60).padStart(2, '0');
// time = Math.round(time);
// return String(Math.floor(time / 60)).padStart(2, '0') + ':' + String(time % 60).padStart(2, '0');
if (isNaN(time) || time < 0) {
return '0:00';
}
time = Math.floor(time);
const m = Math.floor(time / 60);
const h = Math.floor(m / 60);
if (h > 0) {
return h + ':' + String(m % 60).padStart(2, '0') + ':' + String(time % 60).padStart(2, '0');
}
return m + ':' + String(time % 60).padStart(2, '0');
}
export function createAudio(mime, url) {
if ((mime === 'audio/amr' || mime === '.amr') && typeof AMR !== 'undefined') {
const timestamp = createElement('span', 'ui-media-timestamp');
timestamp.textContent = '00:00 / 00:00';
timestamp.textContent = '0:00 / 0:00';
let context;
let timer;
return createElement('div', 'ui-media-audio',
@ -80,7 +90,7 @@ export function createAudio(mime, url) {
clearInterval(timer);
context.close();
context = null;
timestamp.textContent = '00:00 / 00:00';
timestamp.textContent = '0:00 / 0:00';
button.className = 'play';
button.replaceChildren(createIcon('fa-solid', 'play'));
return;
@ -92,7 +102,7 @@ export function createAudio(mime, url) {
.then(r => playPcm(r, ctx => {
context = null;
clearInterval(timer);
timestamp.textContent = '00:00 / ' + getTimeLabel(ctx.duration);
timestamp.textContent = '0:00 / ' + getTimeLabel(ctx.duration);
button.className = 'play';
button.replaceChildren(createIcon('fa-solid', 'play'));
}))
@ -139,4 +149,277 @@ export function createFile(url, icon = 'file-alt') {
a.innerText = 'Click here to view the file';
})
);
}
/**
* 创建联动视频元素
* @param {string[]} urls - 视频 url 数组
* @param {any} [options] - 播放参数
* @param {boolean} options.[autoPlay] - 是否自动播放
* @param {boolean} options.[autoFullScreen] - 是否自动全屏
* @param {boolean} options.[autoLoop] - 是否循环播放
* @param {Function} [callback] - 视频元素处理回调函数
* @returns {HTMLDivElement} 返回联动视频元素
*/
export function createVideoList(urls, options, callback) {
if (!Array.isArray(urls)) {
urls = [urls];
}
const length = urls.length;
const container = createElement('div', 'ui-media-video-container');
let seekProgressBar;
let playIcon;
let timeLabel;
let volumeProgressBar;
let volumeIcon;
const videos = Array(length);
const waiting = Array(length);
let prepared = 0;
let ended = 0;
let playing;
let duration = -1;
let durationLabel;
let seekBarWidth;
let mousing;
const controller = createElement('div', 'ui-video-control active',
createElement('div', seek => {
seek.className = 'ui-video-bar seek-bar';
seek.addEventListener('mousedown', e => {
if (prepared < length || waiting.find(p => p) != null) {
return;
}
const width = seekBarWidth = seek.offsetWidth;
const currentTime = Math.min(Math.max(e.offsetX * duration / width, 0), duration);
videos.forEach(video => typeof video.fastSeek === 'function' ?
video.fastSeek(currentTime) :
(video.currentTime = currentTime));
mousing = true;
e.stopPropagation();
});
seek.addEventListener('mousemove', e => {
if (mousing) {
if (!e.buttons) {
mousing = false;
} else {
const v = Math.min(Math.max(e.offsetX / seekBarWidth, 0), 1);
seekProgressBar.style.width = `${(v * 100).toFixed(2)}%`;
}
}
});
seek.addEventListener('mouseup', e => {
if (mousing) {
mousing = false;
const currentTime = Math.min(Math.max(e.offsetX * duration / seekBarWidth, 0), duration);
videos.forEach(video => typeof video.fastSeek === 'function' ?
video.fastSeek(currentTime) :
(video.currentTime = currentTime));
}
});
},
createElement('div', 'ui-video-duration seek-duration'),
seekProgressBar = createElement('div', 'ui-video-progress seek-progress')
),
playIcon = createElement('div', 'ui-video-icon play-icon',
createIcon('fa-solid', 'play'),
createIcon('fa-solid', 'pause')
),
timeLabel = createElement('div', 'ui-video-time-label'),
createElement('div', 'ui-video-volume-container',
createElement('div', volume => {
volume.className = 'ui-video-bar volume-bar';
volume.addEventListener('mousedown', e => {
const v = Math.min(Math.max(e.offsetX / 60, 0), 1);
const video = videos[0];
if (video != null) {
video.volume = v;
}
volumeIcon.classList[v === 0 ? 'add' : 'remove']('muted');
mousing = true;
e.stopPropagation();
});
volume.addEventListener('mousemove', e => {
if (mousing) {
if (!e.buttons) {
mousing = false;
} else {
const v = Math.min(Math.max(e.offsetX / 60, 0), 1);
volumeProgressBar.style.width = `${(v * 100).toFixed(2)}%`;
const video = videos[0];
if (video != null) {
video.volume = v;
}
volumeIcon.classList[v === 0 ? 'add' : 'remove']('muted');
}
}
});
volume.addEventListener('mouseup', e => {
if (mousing) {
mousing = false;
const v = Math.min(Math.max(e.offsetX / 60, 0), 1);
const video = videos[0];
if (video != null) {
video.volume = v;
}
volumeIcon.classList[v === 0 ? 'add' : 'remove']('muted');
}
});
},
createElement('div', 'ui-video-duration video-duration'),
volumeProgressBar = createElement('div', 'ui-video-progress video-progress')
),
volumeIcon = createElement('div', 'ui-video-icon volume-icon',
createIcon('fa-solid', 'volume'),
createIcon('fa-solid', 'volume-mute')
)
)
);
controller.addEventListener('mousedown', () => {
if (prepared < length || waiting.find(p => p) != null) {
// not prepared.
return;
}
if (playing) {
videos.forEach(video => video.pause());
} else {
ended = 0;
videos.forEach(video => video.play().catch(() => { }));
}
});
volumeIcon.addEventListener('mousedown', e => {
if (volumeIcon.classList.contains('disabled')) {
return;
}
const video = videos[0];
if (video == null) {
return;
}
if (volumeIcon.classList.contains('muted')) {
video.volume = 1;
volumeIcon.classList.remove('muted');
} else {
video.volume = 0;
volumeIcon.classList.add('muted');
}
e.stopPropagation();
});
if (length === 1) {
controller.append(createElement('div', icon => {
icon.className = 'ui-video-icon fullscreen-icon';
icon.addEventListener('mousedown', e => {
if (prepared < length) {
e.stopPropagation();
return;
}
const video = videos[0];
if (video != null) {
if (document.fullscreenElement == null) {
video.requestFullscreen().catch(() => { });
} else if (typeof document.exitFullscreen === 'function') {
document.exitFullscreen();
}
}
});
},
createIcon('fa-regular', 'expand')
));
} else {
controller.classList.add('no-fullscreen');
}
urls.forEach((url, i) => {
const video = createElement('video');
videos[i] = video;
video.src = url;
video.addEventListener('durationchange', () => {
const d = video.duration;
if (d > duration) {
duration = d;
durationLabel = getTimeLabel(d);
timeLabel.innerText = '0:00 / ' + durationLabel;
}
});
video.addEventListener('loadeddata', () => {
if (i === 0) {
volumeProgressBar.style.width = `${(video.volume * 100).toFixed(2)}%`;
} else {
video.volume = 0;
}
prepared += 1;
if (options?.autoPlay && prepared >= length) {
// auto play
videos.forEach(v => v.play().catch(() => { }));
if (options?.autoFullScreen && length === 1 && document.fullscreenElement == null) {
video.requestFullscreen().catch(() => { });
}
}
});
video.addEventListener('canplaythrough', () => {
const w = video.parentElement.querySelector('.ui-video-waiting');
if (w != null) {
w.style.opacity = 0;
}
if (waiting[i]) {
waiting[i] = false;
if (waiting.find(p => p) == null) {
videos.forEach(v => v.play().catch(() => { }));
}
}
});
video.addEventListener('pause', () => {
playing = false;
controller.classList.add('active');
playIcon.classList.remove('pause');
});
video.addEventListener('playing', () => {
playing = true;
controller.classList.remove('active');
playIcon.classList.add('pause');
});
video.addEventListener('waiting', () => {
waiting[i] = true;
const w = video.parentElement.querySelector('.ui-video-waiting');
if (w != null) {
w.style.opacity = 1;
}
videos.forEach(v => v.pause());
});
video.addEventListener('ended', () => {
ended += 1;
if (ended >= length) {
if (options?.autoLoop) {
videos.forEach(v => v.play().catch(() => { }));
} else {
playing = false;
controller.classList.add('active');
seekProgressBar.style.width = '0';
playIcon.classList.remove('pause');
timeLabel.innerText = '0:00 / ' + durationLabel;
}
}
});
if (i === 0) {
video.addEventListener('timeupdate', () => {
seekProgressBar.style.width = `${(video.currentTime * 100 / duration).toFixed(2)}%`;
timeLabel.innerText = getTimeLabel(video.currentTime) + ' / ' + durationLabel;
});
video.addEventListener('volumechange', () => {
volumeProgressBar.style.width = `${(video.volume * 100).toFixed(2)}%`;
});
}
const wrapper = createElement('div', 'ui-video-wrapper',
video,
createElement('div', 'ui-video-waiting',
createIcon('fa-regular', 'spinner-third')
)
);
if (typeof callback === 'function') {
callback(wrapper);
}
container.append(wrapper);
});
container.append(controller);
return container;
}