chore: Монорепо с общими пакетами

This commit is contained in:
2026-03-04 16:31:57 +03:00
parent 8f2c799235
commit 915c56351b
420 changed files with 13403 additions and 7071 deletions

View File

@@ -0,0 +1,73 @@
import {
type VideoPlayerRuntimeEventMap,
type VideoPlayerRuntimeInitOptions,
type VideoPlayerRuntimeState,
type VideoPlayerRuntimeUnsubscribe,
type VideoPlayerRuntimeUpdateOptions,
VideoPlayerRuntime,
} from "../core";
export interface AngularVideoPlayerInput
extends Omit<VideoPlayerRuntimeUpdateOptions, "source"> {
source?: VideoPlayerRuntimeInitOptions["source"];
}
export interface AngularVideoPlayerAttachInput
extends Omit<VideoPlayerRuntimeInitOptions, "container"> {}
export interface AngularVideoPlayerState {
input: AngularVideoPlayerInput;
runtime: VideoPlayerRuntimeState;
}
// Angular-friendly wrapper around framework-agnostic runtime.
export class AngularVideoPlayerAdapter {
private readonly runtime = new VideoPlayerRuntime();
private input: AngularVideoPlayerInput = {};
async attach(container: HTMLElement, input: AngularVideoPlayerAttachInput) {
this.input = { ...input };
const runtime = await this.runtime.init({
container,
...input,
});
return {
input: this.input,
runtime,
} satisfies AngularVideoPlayerState;
}
async update(nextInput: AngularVideoPlayerInput) {
this.input = {
...this.input,
...nextInput,
};
const runtime = await this.runtime.update(nextInput);
return {
input: this.input,
runtime,
} satisfies AngularVideoPlayerState;
}
on<K extends keyof VideoPlayerRuntimeEventMap>(
event: K,
handler: (payload: VideoPlayerRuntimeEventMap[K]) => void,
): VideoPlayerRuntimeUnsubscribe {
return this.runtime.on(event, handler);
}
destroy() {
this.runtime.dispose();
}
getState(): AngularVideoPlayerState {
return {
input: this.input,
runtime: this.runtime.getState(),
};
}
}

View File

@@ -0,0 +1,48 @@
export type PlaybackEngine = "hls" | "videojs";
export type EngineStrategy = "auto" | "force-hls" | "force-videojs";
export interface EngineSelectionInput {
src?: string | null;
type?: string | null;
strategy?: EngineStrategy;
hlsSupported?: boolean;
isIOS?: boolean;
}
const HLS_MIME_TYPES = new Set([
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
]);
export const isHlsSource = ({ src, type }: Pick<EngineSelectionInput, "src" | "type">): boolean => {
const sourceType = (type || "").toLowerCase();
return HLS_MIME_TYPES.has(sourceType) || /\.m3u8($|\?)/i.test(src || "");
};
export const selectPlaybackEngine = ({
src,
type,
strategy = "auto",
hlsSupported = false,
isIOS = false,
}: EngineSelectionInput): PlaybackEngine => {
if (strategy === "force-hls") {
return "hls";
}
if (strategy === "force-videojs") {
return "videojs";
}
if (!isHlsSource({ src, type })) {
return "videojs";
}
if (isIOS) {
return "videojs";
}
return hlsSupported ? "hls" : "videojs";
};

View File

@@ -0,0 +1,12 @@
export const formatTime = (seconds: number) => {
const pad = (num: number) => String(num).padStart(2, "0");
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (seconds < 3600) {
return `${pad(mins)}:${pad(secs)}`;
}
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
};

View File

@@ -0,0 +1,24 @@
export {
isHlsSource,
selectPlaybackEngine,
type EngineSelectionInput,
type EngineStrategy,
type PlaybackEngine,
} from "./engine-selector";
export {
resolveVideoPlayerToken,
setVideoPlayerTokenProvider,
type VideoPlayerToken,
type VideoPlayerTokenProvider,
} from "./token-provider";
export {
VideoPlayerRuntime,
type VideoPlayerRuntimeEventMap,
type VideoPlayerRuntimeInitOptions,
type VideoPlayerRuntimePlayer,
type VideoPlayerRuntimePreload,
type VideoPlayerRuntimeSource,
type VideoPlayerRuntimeState,
type VideoPlayerRuntimeUnsubscribe,
type VideoPlayerRuntimeUpdateOptions,
} from "./player-runtime";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import videojs from "video.js";
import Player from "video.js/dist/types/player";
const BigPlayButton = videojs.getComponent("BigPlayButton");
class BigPlayPauseButton extends BigPlayButton {
handleClick() {
const player = this.player() as Player;
if (player.paused()) {
player.play();
} else {
player.pause();
}
}
}
videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton);
// Функция плагина с аннотацией типа this
function bigPlayPauseButtonPlugin(this: Player) {
const player = this;
const defaultButton = player.getChild("bigPlayButton");
if (defaultButton) {
defaultButton.dispose();
}
player.addChild("BigPlayPauseButton", {});
}
videojs.registerPlugin("bigPlayPauseButton", bigPlayPauseButtonPlugin);
export default bigPlayPauseButtonPlugin;

View File

@@ -0,0 +1,3 @@
import "./settings";
import "./big-play-pause-button";
import "./skip-buttons";

View File

@@ -0,0 +1,116 @@
import videojs from "video.js";
import Component from "video.js/dist/types/component";
import Player from "video.js/dist/types/player";
import TachVideoMenu from "../tach-video-menu";
import TachVideoMenuItem from "../tach-video-menu-item";
const MenuButton = videojs.getComponent("MenuButton");
const Menu = videojs.getComponent("Menu");
type BaseMenuOptions = ConstructorParameters<typeof Menu>[1];
interface CustomMenuOptions extends NonNullable<BaseMenuOptions> {
menuButton: TachVideoMenuButton;
}
export default class TachVideoMenuButton extends MenuButton {
private hideThreshold_: number = 0;
private buttonPressed_ = false;
private menu!: TachVideoMenu;
private menuButton_!: Component;
public items: TachVideoMenuItem[] = [];
/**
* Button constructor.
*
* @param {Player} player - videojs player instance
*/
constructor(player: Player, title: string, name: string) {
super(player, {
title: title,
name: name,
} as any);
// Перехватываем событие 'mouseenter' на уровне захвата и предотвращаем его дальнейшее распространение
const el = this.menuButton_.el();
el.addEventListener(
"mouseenter",
e => {
e.stopImmediatePropagation();
},
true,
);
}
/**
* Creates button items.
*
* @return {TachVideoMenuItem[]} - Button items
*/
createItems(): TachVideoMenuItem[] {
return [];
}
/**
* Создаёт меню и добавляет в него все пункты.
*
* @return {Menu} - Сконструированное меню
*/
createMenu() {
const menu = new TachVideoMenu(this.player_, {
menuButton: this,
} as CustomMenuOptions);
this.hideThreshold_ = 0;
this.items = this.createItems();
if (this.items) {
// Если метод updateItems присутствует, используем его для обновления списка
if (typeof menu.updateItems === "function") {
menu.updateItems(this.items);
} else {
// Если по какой-то причине обновление недоступно, добавляем элементы по одному
this.items.forEach(item => menu.addItem(item));
}
}
return menu;
}
/**
* Обновление меню без его пересоздания.
*
* @return {Menu} - Обновлённое меню
*/
update() {
// Если меню уже создано и поддерживает updateItems, обновляем его содержимое
if (this.menu && typeof this.menu.updateItems === "function") {
this.items = this.createItems();
this.menu.updateItems(this.items);
} else {
// Если меню ещё не создано, создаём его
this.menu = this.createMenu();
}
this.addChild(this.menu);
/**
* Track the state of the menu button
*
* @type {Boolean}
* @private
*/
this.buttonPressed_ = false;
this.menuButton_.el_.setAttribute("aria-expanded", "false");
if (this.items && this.items?.length <= this.hideThreshold_) {
this.hide();
this.menu.contentEl().removeAttribute("role");
} else {
this.show();
this.menu.contentEl().setAttribute("role", "menu");
}
}
}

View File

@@ -0,0 +1,69 @@
import videojs from "video.js";
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions, TachPlayerPlugin } from "../../types";
import TachVideoMenuButton from "../tach-video-menu-button";
// Concrete classes
const VideoJsMenuItemClass = videojs.getComponent("MenuItem");
// Интерфейс пункта меню расширяет базовые опции
export interface ITachPlayerMenuItem extends TachPlayerMenuItemOptions {
onClick: () => void;
enabled?: boolean;
value?: unknown;
}
/**
* Extend vjs menu item class.
*/
export default class TachVideoMenuItem extends VideoJsMenuItemClass {
private item: ITachPlayerMenuItem;
private button: TachVideoMenuButton;
private plugin: TachPlayerPlugin;
/**
* Menu item constructor.
*
* @param {Player} player - vjs player
* @param {ITachVideoMenuItem} item - Item object
* @param {ConcreteButton} button - The containing button.
* @param {TachPlayerPlugin} plugin - This plugin instance.
*/
constructor(
player: Player,
item: ITachPlayerMenuItem,
button: TachVideoMenuButton,
plugin: TachPlayerPlugin,
) {
super(player, {
label: item.label,
selectable: item.selectable || true,
selected: item.selected || false,
} as any);
this.item = item;
this.button = button;
this.plugin = plugin;
item.className && this.addClass(item.className);
}
/**
* Click event for menu item.
*/
handleClick() {
if (this.item.onClick) {
// Reset other menu items selected status.
for (let i = 0; i < this.button.items?.length; ++i) {
this.button.items[i].selected(false);
}
this.selected(true);
return this.item.onClick();
}
}
selected(val: boolean) {
//@ts-expect-error // getComponent reduant
super.selected(val);
}
}

View File

@@ -0,0 +1,77 @@
import videojs from "video.js";
import Player from "video.js/dist/types/player";
import TachVideoMenuButton from "../tach-video-menu-button";
import TachVideoMenuItem from "../tach-video-menu-item";
// Concrete classes
const VideoJsMenuClass = videojs.getComponent("Menu");
type TachMenu = typeof VideoJsMenuClass & {
addItem: (item: TachVideoMenuItem) => void;
};
type BaseMenuOptions = ConstructorParameters<typeof VideoJsMenuClass>[1];
interface TachMenuOptions extends NonNullable<BaseMenuOptions> {
menuButton: TachVideoMenuButton;
}
/**
* Extend vjs menu item class.
*/
export default class TachVideoMenu extends VideoJsMenuClass {
private menuOpened_: boolean = false;
/**
* Menu item constructor.
*
* @param {Player} player - vjs player
* @param {TachPlayerMenuItemOptions} item - Item object
* @param {ConcreteButton} button - The containing button.
* @param {TachPlayerPlugin} plugin - This plugin instance.
*/
constructor(player: Player, options: TachMenuOptions) {
super(player, options);
player.on("userinactive", () => {
if (this.menuOpened_) {
player.userActive(true);
}
});
}
hide() {
this.menuOpened_ = false;
// Вызов родительского метода скрытия
super.hide();
}
show() {
this.menuOpened_ = true;
// Вызов родительского метода скрытия
super.show();
}
addItem(item: TachVideoMenuItem) {
//@ts-expect-error getComponent reduant method
super.addItem(item);
}
/**
* Обновляет пункты меню.
* @param {Array<Object|string>} newItems - Массив новых компонентов или их имён, которые будут добавлены в меню.
*/
updateItems(newItems: TachVideoMenuItem[]) {
// Получаем текущих потомков
const currentChildren = this.children().slice();
// Удаляем все остальные дочерние компоненты.
currentChildren.forEach(child => {
this.removeChild(child);
});
// Добавляем новые пункты меню.
newItems.forEach(item => {
this.addItem(item);
});
}
}

View File

@@ -0,0 +1,201 @@
import videojs from "video.js";
import Component from "video.js/dist/types/component";
import Player from "video.js/dist/types/player";
import Plugin from "video.js/dist/types/plugin";
import TachVideoMenuButton from "./components/tach-video-menu-button";
import TachVideoMenuItem, {
ITachPlayerMenuItem,
} from "./components/tach-video-menu-item";
import audioTrackSelector from "./selectors/audio-track-selector";
import playbackRateSelector from "./selectors/playback-rate-selector";
import qualityRateSelector from "./selectors/quality-rate-selector";
import textTracksSelector from "./selectors/text-track-selector";
import "./settings.css";
// Интерфейс опций плагина (расширяем по необходимости)
interface SettingsButtonOptions {
// например, customLabel?: string;
}
// Интерфейс плеера с controlBar
interface PlayerWithControlBar extends Player {
hlsInstance?: unknown;
controlBar: Component;
}
const BasePlugin = videojs.getPlugin("plugin")! as typeof Plugin;
// Значения опций по умолчанию
const defaults: SettingsButtonOptions = {};
class SettingsButton extends BasePlugin {
private options: SettingsButtonOptions;
private mainMenu: ITachPlayerMenuItem[] = [];
private settingsButton!: TachVideoMenuButton;
private buttonInstance!: Component;
// Фабрики для создания кнопок меню
private backButton!: ITachPlayerMenuItem;
private playbackRateButton!: () => ITachPlayerMenuItem;
private qualityRateButton!: () => ITachPlayerMenuItem;
private audioTracksButton!: () => ITachPlayerMenuItem;
private textTracksButton!: () => ITachPlayerMenuItem;
constructor(player: PlayerWithControlBar, options: SettingsButtonOptions) {
super(player);
this.options = videojs.obj.merge(defaults, options);
this.player.ready(() => this.initialize());
}
/**
* Инициализация плагина: создание кнопки настроек и привязка событий
*/
private initialize(): void {
this.createSettingsButton();
this.bindPlayerEvents();
}
/**
* Привязка событий плеера (например, для обновления меню)
*/
private bindPlayerEvents(): void {
// При необходимости можно привязать событие, например:
// this.settingsButton.on("click", this.setMenu.bind(this, undefined, false, true));
}
/**
* Создание кнопки настроек и определение пунктов меню.
* Здесь создаются фабрики для формирования кнопок и устанавливается начальное меню.
*/
private createSettingsButton(): void {
const player = this.player as PlayerWithControlBar;
// Создаем кнопку настроек с помощью компонента TachVideoMenuButton
this.settingsButton = new TachVideoMenuButton(
player,
"Settings",
"Settings",
);
this.buttonInstance = player.controlBar.addChild(this.settingsButton, {
componentClass: "settingsButton",
});
this.buttonInstance.addClass("vjs-settings-button");
// Определяем кнопку "Назад" для возврата в главное меню
this.backButton = {
label: "Назад",
value: this.mainMenu,
selectable: false,
selected: false,
onClick: () => this.setMenu(undefined, true, true),
className: "settings-back",
};
// Создаем фабрики для дополнительных настроек
const { menuItem: audioMenuItem, menuItems: audioMenuItems } =
audioTrackSelector(player);
this.audioTracksButton = () => ({
...audioMenuItem(),
onClick: () => this.setMenu(audioMenuItems(), false, true),
});
const { menuItem: textMenuItem, menuItems: textMenuItems } =
textTracksSelector(player);
this.textTracksButton = () => ({
...textMenuItem(),
onClick: () => this.setMenu(textMenuItems(), false, true),
});
const { menuItem: playbackMenuItem, menuItems: playbackMenuItems } =
playbackRateSelector(player);
this.playbackRateButton = () => ({
...playbackMenuItem(),
onClick: () => this.setMenu(playbackMenuItems(), false, true),
});
// В createSettingsButton замени этот участок:
const qualitySelector = qualityRateSelector(player);
this.qualityRateButton = () => ({
...qualitySelector.menuItem(), // пересоздание при каждом открытии меню
onClick: () => this.setMenu(qualitySelector.menuItems(), false, true),
});
// Подписка на автообновление, когда hls переключает уровень
qualitySelector.setMenuUpdateCallback(() => {
this.setMenu(undefined, true); // Обновить главное меню без перезахода
});
// Инициализируем меню с кнопками по умолчанию без показа
this.setMenu(undefined, true, false);
}
/**
* Обёртка для создания экземпляра пункта меню.
*
* @param item - объект настроек пункта меню
* @returns экземпляр TachVideoMenuItem
*/
private getMenuItem(item: ITachPlayerMenuItem): TachVideoMenuItem {
return new TachVideoMenuItem(this.player, item, this.settingsButton, this);
}
/**
* Устанавливает (обновляет) пункты меню плагина.
*
* @param items - массив пунктов меню (если не передан, используются кнопки по умолчанию)
* @param skipBackButton - если true, не добавлять кнопку "Назад"
* @param forceShow - если true, принудительно показать меню после обновления
*/
private async setMenu(
items: ITachPlayerMenuItem[] = [],
skipBackButton: boolean = false,
forceShow: boolean = false,
): Promise<void> {
const menuButtons: ITachPlayerMenuItem[] = [];
if (!skipBackButton) {
menuButtons.push(this.backButton);
}
if (items?.length === 0) {
// Если не переданы конкретные пункты меню используем кнопки по умолчанию
const defaultButtons = [
this.playbackRateButton,
this.qualityRateButton,
this.textTracksButton,
this.audioTracksButton,
];
defaultButtons.forEach(createButton => {
const btn = createButton();
if (btn.enabled) {
menuButtons.push(btn);
}
});
} else {
menuButtons.push(...items);
}
// Формируем список пунктов меню: вместо пересоздания меню, назначаем функцию,
// возвращающую актуальный список пунктов, и вызываем метод обновления.
this.settingsButton.createItems = () =>
menuButtons.map(item => this.getMenuItem(item));
await this.settingsButton.update();
// Если требуется принудительно показать меню, эмулируем клик по кнопке меню
if (forceShow) {
const menuElement = this.buttonInstance.el().querySelector(".vjs-menu");
const menuButton = this.buttonInstance
.el()
.querySelector(".vjs-menu-button");
if (menuElement && menuButton) {
(menuButton as HTMLElement).click();
}
}
}
}
// Регистрируем плагин в Video.js
videojs.registerPlugin("settingsMenu", SettingsButton);
export default SettingsButton;

View File

@@ -0,0 +1,177 @@
import Hls from "hls.js";
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../types";
const defaults = {
label: "Язык",
selected: false,
selectable: false,
className: "audio-selector",
};
interface TrackMenuOptions extends TachPlayerMenuItemOptions {}
type VideoJsAudioTrack = {
label?: string;
enabled: boolean;
};
type PlayerWithTracks = Player & {
audioTracks?: () => { tracks_?: VideoJsAudioTrack[] } | undefined;
hlsInstance?: Hls | null;
};
type HlsAudioTrack = {
id: number;
name?: string;
lang?: string;
};
const audioTrack = (player: Player, options: TrackMenuOptions = defaults) => {
const playerWithTracks = player as PlayerWithTracks;
const getNativeTrackList = () => {
if (typeof playerWithTracks.audioTracks !== "function") {
return undefined;
}
return playerWithTracks.audioTracks();
};
const getNativeTracks = (): VideoJsAudioTrack[] => {
return getNativeTrackList()?.tracks_ ?? [];
};
const setNativeTrack = (track: VideoJsAudioTrack) => {
const audioTracks = getNativeTracks();
for (let i = 0; i < audioTracks.length; ++i) {
audioTracks[i].enabled = audioTracks[i] === track;
}
};
const getSelectedNativeTrack = () => {
const audioTracks = getNativeTracks();
for (let i = 0; i < audioTracks.length; ++i) {
if (audioTracks[i].enabled === true) {
return audioTracks[i];
}
}
return null;
};
const getHlsInstance = (): Hls | null => playerWithTracks.hlsInstance ?? null;
const getHlsTracks = (): HlsAudioTrack[] => {
const hls = getHlsInstance();
return hls?.audioTracks ?? [];
};
const setHlsTrack = (trackId: number) => {
const hls = getHlsInstance();
if (!hls) return;
hls.audioTrack = trackId;
};
const getSelectedHlsTrack = (): HlsAudioTrack | null => {
const hls = getHlsInstance();
if (!hls) return null;
const tracks = getHlsTracks();
const currentId = hls.audioTrack;
return tracks.find(track => track.id === currentId) ?? null;
};
const formatHlsTrackLabel = (track: HlsAudioTrack, index?: number) => {
if (track.name?.trim()) {
return track.name.trim();
}
if (track.lang?.trim()) {
return track.lang.trim().toUpperCase();
}
const fallbackIndex =
typeof index === "number"
? index + 1
: typeof track.id === "number"
? track.id + 1
: 1;
return `Дорожка ${fallbackIndex}`;
};
const buildHlsMenuItems = () => {
const hls = getHlsInstance();
const hlsTracks = getHlsTracks();
if (!hls || hlsTracks.length === 0) {
return [];
}
return hlsTracks.map((track, index) => ({
label: formatHlsTrackLabel(track, index),
value: track.id,
selected: track.id === hls.audioTrack,
onClick: () => setHlsTrack(track.id),
}));
};
const hasAudioTracks = () => {
if (getHlsTracks().length > 0) {
return true;
}
return getNativeTracks().length > 0;
};
const menuItems = () => {
const hlsTracks = buildHlsMenuItems();
if (hlsTracks.length > 0) {
return hlsTracks;
}
const nativeTracks = getNativeTracks();
const trackItems = [];
for (let i = 0; i < nativeTracks?.length; ++i) {
const trackItem = {
label: nativeTracks[i].label || "Default",
value: nativeTracks[i].label,
selected: nativeTracks[i].enabled,
onClick: () => setNativeTrack(nativeTracks[i]),
};
trackItems.push(trackItem);
}
return trackItems;
};
const getSelectedTrackLabel = () => {
const selectedHlsTrack = getSelectedHlsTrack();
if (selectedHlsTrack) {
const tracks = getHlsTracks();
const index = tracks.findIndex(track => track.id === selectedHlsTrack.id);
return formatHlsTrackLabel(selectedHlsTrack, index);
}
return getSelectedNativeTrack()?.label ?? null;
};
const menuItem = () => {
return {
...options,
value: getSelectedTrackLabel(),
enabled: hasAudioTracks(),
};
};
return {
menuItems,
menuItem,
};
};
export default audioTrack;

View File

@@ -0,0 +1,46 @@
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../types";
const defaults = {
label: "Скорость воспроизведения",
selected: false,
selectable: false,
speeds: [
{ label: "0.5x", value: 0.5 },
{ label: "Обычная", value: 1 },
{ label: "1.5x", value: 1.5 },
{ label: "1.75x", value: 1.75 },
{ label: "2x", value: 2 },
],
className: "speed-selector",
};
interface SpeedMenuOptions extends TachPlayerMenuItemOptions {
speeds: Array<{ label: string; value: number }>;
}
const playbackRate = (player: Player, options: SpeedMenuOptions = defaults) => {
const menuItems = () =>
options.speeds.map(speed => {
return {
label: speed.label,
selectable: true,
selected: speed.value === player.playbackRate(),
onClick: () => player.playbackRate(speed.value),
};
});
const menuItem = () => {
return {
...options,
enabled: true,
};
};
return {
menuItems,
menuItem,
};
};
export default playbackRate;

View File

@@ -0,0 +1,163 @@
import Hls from "hls.js";
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../types";
const defaults = {
label: "Качество",
selected: false,
selectable: false,
className: "quality-selector",
};
type HlsLevel = {
height: number;
width: number;
bitrate: number;
name?: string;
};
interface QualityMenuOptions extends TachPlayerMenuItemOptions {}
const qualityRate = (
player: Player,
options: QualityMenuOptions = defaults,
) => {
const hls: Hls | undefined = (player as any).hlsInstance;
let onMenuUpdate: (() => void) | null = null;
if (!hls) {
return {
menuItems: () => [
{ label: "Авто", value: "auto", selected: true, onClick: () => {} },
],
menuItem: () => ({ ...options, enabled: false }),
setMenuUpdateCallback: () => {},
};
}
// Инициализация состояния уровней качества
let levels: HlsLevel[] = [];
let isMenuInitialized = false;
const updateLevels = () => {
if (hls.levels?.length > 0) {
levels = hls.levels;
if (!isMenuInitialized) {
isMenuInitialized = true;
}
if (onMenuUpdate) {
onMenuUpdate();
}
}
};
// Обновляем уровни при загрузке манифеста
hls.on(Hls.Events.MANIFEST_PARSED, () => {
updateLevels();
});
// Проверяем, есть ли уже доступные уровни
if (hls.levels && hls.levels?.length > 0) {
levels = hls.levels;
}
const getPixels = (l: HlsLevel) => (l.width > l.height ? l.height : l.width);
const formatBitrate = (bps: number) =>
(bps / 1_000_000).toFixed(1).replace(/\.0$/, "") + " Мбит";
const getCurrentAutoLevel = () => {
const current = levels[hls.currentLevel];
return current ? `${getPixels(current)}p` : "";
};
const getCurrentManualLevel = () => {
const manual = levels[hls.manualLevel];
return manual
? `${getPixels(manual)}p / ${formatBitrate(manual.bitrate)}`
: "Качество";
};
const menuItem = () => {
return {
...options,
enabled: levels?.length > 0,
label: levels?.length === 0
? "Качество"
: levels?.length === 1
? "Авто"
: hls.autoLevelEnabled
? `Авто (${getCurrentAutoLevel()})`
: getCurrentManualLevel(),
};
};
const setQuality = (quality: number | 'auto'): void => {
if (!levels?.length) return;
if (quality === 'auto') {
hls.currentLevel = -1;
} else if (typeof quality === 'number') {
hls.currentLevel = quality;
}
};
const menuItems = () => {
const isAuto = hls.autoLevelEnabled;
// Если доступен только один уровень качества, показываем только "Авто"
if (levels?.length <= 1) {
return [{
label: "Авто",
value: -1,
selected: true,
onClick: () => setQuality('auto'),
}];
}
const items = levels.map((level, index) => {
const label = `${getPixels(level)}p / ${formatBitrate(level.bitrate)}`;
const selected = !isAuto && hls.currentLevel === index;
return {
label,
value: index,
selected,
onClick: () => setQuality(index),
};
});
items.sort((a, b) => b.value - a.value);
// Добавляем "Авто" с информацией о текущем качестве только если есть выбор
items.push({
label: isAuto ? `Авто (${getCurrentAutoLevel()})` : "Авто",
value: -1,
selected: isAuto,
onClick: () => setQuality('auto'),
});
return items;
};
// Позволяет установить внешний колбэк на обновление меню
const setMenuUpdateCallback = (cb: () => void) => {
onMenuUpdate = cb;
// Если уже есть уровни качества, сразу обновляем меню
if (levels?.length > 0) {
requestAnimationFrame(() => cb());
}
};
// Подписка на смену уровня (в т.ч. в авто режиме)
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
if (onMenuUpdate) {
onMenuUpdate(); // триггерим обновление главного меню
}
});
return { menuItem, menuItems, setMenuUpdateCallback };
};
export default qualityRate;

View File

@@ -0,0 +1,66 @@
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../types";
const defaults = {
label: "Субтитры",
selected: false,
selectable: false,
className: "subs-selector",
};
interface QualityMenuOptions extends TachPlayerMenuItemOptions {}
const textTracks = (
player: Player & { textTracks: () => { tracks_?: TextTrack[] } },
options: QualityMenuOptions = defaults,
) => {
const menuItems = () => {
const textTracks = player.textTracks().tracks_;
const trackItems = [];
for (let i = 0; i < textTracks?.length; ++i) {
if (textTracks[i].mode === "hidden") {
continue;
}
const trackItem = {
label: textTracks[i].label,
value: textTracks[i].label,
selected: textTracks[i].mode === "showing",
onClick: () => setTrack(textTracks[i]),
};
trackItems.push(trackItem);
}
return trackItems;
};
const setTrack = (track: TextTrack) => {
const textTracks = player.textTracks().tracks_;
for (let i = 0; i < textTracks?.length; ++i) {
if (textTracks[i].mode === "hidden") {
continue;
}
if (textTracks[i].label === track.label) textTracks[i].mode = "showing";
else textTracks[i].mode = "disabled";
}
};
const menuItem = () => {
return {
...options,
enabled: menuItems().length > 0,
};
};
return {
menuItems,
menuItem,
};
};
export default textTracks;

View File

@@ -0,0 +1,93 @@
.video-js {
.vjs-control-bar {
.vjs-settings-button {
.vjs-menu {
z-index: 10 !important;
&.vjs-lock-showing {
width: 100%;
.vjs-menu-content {
height: fit-content;
bottom: 3em;
width: 340px;
left: -220px;
}
}
}
}
}
&.vjs-mobile-ui {
.vjs-control-bar {
.vjs-settings-button {
.vjs-menu {
&.vjs-lock-showing {
width: 100%;
position: fixed;
.vjs-menu-content {
border-radius: 0;
width: 100vw;
height: 100vh;
padding-top: 50vh;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 5px;
max-height: 100vh;
position: fixed;
top: 0;
left: 0;
/* top: 85%;
left: 50%; */
/* transform: translate(-50%, -85%); */
background: rgba(
0,
0,
0,
0.5
); /* затемнение в области паддингов */
&:hover {
border-radius: 0 !important;
}
&::before {
content: "";
position: absolute;
top: 50vh;
left: 20px;
right: 20px;
bottom: 5px;
background: var(--Default-BgDarken);
border-radius: 10px; /* желаемый border-radius */
}
.vjs-menu-item {
position: relative;
z-index: 2;
border-radius: 0;
&:hover {
border-radius: 0 !important;
}
}
@media (orientation: landscape) {
padding-top: 20px;
padding-left: 25vw;
padding-right: 25vw;
&::before {
top: 20px;
left: 25vw;
right: 25vw;
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
export interface TachPlayerMenuItemOptions {
label: string; // Название элемента меню
value?: any; // Значение элемента меню
selectable?: boolean; // Опционально: является ли элемент меню выбираемым
selected?: boolean; // Опционально: выбрано ли меню
className?: string;
}
export interface TachPlayerPlugin {}

View File

@@ -0,0 +1,126 @@
.video-js {
.vjs-skip-button-component {
position: absolute;
top: 0;
width: calc(50% - 48px);
height: calc(100% - 73px);
opacity: 1;
transition: opacity 1s;
&.vjs-skip-backward {
left: 0;
}
&.vjs-skip-forward {
right: 0;
}
.vjs-skip-button {
position: relative;
width: 100%;
height: 100%;
&:focus {
outline: none;
box-shadow: none;
}
.icon-placeholder {
width: 30px;
height: 30px;
align-content: center;
position: absolute;
top: calc(50% + calc(48px / 2));
}
.scroll-info {
width: 100%;
height: calc(100% + 73px);
position: absolute;
top: 0;
padding: 6%;
border-radius: 50%;
box-sizing: border-box;
align-content: center;
color: var(--Light-theme-Default-White, #fff);
text-align: center;
/* app/bold/Headline */
font-family: Inter;
font-size: 1.3vw;
font-style: normal;
font-weight: 400;
display: flex;
flex-direction: column;
justify-content: center;
.scroll-icon {
display: flex;
width: 100%;
justify-content: center;
pointer-events: none;
}
.scroll-text {
margin-top: 4px;
}
@media (min-width: 1200px) {
font-size: 1vw;
}
}
&.vjs-skip-button-backward {
.icon-placeholder {
right: 10px;
background-repeat: no-repeat;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M31.8191 5.58705C29.3458 4.53678 26.6859 3.99697 23.9988 4.00001C17.4569 4.00039 11.6487 7.14174 8 11.9982V8.00001C8 6.89544 7.10457 6.00001 6 6.00001C4.89543 6.00001 4 6.89544 4 8.00001V24C4 25.1046 4.89543 26 6 26C7.10457 26 8 25.1046 8 24C8 15.1636 15.1636 8.00001 24 8.00001L24.0024 8.00001C26.151 7.99745 28.278 8.42902 30.2557 9.26884C32.2334 10.1087 34.021 11.3394 35.5113 12.8872C36.2774 13.6829 37.5435 13.7069 38.3392 12.9407C39.1349 12.1746 39.1589 10.9085 38.3927 10.1128C36.5287 8.17687 34.2928 6.63748 31.8191 5.58705ZM42 22C40.8954 22 40 22.8954 40 24C40 32.8364 32.8364 40 24 40H23.9973C19.8528 40.0056 15.8691 38.3968 12.8908 35.5148C12.0971 34.7466 10.8309 34.7674 10.0628 35.5612C9.29464 36.3549 9.31542 37.6211 10.1092 38.3892C13.8348 41.9945 18.8182 44.007 24.0027 44L24 42V44H24.0027C35.047 43.9985 44 35.0447 44 24C44 22.8954 43.1046 22 42 22Z' fill='white'/%3E%3C/svg%3E") no-repeat center / 24px 24px;
}
.scroll-info {
left: -65%;
align-items: end;
background: linear-gradient(
270deg,
rgba(0, 0, 0, 0.25) 0%,
rgba(0, 0, 0, 0) 73.42%
);
}
}
&.vjs-skip-button-forward {
.icon-placeholder {
left: 10px;
background-repeat: no-repeat;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16.1809 5.58705C18.6542 4.53678 21.3141 3.99697 24.0012 4.00001C30.5431 4.00039 36.3513 7.14174 40 11.9982V8.00001C40 6.89544 40.8954 6.00001 42 6.00001C43.1046 6.00001 44 6.89544 44 8.00001V24C44 25.1046 43.1046 26 42 26C40.8954 26 40 25.1046 40 24C40 15.1636 32.8364 8.00001 24 8.00001L23.9976 8.00001C21.849 7.99745 19.722 8.42902 17.7443 9.26884C15.7666 10.1087 13.979 11.3394 12.4887 12.8872C11.7226 13.6829 10.4565 13.7069 9.66082 12.9407C8.86512 12.1746 8.84114 10.9085 9.60727 10.1128C11.4713 8.17687 13.7072 6.63748 16.1809 5.58705ZM6 22C7.10457 22 8 22.8954 8 24C8 32.8364 15.1636 40 24 40H24.0027C28.1472 40.0056 32.1309 38.3968 35.1092 35.5148C35.9029 34.7466 37.1691 34.7674 37.9372 35.5612C38.7054 36.3549 38.6846 37.6211 37.8908 38.3892C34.1652 41.9945 29.1818 44.007 23.9973 44L24 42V44H23.9973C12.953 43.9985 4 35.0447 4 24C4 22.8954 4.89543 22 6 22Z' fill='white'/%3E%3C/svg%3E") no-repeat center / 24px 24px;
}
.scroll-info {
align-items: start;
right: -65%;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.25) 0%,
rgba(0, 0, 0, 0) 73.42%
);
}
}
}
}
&.vjs-user-inactive {
.vjs-skip-button-component {
opacity: 0;
pointer-events: none;
}
}
/* &.vjs-hls-live, */
&.vjs-controls-disabled {
.vjs-skip-button-component {
display: none;
}
}
}

View File

@@ -0,0 +1,196 @@
import videojs from "video.js";
import type Player from "video.js/dist/types/player";
import "./skip-buttons.css";
export interface SkipButtonsOptions {
skip: number;
}
const BACKWARD_SCROLL_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" style="width: 20%; max-width: 48px; height: auto;" fill="none"><path d="M41.1751 35.3188C40.5985 35.3188 40.1204 35.1359 39.5579 34.7984L25.2282 26.361C24.2298 25.7704 23.7938 25.0812 23.7938 24.2656C23.7938 23.4641 24.2298 22.775 25.2282 22.1844L39.5579 13.7469C40.1344 13.4094 40.5985 13.2266 41.1751 13.2266C42.2719 13.2266 43.2001 14.0703 43.2001 15.6312V32.9141C43.2001 34.475 42.2719 35.3188 41.1751 35.3188ZM21.8532 35.3188C21.2767 35.3188 20.7985 35.1359 20.236 34.7984L5.89231 26.361C4.90783 25.7704 4.47199 25.0812 4.47199 24.2656C4.47199 23.4641 4.90783 22.775 5.89231 22.1844L20.236 13.7469C20.7985 13.4094 21.2767 13.2266 21.8532 13.2266C22.9501 13.2266 23.8782 14.0703 23.8782 15.6312V32.9141C23.8782 34.475 22.9501 35.3188 21.8532 35.3188Z" fill="url(#paint0_linear_17944_473880)"/><defs><linearGradient id="paint0_linear_17944_473880" x1="43.2001" y1="23.9984" x2="4.80007" y2="23.9984" gradientUnits="userSpaceOnUse"><stop stop-color="white"/><stop offset="0.49" stop-color="white"/><stop offset="0.5" stop-color="white" stop-opacity="0.3"/><stop offset="1" stop-color="white" stop-opacity="0.3"/></linearGradient></defs></svg>`;
const FORWARD_SCROLL_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" style="width: 20%; max-width: 48px; height: auto;" fill="none"><path d="M2.82499 22.3188C3.40155 22.3188 3.87968 22.1359 4.44218 21.7984L18.7719 13.361C19.7703 12.7704 20.2063 12.0812 20.2063 11.2656C20.2063 10.4641 19.7703 9.775 18.7719 9.18437L4.44218 0.746875C3.86562 0.409375 3.40155 0.226562 2.82499 0.226562C1.72811 0.226562 0.799988 1.07031 0.799988 2.63125V19.9141C0.799988 21.475 1.72811 22.3188 2.82499 22.3188ZM22.1469 22.3188C22.7234 22.3188 23.2016 22.1359 23.764 21.7984L38.1077 13.361C39.0922 12.7704 39.5281 12.0812 39.5281 11.2656C39.5281 10.4641 39.0922 9.775 38.1077 9.18437L23.764 0.746875C23.2016 0.409375 22.7234 0.226562 22.1469 0.226562C21.05 0.226562 20.1219 1.07031 20.1219 2.63125V19.9141C20.1219 21.475 21.05 22.3188 22.1469 22.3188Z" fill="url(#paint0_linear_17959_7312)"/><defs><linearGradient id="paint0_linear_17959_7312" x1="0.799988" y1="10.9984" x2="39.2" y2="10.9984" gradientUnits="userSpaceOnUse"><stop stop-color="white"/><stop offset="0.49" stop-color="white"/><stop offset="0.5" stop-color="white" stop-opacity="0.3"/><stop offset="1" stop-color="white" stop-opacity="0.3"/></linearGradient></defs></svg>`;
type PlayerWithActivity = Player & {
reportUserActivity?: () => void;
userActive?: (active?: boolean) => void;
};
const wakePlayerUI = (player: Player) => {
const playerWithActivity = player as PlayerWithActivity;
playerWithActivity.reportUserActivity?.();
playerWithActivity.userActive?.(true);
(player.el() as HTMLElement | null)?.focus?.();
};
const secondsLabel = (accumulated: number) => {
const seconds = Math.abs(accumulated);
const mod10 = seconds % 10;
const mod100 = seconds % 100;
if (mod10 === 1 && mod100 !== 11) {
return `${seconds} секунду`;
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${seconds} секунды`;
}
return `${seconds} секунд`;
};
const createSkipButton = (
player: Player,
skip: number,
direction: "forward" | "backward",
) => {
const button = document.createElement("button");
button.type = "button";
button.className = `vjs-skip-button vjs-skip-button-${direction}`;
const icon = document.createElement("span");
icon.className = "icon-placeholder";
icon.textContent = String(skip);
button.appendChild(icon);
const scrollInfo = document.createElement("div");
scrollInfo.className = "scroll-info";
scrollInfo.style.display = "none";
const scrollIcon = document.createElement("span");
scrollIcon.className = `scroll-icon scroll-icon-${direction}`;
scrollIcon.innerHTML =
direction === "backward" ? BACKWARD_SCROLL_ICON : FORWARD_SCROLL_ICON;
scrollInfo.appendChild(scrollIcon);
const scrollText = document.createElement("span");
scrollText.className = "scroll-text";
scrollInfo.appendChild(scrollText);
button.appendChild(scrollInfo);
let accumulated = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const renderScrollInfo = () => {
if (accumulated === 0) {
scrollInfo.style.display = "none";
scrollText.textContent = "";
return;
}
scrollInfo.style.display = "flex";
scrollText.textContent = secondsLabel(accumulated);
};
button.addEventListener("click", () => {
wakePlayerUI(player);
accumulated += direction === "forward" ? skip : -skip;
renderScrollInfo();
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
const currentTime = player.currentTime() || 0;
player.currentTime(currentTime + accumulated);
wakePlayerUI(player);
accumulated = 0;
renderScrollInfo();
}, 500);
});
return button;
};
class SkipButtonComponent extends videojs.getComponent("Component") {
private element: HTMLElement | null = null;
createEl(): HTMLElement {
const direction = this.options_.direction as "forward" | "backward";
const skip = Number(this.options_.skip) || 10;
const el = super.createEl("div", {
className: `vjs-skip-button-component vjs-skip-${direction}`,
}) as HTMLElement;
this.element = createSkipButton(this.player(), skip, direction);
el.appendChild(this.element);
return el;
}
}
class SkipBackwardButtonComponent extends SkipButtonComponent {
constructor(player: Player, options: Record<string, unknown>) {
options.direction = "backward";
super(player, options);
}
}
class SkipForwardButtonComponent extends SkipButtonComponent {
constructor(player: Player, options: Record<string, unknown>) {
options.direction = "forward";
super(player, options);
}
}
videojs.registerComponent(
"SkipBackwardButtonComponent",
SkipBackwardButtonComponent,
);
videojs.registerComponent("SkipForwardButtonComponent", SkipForwardButtonComponent);
const skipButtonsPlugin = function (this: Player, options: SkipButtonsOptions) {
const player = this;
player.ready(() => {
player.addChild("SkipBackwardButtonComponent", { skip: options.skip });
player.addChild("SkipForwardButtonComponent", { skip: options.skip });
const triggerSkip = (direction: "forward" | "backward") => {
const selector = `.vjs-skip-button-${direction}`;
const button = player.el().querySelector<HTMLButtonElement>(selector);
wakePlayerUI(player);
button?.click();
};
const onKeydown = (event: KeyboardEvent) => {
const active = document.activeElement;
const tag = active?.tagName;
const isEditable =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
active?.getAttribute("contenteditable") === "true";
if (isEditable) {
return;
}
if (event.key === " " || event.code === "Space") {
event.preventDefault();
wakePlayerUI(player);
if (player.paused()) {
void player.play();
} else {
player.pause();
}
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
triggerSkip("forward");
} else if (event.key === "ArrowLeft") {
event.preventDefault();
triggerSkip("backward");
}
};
document.addEventListener("keydown", onKeydown);
player.on("dispose", () => {
document.removeEventListener("keydown", onKeydown);
});
});
};
videojs.registerPlugin("skipButtons", skipButtonsPlugin);
export default skipButtonsPlugin;

View File

@@ -0,0 +1,23 @@
export type VideoPlayerToken =
| string
| null
| undefined
| Promise<string | null | undefined>;
export type VideoPlayerTokenProvider = () => VideoPlayerToken;
let tokenProvider: VideoPlayerTokenProvider | null = null;
export const setVideoPlayerTokenProvider = (
provider?: VideoPlayerTokenProvider | null,
) => {
tokenProvider = provider ?? null;
};
export const resolveVideoPlayerToken = async () => {
if (!tokenProvider) {
return null;
}
return tokenProvider();
};

View File

@@ -0,0 +1,13 @@
export {
VideoPlayer as VideoPlayerSsr,
type IVideoPlayerProps,
default,
} from "./video-player/video-player";
export type {
IVideoJSOptions,
IVideoJSProps,
IVideoJSSource,
PreloadType,
VideoJsPlayer,
} from "./video-player/components/video-js/types";

View File

@@ -0,0 +1,31 @@
import React, { type ReactNode, type Ref } from "react";
import cn from "classnames";
import baseStyles from "../../video.module.scss";
import { IBaseComponentProps } from "../../shared/types";
interface IPlayerExtensionProps extends IBaseComponentProps {
className?: string;
children: ReactNode;
conteinerRef?: Ref<any>;
}
const PlayerExtension = ({
className,
children,
conteinerRef,
...rest
}: IPlayerExtensionProps) => {
return (
<div
ref={conteinerRef}
className={cn([baseStyles.videoMain, className])}
{...rest}
>
{children}
</div>
);
};
export default PlayerExtension;

View File

@@ -0,0 +1,450 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import Hls from "hls.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import videojs from "video.js";
import VideoJS from "../hls-or-videojs-player";
import type { IVideoJSOptions } from "../types";
vi.mock("../videojs.module.scss", () => ({
default: { player: "player" },
}));
vi.mock("../plugins/settings", () => ({}));
vi.mock("../plugins/big-play-pause-button", () => ({}));
vi.mock("../plugins/skip-buttons", () => ({}));
vi.mock("@hublib-web/video-player/core", async () => {
const actual = await vi.importActual<typeof import("@hublib-web/video-player/core")>(
"@hublib-web/video-player/core",
);
return {
...actual,
resolveVideoPlayerToken: vi.fn().mockResolvedValue(null),
};
});
vi.mock("react-device-detect", () => ({
isIOS: false,
isMobile: false,
}));
vi.mock("video.js", () => {
type Handler = {
cb: (...args: any[]) => void;
once: boolean;
};
class FakePlayer {
private handlers = new Map<string, Handler[]>();
private current = 0;
private durationValue = 0;
liveTracker = {
isLive_: false,
startTracking: vi.fn(),
trigger: vi.fn(),
};
play = vi.fn(async () => {});
pause = vi.fn();
src = vi.fn();
bigPlayPauseButton = vi.fn();
settingsMenu = vi.fn();
skipButtons = vi.fn();
textTracks = () => [];
constructor(private video: HTMLVideoElement) {}
private addHandler(
event: string,
cb: (...args: any[]) => void,
once: boolean,
) {
const existing = this.handlers.get(event) ?? [];
existing.push({ cb, once });
this.handlers.set(event, existing);
}
ready(callback: () => void) {
callback();
return this;
}
currentTime = vi.fn((time?: number) => {
if (typeof time === "number") {
this.current = time;
this.video.currentTime = time;
}
return this.current;
});
duration = vi.fn((value?: number) => {
if (typeof value === "number") {
this.durationValue = value;
}
return this.durationValue;
});
on(event: string, cb: (...args: any[]) => void) {
this.addHandler(event, cb, false);
return this;
}
one(event: string, cb: (...args: any[]) => void) {
this.addHandler(event, cb, true);
return this;
}
emit(event: string, ...args: any[]) {
const handlers = this.handlers.get(event);
if (!handlers?.length) return;
const persistent: Handler[] = [];
for (const handler of handlers) {
handler.cb(...args);
if (!handler.once) {
persistent.push(handler);
}
}
this.handlers.set(event, persistent);
}
trigger = this.emit.bind(this);
readyState = 4;
el() {
return this.video.parentElement ?? this.video;
}
dispose = vi.fn(() => {
this.emit("dispose");
this.handlers.clear();
});
}
const players: FakePlayer[] = [];
const mockVideoJs = vi.fn((video: HTMLVideoElement) => {
const player = new FakePlayer(video);
players.push(player);
return player;
}) as any;
mockVideoJs.formatTime = vi.fn();
mockVideoJs.setFormatTime = vi.fn();
mockVideoJs.__getPlayers = () => players;
mockVideoJs.__reset = () => {
mockVideoJs.mockClear();
players.splice(0, players.length);
};
return {
__esModule: true,
default: mockVideoJs,
};
});
vi.mock("hls.js", () => {
type Handler = {
cb: (...args: any[]) => void;
once: boolean;
};
const Events = {
MANIFEST_PARSED: "manifestParsed",
FRAG_CHANGED: "fragChanged",
FRAG_BUFFERED: "fragBuffered",
ERROR: "error",
};
const ErrorTypes = {
NETWORK_ERROR: "networkError",
MEDIA_ERROR: "mediaError",
};
class BasePlaylistLoader {
constructor(public config: any) {}
load(
context: any,
config: any,
callbacks: { onSuccess?: () => void } = {},
): void {
callbacks.onSuccess?.();
}
}
class MockHls {
static Events = Events;
static ErrorTypes = ErrorTypes;
static DefaultConfig = {
loader: BasePlaylistLoader,
};
static isSupported = vi.fn(() => true);
private handlers = new Map<string, Handler[]>();
currentLevel = 0;
levels = [
{
details: {
live: false,
totalduration: 0,
startSN: 0,
endSN: 1,
fragments: [{ start: 0 }, { start: 10 }],
},
},
];
liveSyncPosition = 0;
loadSource = vi.fn();
attachMedia = vi.fn();
startLoad = vi.fn();
stopLoad = vi.fn();
detachMedia = vi.fn();
recoverMediaError = vi.fn();
destroy = vi.fn();
constructor(public config: any) {
instances.push(this);
}
private addHandler(
event: string,
cb: (...args: any[]) => void,
once: boolean,
) {
const existing = this.handlers.get(event) ?? [];
existing.push({ cb, once });
this.handlers.set(event, existing);
}
on(event: string, cb: (...args: any[]) => void) {
this.addHandler(event, cb, false);
}
once(event: string, cb: (...args: any[]) => void) {
this.addHandler(event, cb, true);
}
emit(event: string, ...args: any[]) {
const handlers = this.handlers.get(event);
if (!handlers?.length) return;
const persistent: Handler[] = [];
for (const handler of handlers) {
handler.cb(event, ...args);
if (!handler.once) {
persistent.push(handler);
}
}
this.handlers.set(event, persistent);
}
}
const instances: MockHls[] = [];
const api = {
__esModule: true,
default: MockHls,
Events,
ErrorTypes,
};
(MockHls as any).__getInstances = () => instances;
(MockHls as any).__reset = () => {
instances.splice(0, instances.length);
MockHls.isSupported.mockReturnValue(true);
};
return api;
});
type MockFn = ReturnType<typeof vi.fn>;
type MockedPlayer = {
emit: (event: string, ...args: any[]) => void;
currentTime: MockFn;
pause: MockFn;
play: MockFn;
};
type VideoJsMocked = typeof videojs & {
__getPlayers(): MockedPlayer[];
__reset(): void;
};
type MockedHlsInstance = {
emit: (event: string, ...args: any[]) => void;
startLoad: MockFn;
loadSource: MockFn;
destroy: MockFn;
recoverMediaError: MockFn;
levels: Array<{
details: {
startSN: number;
endSN: number;
fragments: Array<{ start: number }>;
};
}>;
currentLevel: number;
};
type HlsMocked = typeof Hls & {
__getInstances(): MockedHlsInstance[];
__reset(): void;
};
const getVideoJs = () => videojs as unknown as VideoJsMocked;
const getHls = () => Hls as unknown as HlsMocked;
afterEach(() => {
cleanup();
});
beforeEach(() => {
getVideoJs().__reset();
getHls().__reset();
});
const createOptions = (overrides: Partial<IVideoJSOptions> = {}): IVideoJSOptions => {
const baseSource = {
src: "https://cdn-video.tach.id/edaa05ca-defa-41b4-baa9-e6e5ef3992d2/edaa05cadefa41b4baa9e6e5ef3992d2-master.m3u8",
type: "application/x-mpegURL",
};
return {
autoplay: false,
controls: true,
responsive: true,
preload: "auto",
fluid: false,
muted: false,
sources: overrides.sources ?? [baseSource],
...overrides,
};
};
const getLastPlayer = (): MockedPlayer => {
const players = getVideoJs().__getPlayers();
if (!players.length) {
throw new Error("Player was not created");
}
return players[players.length - 1];
};
const getLastHlsInstance = (): MockedHlsInstance => {
const instances = getHls().__getInstances();
if (!instances.length) {
throw new Error("HLS instance was not created");
}
return instances[instances.length - 1];
};
describe("VideoJS timeline control", () => {
it("seeks to initial time after metadata and manifest are ready", () => {
const initialTime = 35;
render(<VideoJS options={createOptions()} initialTime={initialTime} />);
const player = getLastPlayer();
const hlsInstance = getLastHlsInstance();
player.emit("loadedmetadata");
expect(player.currentTime).toHaveBeenCalledWith(initialTime);
hlsInstance.emit(Hls.Events.MANIFEST_PARSED, {
levels: [{ details: {} }],
});
expect(hlsInstance.startLoad).toHaveBeenCalledWith(initialTime);
});
it("restarts buffering and updates currentTime when initialTime prop changes", async () => {
const { rerender } = render(
<VideoJS options={createOptions()} initialTime={0} />,
);
const player = getLastPlayer();
const hlsInstance = getLastHlsInstance();
const nextSeek = 82;
rerender(<VideoJS options={createOptions()} initialTime={nextSeek} />);
await waitFor(() => {
expect(hlsInstance.startLoad).toHaveBeenCalledWith(nextSeek);
expect(player.currentTime).toHaveBeenCalledWith(nextSeek);
});
});
it("destroys the previous HLS instance and boots a new one when source changes", async () => {
const { rerender } = render(
<VideoJS options={createOptions({ autoplay: true })} initialTime={0} />,
);
const firstHls = getLastHlsInstance();
const player = getLastPlayer();
const nextSource = {
src: "https://cdn-video.tach.id/45a55dcf-3a8f-4ae4-b119-09df747954a3/45a55dcf3a8f4ae4b11909df747954a3-master.m3u8",
type: "application/x-mpegURL",
};
rerender(
<VideoJS
options={createOptions({ autoplay: true, sources: [nextSource] })}
initialTime={0}
/>,
);
await waitFor(() => expect(firstHls.destroy).toHaveBeenCalled());
const instances = getHls().__getInstances();
expect(instances.length).toBe(2);
const nextHls = instances[instances.length - 1];
expect(nextHls.loadSource).toHaveBeenCalledWith(nextSource.src);
expect(player.pause).toHaveBeenCalled();
await waitFor(() => {
expect(player.play).toHaveBeenCalled();
});
});
it("attempts to recover playback by skipping corrupted fragments on fatal media errors", async () => {
vi.useFakeTimers();
try {
render(<VideoJS options={createOptions({ autoplay: true })} initialTime={0} />);
const hlsInstance = getLastHlsInstance();
const video = document.querySelector("video") as HTMLVideoElement;
const recoverableStart = 25;
const levelDetails = hlsInstance.levels[hlsInstance.currentLevel].details;
Object.assign(levelDetails, {
startSN: 0,
endSN: 5,
fragments: [{ start: 0 }, { start: recoverableStart }, { start: 50 }],
});
Object.defineProperty(video, "paused", {
configurable: true,
get: () => false,
});
hlsInstance.emit(Hls.Events.ERROR, {
fatal: true,
type: Hls.ErrorTypes.MEDIA_ERROR,
});
expect(video.currentTime).toBe(recoverableStart);
await vi.advanceTimersByTimeAsync(150);
expect(hlsInstance.recoverMediaError).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,122 @@
"use client";
import cn from "classnames";
import styles from "./videojs.module.scss";
import React, { useEffect, useMemo, useRef } from "react";
import { isIOS, isMobile } from "react-device-detect";
import {
type VideoPlayerRuntimeInitOptions,
VideoPlayerRuntime,
} from "../../../../core";
import { IVideoJSProps, VideoJsPlayer } from "./types";
const DEFAULT_SOURCE_TYPE = "application/x-mpegurl";
const VideoJS = ({
options,
classNames = [],
onReady,
className,
initialTime = 0,
full = false,
withRewind = true,
}: IVideoJSProps) => {
const videoContainerRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<VideoPlayerRuntime | null>(null);
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
const runtimeOptions = useMemo<
Omit<VideoPlayerRuntimeInitOptions, "container">
>(() => {
const source = options.sources?.[0];
return {
source: {
src: source?.src ?? "",
type: source?.type ?? DEFAULT_SOURCE_TYPE,
},
strategy: "auto",
preload: options.preload,
autoplay: options.autoplay,
controls: options.controls,
responsive: options.responsive,
aspectRatio: options.aspectRatio,
fluid: options.fluid,
muted: options.muted,
poster: options.poster,
preferHQ: options.preferHQ ?? false,
debug: options.debug ?? false,
initialTime,
isIOS,
isMobile,
full,
withRewind,
skipSeconds: 10,
classNames,
onPlayerReady: player => {
onReadyRef.current?.(player as VideoJsPlayer);
},
};
}, [
options.sources,
options.preload,
options.autoplay,
options.controls,
options.responsive,
options.aspectRatio,
options.fluid,
options.muted,
options.poster,
options.preferHQ,
options.debug,
initialTime,
full,
withRewind,
classNames,
]);
useEffect(() => {
const container = videoContainerRef.current;
if (!container || runtimeRef.current) {
return;
}
const runtime = new VideoPlayerRuntime();
runtimeRef.current = runtime;
void runtime.init({
container,
...runtimeOptions,
});
return () => {
runtime.dispose();
runtimeRef.current = null;
};
// init once; subsequent prop updates go through runtime.update
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!runtimeRef.current) {
return;
}
void runtimeRef.current.update(runtimeOptions);
}, [runtimeOptions]);
return (
<div className={cn([styles.player, className])} data-vjs-player>
<div ref={videoContainerRef} className={cn([styles.player, className])} />
</div>
);
};
export default VideoJS;

View File

@@ -0,0 +1,3 @@
import SelectPlayer from "./hls-or-videojs-player";
export default SelectPlayer;

View File

@@ -0,0 +1,31 @@
import videojs from "video.js";
import Player from "video.js/dist/types/player";
const BigPlayButton = videojs.getComponent("BigPlayButton");
class BigPlayPauseButton extends BigPlayButton {
handleClick(e: React.MouseEvent) {
const player = this.player() as Player;
if (player.paused()) {
player.play();
} else {
player.pause();
}
}
}
videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton);
// Функция плагина с аннотацией типа this
function bigPlayPauseButtonPlugin(this: Player) {
const player = this;
const defaultButton = player.getChild("bigPlayButton");
if (defaultButton) {
defaultButton.dispose();
}
player.addChild("BigPlayPauseButton", {});
}
videojs.registerPlugin("bigPlayPauseButton", bigPlayPauseButtonPlugin);
export default bigPlayPauseButtonPlugin;

View File

@@ -0,0 +1,116 @@
import videojs from "video.js";
import Component from "video.js/dist/types/component";
import Player from "video.js/dist/types/player";
import TachVideoMenu from "../tach-video-menu";
import TachVideoMenuItem from "../tach-video-menu-item";
const MenuButton = videojs.getComponent("MenuButton");
const Menu = videojs.getComponent("Menu");
type BaseMenuOptions = ConstructorParameters<typeof Menu>[1];
interface CustomMenuOptions extends NonNullable<BaseMenuOptions> {
menuButton: TachVideoMenuButton;
}
export default class TachVideoMenuButton extends MenuButton {
private hideThreshold_: number = 0;
private buttonPressed_ = false;
private menu!: TachVideoMenu;
private menuButton_!: Component;
public items: TachVideoMenuItem[] = [];
/**
* Button constructor.
*
* @param {Player} player - videojs player instance
*/
constructor(player: Player, title: string, name: string) {
super(player, {
title: title,
name: name,
} as any);
// Перехватываем событие 'mouseenter' на уровне захвата и предотвращаем его дальнейшее распространение
const el = this.menuButton_.el();
el.addEventListener(
"mouseenter",
e => {
e.stopImmediatePropagation();
},
true,
);
}
/**
* Creates button items.
*
* @return {TachVideoMenuItem[]} - Button items
*/
createItems(): TachVideoMenuItem[] {
return [];
}
/**
* Создаёт меню и добавляет в него все пункты.
*
* @return {Menu} - Сконструированное меню
*/
createMenu() {
const menu = new TachVideoMenu(this.player_, {
menuButton: this,
} as CustomMenuOptions);
this.hideThreshold_ = 0;
this.items = this.createItems();
if (this.items) {
// Если метод updateItems присутствует, используем его для обновления списка
if (typeof menu.updateItems === "function") {
menu.updateItems(this.items);
} else {
// Если по какой-то причине обновление недоступно, добавляем элементы по одному
this.items.forEach(item => menu.addItem(item));
}
}
return menu;
}
/**
* Обновление меню без его пересоздания.
*
* @return {Menu} - Обновлённое меню
*/
update() {
// Если меню уже создано и поддерживает updateItems, обновляем его содержимое
if (this.menu && typeof this.menu.updateItems === "function") {
this.items = this.createItems();
this.menu.updateItems(this.items);
} else {
// Если меню ещё не создано, создаём его
this.menu = this.createMenu();
}
this.addChild(this.menu);
/**
* Track the state of the menu button
*
* @type {Boolean}
* @private
*/
this.buttonPressed_ = false;
this.menuButton_.el_.setAttribute("aria-expanded", "false");
if (this.items && this.items?.length <= this.hideThreshold_) {
this.hide();
this.menu.contentEl().removeAttribute("role");
} else {
this.show();
this.menu.contentEl().setAttribute("role", "menu");
}
}
}

View File

@@ -0,0 +1,69 @@
import videojs from "video.js";
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions, TachPlayerPlugin } from "../../types";
import TachVideoMenuButton from "../tach-video-menu-button";
// Concrete classes
const VideoJsMenuItemClass = videojs.getComponent("MenuItem");
// Интерфейс пункта меню расширяет базовые опции
export interface ITachPlayerMenuItem extends TachPlayerMenuItemOptions {
onClick: () => void;
enabled?: boolean;
value?: unknown;
}
/**
* Extend vjs menu item class.
*/
export default class TachVideoMenuItem extends VideoJsMenuItemClass {
private item: ITachPlayerMenuItem;
private button: TachVideoMenuButton;
private plugin: TachPlayerPlugin;
/**
* Menu item constructor.
*
* @param {Player} player - vjs player
* @param {ITachVideoMenuItem} item - Item object
* @param {ConcreteButton} button - The containing button.
* @param {TachPlayerPlugin} plugin - This plugin instance.
*/
constructor(
player: Player,
item: ITachPlayerMenuItem,
button: TachVideoMenuButton,
plugin: TachPlayerPlugin,
) {
super(player, {
label: item.label,
selectable: item.selectable || true,
selected: item.selected || false,
} as any);
this.item = item;
this.button = button;
this.plugin = plugin;
item.className && this.addClass(item.className);
}
/**
* Click event for menu item.
*/
handleClick() {
if (this.item.onClick) {
// Reset other menu items selected status.
for (let i = 0; i < this.button.items?.length; ++i) {
this.button.items[i].selected(false);
}
this.selected(true);
return this.item.onClick();
}
}
selected(val: boolean) {
//@ts-expect-error // getComponent reduant
super.selected(val);
}
}

View File

@@ -0,0 +1,77 @@
import videojs from "video.js";
import Player from "video.js/dist/types/player";
import TachVideoMenuButton from "../tach-video-menu-button";
import TachVideoMenuItem from "../tach-video-menu-item";
// Concrete classes
const VideoJsMenuClass = videojs.getComponent("Menu");
type TachMenu = typeof VideoJsMenuClass & {
addItem: (item: TachVideoMenuItem) => void;
};
type BaseMenuOptions = ConstructorParameters<typeof VideoJsMenuClass>[1];
interface TachMenuOptions extends NonNullable<BaseMenuOptions> {
menuButton: TachVideoMenuButton;
}
/**
* Extend vjs menu item class.
*/
export default class TachVideoMenu extends VideoJsMenuClass {
private menuOpened_: boolean = false;
/**
* Menu item constructor.
*
* @param {Player} player - vjs player
* @param {TachPlayerMenuItemOptions} item - Item object
* @param {ConcreteButton} button - The containing button.
* @param {TachPlayerPlugin} plugin - This plugin instance.
*/
constructor(player: Player, options: TachMenuOptions) {
super(player, options);
player.on("userinactive", () => {
if (this.menuOpened_) {
player.userActive(true);
}
});
}
hide() {
this.menuOpened_ = false;
// Вызов родительского метода скрытия
super.hide();
}
show() {
this.menuOpened_ = true;
// Вызов родительского метода скрытия
super.show();
}
addItem(item: TachVideoMenuItem) {
//@ts-expect-error getComponent reduant method
super.addItem(item);
}
/**
* Обновляет пункты меню.
* @param {Array<Object|string>} newItems - Массив новых компонентов или их имён, которые будут добавлены в меню.
*/
updateItems(newItems: TachVideoMenuItem[]) {
// Получаем текущих потомков
const currentChildren = this.children().slice();
// Удаляем все остальные дочерние компоненты.
currentChildren.forEach(child => {
this.removeChild(child);
});
// Добавляем новые пункты меню.
newItems.forEach(item => {
this.addItem(item);
});
}
}

View File

@@ -0,0 +1,201 @@
import videojs from "video.js";
import Component from "video.js/dist/types/component";
import Player from "video.js/dist/types/player";
import Plugin from "video.js/dist/types/plugin";
import TachVideoMenuButton from "../components/tach-video-menu-button";
import TachVideoMenuItem, {
ITachPlayerMenuItem,
} from "../components/tach-video-menu-item";
import { TachPlayerMenuItemOptions } from "../types";
import audioTrackSelector from "./selectors/audio-track-selector";
import playbackRateSelector from "./selectors/playback-rate-selector";
import qualityRateSelector from "./selectors/quality-rate-selector";
import textTracksSelector from "./selectors/text-track-selector";
import "./settings.css";
// Интерфейс опций плагина (расширяем по необходимости)
interface SettingsButtonOptions {
// например, customLabel?: string;
}
// Интерфейс плеера с controlBar
interface PlayerWithControlBar extends Player {
controlBar: Component;
}
const BasePlugin = videojs.getPlugin("plugin")! as typeof Plugin;
// Значения опций по умолчанию
const defaults: SettingsButtonOptions = {};
class SettingsButton extends BasePlugin {
private options: SettingsButtonOptions;
private mainMenu: ITachPlayerMenuItem[] = [];
private settingsButton!: TachVideoMenuButton;
private buttonInstance!: Component;
// Фабрики для создания кнопок меню
private backButton!: ITachPlayerMenuItem;
private playbackRateButton!: () => ITachPlayerMenuItem;
private qualityRateButton!: () => ITachPlayerMenuItem;
private audioTracksButton!: () => ITachPlayerMenuItem;
private textTracksButton!: () => ITachPlayerMenuItem;
constructor(player: PlayerWithControlBar, options: SettingsButtonOptions) {
super(player);
this.options = videojs.obj.merge(defaults, options);
this.player.ready(() => this.initialize());
}
/**
* Инициализация плагина: создание кнопки настроек и привязка событий
*/
private initialize(): void {
this.createSettingsButton();
this.bindPlayerEvents();
}
/**
* Привязка событий плеера (например, для обновления меню)
*/
private bindPlayerEvents(): void {
// При необходимости можно привязать событие, например:
// this.settingsButton.on("click", this.setMenu.bind(this, undefined, false, true));
}
/**
* Создание кнопки настроек и определение пунктов меню.
* Здесь создаются фабрики для формирования кнопок и устанавливается начальное меню.
*/
private createSettingsButton(): void {
const player = this.player as PlayerWithControlBar;
// Создаем кнопку настроек с помощью компонента TachVideoMenuButton
this.settingsButton = new TachVideoMenuButton(
player,
"Settings",
"Settings",
);
this.buttonInstance = player.controlBar.addChild(this.settingsButton, {
componentClass: "settingsButton",
});
this.buttonInstance.addClass("vjs-settings-button");
// Определяем кнопку "Назад" для возврата в главное меню
this.backButton = {
label: "Назад",
value: this.mainMenu,
selectable: false,
selected: false,
onClick: () => this.setMenu(undefined, true, true),
className: "settings-back",
};
// Создаем фабрики для дополнительных настроек
const { menuItem: audioMenuItem, menuItems: audioMenuItems } =
audioTrackSelector(player);
this.audioTracksButton = () => ({
...audioMenuItem(),
onClick: () => this.setMenu(audioMenuItems(), false, true),
});
const { menuItem: textMenuItem, menuItems: textMenuItems } =
textTracksSelector(player);
this.textTracksButton = () => ({
...textMenuItem(),
onClick: () => this.setMenu(textMenuItems(), false, true),
});
const { menuItem: playbackMenuItem, menuItems: playbackMenuItems } =
playbackRateSelector(player);
this.playbackRateButton = () => ({
...playbackMenuItem(),
onClick: () => this.setMenu(playbackMenuItems(), false, true),
});
// В createSettingsButton замени этот участок:
const qualitySelector = qualityRateSelector(player);
this.qualityRateButton = () => ({
...qualitySelector.menuItem(), // пересоздание при каждом открытии меню
onClick: () => this.setMenu(qualitySelector.menuItems(), false, true),
});
// Подписка на автообновление, когда hls переключает уровень
qualitySelector.setMenuUpdateCallback(() => {
this.setMenu(undefined, true); // Обновить главное меню без перезахода
});
// Инициализируем меню с кнопками по умолчанию без показа
this.setMenu(undefined, true, false);
}
/**
* Обёртка для создания экземпляра пункта меню.
*
* @param item - объект настроек пункта меню
* @returns экземпляр TachVideoMenuItem
*/
private getMenuItem(item: ITachPlayerMenuItem): TachVideoMenuItem {
return new TachVideoMenuItem(this.player, item, this.settingsButton, this);
}
/**
* Устанавливает (обновляет) пункты меню плагина.
*
* @param items - массив пунктов меню (если не передан, используются кнопки по умолчанию)
* @param skipBackButton - если true, не добавлять кнопку "Назад"
* @param forceShow - если true, принудительно показать меню после обновления
*/
private async setMenu(
items: ITachPlayerMenuItem[] = [],
skipBackButton: boolean = false,
forceShow: boolean = false,
): Promise<void> {
const menuButtons: ITachPlayerMenuItem[] = [];
if (!skipBackButton) {
menuButtons.push(this.backButton);
}
if (items?.length === 0) {
// Если не переданы конкретные пункты меню используем кнопки по умолчанию
const defaultButtons = [
this.playbackRateButton,
this.qualityRateButton,
this.textTracksButton,
this.audioTracksButton,
];
defaultButtons.forEach(createButton => {
const btn = createButton();
if (btn.enabled) {
menuButtons.push(btn);
}
});
} else {
menuButtons.push(...items);
}
// Формируем список пунктов меню: вместо пересоздания меню, назначаем функцию,
// возвращающую актуальный список пунктов, и вызываем метод обновления.
this.settingsButton.createItems = () =>
menuButtons.map(item => this.getMenuItem(item));
await this.settingsButton.update();
// Если требуется принудительно показать меню, эмулируем клик по кнопке меню
if (forceShow) {
const menuElement = this.buttonInstance.el().querySelector(".vjs-menu");
const menuButton = this.buttonInstance
.el()
.querySelector(".vjs-menu-button");
if (menuElement && menuButton) {
(menuButton as HTMLElement).click();
}
}
}
}
// Регистрируем плагин в Video.js
videojs.registerPlugin("settingsMenu", SettingsButton);
export default SettingsButton;

View File

@@ -0,0 +1,177 @@
import Hls from "hls.js";
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../../types";
const defaults = {
label: "Язык",
selected: false,
selectable: false,
className: "audio-selector",
};
interface TrackMenuOptions extends TachPlayerMenuItemOptions {}
type VideoJsAudioTrack = {
label?: string;
enabled: boolean;
};
type PlayerWithTracks = Player & {
audioTracks?: () => { tracks_?: VideoJsAudioTrack[] } | undefined;
hlsInstance?: Hls | null;
};
type HlsAudioTrack = {
id: number;
name?: string;
lang?: string;
};
const audioTrack = (player: Player, options: TrackMenuOptions = defaults) => {
const playerWithTracks = player as PlayerWithTracks;
const getNativeTrackList = () => {
if (typeof playerWithTracks.audioTracks !== "function") {
return undefined;
}
return playerWithTracks.audioTracks();
};
const getNativeTracks = (): VideoJsAudioTrack[] => {
return getNativeTrackList()?.tracks_ ?? [];
};
const setNativeTrack = (track: VideoJsAudioTrack) => {
const audioTracks = getNativeTracks();
for (let i = 0; i < audioTracks.length; ++i) {
audioTracks[i].enabled = audioTracks[i] === track;
}
};
const getSelectedNativeTrack = () => {
const audioTracks = getNativeTracks();
for (let i = 0; i < audioTracks.length; ++i) {
if (audioTracks[i].enabled === true) {
return audioTracks[i];
}
}
return null;
};
const getHlsInstance = (): Hls | null => playerWithTracks.hlsInstance ?? null;
const getHlsTracks = (): HlsAudioTrack[] => {
const hls = getHlsInstance();
return hls?.audioTracks ?? [];
};
const setHlsTrack = (trackId: number) => {
const hls = getHlsInstance();
if (!hls) return;
hls.audioTrack = trackId;
};
const getSelectedHlsTrack = (): HlsAudioTrack | null => {
const hls = getHlsInstance();
if (!hls) return null;
const tracks = getHlsTracks();
const currentId = hls.audioTrack;
return tracks.find(track => track.id === currentId) ?? null;
};
const formatHlsTrackLabel = (track: HlsAudioTrack, index?: number) => {
if (track.name?.trim()) {
return track.name.trim();
}
if (track.lang?.trim()) {
return track.lang.trim().toUpperCase();
}
const fallbackIndex =
typeof index === "number"
? index + 1
: typeof track.id === "number"
? track.id + 1
: 1;
return `Дорожка ${fallbackIndex}`;
};
const buildHlsMenuItems = () => {
const hls = getHlsInstance();
const hlsTracks = getHlsTracks();
if (!hls || hlsTracks.length === 0) {
return [];
}
return hlsTracks.map((track, index) => ({
label: formatHlsTrackLabel(track, index),
value: track.id,
selected: track.id === hls.audioTrack,
onClick: () => setHlsTrack(track.id),
}));
};
const hasAudioTracks = () => {
if (getHlsTracks().length > 0) {
return true;
}
return getNativeTracks().length > 0;
};
const menuItems = () => {
const hlsTracks = buildHlsMenuItems();
if (hlsTracks.length > 0) {
return hlsTracks;
}
const nativeTracks = getNativeTracks();
const trackItems = [];
for (let i = 0; i < nativeTracks?.length; ++i) {
const trackItem = {
label: nativeTracks[i].label || "Default",
value: nativeTracks[i].label,
selected: nativeTracks[i].enabled,
onClick: () => setNativeTrack(nativeTracks[i]),
};
trackItems.push(trackItem);
}
return trackItems;
};
const getSelectedTrackLabel = () => {
const selectedHlsTrack = getSelectedHlsTrack();
if (selectedHlsTrack) {
const tracks = getHlsTracks();
const index = tracks.findIndex(track => track.id === selectedHlsTrack.id);
return formatHlsTrackLabel(selectedHlsTrack, index);
}
return getSelectedNativeTrack()?.label ?? null;
};
const menuItem = () => {
return {
...options,
value: getSelectedTrackLabel(),
enabled: hasAudioTracks(),
};
};
return {
menuItems,
menuItem,
};
};
export default audioTrack;

View File

@@ -0,0 +1,46 @@
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../../types";
const defaults = {
label: "Скорость воспроизведения",
selected: false,
selectable: false,
speeds: [
{ label: "0.5x", value: 0.5 },
{ label: "Обычная", value: 1 },
{ label: "1.5x", value: 1.5 },
{ label: "1.75x", value: 1.75 },
{ label: "2x", value: 2 },
],
className: "speed-selector",
};
interface SpeedMenuOptions extends TachPlayerMenuItemOptions {
speeds: Array<{ label: string; value: number }>;
}
const playbackRate = (player: Player, options: SpeedMenuOptions = defaults) => {
const menuItems = () =>
options.speeds.map(speed => {
return {
label: speed.label,
selectable: true,
selected: speed.value === player.playbackRate(),
onClick: () => player.playbackRate(speed.value),
};
});
const menuItem = () => {
return {
...options,
enabled: true,
};
};
return {
menuItems,
menuItem,
};
};
export default playbackRate;

View File

@@ -0,0 +1,163 @@
import Hls from "hls.js";
import Player from "video.js/dist/types/player";
import { TachPlayerMenuItemOptions } from "../../../types";
const defaults = {
label: "Качество",
selected: false,
selectable: false,
className: "quality-selector",
};
type HlsLevel = {
height: number;
width: number;
bitrate: number;
name?: string;
};
interface QualityMenuOptions extends TachPlayerMenuItemOptions {}
const qualityRate = (
player: Player,
options: QualityMenuOptions = defaults,
) => {
const hls: Hls | undefined = (player as any).hlsInstance;
let onMenuUpdate: (() => void) | null = null;
if (!hls) {
return {
menuItems: () => [
{ label: "Авто", value: "auto", selected: true, onClick: () => {} },
],
menuItem: () => ({ ...options, enabled: false }),
setMenuUpdateCallback: () => {},
};
}
// Инициализация состояния уровней качества
let levels: HlsLevel[] = [];
let isMenuInitialized = false;
const updateLevels = () => {
if (hls.levels?.length > 0) {
levels = hls.levels;
if (!isMenuInitialized) {
isMenuInitialized = true;
}
if (onMenuUpdate) {
onMenuUpdate();
}
}
};
// Обновляем уровни при загрузке манифеста
hls.on(Hls.Events.MANIFEST_PARSED, () => {
updateLevels();
});
// Проверяем, есть ли уже доступные уровни
if (hls.levels && hls.levels?.length > 0) {
levels = hls.levels;
}
const getPixels = (l: HlsLevel) => (l.width > l.height ? l.height : l.width);
const formatBitrate = (bps: number) =>
(bps / 1_000_000).toFixed(1).replace(/\.0$/, "") + " Мбит";
const getCurrentAutoLevel = () => {
const current = levels[hls.currentLevel];
return current ? `${getPixels(current)}p` : "";
};
const getCurrentManualLevel = () => {
const manual = levels[hls.manualLevel];
return manual
? `${getPixels(manual)}p / ${formatBitrate(manual.bitrate)}`
: "Качество";
};
const menuItem = () => {
return {
...options,
enabled: levels?.length > 0,
label: levels?.length === 0
? "Качество"
: levels?.length === 1
? "Авто"
: hls.autoLevelEnabled
? `Авто (${getCurrentAutoLevel()})`
: getCurrentManualLevel(),
};
};
const setQuality = (quality: number | 'auto'): void => {
if (!levels?.length) return;
if (quality === 'auto') {
hls.currentLevel = -1;
} else if (typeof quality === 'number') {
hls.currentLevel = quality;
}
};
const menuItems = () => {
const isAuto = hls.autoLevelEnabled;
// Если доступен только один уровень качества, показываем только "Авто"
if (levels?.length <= 1) {
return [{
label: "Авто",
value: -1,
selected: true,
onClick: () => setQuality('auto'),
}];
}
const items = levels.map((level, index) => {
const label = `${getPixels(level)}p / ${formatBitrate(level.bitrate)}`;
const selected = !isAuto && hls.currentLevel === index;
return {
label,
value: index,
selected,
onClick: () => setQuality(index),
};
});
items.sort((a, b) => b.value - a.value);
// Добавляем "Авто" с информацией о текущем качестве только если есть выбор
items.push({
label: isAuto ? `Авто (${getCurrentAutoLevel()})` : "Авто",
value: -1,
selected: isAuto,
onClick: () => setQuality('auto'),
});
return items;
};
// Позволяет установить внешний колбэк на обновление меню
const setMenuUpdateCallback = (cb: () => void) => {
onMenuUpdate = cb;
// Если уже есть уровни качества, сразу обновляем меню
if (levels?.length > 0) {
requestAnimationFrame(() => cb());
}
};
// Подписка на смену уровня (в т.ч. в авто режиме)
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
if (onMenuUpdate) {
onMenuUpdate(); // триггерим обновление главного меню
}
});
return { menuItem, menuItems, setMenuUpdateCallback };
};
export default qualityRate;

View File

@@ -0,0 +1,70 @@
import { TachPlayerMenuItemOptions } from "../../../types";
import { VideoJsTextTrackList } from "../../../../types";
const defaults = {
label: "Субтитры",
selected: false,
selectable: false,
className: "subs-selector",
};
interface QualityMenuOptions extends TachPlayerMenuItemOptions {}
interface ITextTrackPlayer {
textTracks: () => VideoJsTextTrackList;
}
const textTracks = (
player: ITextTrackPlayer,
options: QualityMenuOptions = defaults,
) => {
const menuItems = () => {
const textTracks = player.textTracks().tracks_;
const trackItems = [];
for (let i = 0; i < textTracks?.length; ++i) {
const track = textTracks[i];
if (!track || track.mode === "hidden") {
continue;
}
const trackItem = {
label: track.label,
value: track.label,
selected: track.mode === "showing",
onClick: () => setTrack(track),
};
trackItems.push(trackItem);
}
return trackItems;
};
const setTrack = (track: TextTrack) => {
const textTracks = player.textTracks().tracks_;
for (let i = 0; i < textTracks?.length; ++i) {
const currentTrack = textTracks[i];
if (!currentTrack || currentTrack.mode === "hidden") {
continue;
}
if (currentTrack.label === track.label) currentTrack.mode = "showing";
else currentTrack.mode = "disabled";
}
};
const menuItem = () => {
return {
...options,
enabled: menuItems().length > 0,
};
};
return {
menuItems,
menuItem,
};
};
export default textTracks;

View File

@@ -0,0 +1,93 @@
.video-js {
.vjs-control-bar {
.vjs-settings-button {
.vjs-menu {
z-index: 10 !important;
&.vjs-lock-showing {
width: 100%;
.vjs-menu-content {
height: fit-content;
bottom: 3em;
width: 340px;
left: -220px;
}
}
}
}
}
&.vjs-mobile-ui {
.vjs-control-bar {
.vjs-settings-button {
.vjs-menu {
&.vjs-lock-showing {
width: 100%;
position: fixed;
.vjs-menu-content {
border-radius: 0;
width: 100vw;
height: 100vh;
padding-top: 50vh;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 5px;
max-height: 100vh;
position: fixed;
top: 0;
left: 0;
/* top: 85%;
left: 50%; */
/* transform: translate(-50%, -85%); */
background: rgba(
0,
0,
0,
0.5
); /* затемнение в области паддингов */
&:hover {
border-radius: 0 !important;
}
&::before {
content: "";
position: absolute;
top: 50vh;
left: 20px;
right: 20px;
bottom: 5px;
background: var(--Default-BgDarken);
border-radius: 10px; /* желаемый border-radius */
}
.vjs-menu-item {
position: relative;
z-index: 2;
border-radius: 0;
&:hover {
border-radius: 0 !important;
}
}
@media (orientation: landscape) {
padding-top: 20px;
padding-left: 25vw;
padding-right: 25vw;
&::before {
top: 20px;
left: 25vw;
right: 25vw;
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,204 @@
// skipButtonsPlugin.tsx
import React from "react";
import { createRoot, Root } from "react-dom/client";
import videojs from "video.js";
import Player from "video.js/dist/types/player";
import BackwardSvg from "./skip-backward.svg";
import ForwardSvg from "./skip-forward.svg";
import "./skip-buttons.css";
import { numWord } from "../../../../shared/math";
// Опции плагина
export interface SkipButtonsOptions {
skip: number;
}
// React-компонент для одной кнопки перемотки
interface SkipButtonProps {
player: Player;
skip: number;
direction: "forward" | "backward";
}
const renderSvg = (asset: unknown) => {
if (typeof asset === "string") {
return <img src={asset} alt="" aria-hidden="true" />;
}
const SvgComponent =
asset as React.ComponentType<React.SVGProps<SVGSVGElement>>;
return <SvgComponent />;
};
const SkipButton: React.FC<SkipButtonProps> = ({ player, skip, direction }) => {
// Накопленная сумма перемотки
const [accumulated, setAccumulated] = React.useState<number>(0);
// Храним ID таймера debounce
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Используем ref для мгновенного доступа к накопленному значению
const accumulatedRef = React.useRef<number>(0);
const handleClick = () => {
// Вычисляем новое накопленное значение
const newAccumulated =
direction === "forward"
? accumulatedRef.current + skip
: accumulatedRef.current - skip;
accumulatedRef.current = newAccumulated;
setAccumulated(newAccumulated);
// Сбрасываем предыдущий таймер
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Устанавливаем debounce (500 мс)
timerRef.current = setTimeout(() => {
const currentTime = player.currentTime() || 0;
const newTime = currentTime + accumulatedRef.current;
player.currentTime(newTime);
// Сбрасываем накопленное значение
accumulatedRef.current = 0;
setAccumulated(0);
}, 500);
};
return (
<button
onClick={handleClick}
className={`vjs-skip-button vjs-skip-button-${direction}`}
>
<span className="icon-placeholder">{skip}</span>
{accumulated ? (
<div className="scroll-info">
{direction === "backward"
? renderSvg(BackwardSvg)
: renderSvg(ForwardSvg)}
{`${Math.abs(accumulated)} ${numWord(accumulated, ["секунду", "секунды", "секунд"])}`}
</div>
) : null}
</button>
);
};
// Базовый Video.js компонент, обёртывающий React-компонент
class SkipButtonComponent extends videojs.getComponent("Component") {
private reactRoot: Root | null = null;
createEl(): HTMLElement {
const direction = this.options_.direction;
const el = super.createEl("div", {
className: `vjs-skip-button-component vjs-skip-${direction}`,
}) as HTMLElement;
// Рендерим React-компонент сразу внутри созданного элемента
this.reactRoot = createRoot(el);
this.reactRoot.render(
<SkipButton
player={this.player()}
skip={this.options_.skip}
direction={direction}
/>,
);
return el;
}
dispose() {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
super.dispose();
}
}
// Компонент для кнопки перемотки назад задаём direction через options
class SkipBackwardButtonComponent extends SkipButtonComponent {
constructor(player: Player, options: any) {
options.direction = "backward";
super(player, options);
}
}
// Компонент для кнопки перемотки вперёд задаём direction через options
class SkipForwardButtonComponent extends SkipButtonComponent {
constructor(player: Player, options: any) {
options.direction = "forward";
super(player, options);
}
}
// Регистрируем компоненты в Video.js
videojs.registerComponent(
"SkipBackwardButtonComponent",
SkipBackwardButtonComponent,
);
videojs.registerComponent(
"SkipForwardButtonComponent",
SkipForwardButtonComponent,
);
// Плагин, который добавляет два отдельных компонента через player.addChild
const skipButtonsPlugin = function (this: Player, options: SkipButtonsOptions) {
const player = this;
player.ready(() => {
// 1) Добавляем кнопки
player.addChild("SkipBackwardButtonComponent", { skip: options.skip });
player.addChild("SkipForwardButtonComponent", { skip: options.skip });
// 2) Вспомогательная функция, которая находит кнопку и вызывает click()
const triggerSkipClick = (direction: "forward" | "backward") => {
// Ищем именно <button class="vjs-skip-button vjs-skip-button-{direction}">
const selector = `.vjs-skip-button-${direction}`;
const btn = player.el().querySelector<HTMLButtonElement>(selector);
if (btn) btn.click();
};
// 3) Обработка стрелок
const onKeydown = (e: KeyboardEvent) => {
// Если фокус в инпуте/textarea/select или contenteditable — выходим
const active = document.activeElement;
const tag = active?.tagName;
const isEditable =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
active?.getAttribute("contenteditable") === "true";
if (isEditable) return;
if (e.key === " " || e.code === "Space") {
(player.el() as HTMLElement | null)?.focus();
e.preventDefault();
if (player.paused()) player.play();
else player.pause();
return;
}
if (e.key === "ArrowRight") {
(player.el() as HTMLElement | null)?.focus();
e.preventDefault();
triggerSkipClick("forward");
} else if (e.key === "ArrowLeft") {
(player.el() as HTMLElement | null)?.focus();
e.preventDefault();
triggerSkipClick("backward");
}
};
document.addEventListener("keydown", onKeydown);
// 4) Убираем слушатель при dispose
player.on("dispose", () => {
document.removeEventListener("keydown", onKeydown);
});
});
};
videojs.registerPlugin("skipButtons", skipButtonsPlugin);
export default skipButtonsPlugin;

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"
style="width: 20%; max-width: 48px; height: auto;" fill="none">
<path
d="M41.1751 35.3188C40.5985 35.3188 40.1204 35.1359 39.5579 34.7984L25.2282 26.361C24.2298 25.7704 23.7938 25.0812 23.7938 24.2656C23.7938 23.4641 24.2298 22.775 25.2282 22.1844L39.5579 13.7469C40.1344 13.4094 40.5985 13.2266 41.1751 13.2266C42.2719 13.2266 43.2001 14.0703 43.2001 15.6312V32.9141C43.2001 34.475 42.2719 35.3188 41.1751 35.3188ZM21.8532 35.3188C21.2767 35.3188 20.7985 35.1359 20.236 34.7984L5.89231 26.361C4.90783 25.7704 4.47199 25.0812 4.47199 24.2656C4.47199 23.4641 4.90783 22.775 5.89231 22.1844L20.236 13.7469C20.7985 13.4094 21.2767 13.2266 21.8532 13.2266C22.9501 13.2266 23.8782 14.0703 23.8782 15.6312V32.9141C23.8782 34.475 22.9501 35.3188 21.8532 35.3188Z"
fill="url(#paint0_linear_17944_473880)" />
<defs>
<linearGradient id="paint0_linear_17944_473880" x1="43.2001" y1="23.9984" x2="4.80007"
y2="23.9984" gradientUnits="userSpaceOnUse">
<stop stop-color="white" />
<stop offset="0.49" stop-color="white" />
<stop offset="0.5" stop-color="white" stop-opacity="0.3" />
<stop offset="1" stop-color="white" stop-opacity="0.3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,115 @@
.video-js {
.vjs-skip-button-component {
position: absolute;
top: 0;
width: calc(50% - 48px);
height: calc(100% - 73px);
opacity: 1;
transition: opacity 1s;
&.vjs-skip-backward {
left: 0;
}
&.vjs-skip-forward {
right: 0;
}
.vjs-skip-button {
position: relative;
width: 100%;
height: 100%;
&:focus {
outline: none;
box-shadow: none;
}
.icon-placeholder {
width: 30px;
height: 30px;
align-content: center;
position: absolute;
top: calc(50% + calc(48px / 2));
}
.scroll-info {
width: 100%;
height: calc(100% + 73px);
position: absolute;
top: 0;
padding: 6%;
border-radius: 50%;
box-sizing: border-box;
align-content: center;
color: var(--Light-theme-Default-White, #fff);
text-align: center;
/* app/bold/Headline */
font-family: Inter;
font-size: 1.3vw;
font-style: normal;
font-weight: 400;
display: flex;
flex-direction: column;
justify-content: center;
@media (min-width: 1200px) {
font-size: 1vw;
}
}
&.vjs-skip-button-backward {
.icon-placeholder {
right: 10px;
background-repeat: no-repeat;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M31.8191 5.58705C29.3458 4.53678 26.6859 3.99697 23.9988 4.00001C17.4569 4.00039 11.6487 7.14174 8 11.9982V8.00001C8 6.89544 7.10457 6.00001 6 6.00001C4.89543 6.00001 4 6.89544 4 8.00001V24C4 25.1046 4.89543 26 6 26C7.10457 26 8 25.1046 8 24C8 15.1636 15.1636 8.00001 24 8.00001L24.0024 8.00001C26.151 7.99745 28.278 8.42902 30.2557 9.26884C32.2334 10.1087 34.021 11.3394 35.5113 12.8872C36.2774 13.6829 37.5435 13.7069 38.3392 12.9407C39.1349 12.1746 39.1589 10.9085 38.3927 10.1128C36.5287 8.17687 34.2928 6.63748 31.8191 5.58705ZM42 22C40.8954 22 40 22.8954 40 24C40 32.8364 32.8364 40 24 40H23.9973C19.8528 40.0056 15.8691 38.3968 12.8908 35.5148C12.0971 34.7466 10.8309 34.7674 10.0628 35.5612C9.29464 36.3549 9.31542 37.6211 10.1092 38.3892C13.8348 41.9945 18.8182 44.007 24.0027 44L24 42V44H24.0027C35.047 43.9985 44 35.0447 44 24C44 22.8954 43.1046 22 42 22Z' fill='white'/%3E%3C/svg%3E") no-repeat center / 24px 24px;
}
.scroll-info {
left: -65%;
align-items: end;
background: linear-gradient(
270deg,
rgba(0, 0, 0, 0.25) 0%,
rgba(0, 0, 0, 0) 73.42%
);
}
}
&.vjs-skip-button-forward {
.icon-placeholder {
left: 10px;
background-repeat: no-repeat;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16.1809 5.58705C18.6542 4.53678 21.3141 3.99697 24.0012 4.00001C30.5431 4.00039 36.3513 7.14174 40 11.9982V8.00001C40 6.89544 40.8954 6.00001 42 6.00001C43.1046 6.00001 44 6.89544 44 8.00001V24C44 25.1046 43.1046 26 42 26C40.8954 26 40 25.1046 40 24C40 15.1636 32.8364 8.00001 24 8.00001L23.9976 8.00001C21.849 7.99745 19.722 8.42902 17.7443 9.26884C15.7666 10.1087 13.979 11.3394 12.4887 12.8872C11.7226 13.6829 10.4565 13.7069 9.66082 12.9407C8.86512 12.1746 8.84114 10.9085 9.60727 10.1128C11.4713 8.17687 13.7072 6.63748 16.1809 5.58705ZM6 22C7.10457 22 8 22.8954 8 24C8 32.8364 15.1636 40 24 40H24.0027C28.1472 40.0056 32.1309 38.3968 35.1092 35.5148C35.9029 34.7466 37.1691 34.7674 37.9372 35.5612C38.7054 36.3549 38.6846 37.6211 37.8908 38.3892C34.1652 41.9945 29.1818 44.007 23.9973 44L24 42V44H23.9973C12.953 43.9985 4 35.0447 4 24C4 22.8954 4.89543 22 6 22Z' fill='white'/%3E%3C/svg%3E") no-repeat center / 24px 24px;
}
.scroll-info {
align-items: start;
right: -65%;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.25) 0%,
rgba(0, 0, 0, 0) 73.42%
);
}
}
}
}
&.vjs-user-inactive {
.vjs-skip-button-component {
opacity: 0;
pointer-events: none;
}
}
/* &.vjs-hls-live, */
&.vjs-controls-disabled {
.vjs-skip-button-component {
display: none;
}
}
}

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" style="width: 20%; max-width: 48px; height: auto;"
viewBox="0 0 48 48" fill="none">
<path
d="M2.82499 22.3188C3.40155 22.3188 3.87968 22.1359 4.44218 21.7984L18.7719 13.361C19.7703 12.7704 20.2063 12.0812 20.2063 11.2656C20.2063 10.4641 19.7703 9.775 18.7719 9.18437L4.44218 0.746875C3.86562 0.409375 3.40155 0.226562 2.82499 0.226562C1.72811 0.226562 0.799988 1.07031 0.799988 2.63125V19.9141C0.799988 21.475 1.72811 22.3188 2.82499 22.3188ZM22.1469 22.3188C22.7234 22.3188 23.2016 22.1359 23.764 21.7984L38.1077 13.361C39.0922 12.7704 39.5281 12.0812 39.5281 11.2656C39.5281 10.4641 39.0922 9.775 38.1077 9.18437L23.764 0.746875C23.2016 0.409375 22.7234 0.226562 22.1469 0.226562C21.05 0.226562 20.1219 1.07031 20.1219 2.63125V19.9141C20.1219 21.475 21.05 22.3188 22.1469 22.3188Z"
fill="url(#paint0_linear_17959_7312)" />
<defs>
<linearGradient id="paint0_linear_17959_7312" x1="0.799988" y1="10.9984" x2="39.2" y2="10.9984"
gradientUnits="userSpaceOnUse">
<stop stop-color="white" />
<stop offset="0.49" stop-color="white" />
<stop offset="0.5" stop-color="white" stop-opacity="0.3" />
<stop offset="1" stop-color="white" stop-opacity="0.3" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,9 @@
export interface TachPlayerMenuItemOptions {
label: string; // Название элемента меню
value?: any; // Значение элемента меню
selectable?: boolean; // Опционально: является ли элемент меню выбираемым
selected?: boolean; // Опционально: выбрано ли меню
className?: string;
}
export interface TachPlayerPlugin {}

View File

@@ -0,0 +1,118 @@
import { SkipButtonsOptions } from "./plugins/skip-buttons";
export interface IVideoJSSource {
src: string;
type: string;
}
export type PreloadType = "auto" | "metadata" | "none" | "visibility";
export interface IVideoJSOptions {
autoplay: boolean;
controls: boolean;
responsive: boolean;
aspectRatio?: string;
preload: PreloadType;
fluid: boolean;
muted: boolean;
sources: IVideoJSSource[];
poster?: string;
preferHQ?: boolean;
/** Включить детальное логирование */
debug?: boolean;
}
export interface IVideoJSProps {
options: IVideoJSOptions;
onReady?: (player: VideoJsPlayer) => void;
className?: string;
classNames?: string[];
initialTime?: number;
full?: boolean;
withRewind?: boolean;
}
export interface VideoJsLiveTracker {
isLive_: boolean;
atLiveEdge?: () => boolean;
startTracking: () => void;
trigger: (event: string) => void;
}
export type VideoJsEventArgument =
| string
| number
| boolean
| null
| undefined
| object;
export type VideoJsEventHandler = (...args: VideoJsEventArgument[]) => void;
export interface VideoJsSegmentInfo {
sn?: number;
level?: number;
duration?: number;
start?: number;
end?: number;
cc?: number;
}
export interface HlsLikeEventData {
fatal?: boolean;
details?: string;
reason?: string;
}
export interface HlsLikeLevelDetails {
live?: boolean;
totalduration?: number;
}
export interface HlsLikeLevel {
details?: HlsLikeLevelDetails;
}
export interface HlsLikeInstance {
on?: (
event: string,
callback: (event: string, data?: HlsLikeEventData) => void,
) => void;
loadSource?: (src: string) => void;
destroy?: () => void;
levels?: HlsLikeLevel[];
}
export interface VideoJsTextTrackList {
tracks_: TextTrack[];
}
// Public player contract exported by the package.
// It intentionally does not depend on video.js types to avoid leaking them to consumers.
export interface VideoJsPlayer {
play: () => void | Promise<void>;
pause: () => void;
load: () => void;
on: (event: string, callback: VideoJsEventHandler) => void;
off?: (event: string, callback?: VideoJsEventHandler) => void;
one: (event: string, callback: VideoJsEventHandler) => void;
currentTime: (seconds?: number) => number | undefined;
duration: () => number;
controls: (state?: boolean) => boolean;
paused: () => boolean;
el: () => Element | null;
dispose: () => void;
hlsInstance?: HlsLikeInstance | null;
liveTracker?: VideoJsLiveTracker;
settingsMenu?: () => void;
mobileUi?: () => void;
bigPlayPauseButton?: () => void;
skipButtons?: (options: SkipButtonsOptions) => void;
subscribeToSegmentChange: (callback: (segment: VideoJsSegmentInfo) => void) => void;
subscribeToDuration: (callback: (duration: number) => void) => void;
subscribeToPlayStart: (callback: () => void) => void;
subscribeToPlayStarted: (callback: () => void) => void;
subscribeToManifestLoaded: (callback: () => void) => void;
mediaduration: () => number | undefined;
textTracks: () => VideoJsTextTrackList;
}

View File

@@ -0,0 +1,10 @@
export const formatTime = (seconds: number) => {
const pad = (num: number) => String(num).padStart(2, "0");
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (seconds < 3600) {
return `${pad(mins)}:${pad(secs)}`;
}
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
};

View File

@@ -0,0 +1,5 @@
.player {
max-width: 100%;
max-height: 100%;
z-index: 1;
}

View File

@@ -0,0 +1,99 @@
import React, { cloneElement, useEffect, useRef, useState } from "react";
import { VideoJsPlayer } from "../video-js/types";
import { IWithObservationProps } from "../with-observation";
import styles from "./with-blur.module.scss";
export interface IWithBlurVideoPlayerProps extends IWithObservationProps {
withBlur?: boolean;
}
const WithBlur = ({
withBlur = false,
onReady,
children,
...props
}: IWithBlurVideoPlayerProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null,
);
const handlePlayerReady = (player: VideoJsPlayer) => {
if (onReady) onReady(player);
const videoEl = player
.el()
?.querySelector("video") as HTMLVideoElement | null;
setVideoElement(videoEl);
};
useEffect(() => {
if (!withBlur || !videoElement || !canvasRef.current) return;
const video = videoElement;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrameId: number;
let isAnimating = false;
const updateCanvas = () => {
if (!isAnimating) return;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
animationFrameId = requestAnimationFrame(updateCanvas);
};
const startAnimation = () => {
if (!isAnimating && video.videoWidth && video.videoHeight) {
isAnimating = true;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
updateCanvas();
}
};
const stopAnimation = () => {
isAnimating = false;
cancelAnimationFrame(animationFrameId);
};
const handlePlay = startAnimation;
const handleLoadedData = startAnimation;
const handleSeeked = startAnimation;
const handleLoadedMetadata = startAnimation; // Добавлен обработчик
video.addEventListener("loadeddata", handleLoadedData);
video.addEventListener("loadedmetadata", handleLoadedMetadata); // Новый обработчик
video.addEventListener("play", handlePlay);
video.addEventListener("seeked", handleSeeked);
video.addEventListener("pause", stopAnimation);
video.addEventListener("ended", stopAnimation);
// Попробовать запустить сразу, если размеры уже известны
startAnimation();
return () => {
stopAnimation();
video.removeEventListener("loadeddata", handleLoadedData);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("play", handlePlay);
video.removeEventListener("seeked", handleSeeked);
video.removeEventListener("pause", stopAnimation);
video.removeEventListener("ended", stopAnimation);
};
}, [withBlur, videoElement]);
return (
<div className={styles.videoMain}>
{withBlur && <canvas ref={canvasRef} className={styles.videoBlur} />}
{cloneElement(children, {
...props,
onReady: handlePlayerReady,
classNames: withBlur ? ["with-blur"] : [],
})}
</div>
);
};
export default WithBlur;

View File

@@ -0,0 +1,9 @@
.video-js {
&.its-blur {
background-color: transparent;
}
&.with-blur {
background-color: transparent;
z-index: 2;
}
}

View File

@@ -0,0 +1,22 @@
.videoMain {
max-width: 100%;
overflow: hidden;
object-fit: cover;
position: relative;
width: 100%;
justify-content: center;
align-items: center;
// max-height: 757px;
}
.videoBlur {
position: absolute;
left: 0;
object-position: center;
z-index: 1;
overflow: hidden;
width: 100%;
max-width: 100%;
height: 100%;
object-fit: cover;
filter: blur(40px);
}

View File

@@ -0,0 +1,130 @@
import React, {
cloneElement,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { isMobile } from "react-device-detect";
import { Skeleton } from "antd";
import PlayerExtension from "../player-extension";
import { VideoJsPlayer } from "../video-js/types";
import { IWithLoadingVideoPlayerProps } from "../with-loader";
export interface IWithCoverVideoPlayerProps
extends IWithLoadingVideoPlayerProps {
cover?: ReactNode | null;
forceHover?: boolean;
}
const HOVER_DELAY = 350; // milliseconds
const WithCover = ({
cover,
children,
onReady,
options,
forceHover = false,
...props
}: IWithCoverVideoPlayerProps) => {
const [showPlayer, setShowPlayer] = useState(false);
const [playerPlaying, setPlayerPlaying] = useState(false);
const hoverTimer = useRef<NodeJS.Timeout | null>(null);
const isHovering = useRef(false);
const handleMouseEnter = () => {
if (isMobile || !cover) return;
isHovering.current = true;
hoverTimer.current = setTimeout(() => {
if (isHovering.current) {
setShowPlayer(true);
}
}, HOVER_DELAY);
};
const handleMouseLeave = () => {
if (isMobile || !cover) return;
isHovering.current = false;
if (hoverTimer.current) {
clearTimeout(hoverTimer.current);
hoverTimer.current = null;
}
setShowPlayer(false);
setPlayerPlaying(false);
};
useEffect(() => {
return () => {
if (hoverTimer.current) {
clearTimeout(hoverTimer.current);
}
};
}, []);
useEffect(() => {
if (isMobile || !cover) return;
if (forceHover) {
handleMouseEnter();
} else {
handleMouseLeave();
}
}, [forceHover, cover]);
return (
<PlayerExtension
className={props.className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Cover always in DOM; hidden when player is ready */}
{cover && typeof cover !== "string" && (
<div
style={{
display: playerPlaying ? "none" : "block",
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
>
{cover}
</div>
)}
{/* Video player mounts on hover, hidden until ready */}
{(showPlayer || !cover || typeof cover === "string") && (
<div
style={{
// display: playerPlaying ? "block" : "none",
position: "relative",
}}
>
{cloneElement(children, {
...props,
options: {
...options,
poster: typeof cover === "string" ? cover : options?.poster,
},
onReady: (player: VideoJsPlayer) => {
player.subscribeToPlayStart(() => {
setPlayerPlaying(true);
});
onReady?.(player);
},
})}
</div>
)}
</div>
</PlayerExtension>
);
};
export default WithCover;

View File

@@ -0,0 +1,10 @@
.cover {
object-position: center;
object-fit: cover;
z-index: 3;
overflow: hidden;
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}

View File

@@ -0,0 +1,105 @@
import React, { useCallback, useEffect, useRef } from "react";
import { VideoJsPlayer } from "../video-js/types";
import { formatTime } from "../video-js/utils";
import { IWithBlurVideoPlayerProps } from "../with-blur";
import styles from "./with-duration-badge.module.scss";
export interface IWithDurationBadgePlayerProps
extends IWithBlurVideoPlayerProps {
duration?: boolean | number;
}
const WithDurationBadge = ({
duration = false,
onReady,
children,
...props
}: IWithDurationBadgePlayerProps) => {
const durationRef = useRef<HTMLDivElement | null>(null);
// Форматирование для живого видео: добавляет красную точку перед временем
const formatLiveElapsed = (elapsed: number) =>
`<span style="color: red; margin-right: 4px;">●</span>${formatTime(elapsed)}`;
useEffect(() => {
const overlay = durationRef.current;
if (!overlay || duration === false) return;
if (typeof duration === "number") {
overlay.textContent = formatTime(duration);
overlay.style.opacity = "1";
}
}, [duration]);
const handlePlayer = useCallback(
(player: VideoJsPlayer) => {
player.one("loadedmetadata", () => {
const overlay = durationRef.current;
if (!overlay) return;
if (typeof duration !== "number") {
const playerDuration = player.duration();
const hls = (player as any).hlsInstance;
const levelDetails = hls?.levels[0]?.details;
if (
playerDuration &&
playerDuration !== Infinity &&
!levelDetails?.live
) {
overlay.textContent = formatTime(playerDuration);
if (!player.controls()) overlay.style.opacity = "1";
} else {
const streamStart =
Date.now() -
(levelDetails?.live
? player.duration() || 0
: player.currentTime() || 0) *
1000;
const updateLiveDuration = () => {
const elapsed = (Date.now() - streamStart) / 1000;
overlay.innerHTML = formatLiveElapsed(elapsed);
};
updateLiveDuration();
const intervalId = setInterval(updateLiveDuration, 1000);
player.one("dispose", () => clearInterval(intervalId));
}
}
});
player.on("play", () => {
const overlay = durationRef.current;
if (overlay) overlay.style.opacity = "0";
});
player.on("pause", () => {
const overlay = durationRef.current;
if (overlay && !player.controls()) overlay.style.opacity = "1";
});
player.on("dispose", () => {
const overlay = durationRef.current;
if (overlay && !player.controls()) overlay.style.opacity = "1";
});
if (onReady) onReady(player);
},
[duration, onReady],
);
return (
<div className={styles.videoMain}>
{duration !== false && (
<div
style={{ opacity: "0" }}
ref={durationRef}
className={styles.videoDurationOverlay}
></div>
)}
{React.cloneElement(children, { ...props, onReady: handlePlayer })}
</div>
);
};
export default WithDurationBadge;

View File

@@ -0,0 +1,22 @@
.videoMain {
width: 100%;
height: 100%;
position: relative;
}
.videoDurationOverlay {
position: absolute;
bottom: 12px;
right: 12px;
background: var(--Opacity-BlackOpacity45, rgba(0, 0, 0, 0.45));
color: var(--Default-White, #fff);
padding: 6px var(--corner-S, 8px);
border-radius: var(--Corner-XL, 32px);
font-size: 10px;
font-weight: 600;
line-height: 12px; /* 120% */
pointer-events: none;
transition: opacity 0.7s ease;
opacity: 0;
z-index: 3;
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { IWithDurationBadgePlayerProps } from "../with-duration-badge";
import VideoError from "./video-error";
import styles from "./with-errors.module.scss";
export interface IWithErrorsVideoPlayerProps
extends IWithDurationBadgePlayerProps {
showErrors?: boolean;
}
const WithErrors = ({
showErrors = false,
children,
...props
}: IWithErrorsVideoPlayerProps) => {
return (
<div
className={styles.videoMain}
style={{ aspectRatio: props.options.aspectRatio }}
>
{showErrors && <VideoError className={styles.errors} />}
{React.cloneElement(children, { ...props })}
</div>
);
};
export default WithErrors;

View File

@@ -0,0 +1,44 @@
import React from "react";
import cn from "classnames";
import VideoErrorIcon from "./video-error-icon.svg";
import { IBaseComponentProps } from "../../../shared/types";
import styles from "./video-error.module.scss";
interface IVideoErrorProps extends IBaseComponentProps {
error?: string;
}
const renderSvg = (asset: unknown) => {
if (typeof asset === "string") {
return (
<img
src={asset}
alt=""
aria-hidden="true"
width={80}
height={80}
/>
);
}
const SvgComponent =
asset as React.ComponentType<React.SVGProps<SVGSVGElement>>;
return <SvgComponent />;
};
const VideoError = ({
error = "Ошибка обработки",
className,
}: IVideoErrorProps) => {
return (
<div className={cn(styles.container, className)}>
{renderSvg(VideoErrorIcon)}
<p className={styles.text}>{error}</p>
</div>
);
};
export default VideoError;

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80" fill="none">
<circle cx="40" cy="40" r="40" fill="black" fill-opacity="0.8" />
<path
d="M29.9417 52.8761H50.7006C52.9774 52.8761 54.3568 51.2957 54.3568 49.2466C54.3568 48.6172 54.1693 47.9609 53.8345 47.3716L43.4416 29.2645C42.7452 28.0458 41.5533 27.4297 40.3211 27.4297C39.0889 27.4297 37.8836 28.0458 37.2006 29.2645L26.8077 47.3716C26.4461 47.9744 26.2854 48.6172 26.2854 49.2466C26.2854 51.2957 27.6649 52.8761 29.9417 52.8761ZM40.3345 43.9297C39.6381 43.9297 39.2497 43.5279 39.2363 42.818L39.0622 35.5324C39.0488 34.8225 39.5711 34.3136 40.3211 34.3136C41.0444 34.3136 41.6068 34.8359 41.5934 35.5458L41.3926 42.818C41.3791 43.5413 40.9908 43.9297 40.3345 43.9297ZM40.3345 48.4163C39.5309 48.4163 38.8345 47.7735 38.8345 46.9833C38.8345 46.1796 39.5175 45.5369 40.3345 45.5369C41.1381 45.5369 41.8212 46.1663 41.8212 46.9833C41.8212 47.7868 41.1247 48.4163 40.3345 48.4163Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@@ -0,0 +1,23 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background-color: var(--Text-Primary);
}
.icon {
stroke: white;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.text {
margin-top: 20px;
font-size: 13px;
text-align: center;
color: white;
}

View File

@@ -0,0 +1,6 @@
.errors {
position: absolute;
top: 0;
left: 0;
z-index: 3;
}

View File

@@ -0,0 +1,78 @@
import styles from "./with-lazy.module.scss";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { IWithErrorsVideoPlayerProps } from "../with-errors";
import PlayerExtension from "../player-extension";
import { debounce, throttle } from "lodash";
import { VideoJsPlayer } from "../video-js/types";
export interface IWithLazyVideoPlayerProps extends IWithErrorsVideoPlayerProps {
lazy?: boolean;
}
const WithLazy = ({
lazy = false,
children,
onShow,
...props
}: IWithLazyVideoPlayerProps) => {
const containerRef = useRef(null);
const [visible, setIsVisible] = useState(true);
const show = useCallback(
(player: VideoJsPlayer | null) => {
if (!visible) {
setIsVisible(true);
onShow && onShow(player);
}
},
[onShow, visible],
);
// Debounce для задержки выгрузки видео
const debouncedHide = useCallback(
debounce(() => {
visible && setIsVisible(false);
}, 5000), // задержка в миллисекундах
[visible],
);
// Throttle для обработки видимости
const handleVisibility = throttle((entry: IntersectionObserverEntry) => {
debouncedHide.cancel();
if (entry.isIntersecting && !visible) {
setIsVisible(true);
show(null);
} else if (!entry.isIntersecting && visible) {
debouncedHide();
}
}, 200);
useEffect(() => {
const container = containerRef.current;
const observer = new IntersectionObserver(
([entry]) => handleVisibility(entry),
{
threshold: 0,
},
);
if (container && lazy) {
observer.observe(container);
}
return () => {
if (container) {
observer.unobserve(container);
}
handleVisibility.cancel();
};
}, [handleVisibility, lazy]);
return (
<PlayerExtension className={styles.lazyHolder} conteinerRef={containerRef}>
{visible && React.cloneElement(children, { ...props, onShow })}
</PlayerExtension>
);
};
export default WithLazy;

View File

@@ -0,0 +1,3 @@
.lazyHolder {
background: var(--Default-BgDarken, #f2f2f2);
}

View File

@@ -0,0 +1,43 @@
import React, { useCallback, useState } from "react";
import PlayerExtension from "../player-extension";
import ContentSkeleton from "../../shared/ui/content-skeleton";
import { VideoJsPlayer } from "../video-js/types";
import { IWithLazyVideoPlayerProps } from "../with-lazy";
import styles from "./with-loader.module.scss";
export interface IWithLoadingVideoPlayerProps
extends IWithLazyVideoPlayerProps {
withLoading?: boolean;
}
const WithLoading = ({
withLoading = false,
onReady,
children,
...props
}: IWithLoadingVideoPlayerProps) => {
const [loading, setIsLoading] = useState(withLoading);
const handlePlayer = useCallback((player: VideoJsPlayer) => {
player.on("loadedmetadata", () => {
setIsLoading(false);
});
player.on("error", () => {
setIsLoading(false);
});
player.on("dispose", () => {
setIsLoading(true);
});
onReady && onReady(player);
}, []);
return (
<PlayerExtension className={props.className}>
{withLoading && loading && <ContentSkeleton className={styles.loading} />}
{React.cloneElement(children, { ...props, onReady: handlePlayer })}
</PlayerExtension>
);
};
export default WithLoading;

View File

@@ -0,0 +1,11 @@
.loading {
position: absolute;
left: 0;
object-position: center;
z-index: 3;
overflow: hidden;
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}

View File

@@ -0,0 +1,88 @@
import type { JSX } from "react";
import React, { MouseEvent, useCallback, useRef, useState } from "react";
import { IVideoJSProps, VideoJsPlayer } from "../video-js/types";
import styles from "./with-mouse-events.module.scss";
export interface IWithMouseEventsProps extends IVideoJSProps {
onClick?: (player: VideoJsPlayer | null) => void;
onMouseLeave?: (player: VideoJsPlayer | null) => void;
onMouseEnter?: (player: VideoJsPlayer | null) => void;
children: JSX.Element;
}
export const WithMouseEvents = ({
children,
onMouseEnter,
onMouseLeave,
onClick,
onReady,
...props
}: IWithMouseEventsProps) => {
const [player, setPlayer] = useState<VideoJsPlayer | null>(null);
const mouseHoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); // Реф для хранения таймера
const isHoveredRef = useRef<boolean>(false); // Реф для отслеживания состояния наведения
const handlePlayer = useCallback(
(player: VideoJsPlayer) => {
setPlayer(player);
onReady && onReady(player);
},
[],
);
// Обработчик клика на область видео
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (onClick) {
e.preventDefault();
e.stopPropagation();
onClick(player);
}
},
[onClick, player],
);
// Обработчик наведения на видео
const handleMouseEnter = useCallback(() => {
// Устанавливаем флаг, что мышь наведена
isHoveredRef.current = true;
// Запускаем таймер на 1 секунды
mouseHoverTimeoutRef.current = setTimeout(() => {
// Проверяем, что курсор все еще находится на видео
if (isHoveredRef.current) {
onMouseEnter && onMouseEnter(player);
}
}, 1);
}, [onMouseEnter, player]);
// Обработчик убирания курсора с видео
const handleMouseLeave = useCallback(() => {
// Сбрасываем флаг, что мышь не наведена
isHoveredRef.current = false;
// Очищаем таймер, если курсор убран до его срабатывания
if (mouseHoverTimeoutRef.current) {
clearTimeout(mouseHoverTimeoutRef.current);
mouseHoverTimeoutRef.current = null;
}
// Останавливаем видео, если оно было запущено
onMouseLeave && onMouseLeave(player);
}, [onMouseLeave, player]);
return (
<div
className={styles.videoArea}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{React.cloneElement(children, { ...props, onReady: handlePlayer })}
</div>
);
};

View File

@@ -0,0 +1,4 @@
.videoArea {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,77 @@
import type { JSX } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { throttle } from "lodash";
import { VideoJsPlayer } from "../video-js/types";
import { IWithMouseEventsProps } from "../with-mouse-events";
import styles from "./with-observation.module.scss";
export interface IWithObservationProps extends IWithMouseEventsProps {
onShow?: (player: VideoJsPlayer | null) => void;
onHide?: (player: VideoJsPlayer | null) => void;
children: JSX.Element;
}
export const WithObservation = ({
children,
onShow,
onHide,
onReady,
...props
}: IWithObservationProps) => {
const videoContainerRef = useRef(null);
const playerRef = useRef<VideoJsPlayer | null>(null);
const [isVisible, setIsVisible] = useState(false);
const handlePlayer = useCallback((player: VideoJsPlayer) => {
playerRef.current = player;
onReady && onReady(player);
}, []);
const hide = useCallback(() => {
onHide && onHide(playerRef.current);
}, [onHide]);
const show = useCallback(() => {
onShow && onShow(playerRef.current);
}, [onShow]);
// Throttle для обработки видимости
const handleVisibility = throttle((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && !isVisible) {
setIsVisible(true);
show();
} else if (!entry.isIntersecting && isVisible) {
setIsVisible(false);
hide();
}
}, 200);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => handleVisibility(entry),
{
threshold: 0.75,
},
);
if (videoContainerRef.current) {
observer.observe(videoContainerRef.current);
}
return () => {
if (videoContainerRef.current) {
observer.unobserve(videoContainerRef.current);
}
handleVisibility.cancel();
};
}, [handleVisibility]);
return (
<div ref={videoContainerRef} className={styles.videoArea}>
{React.cloneElement(children, { ...props, onReady: handlePlayer })}
</div>
);
};

View File

@@ -0,0 +1,4 @@
.videoArea {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import PlayerExtension from "../player-extension";
import { IWithCoverVideoPlayerProps } from "../with-cover";
import VideoProcessing from "./video-processing";
import styles from "./with-processing.module.scss";
export interface IWithProcessingVideoPlayerProps
extends IWithCoverVideoPlayerProps {
showProcessing?: boolean;
}
const WithProcessing = ({
showProcessing = false,
children,
...props
}: IWithProcessingVideoPlayerProps) => {
return (
<PlayerExtension className={props.className}>
{showProcessing && <VideoProcessing className={styles.processing} />}
{React.cloneElement(children, {
...props,
})}
</PlayerExtension>
);
};
export default WithProcessing;

View File

@@ -0,0 +1,29 @@
import React from "react";
import cn from "classnames";
import { IBaseComponentProps } from "../../../shared/types";
import styles from "./video-processing.module.scss";
interface IVideoErrorProps extends IBaseComponentProps {}
const VideoProcessing = ({ className }: IVideoErrorProps) => {
return (
<div className={cn(styles.container, className)}>
<div className={styles.spinner}>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
<div className={styles.spinnerBar}></div>
</div>
<p>Видео обрабатывается, подождите</p>
</div>
);
};
export default VideoProcessing;

View File

@@ -0,0 +1,86 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background-color: var(--Default-BgLight);
pointer-events: none;
}
.text {
margin-top: 20px;
font-size: 13px;
text-align: center;
color: white;
}
.spinner {
position: relative;
width: 100px;
height: 100px;
border-radius: 100px;
background-color: var(--Default-BgLight);
}
.spinnerBar {
position: absolute;
width: 4px; /* Толщина палочек */
height: 16px; /* Длина палочек */
background: linear-gradient(to bottom, white, grey);
border-radius: 4px;
top: 25px; /* Отступ от центра (радиус внутреннего круга) */
left: 48%;
transform-origin: 48% 25px; /* Сдвиг оси вращения */
animation: spinner-fade 1.2s linear infinite;
}
.spinnerBar:nth-child(1) {
transform: rotate(0deg);
animation-delay: -1.1s;
}
.spinnerBar:nth-child(2) {
transform: rotate(45deg);
animation-delay: -1s;
}
.spinnerBar:nth-child(3) {
transform: rotate(90deg);
animation-delay: -0.9s;
}
.spinnerBar:nth-child(4) {
transform: rotate(135deg);
animation-delay: -0.8s;
}
.spinnerBar:nth-child(5) {
transform: rotate(180deg);
animation-delay: -0.7s;
}
.spinnerBar:nth-child(6) {
transform: rotate(225deg);
animation-delay: -0.6s;
}
.spinnerBar:nth-child(7) {
transform: rotate(270deg);
animation-delay: -0.5s;
}
.spinnerBar:nth-child(8) {
transform: rotate(315deg);
animation-delay: -0.4s;
}
@keyframes spinner-fade {
0% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}

View File

@@ -0,0 +1,17 @@
.videoMain {
max-width: 100%;
overflow: hidden;
object-fit: cover;
position: relative;
width: 100%;
justify-content: center;
align-items: center;
// max-height: 757px;
}
.processing {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
z-index: 3;
}

View File

@@ -0,0 +1,5 @@
export {
VideoPlayer as VideoPlayerSsr,
type IVideoPlayerProps,
default,
} from "./video-player";

View File

@@ -0,0 +1,22 @@
export const gcd = (a: number, b: number): number => {
return b === 0 ? a : gcd(b, a % b);
};
export const numWord = (value: number, words: [string, string, string]) => {
const normalized = Math.abs(value) % 100;
const remainder = normalized % 10;
if (normalized > 10 && normalized < 20) {
return words[2];
}
if (remainder > 1 && remainder < 5) {
return words[1];
}
if (remainder === 1) {
return words[0];
}
return words[2];
};

View File

@@ -0,0 +1,3 @@
import type { HTMLAttributes } from "react";
export interface IBaseComponentProps extends HTMLAttributes<HTMLDivElement> {}

View File

@@ -0,0 +1,7 @@
import React, { type HTMLAttributes } from "react";
const ContentSkeleton = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => {
return <div className={className} aria-busy="true" {...props} />;
};
export default ContentSkeleton;

View File

@@ -0,0 +1,695 @@
@import "./components/video-js/default.css";
@import "./components/with-blur/with-blur.css";
.video-js {
&.vjs-tach-skin {
font-family: "Inter" !important;
&.vjs-disable-rewind {
.vjs-control-bar {
.vjs-progress-control {
display: none !important;
}
}
}
&.vjs-fluid:not(.vjs-audio-only-mode) {
height: 100%;
}
&.vjs-mobile-ui {
.vjs-control-bar {
padding: 8px 12px 8px 12px;
}
.vjs-volume-panel {
justify-content: end;
width: 24px;
.vjs-volume-control {
display: none;
}
&.vjs-hover {
width: 24px;
.vjs-volume-control {
display: none;
}
}
}
&.vjs-controls-disabled {
&.vjs-playing {
/* .vjs-volume-panel {
display: block;
position: absolute;
right: 0px;
bottom: -15px;
z-index: 1000;
width: 60px;
height: 60px;
.vjs-mute-control {
.vjs-icon-placeholder {
&::before {
fill: #1d1d1d;
}
}
}
} */
}
}
/* .vjs-big-play-button {
display: block !important;
} */
}
.vjs-poster img {
object-fit: cover;
}
.vjs-error-display {
display: none !important;
}
.vjs-menu {
right: 0;
left: auto;
width: 358px;
.vjs-menu-content {
border-radius: var(--corner-M, 12px);
background: var(--Default-BgLight);
/* white theme/z100 */
box-shadow: var(--Shadow-Z100);
max-height: 30vh;
.vjs-menu-item {
text-transform: none;
justify-content: flex-start;
align-items: center;
text-align: center;
box-sizing: border-box;
padding: var(--corner-M, 12px) 16px;
font-family: "Inter" !important;
color: var(--Text-Primary, #1d1d1d);
/* app/regular/Body */
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
width: 100%;
display: grid;
grid-template-columns: 24px auto auto;
column-gap: var(--corner-M, 12px);
&:hover {
background: var(--Controls-Plashes);
}
&::before {
width: 24px;
height: 24px;
content: "";
}
&.vjs-selected {
background-color: var(--Default-BgDarken);
&::before {
/* video_settings_selected.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M18.2829 8.50263L10.1949 16.5401C10.0599 16.6745 9.87799 16.7498 9.68853 16.7498C9.49907 16.7498 9.3172 16.6745 9.18213 16.5401L5.71585 13.0958C5.64822 13.0285 5.59435 12.9484 5.55732 12.8601C5.52029 12.7717 5.50082 12.6769 5.50003 12.5809C5.49923 12.4849 5.51712 12.3898 5.55268 12.3008C5.58824 12.2118 5.64077 12.1308 5.70727 12.0624C5.77377 11.994 5.85294 11.9395 5.94026 11.902C6.02758 11.8645 6.12134 11.8448 6.21619 11.844C6.31103 11.8432 6.4051 11.8613 6.49303 11.8973C6.58096 11.9333 6.66102 11.9864 6.72865 12.0537L9.68853 14.9947L17.271 7.45959C17.4077 7.3237 17.5922 7.24832 17.7838 7.25003C17.8787 7.25088 17.9725 7.27063 18.0598 7.30815C18.1472 7.34567 18.2264 7.40024 18.2929 7.46872C18.3594 7.53721 18.4119 7.61828 18.4474 7.7073C18.483 7.79633 18.5008 7.89156 18.5 7.98757C18.4991 8.08357 18.4796 8.17847 18.4425 8.26685C18.4054 8.35522 18.3506 8.43534 18.2829 8.50263Z' fill='%235152BA' /%3E%3C/svg%3E");
}
}
&.settings-back {
border-bottom: 1px solid var(--Default-StrokeDividers, #e0e0e0);
color: var(--Text-Primary, #1d1d1d);
/* app/bold/Body */
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 20px; /* 142.857% */
&::before {
/* video_settings_back.svg */
content: "";
display: inline-block;
width: 24px;
height: 24px;
/*
SVG используется как маска: внутри SVG fill="white", поэтому белые пиксели
вырезают форму иконки; цвет «заливки» задаётся через background-color.
*/
background-color: var(--Controls-Primary);
/* Для WebKit (Chrome, Safari и т.п.) */
-webkit-mask: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22white%22%3E%3Cpath%20d%3D%22M2%2011.9947C2%2012.2584%202.11847%2012.501%202.33387%2012.7014L9.4852%2019.6942C9.7006%2019.8946%209.92677%2019.9895%2010.1853%2019.9895C10.713%2019.9895%2011.133%2019.6098%2011.133%2019.0824C11.133%2018.8293%2011.0361%2018.5762%2010.8638%2018.4179L8.45127%2016.0133L4.18633%2012.2057L3.96015%2012.7225L7.42811%2012.9334H21.0523C21.6123%2012.9334%2022%2012.5432%2022%2011.9947C22%2011.4463%2021.6123%2011.056%2021.0523%2011.056H7.42811L3.96015%2011.267L4.18633%2011.7944L8.45127%207.97628L10.8638%205.57152C11.0361%205.40277%2011.133%205.16019%2011.133%204.90705C11.133%204.3797%2010.713%204%2010.1853%204C9.92677%204%209.7006%204.08438%209.46365%204.31641L2.33387%2011.2881C2.11847%2011.4885%202%2011.7311%202%2011.9947Z%22%2F%3E%3C%2Fsvg%3E")
no-repeat center/contain;
/* Для остальных браузеров (Firefox, Edge и т.п.) */
mask: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22white%22%3E%3Cpath%20d%3D%22M2%2011.9947C2%2012.2584%202.11847%2012.501%202.33387%2012.7014L9.4852%2019.6942C9.7006%2019.8946%209.92677%2019.9895%2010.1853%2019.9895C10.713%2019.9895%2011.133%2019.6098%2011.133%2019.0824C11.133%2018.8293%2011.0361%2018.5762%2010.8638%2018.4179L8.45127%2016.0133L4.18633%2012.2057L3.96015%2012.7225L7.42811%2012.9334H21.0523C21.6123%2012.9334%2022%2012.5432%2022%2011.9947C22%2011.4463%2021.6123%2011.056%2021.0523%2011.056H7.42811L3.96015%2011.267L4.18633%2011.7944L8.45127%207.97628L10.8638%205.57152C11.0361%205.40277%2011.133%205.16019%2011.133%204.90705C11.133%204.3797%2010.713%204%2010.1853%204C9.92677%204%209.7006%204.08438%209.46365%204.31641L2.33387%2011.2881C2.11847%2011.4885%202%2011.7311%202%2011.9947Z%22%2F%3E%3C%2Fsvg%3E")
no-repeat center/contain;
}
}
&.audio-selector {
&::before {
/* video_settings_audio.svg */
content: "";
display: inline-block;
width: 24px;
height: 24px;
/*
Используем SVG как маску: внутри SVG fill="white", чтобы маска
вырезала форму иконки; цвет заливается через background-color.
*/
background-color: var(--Controls-Primary);
/* Для WebKit (Chrome, Safari и т.п.) */
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 0.5C10.3729 0.500018 10.7324 0.63892 11.0084 0.889619C11.2844 1.14032 11.4572 1.48484 11.493 1.856L11.5 2V18C11.4998 18.3852 11.3514 18.7556 11.0856 19.0344C10.8198 19.3132 10.4569 19.479 10.0721 19.4975C9.68731 19.516 9.31017 19.3858 9.0188 19.1338C8.72743 18.8818 8.54417 18.5274 8.507 18.144L8.5 18V2C8.5 1.60218 8.65804 1.22064 8.93934 0.93934C9.22064 0.658035 9.60218 0.5 10 0.5ZM6 3.5C6.39782 3.5 6.77936 3.65804 7.06066 3.93934C7.34196 4.22064 7.5 4.60218 7.5 5V15C7.5 15.3978 7.34196 15.7794 7.06066 16.0607C6.77936 16.342 6.39782 16.5 6 16.5C5.60218 16.5 5.22064 16.342 4.93934 16.0607C4.65804 15.7794 4.5 15.3978 4.5 15V5C4.5 4.60218 4.65804 4.22064 4.93934 3.93934C5.22064 3.65804 5.60218 3.5 6 3.5ZM14 3.5C14.3978 3.5 14.7794 3.65804 15.0607 3.93934C15.342 4.22064 15.5 4.60218 15.5 5V15C15.5 15.3978 15.342 15.7794 15.0607 16.0607C14.7794 16.342 14.3978 16.5 14 16.5C13.6022 16.5 13.2206 16.342 12.9393 16.0607C12.658 15.7794 12.5 15.3978 12.5 15V5C12.5 4.60218 12.658 4.22064 12.9393 3.93934C13.2206 3.65804 13.6022 3.5 14 3.5ZM2 6.5C2.39782 6.5 2.77936 6.65804 3.06066 6.93934C3.34196 7.22064 3.5 7.60218 3.5 8V12C3.5 12.3978 3.34196 12.7794 3.06066 13.0607C2.77936 13.342 2.39782 13.5 2 13.5C1.60218 13.5 1.22064 13.342 0.93934 13.0607C0.658035 12.7794 0.5 12.3978 0.5 12V8C0.5 7.60218 0.658035 7.22064 0.93934 6.93934C1.22064 6.65804 1.60218 6.5 2 6.5ZM18 6.5C18.3729 6.50002 18.7324 6.63892 19.0084 6.88962C19.2844 7.14032 19.4572 7.48484 19.493 7.856L19.5 8V12C19.4998 12.3852 19.3514 12.7556 19.0856 13.0344C18.8198 13.3132 18.4569 13.479 18.0721 13.4975C17.6873 13.516 17.3102 13.3858 17.0188 13.1338C16.7274 12.8818 16.5442 12.5274 16.507 12.144L16.5 12V8C16.5 7.60218 16.658 7.22064 16.9393 6.93934C17.2206 6.65804 17.6022 6.5 18 6.5Z" fill="white"/></svg>')
no-repeat center/contain;
/* Для остальных браузеров (Firefox, Edge и т.п.) */
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 0.5C10.3729 0.500018 10.7324 0.63892 11.0084 0.889619C11.2844 1.14032 11.4572 1.48484 11.493 1.856L11.5 2V18C11.4998 18.3852 11.3514 18.7556 11.0856 19.0344C10.8198 19.3132 10.4569 19.479 10.0721 19.4975C9.68731 19.516 9.31017 19.3858 9.0188 19.1338C8.72743 18.8818 8.54417 18.5274 8.507 18.144L8.5 18V2C8.5 1.60218 8.65804 1.22064 8.93934 0.93934C9.22064 0.658035 9.60218 0.5 10 0.5ZM6 3.5C6.39782 3.5 6.77936 3.65804 7.06066 3.93934C7.34196 4.22064 7.5 4.60218 7.5 5V15C7.5 15.3978 7.34196 15.7794 7.06066 16.0607C6.77936 16.342 6.39782 16.5 6 16.5C5.60218 16.5 5.22064 16.342 4.93934 16.0607C4.65804 15.7794 4.5 15.3978 4.5 15V5C4.5 4.60218 4.65804 4.22064 4.93934 3.93934C5.22064 3.65804 5.60218 3.5 6 3.5ZM14 3.5C14.3978 3.5 14.7794 3.65804 15.0607 3.93934C15.342 4.22064 15.5 4.60218 15.5 5V15C15.5 15.3978 15.342 15.7794 15.0607 16.0607C14.7794 16.342 14.3978 16.5 14 16.5C13.6022 16.5 13.2206 16.342 12.9393 16.0607C12.658 15.7794 12.5 15.3978 12.5 15V5C12.5 4.60218 12.658 4.22064 12.9393 3.93934C13.2206 3.65804 13.6022 3.5 14 3.5ZM2 6.5C2.39782 6.5 2.77936 6.65804 3.06066 6.93934C3.34196 7.22064 3.5 7.60218 3.5 8V12C3.5 12.3978 3.34196 12.7794 3.06066 13.0607C2.77936 13.342 2.39782 13.5 2 13.5C1.60218 13.5 1.22064 13.342 0.93934 13.0607C0.658035 12.7794 0.5 12.3978 0.5 12V8C0.5 7.60218 0.658035 7.22064 0.93934 6.93934C1.22064 6.65804 1.60218 6.5 2 6.5ZM18 6.5C18.3729 6.50002 18.7324 6.63892 19.0084 6.88962C19.2844 7.14032 19.4572 7.48484 19.493 7.856L19.5 8V12C19.4998 12.3852 19.3514 12.7556 19.0856 13.0344C18.8198 13.3132 18.4569 13.479 18.0721 13.4975C17.6873 13.516 17.3102 13.3858 17.0188 13.1338C16.7274 12.8818 16.5442 12.5274 16.507 12.144L16.5 12V8C16.5 7.60218 16.658 7.22064 16.9393 6.93934C17.2206 6.65804 17.6022 6.5 18 6.5Z" fill="white"/></svg>')
no-repeat center/contain;
}
}
&.subs-selector {
&::before {
/* video_settings_subs.svg */
content: "";
display: inline-block;
width: 26px;
height: 24px;
/*
Используем SVG как маску: внутри SVG fill="white", чтобы маска
вырезала форму иконки; цвет заливается через background-color.
*/
background-color: var(--Controls-Primary);
/* Для WebKit (Chrome, Safari и т.п.) */
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="26" height="24" viewBox="0 0 26 24" fill="white"><path d="M4.34087 19.3668H20.1257C21.6328 19.3668 22.4666 18.5731 22.4666 17.058V10.5083L21.1759 11.799V17.1221C21.1759 17.7875 20.7991 18.1643 20.1337 18.1643H4.33286C3.66747 18.1643 3.29069 17.7875 3.29069 17.1221V10.3801C3.29069 9.71469 3.66747 9.33791 4.33286 9.33791H17.4L18.6105 8.1354H4.34087C2.83373 8.1354 2 8.92906 2 10.4442V17.058C2 18.5731 2.83373 19.3668 4.34087 19.3668ZM6.25686 14.8934C6.89018 14.8934 7.40324 14.3804 7.40324 13.747C7.40324 13.1138 6.89018 12.6007 6.25686 12.6007C5.62354 12.6007 5.11048 13.1138 5.11048 13.747C5.11048 14.3804 5.62354 14.8934 6.25686 14.8934ZM9.71202 14.8934C10.3454 14.8934 10.8584 14.3804 10.8584 13.747C10.8584 13.1138 10.3454 12.6007 9.71202 12.6007C9.07876 12.6007 8.56566 13.1138 8.56566 13.747C8.56566 14.3804 9.07876 14.8934 9.71202 14.8934ZM13.1672 14.8934C13.8006 14.8934 14.3136 14.3804 14.3136 13.747C14.3136 13.1138 13.8006 12.6007 13.1672 12.6007C12.5339 12.6007 12.0208 13.1138 12.0208 13.747C12.0208 14.3804 12.5339 14.8934 13.1672 14.8934ZM15.8929 14.1639L17.5283 13.4424L24.591 6.38777L23.4607 5.27345L16.414 12.3281L15.6444 13.9074C15.5722 14.0437 15.7406 14.2361 15.8929 14.1639ZM25.1842 5.78652L25.7855 5.17725C26.0661 4.88063 26.0741 4.48782 25.7935 4.21525L25.6011 4.02285C25.3446 3.76632 24.9438 3.79037 24.6712 4.06293L24.0699 4.65617L25.1842 5.78652Z"/></svg>')
no-repeat center/contain;
/* Для остальных браузеров (Firefox, Edge и т.п.) */
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="26" height="24" viewBox="0 0 26 24" fill="white"><path d="M4.34087 19.3668H20.1257C21.6328 19.3668 22.4666 18.5731 22.4666 17.058V10.5083L21.1759 11.799V17.1221C21.1759 17.7875 20.7991 18.1643 20.1337 18.1643H4.33286C3.66747 18.1643 3.29069 17.7875 3.29069 17.1221V10.3801C3.29069 9.71469 3.66747 9.33791 4.33286 9.33791H17.4L18.6105 8.1354H4.34087C2.83373 8.1354 2 8.92906 2 10.4442V17.058C2 18.5731 2.83373 19.3668 4.34087 19.3668ZM6.25686 14.8934C6.89018 14.8934 7.40324 14.3804 7.40324 13.747C7.40324 13.1138 6.89018 12.6007 6.25686 12.6007C5.62354 12.6007 5.11048 13.1138 5.11048 13.747C5.11048 14.3804 5.62354 14.8934 6.25686 14.8934ZM9.71202 14.8934C10.3454 14.8934 10.8584 14.3804 10.8584 13.747C10.8584 13.1138 10.3454 12.6007 9.71202 12.6007C9.07876 12.6007 8.56566 13.1138 8.56566 13.747C8.56566 14.3804 9.07876 14.8934 9.71202 14.8934ZM13.1672 14.8934C13.8006 14.8934 14.3136 14.3804 14.3136 13.747C14.3136 13.1138 13.8006 12.6007 13.1672 12.6007C12.5339 12.6007 12.0208 13.1138 12.0208 13.747C12.0208 14.3804 12.5339 14.8934 13.1672 14.8934ZM15.8929 14.1639L17.5283 13.4424L24.591 6.38777L23.4607 5.27345L16.414 12.3281L15.6444 13.9074C15.5722 14.0437 15.7406 14.2361 15.8929 14.1639ZM25.1842 5.78652L25.7855 5.17725C26.0661 4.88063 26.0741 4.48782 25.7935 4.21525L25.6011 4.02285C25.3446 3.76632 24.9438 3.79037 24.6712 4.06293L24.0699 4.65617L25.1842 5.78652Z"/></svg>')
no-repeat center/contain;
}
}
&.quality-selector {
&::before {
/* video_settings_quality.svg */
content: "";
display: inline-block;
width: 24px;
height: 24px;
/*
Используем SVG как маску: внутри SVG fill="white", чтобы маска
вырезала форму иконки; цвет заливается через background-color.
*/
background-color: var(--Controls-Primary);
/* Для WebKit (Chrome, Safari и прочие) */
-webkit-mask: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M8%2011.953C9.16519%2011.5411%2010%2010.4299%2010%209.12366C10%207.81744%209.16519%206.7062%208%206.29436V4C8%203.44772%207.55228%203%207%203C6.44772%203%206%203.44772%206%204V6.29436C4.83481%206.7062%204%207.81744%204%209.12366C4%2010.4299%204.83481%2011.5411%206%2011.953L6%2020C6%2020.5523%206.44772%2021%207%2021C7.55228%2021%208%2020.5523%208%2020L8%2011.953Z%22%20fill%3D%22white%22/%3E%3Cpath%20d%3D%22M18%2012.1707C19.1652%2012.5825%2020%2013.6938%2020%2015C20%2016.3062%2019.1652%2017.4175%2018%2017.8293V20C18%2020.5523%2017.5523%2021%2017%2021C16.4477%2021%2016%2020.5523%2016%2020V17.8293C14.8348%2017.4175%2014%2016.3062%2014%2015C14%2013.6938%2014.8348%2012.5825%2016%2012.1707V4C16%203.44772%2016.4477%203%2017%203C17.5523%203%2018%203.44772%2018%204V12.1707Z%22%20fill%3D%22white%22/%3E%3C/svg%3E")
no-repeat center/contain;
/* Для остальных браузеров (Firefox, Edge и т.п.) */
mask: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M8%2011.953C9.16519%2011.5411%2010%2010.4299%2010%209.12366C10%207.81744%209.16519%206.7062%208%206.29436V4C8%203.44772%207.55228%203%207%203C6.44772%203%206%203.44772%206%204V6.29436C4.83481%206.7062%204%207.81744%204%209.12366C4%2010.4299%204.83481%2011.5411%206%2011.953L6%2020C6%2020.5523%206.44772%2021%207%2021C7.55228%2021%208%2020.5523%208%2020L8%2011.953Z%22%20fill%3D%22white%22/%3E%3Cpath%20d%3D%22M18%2012.1707C19.1652%2012.5825%2020%2013.6938%2020%2015C20%2016.3062%2019.1652%2017.4175%2018%2017.8293V20C18%2020.5523%2017.5523%2021%2017%2021C16.4477%2021%2016%2020.5523%2016%2020V17.8293C14.8348%2017.4175%2014%2016.3062%2014%2015C14%2013.6938%2014.8348%2012.5825%2016%2012.1707V4C16%203.44772%2016.4477%203%2017%203C17.5523%203%2018%203.44772%2018%204V12.1707Z%22%20fill%3D%22white%22/%3E%3C/svg%3E")
no-repeat center/contain;
}
}
&.speed-selector {
&::before {
/* video_settings_speed.svg */
content: "";
display: inline-block;
width: 24px;
height: 24px;
/* ИКОНИЯ.ИСПОЛЬЗУЕМ MASK+BACKGROUND-COLOR, чтобы точно получить нужный цвет */
background-color: var(--Controls-Primary);
-webkit-mask: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M9.608 1.517C10.378 1.342 11.178 1.25 12 1.25C17.937 1.25 22.75 6.063 22.75 12C22.75 17.937 17.937 22.75 12 22.75C11.178 22.75 10.378 22.658 9.608 22.483C9.51194 22.4612 9.42111 22.4207 9.3407 22.3638C9.2603 22.3069 9.19188 22.2347 9.13937 22.1514C9.08686 22.068 9.05128 21.9752 9.03466 21.8781C9.01804 21.781 9.0207 21.6816 9.0425 21.5855C9.0643 21.4894 9.10481 21.3986 9.16171 21.3182C9.21861 21.2378 9.29079 21.1694 9.37413 21.1169C9.45747 21.0644 9.55034 21.0288 9.64743 21.0122C9.74452 20.9955 9.84394 20.9982 9.94 21.02C11.2944 21.3278 12.7007 21.3266 14.0545 21.0166C15.4084 20.7066 16.6751 20.0957 17.7606 19.2292C18.846 18.3628 19.7225 17.263 20.3248 16.0115C20.9271 14.76 21.2399 13.3889 21.2399 12C21.2399 10.6111 20.9271 9.24002 20.3248 7.98852C19.7225 6.73702 18.846 5.63722 17.7606 4.77075C16.6751 3.90428 15.4084 3.29337 14.0545 2.98336C12.7007 2.67335 11.2944 2.6722 9.94 2.98C9.74599 3.02403 9.54245 2.98918 9.37413 2.88313C9.20582 2.77708 9.08653 2.60851 9.0425 2.4145C8.99847 2.22049 9.03332 2.01695 9.13937 1.84863C9.24542 1.68032 9.41399 1.56103 9.608 1.517ZM7.314 3.132C7.4199 3.30029 7.45463 3.50375 7.41056 3.69765C7.3665 3.89155 7.24724 4.06001 7.079 4.166C5.90234 4.90731 4.90691 5.90308 4.166 7.08C4.05991 7.24841 3.89127 7.36778 3.69717 7.41186C3.50307 7.45593 3.29941 7.42109 3.131 7.315C2.96259 7.20891 2.84322 7.04027 2.79914 6.84617C2.75507 6.65207 2.78991 6.44841 2.896 6.28C3.75686 4.91349 4.91324 3.75746 6.28 2.897C6.36337 2.84441 6.45629 2.80877 6.55345 2.79211C6.6506 2.77546 6.75009 2.77812 6.84621 2.79995C6.94234 2.82177 7.03322 2.86234 7.11366 2.91931C7.1941 2.97629 7.26152 3.04856 7.314 3.132ZM2.98 9.94C3.02403 9.74599 2.98918 9.54245 2.88313 9.37413C2.77708 9.20582 2.60851 9.08653 2.4145 9.0425C2.22049 8.99847 2.01695 9.03332 1.84863 9.13937C1.68032 9.24542 1.56103 9.41399 1.517 9.608C1.342 10.378 1.25 11.178 1.25 12C1.25 12.822 1.342 13.622 1.517 14.393C1.56116 14.587 1.68058 14.7555 1.84898 14.8615C2.01739 14.9674 2.22099 15.0022 2.415 14.958C2.60901 14.9138 2.77752 14.7944 2.88348 14.626C2.98944 14.4576 3.02416 14.254 2.98 14.06C2.82681 13.384 2.74967 12.6931 2.75 12C2.75 11.291 2.83 10.602 2.98 9.94ZM3.132 16.686C3.30029 16.5801 3.50375 16.5454 3.69765 16.5894C3.89155 16.6335 4.06001 16.7528 4.166 16.921C4.90703 18.0976 5.90244 19.093 7.079 19.834C7.16239 19.8865 7.23462 19.955 7.29156 20.0354C7.3485 20.1159 7.38903 20.2067 7.41086 20.3028C7.43268 20.3989 7.43536 20.4984 7.41874 20.5955C7.40212 20.6927 7.36653 20.7856 7.314 20.869C7.26147 20.9524 7.19303 21.0246 7.11259 21.0816C7.03215 21.1385 6.94128 21.179 6.84517 21.2009C6.65107 21.2449 6.44741 21.2101 6.279 21.104C4.91296 20.2433 3.75729 19.0873 2.897 17.721C2.84441 17.6376 2.80877 17.5447 2.79211 17.4476C2.77546 17.3504 2.77812 17.2509 2.79995 17.1548C2.82177 17.0587 2.86234 16.9678 2.91931 16.8873C2.97629 16.8069 3.04856 16.7385 3.132 16.686Z' fill='white'/%3E%3Cpath d='M15.414 10.9419C16.195 11.4039 16.195 12.5979 15.414 13.0599L10.694 15.8469C9.934 16.2949 9 15.7109 9 14.7869V9.21494C9 8.29094 9.934 7.70794 10.694 8.15594L15.414 10.9419Z' fill='white'/%3E%3C/svg%3E")
no-repeat center/contain;
mask: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M9.608 1.517C10.378 1.342 11.178 1.25 12 1.25C17.937 1.25 22.75 6.063 22.75 12C22.75 17.937 17.937 22.75 12 22.75C11.178 22.75 10.378 22.658 9.608 22.483C9.51194 22.4612 9.42111 22.4207 9.3407 22.3638C9.2603 22.3069 9.19188 22.2347 9.13937 22.1514C9.08686 22.068 9.05128 21.9752 9.03466 21.8781C9.01804 21.781 9.0207 21.6816 9.0425 21.5855C9.0643 21.4894 9.10481 21.3986 9.16171 21.3182C9.21861 21.2378 9.29079 21.1694 9.37413 21.1169C9.45747 21.0644 9.55034 21.0288 9.64743 21.0122C9.74452 20.9955 9.84394 20.9982 9.94 21.02C11.2944 21.3278 12.7007 21.3266 14.0545 21.0166C15.4084 20.7066 16.6751 20.0957 17.7606 19.2292C18.846 18.3628 19.7225 17.263 20.3248 16.0115C20.9271 14.76 21.2399 13.3889 21.2399 12C21.2399 10.6111 20.9271 9.24002 20.3248 7.98852C19.7225 6.73702 18.846 5.63722 17.7606 4.77075C16.6751 3.90428 15.4084 3.29337 14.0545 2.98336C12.7007 2.67335 11.2944 2.6722 9.94 2.98C9.74599 3.02403 9.54245 2.98918 9.37413 2.88313C9.20582 2.77708 9.08653 2.60851 9.0425 2.4145C8.99847 2.22049 9.03332 2.01695 9.13937 1.84863C9.24542 1.68032 9.41399 1.56103 9.608 1.517ZM7.314 3.132C7.4199 3.30029 7.45463 3.50375 7.41056 3.69765C7.3665 3.89155 7.24724 4.06001 7.079 4.166C5.90234 4.90731 4.90691 5.90308 4.166 7.08C4.05991 7.24841 3.89127 7.36778 3.69717 7.41186C3.50307 7.45593 3.29941 7.42109 3.131 7.315C2.96259 7.20891 2.84322 7.04027 2.79914 6.84617C2.75507 6.65207 2.78991 6.44841 2.896 6.28C3.75686 4.91349 4.91324 3.75746 6.28 2.897C6.36337 2.84441 6.45629 2.80877 6.55345 2.79211C6.6506 2.77546 6.75009 2.77812 6.84621 2.79995C6.94234 2.82177 7.03322 2.86234 7.11366 2.91931C7.1941 2.97629 7.26152 3.04856 7.314 3.132ZM2.98 9.94C3.02403 9.74599 2.98918 9.54245 2.88313 9.37413C2.77708 9.20582 2.60851 9.08653 2.4145 9.0425C2.22049 8.99847 2.01695 9.03332 1.84863 9.13937C1.68032 9.24542 1.56103 9.41399 1.517 9.608C1.342 10.378 1.25 11.178 1.25 12C1.25 12.822 1.342 13.622 1.517 14.393C1.56116 14.587 1.68058 14.7555 1.84898 14.8615C2.01739 14.9674 2.22099 15.0022 2.415 14.958C2.60901 14.9138 2.77752 14.7944 2.88348 14.626C2.98944 14.4576 3.02416 14.254 2.98 14.06C2.82681 13.384 2.74967 12.6931 2.75 12C2.75 11.291 2.83 10.602 2.98 9.94ZM3.132 16.686C3.30029 16.5801 3.50375 16.5454 3.69765 16.5894C3.89155 16.6335 4.06001 16.7528 4.166 16.921C4.90703 18.0976 5.90244 19.093 7.079 19.834C7.16239 19.8865 7.23462 19.955 7.29156 20.0354C7.3485 20.1159 7.38903 20.2067 7.41086 20.3028C7.43268 20.3989 7.43536 20.4984 7.41874 20.5955C7.40212 20.6927 7.36653 20.7856 7.314 20.869C7.26147 20.9524 7.19303 21.0246 7.11259 21.0816C7.03215 21.1385 6.94128 21.179 6.84517 21.2009C6.65107 21.2449 6.44741 21.2101 6.279 21.104C4.91296 20.2433 3.75729 19.0873 2.897 17.721C2.84441 17.6376 2.80877 17.5447 2.79211 17.4476C2.77546 17.3504 2.77812 17.2509 2.79995 17.1548C2.82177 17.0587 2.86234 16.9678 2.91931 16.8873C2.97629 16.8069 3.04856 16.7385 3.132 16.686Z' fill='white'/%3E%3Cpath d='M15.414 10.9419C16.195 11.4039 16.195 12.5979 15.414 13.0599L10.694 15.8469C9.934 16.2949 9 15.7109 9 14.7869V9.21494C9 8.29094 9.934 7.70794 10.694 8.15594L15.414 10.9419Z' fill='white'/%3E%3C/svg%3E")
no-repeat center/contain;
}
}
}
}
}
.vjs-control {
width: 24px;
}
.vjs-big-play-button {
width: 48px;
height: 48px;
border-radius: 40px;
border: none;
margin-top: -24px;
margin-left: -24px;
padding: var(--corner-M, 12px);
justify-content: center;
align-items: center;
background: var(--Opacity-BlackOpacity45, rgba(0, 0, 0, 0.45));
box-shadow: var(--Shadow-Z100);
transition: opacity 1s !important;
.vjs-icon-placeholder {
&::before {
/* play_filled.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M18.8579 13.5611C19.0962 13.3766 19.2892 13.1401 19.4219 12.8694C19.5546 12.5989 19.6237 12.3014 19.6237 12C19.6237 11.6986 19.5546 11.4011 19.4219 11.1306C19.2892 10.8599 19.0962 10.6234 18.8579 10.4389C15.7707 8.05097 12.324 6.1684 8.64648 4.86153L7.97406 4.62261C6.68884 4.16642 5.33056 5.03556 5.15656 6.36293C4.67047 10.1053 4.67047 13.8947 5.15656 17.6371C5.33161 18.9644 6.68884 19.8336 7.97406 19.3774L8.64648 19.1385C12.324 17.8316 15.7707 15.949 18.8579 13.5611Z' fill='white'/%3E%3C/svg%3E");
}
}
}
.vjs-slider {
background-color: rgba(255, 255, 255, 0.25);
height: 5px;
.vjs-play-progress {
&::before {
content: "";
}
}
}
.vjs-control-bar {
visibility: visible !important;
transition: opacity 1s !important;
pointer-events: none !important;
opacity: 0;
height: min-content;
padding: 16px 24px 16px 24px;
background: var(
--Opacity-VideoPlayerLinerBottom,
linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.4) 100%)
);
display: grid;
grid-template-columns:
24px min-content min-content min-content auto minmax(24px, 124px)
24px 24px 24px;
justify-items: flex-end;
row-gap: 12px;
column-gap: 16px;
grid-template-rows: 5px 24px;
grid-template-areas:
"progress progress progress progress progress progress progress progress progress"
"playControl remainingTime timeDivider duration . volumePanel settings pictureInPicture fullscreen";
/* .vjs-menu-button {
&.vjs-hover {
.vjs-menu {
display: none;
}
}
.vjs-menu {
}
} */
.vjs-progress-control {
width: 100%;
border-radius: 5px;
box-sizing: border-box;
overflow: hidden;
grid-area: progress;
.vjs-progress-holder {
margin: 0;
}
}
.vjs-play-control {
grid-area: playControl;
.vjs-icon-placeholder {
&::before {
/* play_filled.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M18.8579 13.5611C19.0962 13.3766 19.2892 13.1401 19.4219 12.8694C19.5546 12.5989 19.6237 12.3014 19.6237 12C19.6237 11.6986 19.5546 11.4011 19.4219 11.1306C19.2892 10.8599 19.0962 10.6234 18.8579 10.4389C15.7707 8.05097 12.324 6.1684 8.64648 4.86153L7.97406 4.62261C6.68884 4.16642 5.33056 5.03556 5.15656 6.36293C4.67047 10.1053 4.67047 13.8947 5.15656 17.6371C5.33161 18.9644 6.68884 19.8336 7.97406 19.3774L8.64648 19.1385C12.324 17.8316 15.7707 15.949 18.8579 13.5611Z' fill='white'/%3E%3C/svg%3E");
}
}
&.vjs-playing {
.vjs-icon-placeholder {
&::before {
/* pause_filled.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M5.5 6.5C5.5 5.39543 6.39543 4.5 7.5 4.5H10.5V19.5H7.5C6.39543 19.5 5.5 18.6046 5.5 17.5V6.5Z' fill='white'/%3E%3Cpath d='M13.5 4.5H16.5C17.6046 4.5 18.5 5.39543 18.5 6.5V17.5C18.5 18.6046 17.6046 19.5 16.5 19.5H13.5V4.5Z' fill='white'/%3E%3C/svg%3E");
}
}
}
}
.vjs-volume-panel {
grid-area: volumePanel;
width: 100%;
margin-left: 0;
margin-right: 0;
@media (max-width: 575px) {
width: 24px;
.vjs-volume-control {
display: none;
}
}
.vjs-volume-control {
width: calc(100% - 24px);
margin-left: 4px;
height: 24px;
align-items: center;
opacity: 1;
visibility: visible;
}
&.vjs-hover {
width: 100%;
@media (max-width: 575px) {
width: 24px;
.vjs-volume-control {
display: none;
}
}
.vjs-volume-control {
width: calc(100% - 24px);
height: 24px;
align-items: center;
}
}
.vjs-volume-control {
height: 24px;
align-items: center;
.vjs-volume-bar {
width: 100px;
border: 5px;
.vjs-volume-level {
height: 5px;
border-radius: 5px;
&::before {
font-size: 16px;
}
}
}
}
.vjs-mute-control {
.vjs-icon-placeholder {
&::before {
/* volume_fill.svg */
content: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M18.7042%207.00426C19.2538%206.94971%2019.7436%207.35102%2019.7981%207.9006C20.0686%2010.6264%2020.0686%2013.3723%2019.7981%2016.0981C19.7436%2016.6477%2019.2538%2017.049%2018.7042%2016.9945C18.1546%2016.9399%2017.7533%2016.4502%2017.8079%2015.9006C18.0654%2013.3062%2018.0654%2010.6926%2017.8079%208.09813C17.7533%207.54855%2018.1546%207.0588%2018.7042%207.00426Z%22%20fill%3D%22white%22/%3E%3Cpath%20d%3D%22M12.525%206.47437C12.402%205.77037%2011.588%205.45537%2011.005%205.86937L8.52%207.63137C8.18178%207.87096%207.77748%207.99955%207.363%207.99937H5C4.46957%207.99937%203.96086%208.21008%203.58579%208.58515C3.21071%208.96023%203%209.46893%203%209.99937V13.9994C3%2014.5298%203.21071%2015.0385%203.58579%2015.4136C3.96086%2015.7887%204.46957%2015.9994%205%2015.9994H7.363C7.77748%2015.9992%208.18178%2016.1278%208.52%2016.3674L11.005%2018.1294C11.588%2018.5434%2012.402%2018.2294%2012.525%2017.5244C13.1603%2013.8782%2013.1588%2010.1207%2012.525%206.47437Z%22%20fill%3D%22white%22/%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.5712%205.25756C11.5811%204.54084%2013.0412%205.07142%2013.2638%206.34529C13.9123%2010.0764%2013.9141%2013.9215%2013.2639%2017.6531C13.0413%2018.929%2011.5804%2019.4574%2010.5712%2018.7412L8.08646%2016.9794C7.87513%2016.8297%207.62228%2016.7493%207.36334%2016.7494H5C4.27065%2016.7494%203.57118%2016.4596%203.05546%2015.9439C2.53973%2015.4282%202.25%2014.7287%202.25%2013.9994V9.99937C2.25%209.27002%202.53973%208.57055%203.05546%208.05482C3.57118%207.5391%204.27065%207.24937%205%207.24937H7.363C7.62194%207.24948%207.87486%207.16918%208.08619%207.01956L10.5712%205.25756ZM11.7862%206.60345C11.7628%206.4695%2011.5953%206.37004%2011.4392%206.48087L8.95381%208.24318C8.48885%208.57255%207.93279%208.74955%207.363%208.74937H5C4.66848%208.74937%204.35054%208.88106%204.11612%209.11548C3.8817%209.34991%203.75%209.66785%203.75%209.99937V13.9994C3.75%2014.3309%203.8817%2014.6488%204.11612%2014.8833C4.35054%2015.1177%204.66848%2015.2494%205%2015.2494H7.36266C7.93246%2015.2492%208.48858%2015.426%208.95354%2015.7554L11.4388%2017.5176C11.5138%2017.5708%2011.5952%2017.5752%2011.6627%2017.5486C11.7275%2017.5231%2011.7725%2017.4736%2011.7861%2017.3956C12.4066%2013.8348%2012.4052%2010.165%2011.7862%206.60345Z%22%20fill%3D%22white%22/%3E%3C/svg%3E");
}
}
&.vjs-vol-0 {
.vjs-icon-placeholder {
&::before {
/* volume_spalsh_fill.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.5712 5.25756C11.5811 4.54084 13.0412 5.07142 13.2638 6.34529C13.9123 10.0764 13.9141 13.9215 13.2639 17.6531C13.0413 18.929 11.5804 19.4574 10.5712 18.7412L8.08646 16.9794C7.87513 16.8297 7.62228 16.7493 7.36334 16.7494H5C4.27065 16.7494 3.57118 16.4596 3.05546 15.9439C2.53973 15.4282 2.25 14.7287 2.25 13.9994V9.99937C2.25 9.27002 2.53973 8.57055 3.05546 8.05482C3.57118 7.5391 4.27065 7.24937 5 7.24937H7.363C7.62194 7.24948 7.87486 7.16918 8.08619 7.01956L10.5712 5.25756ZM11.7862 6.60345C11.7628 6.4695 11.5953 6.37004 11.4392 6.48087L8.95381 8.24318C8.48885 8.57255 7.93279 8.74955 7.363 8.74937H5C4.66848 8.74937 4.35054 8.88106 4.11612 9.11548C3.8817 9.34991 3.75 9.66785 3.75 9.99937V13.9994C3.75 14.3309 3.8817 14.6488 4.11612 14.8833C4.35054 15.1177 4.66848 15.2494 5 15.2494H7.36266C7.93246 15.2492 8.48858 15.426 8.95354 15.7554L11.4388 17.5176C11.5138 17.5708 11.5952 17.5752 11.6627 17.5486C11.7275 17.5231 11.7725 17.4736 11.7861 17.3956C12.4066 13.8348 12.4052 10.165 11.7862 6.60345Z' fill='white'/%3E%3Cpath d='M12.525 6.47437C12.402 5.77037 11.588 5.45537 11.005 5.86937L8.52 7.63137C8.18178 7.87096 7.77748 7.99955 7.363 7.99937H5C4.46957 7.99937 3.96086 8.21008 3.58579 8.58515C3.21071 8.96023 3 9.46893 3 9.99937V13.9994C3 14.5298 3.21071 15.0385 3.58579 15.4136C3.96086 15.7887 4.46957 15.9994 5 15.9994H7.363C7.77748 15.9992 8.18178 16.1278 8.52 16.3674L11.005 18.1294C11.588 18.5434 12.402 18.2294 12.525 17.5244C13.1603 13.8782 13.1588 10.1207 12.525 6.47437Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M17.5303 8.96904C17.2374 8.67614 16.7626 8.67614 16.4697 8.96904C16.1768 9.26193 16.1768 9.7368 16.4697 10.0297L18.4393 11.9994L16.4697 13.969C16.1768 14.2619 16.1768 14.7368 16.4697 15.0297C16.7626 15.3226 17.2374 15.3226 17.5303 15.0297L19.5 13.06L21.4697 15.0297C21.7626 15.3226 22.2374 15.3226 22.5303 15.0297C22.8232 14.7368 22.8232 14.2619 22.5303 13.969L20.5607 11.9994L22.5303 10.0297C22.8232 9.7368 22.8232 9.26193 22.5303 8.96904C22.2374 8.67614 21.7626 8.67614 21.4697 8.96904L19.5 10.9387L17.5303 8.96904ZM19.5 10.9387L18.4393 11.9994L19.5 13.06L20.5607 11.9994L19.5 10.9387Z' fill='white'/%3E%3Cpath d='M19.5 10.9387L18.4393 11.9994L19.5 13.06L20.5607 11.9994L19.5 10.9387Z' fill='white'/%3E%3C/svg%3E");
}
}
}
}
}
.vjs-remaining-time {
/* grid-area: remainingTime; */
display: none;
}
.vjs-current-time {
display: contents;
grid-area: remainingTime;
justify-self: flex-start;
.vjs-current-time-display {
font-size: 13px;
line-height: 16px;
align-content: center;
}
}
.vjs-time-divider {
display: block;
grid-area: timeDivider;
font-size: 13px;
line-height: 16px;
width: unset;
min-width: unset;
padding: 0;
align-content: center;
}
.vjs-duration {
grid-area: duration;
display: contents;
justify-self: flex-start;
.vjs-duration-display {
font-size: 13px;
line-height: 16px;
align-content: center;
}
}
/* .vjs-time-divider,
.vjs-duration {
margin-left: -16px;
} */
.vjs-picture-in-picture-control {
grid-area: pictureInPicture;
.vjs-icon-placeholder {
&::before {
/* picture-in-picture.svg */
content: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19.875 3C20.7701 3 21.6286 3.35558 22.2615 3.98851C22.8944 4.62145 23.25 5.47989 23.25 6.375V9.875C23.25 10.1734 23.1315 10.4595 22.9205 10.6705C22.7095 10.8815 22.4234 11 22.125 11C21.8266 11 21.5405 10.8815 21.3295 10.6705C21.1185 10.4595 21 10.1734 21 9.875V6.375C21 6.07663 20.8815 5.79048 20.6705 5.5795C20.4595 5.36853 20.1734 5.25 19.875 5.25H4.125C3.82663 5.25 3.54048 5.36853 3.3295 5.5795C3.11853 5.79048 3 6.07663 3 6.375V17.625C3 17.9234 3.11853 18.2095 3.3295 18.4205C3.54048 18.6315 3.82663 18.75 4.125 18.75H7H9.875C10.1734 18.75 10.4595 18.8685 10.6705 19.0795C10.8815 19.2905 11 19.5766 11 19.875C11 20.1734 10.8815 20.4595 10.6705 20.6705C10.4595 20.8815 10.1734 21 9.875 21H4.125C3.22989 21 2.37145 20.6444 1.73851 20.0115C1.10558 19.3786 0.75 18.5201 0.75 17.625V6.375C0.75 5.47989 1.10558 4.62145 1.73851 3.98851C2.37145 3.35558 3.22989 3 4.125 3H19.875Z' /%3E%3Cpath d='M21 13.125C21.5967 13.125 22.169 13.3621 22.591 13.784C23.0129 14.206 23.25 14.7783 23.25 15.375V18.75C23.25 19.3467 23.0129 19.919 22.591 20.341C22.169 20.7629 21.5967 21 21 21H15.375C14.7783 21 14.206 20.7629 13.784 20.341C13.3621 19.919 13.125 19.3467 13.125 18.75V15.375C13.125 14.7783 13.3621 14.206 13.784 13.784C14.206 13.3621 14.7783 13.125 15.375 13.125H21Z' /%3E%3C/svg%3E");
}
}
}
.vjs-fullscreen-control {
grid-area: fullscreen;
.vjs-icon-placeholder {
&::before {
/* fullscreen.svg */
content: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.11111 15.3333C3.4058 15.3333 3.68841 15.4504 3.89679 15.6588C4.10516 15.8671 4.22222 16.1498 4.22222 16.4444V19.7778H7.55556C7.85024 19.7778 8.13286 19.8948 8.34123 20.1032C8.5496 20.3116 8.66667 20.5942 8.66667 20.8889C8.66667 21.1836 8.5496 21.4662 8.34123 21.6746C8.13286 21.8829 7.85024 22 7.55556 22H4.22222C3.63285 22 3.06762 21.7659 2.65087 21.3491C2.23413 20.9324 2 20.3671 2 19.7778V16.4444C2 16.1498 2.11706 15.8671 2.32544 15.6588C2.53381 15.4504 2.81643 15.3333 3.11111 15.3333ZM20.8889 15.3333C21.161 15.3334 21.4237 15.4333 21.6271 15.6141C21.8305 15.795 21.9604 16.0442 21.9922 16.3144L22 16.4444V19.7778C22.0002 20.3384 21.7884 20.8784 21.4072 21.2895C21.026 21.7006 20.5035 21.9524 19.9444 21.9944L19.7778 22H16.4444C16.1612 21.9997 15.8889 21.8912 15.6829 21.6968C15.477 21.5024 15.3531 21.2367 15.3365 20.954C15.3199 20.6713 15.4119 20.3929 15.5936 20.1757C15.7754 19.9586 16.0332 19.819 16.3144 19.7856L16.4444 19.7778H19.7778V16.4444C19.7778 16.1498 19.8948 15.8671 20.1032 15.6588C20.3116 15.4504 20.5942 15.3333 20.8889 15.3333ZM19.7778 2C20.3384 1.99982 20.8784 2.21156 21.2895 2.59277C21.7006 2.97399 21.9524 3.49649 21.9944 4.05556L22 4.22222V7.55556C21.9997 7.83876 21.8912 8.11115 21.6968 8.31708C21.5024 8.52301 21.2367 8.64693 20.954 8.66353C20.6713 8.68012 20.3929 8.58814 20.1757 8.40637C19.9586 8.22461 19.819 7.96677 19.7856 7.68556L19.7778 7.55556V4.22222H16.4444C16.1612 4.22191 15.8889 4.11347 15.6829 3.91906C15.477 3.72465 15.3531 3.45894 15.3365 3.17623C15.3199 2.89352 15.4119 2.61513 15.5936 2.39796C15.7754 2.18079 16.0332 2.04123 16.3144 2.00778L16.4444 2H19.7778ZM7.55556 2C7.83876 2.00031 8.11115 2.10875 8.31708 2.30316C8.52301 2.49758 8.64693 2.76328 8.66353 3.04599C8.68012 3.32871 8.58814 3.60709 8.40637 3.82426C8.22461 4.04143 7.96677 4.181 7.68556 4.21444L7.55556 4.22222H4.22222V7.55556C4.22191 7.83876 4.11347 8.11115 3.91906 8.31708C3.72465 8.52301 3.45894 8.64693 3.17623 8.66353C2.89352 8.68012 2.61513 8.58814 2.39796 8.40637C2.18079 8.22461 2.04123 7.96677 2.00778 7.68556L2 7.55556V4.22222C1.99982 3.66158 2.21156 3.12159 2.59277 2.7105C2.97399 2.29941 3.49649 2.0476 4.05556 2.00556L4.22222 2H7.55556Z' fill='white'/%3E%3C/svg%3E%0A");
}
}
}
.vjs-settings-button {
grid-area: settings;
.vjs-icon-placeholder {
&::before {
/* video_settings.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M20.4529 8.85818C20.5656 9.16409 20.4705 9.50518 20.2274 9.72319L18.7021 11.1086C18.7408 11.4005 18.7619 11.6994 18.7619 12.0018C18.7619 12.3042 18.7408 12.603 18.7021 12.8949L20.2274 14.2803C20.4705 14.4983 20.5656 14.8394 20.4529 15.1453C20.2979 15.5638 20.1112 15.9646 19.8963 16.3514L19.7307 16.6363C19.4982 17.0231 19.2375 17.3887 18.9522 17.7333C18.7443 17.9865 18.3991 18.0709 18.0891 17.9725L16.1269 17.3501C15.6549 17.7122 15.1335 18.0147 14.5769 18.2432L14.1366 20.251C14.0661 20.571 13.8195 20.8242 13.4954 20.8769C13.0093 20.9578 12.509 21 11.9982 21C11.4874 21 10.9872 20.9578 10.5011 20.8769C10.177 20.8242 9.93038 20.571 9.85992 20.251L9.41957 18.2432C8.86298 18.0147 8.34161 17.7122 7.86956 17.3501L5.9109 17.976C5.6009 18.0744 5.25567 17.9865 5.04782 17.7369C4.76248 17.3923 4.5018 17.0266 4.26929 16.6398L4.10372 16.355C3.88883 15.9682 3.70213 15.5673 3.54713 15.1489C3.4344 14.8429 3.52951 14.5019 3.77258 14.2838L5.29794 12.8984C5.25919 12.603 5.23805 12.3042 5.23805 12.0018C5.23805 11.6994 5.25919 11.4005 5.29794 11.1086L3.77258 9.72319C3.52951 9.50518 3.4344 9.16409 3.54713 8.85818C3.70213 8.43973 3.88883 8.03888 4.10372 7.65208L4.26929 7.36726C4.5018 6.98046 4.76248 6.61477 5.04782 6.27017C5.25567 6.017 5.6009 5.9326 5.9109 6.03106L7.87308 6.65345C8.34513 6.29127 8.8665 5.98887 9.4231 5.7603L9.86344 3.75249C9.9339 3.43251 10.1805 3.17933 10.5046 3.12659C10.9907 3.0422 11.491 3 12.0018 3C12.5126 3 13.0128 3.0422 13.4989 3.12307C13.823 3.17582 14.0696 3.42899 14.1401 3.74897L14.5804 5.75679C15.137 5.98535 15.6584 6.28775 16.1304 6.64993L18.0926 6.02754C18.4026 5.92909 18.7479 6.017 18.9557 6.26665C19.241 6.61125 19.5017 6.97695 19.7342 7.36374L19.8998 7.64856C20.1147 8.03536 20.3014 8.43622 20.4564 8.85466L20.4529 8.85818ZM12.0018 14.8148C12.7492 14.8148 13.466 14.5184 13.9945 13.9909C14.5231 13.4633 14.82 12.7478 14.82 12.0018C14.82 11.2557 14.5231 10.5402 13.9945 10.0126C13.466 9.48508 12.7492 9.18871 12.0018 9.18871C11.2543 9.18871 10.5375 9.48508 10.009 10.0126C9.48047 10.5402 9.18355 11.2557 9.18355 12.0018C9.18355 12.7478 9.48047 13.4633 10.009 13.9909C10.5375 14.5184 11.2543 14.8148 12.0018 14.8148Z' fill='white'/%3E%3C/svg%3E");
}
}
}
}
&.vjs-fullscreen {
background-color: black;
.vjs-fullscreen-control {
.vjs-icon-placeholder {
&::before {
/* exit_fullscreen.svg */
content: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M20.8889 6.44444H17.5556V3.11111C17.5556 2.81643 17.4385 2.53381 17.2301 2.32544C17.0217 2.11706 16.7391 2 16.4444 2C16.1498 2 15.8671 2.11706 15.6588 2.32544C15.4504 2.53381 15.3333 2.81643 15.3333 3.11111V6.44444C15.3333 7.03381 15.5675 7.59905 15.9842 8.01579C16.401 8.43254 16.9662 8.66667 17.5556 8.66667H20.8889C21.1836 8.66667 21.4662 8.5496 21.6746 8.34123C21.8829 8.13286 22 7.85024 22 7.55556C22 7.26087 21.8829 6.97826 21.6746 6.76988C21.4662 6.56151 21.1836 6.44444 20.8889 6.44444ZM6.44444 8.66667C7.03381 8.66667 7.59905 8.43254 8.01579 8.01579C8.43254 7.59905 8.66667 7.03381 8.66667 6.44444V3.11111C8.66667 2.81643 8.5496 2.53381 8.34123 2.32544C8.13286 2.11706 7.85024 2 7.55556 2C7.26087 2 6.97826 2.11706 6.76988 2.32544C6.56151 2.53381 6.44444 2.81643 6.44444 3.11111V6.44444H3.11111C2.81643 6.44444 2.53381 6.56151 2.32544 6.76988C2.11706 6.97826 2 7.26087 2 7.55556C2 7.85024 2.11706 8.13286 2.32544 8.34123C2.53381 8.5496 2.81643 8.66667 3.11111 8.66667H6.44444ZM6.44444 17.5556H3.11111C2.81643 17.5556 2.53381 17.4385 2.32544 17.2301C2.11706 17.0217 2 16.7391 2 16.4444C2 16.1498 2.11706 15.8671 2.32544 15.6588C2.53381 15.4504 2.81643 15.3333 3.11111 15.3333H6.44444C7.03381 15.3333 7.59905 15.5675 8.01579 15.9842C8.43254 16.401 8.66667 16.9662 8.66667 17.5556V20.8889C8.66667 21.1836 8.5496 21.4662 8.34123 21.6746C8.13286 21.8829 7.85024 22 7.55556 22C7.26087 22 6.97826 21.8829 6.76988 21.6746C6.56151 21.4662 6.44444 21.1836 6.44444 20.8889V17.5556ZM17.5556 15.3333C16.9662 15.3333 16.401 15.5675 15.9842 15.9842C15.5675 16.401 15.3333 16.9662 15.3333 17.5556V20.8889C15.3333 21.1836 15.4504 21.4662 15.6588 21.6746C15.8671 21.8829 16.1498 22 16.4444 22C16.7391 22 17.0217 21.8829 17.2301 21.6746C17.4385 21.4662 17.5556 21.1836 17.5556 20.8889V17.5556H20.8889C21.1836 17.5556 21.4662 17.4385 21.6746 17.2301C21.8829 17.0217 22 16.7391 22 16.4444C22 16.1498 21.8829 15.8671 21.6746 15.6588C21.4662 15.4504 21.1836 15.3333 20.8889 15.3333H17.5556Z' fill='white'/%3E%3C/svg%3E%0A");
}
}
}
}
&.vjs-picture-in-picture {
.vjs-picture-in-picture-control {
.vjs-icon-placeholder {
&::before {
/* exit-picture-in-picture.svg */
content: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 7C5.44772 7 5 7.44772 5 8V16C5 16.5523 5.44772 17 6 17H18C18.5523 17 19 16.5523 19 16V8C19 7.44772 18.5523 7 18 7H6Z' /%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M19.875 3C20.6582 3 21.4134 3.27224 22.0139 3.76432C22.0997 3.83461 22.1824 3.9094 22.2615 3.98851C22.8944 4.62145 23.25 5.47989 23.25 6.375V17.625C23.25 18.5201 22.8944 19.3785 22.2615 20.0115C21.6285 20.6444 20.7701 21 19.875 21H4.125C3.22989 21 2.37145 20.6444 1.73851 20.0115C1.10558 19.3786 0.75 18.5201 0.75 17.625V6.375C0.75 5.47989 1.10558 4.62145 1.73851 3.98851C2.37145 3.35558 3.22989 3 4.125 3H19.875ZM19.875 18.75H4.125C3.82663 18.75 3.54048 18.6315 3.3295 18.4205C3.30313 18.3941 3.2782 18.3666 3.25477 18.338C3.09075 18.1378 3 17.8861 3 17.625V6.375C3 6.07663 3.11853 5.79048 3.3295 5.5795C3.54048 5.36853 3.82663 5.25 4.125 5.25H19.875C20.1734 5.25 20.4595 5.36852 20.6705 5.57949C20.8815 5.79046 21 6.07663 21 6.375V17.625C21 17.9234 20.8815 18.2095 20.6705 18.4205C20.4595 18.6315 20.1734 18.75 19.875 18.75Z' /%3E%3C/svg%3E");
}
}
}
}
/* Скрываем лишние кнопки */
.vjs-audio-button {
display: none !important;
}
.vjs-subs-caps-button {
display: none !important;
}
}
&.vjs-live,
&.vjs-hls-live {
.vjs-control-bar {
grid-template-rows: 5px 24px;
grid-template-columns:
24px min-content min-content auto minmax(24px, 124px)
24px 24px 24px;
grid-template-areas:
"progress progress progress progress progress progress progress progress"
"playControl currentTime seekToLive . volumePanel settings pictureInPicture fullscreen";
.vjs-time-divider,
.vjs-live-control {
display: none;
}
.vjs-seek-to-live-control {
display: flex;
grid-area: seekToLive;
}
.vjs-duration {
display: none;
}
.vjs-current-time {
align-items: center;
grid-area: currentTime;
}
.vjs-progress-control {
display: block !important;
}
}
}
&.vjs-has-started {
&.vjs-user-active {
.vjs-control-bar {
opacity: 1 !important;
pointer-events: auto !important;
}
}
&.vjs-paused {
.vjs-big-play-button {
display: block;
.vjs-icon-placeholder {
&::before {
/* play_filled.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M18.8579 13.5611C19.0962 13.3766 19.2892 13.1401 19.4219 12.8694C19.5546 12.5989 19.6237 12.3014 19.6237 12C19.6237 11.6986 19.5546 11.4011 19.4219 11.1306C19.2892 10.8599 19.0962 10.6234 18.8579 10.4389C15.7707 8.05097 12.324 6.1684 8.64648 4.86153L7.97406 4.62261C6.68884 4.16642 5.33056 5.03556 5.15656 6.36293C4.67047 10.1053 4.67047 13.8947 5.15656 17.6371C5.33161 18.9644 6.68884 19.8336 7.97406 19.3774L8.64648 19.1385C12.324 17.8316 15.7707 15.949 18.8579 13.5611Z' fill='white'/%3E%3C/svg%3E");
}
}
}
&.vjs-controls-disabled {
.vjs-poster {
display: block !important;
}
}
}
&.vjs-ended {
.vjs-big-play-button {
.vjs-icon-placeholder {
&::before {
/* restart_filled.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28' fill='none'%3E%3Cg clip-path='url(%23clip0_17940_48973)'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.5083 1.12995C13.1305 0.417989 15.9075 0.5241 18.4676 1.43407C21.0278 2.34405 23.2488 4.01445 24.8333 6.22162V3.57995C24.8333 3.24843 24.965 2.93049 25.1995 2.69607C25.4339 2.46165 25.7518 2.32995 26.0833 2.32995C26.4149 2.32995 26.7328 2.46165 26.9672 2.69607C27.2017 2.93049 27.3333 3.24843 27.3333 3.57995V10.6633H20.25C19.9185 10.6633 19.6006 10.5316 19.3661 10.2972C19.1317 10.0627 19 9.7448 19 9.41328C19 9.08176 19.1317 8.76382 19.3661 8.5294C19.6006 8.29498 19.9185 8.16328 20.25 8.16328H23.1283C21.8228 6.12076 19.8699 4.57514 17.5821 3.77367C15.2943 2.9722 12.8038 2.96121 10.509 3.74244C8.21426 4.52368 6.24781 6.052 4.92428 8.08291C3.60074 10.1138 2.9966 12.53 3.20852 14.9448C3.42043 17.3596 4.43614 19.6336 6.09317 21.403C7.7502 23.1723 9.95278 24.3348 12.3486 24.7044C14.7443 25.0741 17.1948 24.6295 19.3081 23.4418C21.4213 22.2541 23.0752 20.392 24.005 18.1533C24.0654 17.9981 24.1561 17.8565 24.272 17.7369C24.3878 17.6172 24.5264 17.5219 24.6796 17.4566C24.8327 17.3912 24.9974 17.3572 25.164 17.3564C25.3305 17.3556 25.4955 17.388 25.6493 17.4519C25.8031 17.5157 25.9426 17.6097 26.0596 17.7282C26.1766 17.8467 26.2687 17.9874 26.3306 18.142C26.3924 18.2966 26.4228 18.4621 26.4198 18.6286C26.4169 18.7951 26.3807 18.9593 26.3133 19.1116C25.4447 21.2025 24.0579 23.0378 22.2837 24.4444C20.5095 25.851 18.4063 26.7826 16.1724 27.1514C13.9385 27.5203 11.6474 27.3142 9.51523 26.5526C7.38302 25.7909 5.47988 24.4989 3.9852 22.7982C2.49052 21.0975 1.45354 19.0442 0.972017 16.8319C0.490491 14.6195 0.580276 12.321 1.23291 10.1529C1.88554 7.98488 3.07953 6.01873 4.70231 4.43983C6.3251 2.86092 8.32323 1.72293 10.5083 1.12995Z' fill='white'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_17940_48973'%3E%3Crect width='26.6667' height='26.6667' fill='white' transform='translate(0.666626 0.664062)'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
}
}
}
}
}
&.vjs-controls-disabled {
.vjs-big-play-button {
display: none !important;
}
.vjs-control-bar {
height: 10px;
display: flex !important;
opacity: 1 !important;
padding: 0;
background: none !important;
.vjs-picture-in-picture-control,
.vjs-fullscreen-control,
.vjs-settings-button,
.vjs-duration,
.vjs-remaining-time,
.vjs-play-control,
.vjs-volume-panel,
.vjs-custom-control-spacer,
.vjs-current-time,
.vjs-seek-to-live-control,
.vjs-time-divider {
display: none;
}
.vjs-progress-control {
border-radius: 0px;
.vjs-progress-holder {
margin: 0;
width: 100%;
position: absolute;
bottom: 0;
.vjs-load-progress {
opacity: 0;
}
.vjs-play-progress {
background-color: var(--Accent-Primary, #5152ba);
}
}
}
}
}
&.vjs-playing {
.vjs-big-play-button {
display: block;
opacity: 1;
.vjs-icon-placeholder {
&::before {
/* pause_filled.svg */
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M5.5 6.5C5.5 5.39543 6.39543 4.5 7.5 4.5H10.5V19.5H7.5C6.39543 19.5 5.5 18.6046 5.5 17.5V6.5Z' fill='white'/%3E%3Cpath d='M13.5 4.5H16.5C17.6046 4.5 18.5 5.39543 18.5 6.5V17.5C18.5 18.6046 17.6046 19.5 16.5 19.5H13.5V4.5Z' fill='white'/%3E%3C/svg%3E");
}
}
}
&.vjs-user-inactive {
.vjs-big-play-button {
opacity: 0;
}
}
}
&.vjs-layout-x-small {
.vjs-control-bar {
pointer-events: none;
.vjs-progress-control {
display: block;
.vjs-mouse-display {
display: none;
}
.vjs-time-tooltip {
display: none;
}
}
}
}
}
/* .video-js.vjs-layout-x-small .vjs-custom-control-spacer */

View File

@@ -0,0 +1,160 @@
import "./tach-video-js.css";
import React, { memo, ReactNode, useMemo } from "react";
import cn from "classnames";
import VideoJS from "./components/video-js";
import { IVideoJSOptions, PreloadType } from "./components/video-js/types";
import WithBlur from "./components/with-blur";
import WithCover from "./components/with-cover";
import WithDurationBadge from "./components/with-duration-badge";
import WithErrors from "./components/with-errors";
import { WithMouseEvents } from "./components/with-mouse-events";
import { WithObservation } from "./components/with-observation";
import WithProcessing, {
IWithProcessingVideoPlayerProps,
} from "./components/with-processing";
import { gcd } from "./shared/math";
import styles from "./video.module.scss";
export interface IVideoPlayerProps
extends Omit<IWithProcessingVideoPlayerProps, "options" | "children"> {
className?: string;
src: string;
type?: string;
preload?: PreloadType;
muted?: boolean;
fluid?: boolean;
responsive?: boolean;
controls?: boolean;
aspectRatio?: string;
preferHQ?: boolean;
width?: number | null;
height?: number | null;
autoplay?: boolean;
cover?: ReactNode;
lazy?: boolean;
debug?: boolean;
forceHover?: boolean;
}
const calculateAspectWidth = (width: number, height: number): number => {
const divisor = gcd(width, height);
return width / divisor;
};
const calculateAspectHeight = (width: number, height: number): number => {
const divisor = gcd(width, height);
return height / divisor;
};
const calculateAspectRatio = (width: number, height: number) => {
const aspectWidth =
calculateAspectWidth(width, height) > 20
? 20
: calculateAspectWidth(width, height);
const aspectHeight =
calculateAspectHeight(width, height) > 15
? 11
: calculateAspectHeight(width, height);
return `${aspectWidth}:${aspectHeight}`;
};
export const VideoPlayer = memo(
({
src,
type,
showErrors = false,
showProcessing = false,
withLoading = false,
withBlur = false,
duration = false,
preload = "auto",
controls = true,
muted = false,
fluid = true,
responsive = true,
autoplay = false,
width,
height,
aspectRatio,
preferHQ = false,
lazy = false,
debug = false,
forceHover = false,
...props
}: IVideoPlayerProps) => {
const videoJsOptions: IVideoJSOptions = {
autoplay,
controls,
responsive: true,
aspectRatio: useMemo(
() =>
aspectRatio
? aspectRatio === "none"
? undefined
: aspectRatio
: width && height
? calculateAspectRatio(width, height)
: "4:3",
[aspectRatio, width, height],
),
preload,
fluid,
muted,
sources: [{ src: src, type: type || "application/x-mpegurl" }],
preferHQ,
debug,
};
// TODO: change to provider or split props between layers
return (
<div
className={cn([styles.videoMain, props.className])}
style={{
// width: width || undefined,
aspectRatio: videoJsOptions.aspectRatio?.split(":").join("/"),
}}
>
<WithProcessing
options={videoJsOptions}
showProcessing={showProcessing}
{...props}
>
{/* @ts-expect-errors */}
<WithDurationBadge duration={!showProcessing && duration}>
{/* @ts-expect-errors */}
<WithCover
cover={videoJsOptions.poster}
forceHover={forceHover}
>
{/* @ts-expect-errors */}
<WithErrors showErrors={!showProcessing && showErrors}>
{/* @ts-expect-errors */}
<WithBlur withBlur={!showProcessing && withBlur}>
{/* @ts-expect-errors */}
<WithObservation>
{/* @ts-expect-errors */}
<WithMouseEvents>
{/* @ts-expect-errors */}
<VideoJS />
</WithMouseEvents>
</WithObservation>
</WithBlur>
</WithErrors>
{/* </WithLazy> */}
</WithCover>
</WithDurationBadge>
</WithProcessing>
</div>
);
},
);
VideoPlayer.displayName = "VideoPlayer";
export default VideoPlayer;

View File

@@ -0,0 +1,37 @@
.videoMain {
max-width: 100%;
overflow: hidden;
object-fit: cover;
position: relative;
width: 100%;
justify-content: center;
align-items: center;
height: 100%;
max-height: 757px;
position: relative;
}
@media (min-width: 576px) and (max-width: 768px) {
.videoMain {
//max-width: calc(100vw - 164px);
}
}
@media (max-width: 575px) {
.videoMain {
max-width: 100vw;
}
}
.videoBlur {
position: absolute;
left: 0;
object-position: center;
z-index: -1;
overflow: hidden;
max-width: 100%;
height: 757px;
object-fit: cover;
filter: blur(80px);
}

View File

@@ -0,0 +1,21 @@
declare module "*.module.scss" {
const classes: Record<string, string>;
export default classes;
}
declare module "*.scss" {
const content: string;
export default content;
}
declare module "*.css" {
const content: string;
export default content;
}
declare module "*.svg" {
import type { FC, SVGProps } from "react";
const Component: FC<SVGProps<SVGSVGElement>>;
export default Component;
}

View File

@@ -0,0 +1,18 @@
declare module "lodash" {
type AnyFn = (...args: any[]) => any;
type Cancelable<T extends AnyFn> = T & {
cancel: () => void;
flush: () => ReturnType<T>;
};
export function debounce<T extends AnyFn>(
func: T,
wait?: number,
): Cancelable<T>;
export function throttle<T extends AnyFn>(
func: T,
wait?: number,
): Cancelable<T>;
}