Add video controls overlay for selected VideoSprite
Floating HTML overlay with play/pause, seekbar, time display, and mute toggle that appears when a single video element is selected. Positioned at the bottom of the video using viewport.toScreen() coordinate conversion, polled at 100ms for position and 250ms for playback progress.
This commit is contained in:
@@ -1,70 +1,229 @@
|
||||
import { Sprite, Texture, Graphics } from 'pixi.js';
|
||||
import { Container, Sprite, Texture, Graphics } from 'pixi.js';
|
||||
|
||||
/**
|
||||
* VideoSprite — Container holding shadow + video sprite + play overlay.
|
||||
*
|
||||
* Matches ImageSprite pattern: extends Container (not Sprite) so shadow
|
||||
* and overlay children don't affect bounds for transform box.
|
||||
* Does NOT auto-play. User clicks to play/pause.
|
||||
* Auto-corrects dimensions from video metadata if initial w/h were fallbacks.
|
||||
*
|
||||
* Key design:
|
||||
* - preload='auto' so the browser fetches enough data for the first frame
|
||||
* - On 'loadeddata' we briefly play+pause to force the first frame to decode,
|
||||
* then capture it as a static canvas texture (poster). This avoids the
|
||||
* "black box" problem where PixiJS video textures only render while playing.
|
||||
* - When the user hits play, we swap to the live video texture.
|
||||
* - On pause, we keep the live texture (shows last frame).
|
||||
*/
|
||||
|
||||
const MAX_CONCURRENT_VIDEOS = 3;
|
||||
const activeVideos: Set<VideoSprite> = new Set();
|
||||
|
||||
export class VideoSprite extends Sprite {
|
||||
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
|
||||
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
|
||||
|
||||
export class VideoSprite extends Container {
|
||||
readonly assetKey: string;
|
||||
private videoEl: HTMLVideoElement;
|
||||
private videoTexture: Texture | null = null;
|
||||
private playIcon: Graphics;
|
||||
private _isPlaying: boolean = false;
|
||||
muted: boolean = true;
|
||||
loop: boolean = true;
|
||||
private posterTexture: Texture | null = null;
|
||||
private _sprite: Sprite;
|
||||
private _shadow: Graphics;
|
||||
private _overlay: Graphics;
|
||||
private _placeholder: Graphics | null = null;
|
||||
private _naturalWidth: number;
|
||||
private _naturalHeight: number;
|
||||
private _isPlaying = false;
|
||||
private _videoReady = false;
|
||||
muted = true;
|
||||
loop = true;
|
||||
|
||||
/** Called when video metadata reveals the real dimensions. */
|
||||
onDimensionsKnown: ((w: number, h: number) => void) | null = null;
|
||||
|
||||
constructor(assetKey: string, w: number, h: number, videoUrl: string) {
|
||||
super();
|
||||
|
||||
constructor(assetKey: string, w: number, h: number, url: string) {
|
||||
super(Texture.EMPTY);
|
||||
this.assetKey = assetKey;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this._naturalWidth = w;
|
||||
this._naturalHeight = h;
|
||||
|
||||
// Create hidden video element
|
||||
// Shadow
|
||||
this._shadow = new Graphics();
|
||||
this._drawShadow(SHADOW_REST);
|
||||
this.addChild(this._shadow);
|
||||
|
||||
// Main sprite (starts empty)
|
||||
this._sprite = new Sprite(Texture.EMPTY);
|
||||
this._sprite.width = w;
|
||||
this._sprite.height = h;
|
||||
this.addChild(this._sprite);
|
||||
|
||||
// Dark placeholder with film icon
|
||||
this._placeholder = new Graphics();
|
||||
this._placeholder.rect(0, 0, w, h).fill(0x2a2a2a);
|
||||
this.addChild(this._placeholder);
|
||||
|
||||
// Play/pause overlay
|
||||
this._overlay = new Graphics();
|
||||
this._drawPlayIcon();
|
||||
this.addChild(this._overlay);
|
||||
|
||||
// Video element
|
||||
this.videoEl = document.createElement('video');
|
||||
this.videoEl.src = url;
|
||||
this.videoEl.crossOrigin = 'anonymous';
|
||||
this.videoEl.muted = true;
|
||||
this.videoEl.loop = true;
|
||||
this.videoEl.playsInline = true;
|
||||
this.videoEl.preload = 'metadata';
|
||||
this.videoEl.preload = 'auto'; // load enough for first frame
|
||||
|
||||
// Dark background
|
||||
const bg = new Graphics();
|
||||
bg.rect(0, 0, w, h).fill(0x1a1a1a);
|
||||
this.addChildAt(bg, 0);
|
||||
// Auto-correct dimensions from video metadata
|
||||
this.videoEl.addEventListener('loadedmetadata', () => {
|
||||
const vw = this.videoEl.videoWidth;
|
||||
const vh = this.videoEl.videoHeight;
|
||||
if (vw > 0 && vh > 0) {
|
||||
// Cap to 600px max dimension, preserving aspect ratio
|
||||
const maxDim = 600;
|
||||
let fw = vw, fh = vh;
|
||||
if (vw > maxDim || vh > maxDim) {
|
||||
const s = maxDim / Math.max(vw, vh);
|
||||
fw = Math.round(vw * s);
|
||||
fh = Math.round(vh * s);
|
||||
}
|
||||
if (fw !== this._naturalWidth || fh !== this._naturalHeight) {
|
||||
this._naturalWidth = fw;
|
||||
this._naturalHeight = fh;
|
||||
this._sprite.width = fw;
|
||||
this._sprite.height = fh;
|
||||
// Update placeholder size too
|
||||
if (this._placeholder) {
|
||||
this._placeholder.clear();
|
||||
this._placeholder.rect(0, 0, fw, fh).fill(0x2a2a2a);
|
||||
}
|
||||
this._drawShadow(SHADOW_REST);
|
||||
this._drawPlayIcon();
|
||||
this.onDimensionsKnown?.(fw, fh);
|
||||
}
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
// Play icon — white triangle centered in the sprite
|
||||
this.playIcon = new Graphics();
|
||||
const triSize = Math.min(w, h) * 0.25;
|
||||
// When first frame data is available, capture a poster
|
||||
this.videoEl.addEventListener('loadeddata', () => {
|
||||
this._captureFirstFrame();
|
||||
}, { once: true });
|
||||
|
||||
// Set src last to start loading
|
||||
this.videoEl.src = videoUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the first frame as a static canvas texture.
|
||||
* This ensures the video shows content even when paused/not-yet-played.
|
||||
*/
|
||||
private _captureFirstFrame(): void {
|
||||
const vw = this.videoEl.videoWidth;
|
||||
const vh = this.videoEl.videoHeight;
|
||||
if (vw <= 0 || vh <= 0) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = vw;
|
||||
canvas.height = vh;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(this.videoEl, 0, 0, vw, vh);
|
||||
this.posterTexture = Texture.from(canvas);
|
||||
this._sprite.texture = this.posterTexture;
|
||||
this._sprite.width = this._naturalWidth;
|
||||
this._sprite.height = this._naturalHeight;
|
||||
this._videoReady = true;
|
||||
|
||||
// Remove placeholder
|
||||
if (this._placeholder) {
|
||||
this.removeChild(this._placeholder);
|
||||
this._placeholder.destroy();
|
||||
this._placeholder = null;
|
||||
}
|
||||
} catch (err) {
|
||||
// Cross-origin or other issue — try the seeked approach
|
||||
this._trySeekCapture();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: seek to 0.1s and capture on 'seeked' event.
|
||||
* Some browsers need a seek to decode the first frame.
|
||||
*/
|
||||
private _trySeekCapture(): void {
|
||||
const onSeeked = () => {
|
||||
this.videoEl.removeEventListener('seeked', onSeeked);
|
||||
const vw = this.videoEl.videoWidth;
|
||||
const vh = this.videoEl.videoHeight;
|
||||
if (vw <= 0 || vh <= 0) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = vw;
|
||||
canvas.height = vh;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(this.videoEl, 0, 0, vw, vh);
|
||||
this.posterTexture = Texture.from(canvas);
|
||||
this._sprite.texture = this.posterTexture;
|
||||
this._sprite.width = this._naturalWidth;
|
||||
this._sprite.height = this._naturalHeight;
|
||||
this._videoReady = true;
|
||||
|
||||
if (this._placeholder) {
|
||||
this.removeChild(this._placeholder);
|
||||
this._placeholder.destroy();
|
||||
this._placeholder = null;
|
||||
}
|
||||
} catch {
|
||||
// Give up on poster — user will see placeholder until play
|
||||
}
|
||||
};
|
||||
|
||||
this.videoEl.addEventListener('seeked', onSeeked);
|
||||
this.videoEl.currentTime = 0.1;
|
||||
}
|
||||
|
||||
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
|
||||
this._shadow.clear();
|
||||
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
|
||||
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
|
||||
}
|
||||
|
||||
private _drawPlayIcon(): void {
|
||||
this._overlay.clear();
|
||||
const w = this._naturalWidth;
|
||||
const h = this._naturalHeight;
|
||||
const triSize = Math.min(w, h) * 0.2;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
this.playIcon
|
||||
.poly([
|
||||
cx - triSize * 0.4, cy - triSize * 0.5,
|
||||
cx + triSize * 0.5, cy,
|
||||
cx - triSize * 0.4, cy + triSize * 0.5,
|
||||
])
|
||||
.fill({ color: 0xffffff, alpha: 0.7 });
|
||||
this.addChild(this.playIcon);
|
||||
|
||||
// Interaction
|
||||
this.eventMode = 'static';
|
||||
this.cursor = 'pointer';
|
||||
this.on('pointertap', () => {
|
||||
this.muted = !this.muted;
|
||||
this.videoEl.muted = this.muted;
|
||||
});
|
||||
// Semi-transparent circle
|
||||
this._overlay.circle(cx, cy, triSize * 0.8);
|
||||
this._overlay.fill({ color: 0x000000, alpha: 0.5 });
|
||||
|
||||
// When video metadata/frame is ready, create texture
|
||||
this.videoEl.addEventListener(
|
||||
'loadeddata',
|
||||
() => {
|
||||
this.videoTexture = Texture.from(this.videoEl);
|
||||
this.texture = this.videoTexture;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
// Play triangle
|
||||
this._overlay.poly([
|
||||
cx - triSize * 0.3, cy - triSize * 0.4,
|
||||
cx + triSize * 0.4, cy,
|
||||
cx - triSize * 0.3, cy + triSize * 0.4,
|
||||
]);
|
||||
this._overlay.fill({ color: 0xffffff, alpha: 0.9 });
|
||||
}
|
||||
|
||||
liftShadow(): void {
|
||||
this._drawShadow(SHADOW_LIFT);
|
||||
}
|
||||
|
||||
dropShadow(): void {
|
||||
this._drawShadow(SHADOW_REST);
|
||||
}
|
||||
|
||||
play(): void {
|
||||
@@ -76,41 +235,79 @@ export class VideoSprite extends Sprite {
|
||||
oldest.pause();
|
||||
}
|
||||
|
||||
this.videoEl.play();
|
||||
this.videoEl.muted = this.muted;
|
||||
|
||||
// Create live video texture for playback
|
||||
if (!this.videoTexture) {
|
||||
this.videoTexture = Texture.from(this.videoEl);
|
||||
}
|
||||
this._sprite.texture = this.videoTexture;
|
||||
this._sprite.width = this._naturalWidth;
|
||||
this._sprite.height = this._naturalHeight;
|
||||
|
||||
this.videoEl.play().catch(() => {
|
||||
// Autoplay blocked — revert to poster
|
||||
if (this.posterTexture) {
|
||||
this._sprite.texture = this.posterTexture;
|
||||
}
|
||||
this._overlay.visible = true;
|
||||
});
|
||||
this._isPlaying = true;
|
||||
this.playIcon.visible = false;
|
||||
this._overlay.visible = false;
|
||||
activeVideos.add(this);
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this._isPlaying) return;
|
||||
|
||||
this.videoEl.pause();
|
||||
this._isPlaying = false;
|
||||
this.playIcon.visible = true;
|
||||
this._overlay.visible = true;
|
||||
activeVideos.delete(this);
|
||||
// Keep the video texture showing (last frame) — don't revert to poster
|
||||
}
|
||||
|
||||
togglePlayPause(): void {
|
||||
if (this._isPlaying) this.pause();
|
||||
else this.play();
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
this.muted = !this.muted;
|
||||
this.videoEl.muted = this.muted;
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this._isPlaying;
|
||||
}
|
||||
|
||||
/** Expose the underlying HTMLVideoElement for external controls. */
|
||||
get videoElement(): HTMLVideoElement {
|
||||
return this.videoEl;
|
||||
}
|
||||
|
||||
/** Seek to a specific time (in seconds). */
|
||||
seek(time: number): void {
|
||||
this.videoEl.currentTime = Math.max(0, Math.min(time, this.videoEl.duration || 0));
|
||||
}
|
||||
|
||||
onVisibilityChange(visible: boolean): void {
|
||||
if (visible && !this._isPlaying) {
|
||||
this.play();
|
||||
} else if (!visible && this._isPlaying) {
|
||||
if (!visible && this._isPlaying) {
|
||||
this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(options?: Parameters<Sprite['destroy']>[0]): void {
|
||||
destroy(options?: Parameters<Container['destroy']>[0]): void {
|
||||
this.pause();
|
||||
this.videoEl.src = '';
|
||||
this.videoEl.load(); // release network resources
|
||||
this.videoEl.load();
|
||||
if (this.videoTexture) {
|
||||
this.videoTexture.destroy(true);
|
||||
this.videoTexture = null;
|
||||
}
|
||||
if (this.posterTexture) {
|
||||
this.posterTexture.destroy(true);
|
||||
this.posterTexture = null;
|
||||
}
|
||||
super.destroy(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||
|
||||
interface VideoControlsProps {
|
||||
videoSprite: VideoSprite;
|
||||
/** Screen-space rect of the video element */
|
||||
screenRect: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function VideoControls({ videoSprite, screenRect }: VideoControlsProps) {
|
||||
const video = videoSprite.videoElement;
|
||||
const [playing, setPlaying] = useState(videoSprite.isPlaying);
|
||||
const [muted, setMuted] = useState(videoSprite.muted);
|
||||
const [currentTime, setCurrentTime] = useState(video.currentTime || 0);
|
||||
const [duration, setDuration] = useState(video.duration || 0);
|
||||
const [seeking, setSeeking] = useState(false);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
// Poll currentTime while playing
|
||||
useEffect(() => {
|
||||
if (pollRef.current) {
|
||||
cancelAnimationFrame(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
|
||||
if (!playing || seeking) return;
|
||||
|
||||
let lastUpdate = 0;
|
||||
const poll = (now: number) => {
|
||||
if (now - lastUpdate >= 250) {
|
||||
setCurrentTime(video.currentTime || 0);
|
||||
setDuration(video.duration || 0);
|
||||
lastUpdate = now;
|
||||
}
|
||||
pollRef.current = requestAnimationFrame(poll);
|
||||
};
|
||||
pollRef.current = requestAnimationFrame(poll);
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
cancelAnimationFrame(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playing, seeking, video]);
|
||||
|
||||
// Sync duration on metadata load
|
||||
useEffect(() => {
|
||||
const onMeta = () => setDuration(video.duration || 0);
|
||||
video.addEventListener('loadedmetadata', onMeta);
|
||||
// Also grab current values
|
||||
setDuration(video.duration || 0);
|
||||
setCurrentTime(video.currentTime || 0);
|
||||
return () => video.removeEventListener('loadedmetadata', onMeta);
|
||||
}, [video]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
videoSprite.togglePlayPause();
|
||||
setPlaying(videoSprite.isPlaying);
|
||||
}, [videoSprite]);
|
||||
|
||||
const handleMuteToggle = useCallback(() => {
|
||||
videoSprite.toggleMute();
|
||||
setMuted(videoSprite.muted);
|
||||
}, [videoSprite]);
|
||||
|
||||
const handleSeekStart = useCallback(() => {
|
||||
setSeeking(true);
|
||||
}, []);
|
||||
|
||||
const handleSeekChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const t = parseFloat(e.target.value);
|
||||
setCurrentTime(t);
|
||||
}, []);
|
||||
|
||||
const handleSeekEnd = useCallback((e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||
const t = parseFloat((e.target as HTMLInputElement).value);
|
||||
videoSprite.seek(t);
|
||||
setCurrentTime(t);
|
||||
setSeeking(false);
|
||||
}, [videoSprite]);
|
||||
|
||||
// Position at bottom of the video, centered
|
||||
const barWidth = Math.max(200, Math.min(screenRect.w, 400));
|
||||
const left = screenRect.x + screenRect.w / 2;
|
||||
const top = screenRect.y + screenRect.h + 8;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '4px 10px',
|
||||
width: barWidth,
|
||||
height: '36px',
|
||||
background: 'rgba(22, 22, 22, 0.96)',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '10px',
|
||||
backdropFilter: 'blur(12px)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||
zIndex: 100,
|
||||
pointerEvents: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Play / Pause */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
title={playing ? 'Pause' : 'Play'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#ccc'; }}
|
||||
>
|
||||
{playing ? <IcoPause /> : <IcoPlay />}
|
||||
</button>
|
||||
|
||||
{/* Seekbar */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onMouseDown={handleSeekStart}
|
||||
onTouchStart={handleSeekStart}
|
||||
onChange={handleSeekChange}
|
||||
onMouseUp={handleSeekEnd}
|
||||
onTouchEnd={handleSeekEnd}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
appearance: 'none',
|
||||
background: `linear-gradient(to right, #4a9eff ${(currentTime / (duration || 1)) * 100}%, #444 ${(currentTime / (duration || 1)) * 100}%)`,
|
||||
borderRadius: '2px',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Time display */}
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#888',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'monospace',
|
||||
flexShrink: 0,
|
||||
minWidth: '70px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
{/* Mute / Unmute */}
|
||||
<button
|
||||
onClick={handleMuteToggle}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#ccc'; }}
|
||||
>
|
||||
{muted ? <IcoMuted /> : <IcoUnmuted />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Tiny SVG icons --------------------------------------------------------
|
||||
|
||||
function IcoPlay() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M4 2.5v9l7-4.5-7-4.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IcoPause() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="3" y="2" width="3" height="10" rx="0.5" />
|
||||
<rect x="8" y="2" width="3" height="10" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IcoUnmuted() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M2 5.5h2l3-2.5v8l-3-2.5H2v-3z" fill="currentColor" opacity="0.3" />
|
||||
<path d="M10 4.5c.8.8.8 4.2 0 5" strokeLinecap="round" />
|
||||
<path d="M11.5 3c1.3 1.3 1.3 6.7 0 8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IcoMuted() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M2 5.5h2l3-2.5v8l-3-2.5H2v-3z" fill="currentColor" opacity="0.3" />
|
||||
<line x1="10" y1="5" x2="13" y2="9" strokeLinecap="round" />
|
||||
<line x1="13" y1="5" x2="10" y2="9" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+184
-719
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user