507 lines
13 KiB
TypeScript
507 lines
13 KiB
TypeScript
import {
|
|
DeesElement,
|
|
html,
|
|
customElement,
|
|
type TemplateResult,
|
|
property,
|
|
state,
|
|
cssManager,
|
|
} from '@design.estate/dees-element';
|
|
import '../../00group-utility/dees-icon/dees-icon.js';
|
|
import { demo } from './demo.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-video-viewer': DeesVideoViewer;
|
|
}
|
|
}
|
|
|
|
@customElement('dees-video-viewer')
|
|
export class DeesVideoViewer extends DeesElement {
|
|
public static demo = demo;
|
|
public static demoGroups = ['Media'];
|
|
|
|
@property()
|
|
accessor src: string = '';
|
|
|
|
@property()
|
|
accessor poster: string = '';
|
|
|
|
@property({ type: Boolean })
|
|
accessor showControls: boolean = true;
|
|
|
|
@property({ type: Boolean })
|
|
accessor autoplay: boolean = false;
|
|
|
|
@property({ type: Boolean })
|
|
accessor loop: boolean = false;
|
|
|
|
@property({ type: Boolean })
|
|
accessor muted: boolean = false;
|
|
|
|
@state()
|
|
accessor isPlaying: boolean = false;
|
|
|
|
@state()
|
|
accessor currentTime: number = 0;
|
|
|
|
@state()
|
|
accessor duration: number = 0;
|
|
|
|
@state()
|
|
accessor volume: number = 1;
|
|
|
|
@state()
|
|
accessor loading: boolean = true;
|
|
|
|
@state()
|
|
accessor error: string = '';
|
|
|
|
@state()
|
|
accessor isFullscreen: boolean = false;
|
|
|
|
@state()
|
|
accessor controlsVisible: boolean = true;
|
|
|
|
private hideControlsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private videoElement: HTMLVideoElement | null = null;
|
|
|
|
public render(): TemplateResult {
|
|
return html`
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
|
|
.video-container {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 16 / 9;
|
|
background: #000000;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.center-play {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 50%;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
color: #ffffff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
opacity: ${this.isPlaying ? 0 : 1};
|
|
pointer-events: ${this.isPlaying ? 'none' : 'auto'};
|
|
}
|
|
|
|
.center-play dees-icon {
|
|
font-size: 28px;
|
|
}
|
|
|
|
.center-play:hover {
|
|
background: rgba(0, 0, 0, 0.8);
|
|
transform: translate(-50%, -50%) scale(1.1);
|
|
}
|
|
|
|
.controls-bar {
|
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
|
padding: 24px 12px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
opacity: ${this.controlsVisible || !this.isPlaying ? 1 : 0};
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.seekbar-row {
|
|
width: 100%;
|
|
height: 4px;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
|
|
.seekbar-row:hover {
|
|
height: 6px;
|
|
}
|
|
|
|
.seekbar-progress {
|
|
height: 100%;
|
|
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
|
border-radius: 2px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.controls-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.ctrl-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
background: transparent;
|
|
color: #ffffff;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.ctrl-button dees-icon {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.ctrl-button:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.time-display {
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
user-select: none;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
.volume-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.volume-slider {
|
|
width: 60px;
|
|
height: 4px;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
border-radius: 2px;
|
|
outline: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.volume-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #ffffff;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.volume-slider::-moz-range-thumb {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #ffffff;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
border-top-color: #ffffff;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.error-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
background: rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
.error-overlay dees-icon {
|
|
color: #f87171;
|
|
font-size: 32px;
|
|
}
|
|
|
|
.error-text {
|
|
font-size: 13px;
|
|
}
|
|
</style>
|
|
|
|
<div
|
|
class="video-container"
|
|
@mousemove=${this.handleMouseMove}
|
|
@mouseleave=${this.handleMouseLeave}
|
|
>
|
|
<video
|
|
.src=${this.src}
|
|
.poster=${this.poster}
|
|
.muted=${this.muted}
|
|
.loop=${this.loop}
|
|
?autoplay=${this.autoplay}
|
|
?controls=${!this.showControls}
|
|
playsinline
|
|
@loadedmetadata=${this.handleLoadedMetadata}
|
|
@play=${this.handlePlay}
|
|
@pause=${this.handlePause}
|
|
@ended=${this.handleEnded}
|
|
@timeupdate=${this.handleTimeUpdate}
|
|
@error=${this.handleError}
|
|
@waiting=${() => { this.loading = true; }}
|
|
@canplay=${() => { this.loading = false; }}
|
|
></video>
|
|
|
|
${this.showControls ? html`
|
|
<div class="overlay" @click=${this.handleOverlayClick}>
|
|
<div class="center-play">
|
|
<dees-icon icon="lucide:Play"></dees-icon>
|
|
</div>
|
|
|
|
<div class="controls-bar" @click=${(e: Event) => e.stopPropagation()}>
|
|
<div class="seekbar-row" @click=${this.handleSeek}>
|
|
<div class="seekbar-progress" style="width: ${this.duration ? (this.currentTime / this.duration) * 100 : 0}%"></div>
|
|
</div>
|
|
|
|
<div class="controls-row">
|
|
<button class="ctrl-button" @click=${this.togglePlay}>
|
|
<dees-icon icon="lucide:${this.isPlaying ? 'Pause' : 'Play'}"></dees-icon>
|
|
</button>
|
|
|
|
<span class="time-display">
|
|
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
|
|
</span>
|
|
|
|
<span class="spacer"></span>
|
|
|
|
<div class="volume-group">
|
|
<button class="ctrl-button" @click=${this.toggleMute}>
|
|
<dees-icon icon="lucide:${this.muted || this.volume === 0 ? 'VolumeX' : this.volume < 0.5 ? 'Volume1' : 'Volume2'}"></dees-icon>
|
|
</button>
|
|
<input
|
|
class="volume-slider"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
.value=${String(this.muted ? 0 : this.volume)}
|
|
@input=${this.handleVolumeChange}
|
|
/>
|
|
</div>
|
|
|
|
<button class="ctrl-button" @click=${this.toggleFullscreen} title="Fullscreen">
|
|
<dees-icon icon="lucide:${this.isFullscreen ? 'Minimize' : 'Maximize'}"></dees-icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${this.loading && !this.error ? html`
|
|
<div class="loading-overlay">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${this.error ? html`
|
|
<div class="error-overlay">
|
|
<dees-icon icon="lucide:VideoOff"></dees-icon>
|
|
<span class="error-text">${this.error}</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
public async firstUpdated(): Promise<void> {
|
|
this.videoElement = this.shadowRoot?.querySelector('video') || null;
|
|
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
|
|
}
|
|
|
|
public async disconnectedCallback(): Promise<void> {
|
|
await super.disconnectedCallback();
|
|
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
|
|
if (this.hideControlsTimer) {
|
|
clearTimeout(this.hideControlsTimer);
|
|
}
|
|
}
|
|
|
|
public play(): void {
|
|
this.videoElement?.play();
|
|
}
|
|
|
|
public pause(): void {
|
|
this.videoElement?.pause();
|
|
}
|
|
|
|
public togglePlay(): void {
|
|
if (this.isPlaying) {
|
|
this.pause();
|
|
} else {
|
|
this.play();
|
|
}
|
|
}
|
|
|
|
public seek(time: number): void {
|
|
if (this.videoElement) {
|
|
this.videoElement.currentTime = time;
|
|
}
|
|
}
|
|
|
|
public setVolume(v: number): void {
|
|
this.volume = Math.max(0, Math.min(1, v));
|
|
if (this.videoElement) {
|
|
this.videoElement.volume = this.volume;
|
|
}
|
|
}
|
|
|
|
public toggleFullscreen(): void {
|
|
const container = this.shadowRoot?.querySelector('.video-container') as HTMLElement;
|
|
if (!container) return;
|
|
|
|
if (this.isFullscreen) {
|
|
document.exitFullscreen?.();
|
|
} else {
|
|
container.requestFullscreen?.();
|
|
}
|
|
}
|
|
|
|
private handleLoadedMetadata(): void {
|
|
if (this.videoElement) {
|
|
this.duration = this.videoElement.duration;
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
private handlePlay(): void {
|
|
this.isPlaying = true;
|
|
this.scheduleHideControls();
|
|
}
|
|
|
|
private handlePause(): void {
|
|
this.isPlaying = false;
|
|
this.controlsVisible = true;
|
|
}
|
|
|
|
private handleEnded(): void {
|
|
this.isPlaying = false;
|
|
this.controlsVisible = true;
|
|
}
|
|
|
|
private handleTimeUpdate(): void {
|
|
if (this.videoElement) {
|
|
this.currentTime = this.videoElement.currentTime;
|
|
}
|
|
}
|
|
|
|
private handleError(): void {
|
|
this.error = 'Failed to load video';
|
|
this.loading = false;
|
|
}
|
|
|
|
private handleOverlayClick(): void {
|
|
this.togglePlay();
|
|
}
|
|
|
|
private handleSeek(e: MouseEvent): void {
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const ratio = (e.clientX - rect.left) / rect.width;
|
|
this.seek(ratio * this.duration);
|
|
}
|
|
|
|
private handleVolumeChange(e: Event): void {
|
|
const value = parseFloat((e.target as HTMLInputElement).value);
|
|
this.setVolume(value);
|
|
this.muted = value === 0;
|
|
}
|
|
|
|
private toggleMute(): void {
|
|
this.muted = !this.muted;
|
|
if (this.videoElement) {
|
|
this.videoElement.muted = this.muted;
|
|
}
|
|
}
|
|
|
|
private handleMouseMove(): void {
|
|
this.controlsVisible = true;
|
|
this.scheduleHideControls();
|
|
}
|
|
|
|
private handleMouseLeave(): void {
|
|
if (this.isPlaying) {
|
|
this.controlsVisible = false;
|
|
}
|
|
}
|
|
|
|
private scheduleHideControls(): void {
|
|
if (this.hideControlsTimer) {
|
|
clearTimeout(this.hideControlsTimer);
|
|
}
|
|
if (this.isPlaying) {
|
|
this.hideControlsTimer = setTimeout(() => {
|
|
this.controlsVisible = false;
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
private handleFullscreenChange = (): void => {
|
|
this.isFullscreen = !!document.fullscreenElement;
|
|
};
|
|
|
|
private formatTime(seconds: number): string {
|
|
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
}
|