Files
dees-catalog/ts_web/elements/00group-media/dees-tile-video/component.ts

318 lines
8.0 KiB
TypeScript

import {
property,
state,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-video': DeesTileVideo;
}
}
@customElement('dees-tile-video')
export class DeesTileVideo extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.video-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-wrapper canvas {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
backdrop-filter: blur(8px);
z-index: 10;
}
.play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 15;
pointer-events: none;
transition: opacity 0.2s ease;
}
.play-overlay dees-icon {
font-size: 20px;
color: white;
}
.tile-container.clickable:hover .play-overlay {
opacity: 0;
}
.video-hover-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 5;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-hover-preview.active {
opacity: 1;
}
`,
] as any;
@property({ type: String })
accessor src: string = '';
@property({ type: String })
accessor poster: string = '';
@state()
accessor duration: number = 0;
@state()
accessor thumbnailCaptured: boolean = false;
@state()
accessor isHovering: boolean = false;
private thumbnailCanvas: HTMLCanvasElement | null = null;
private hoverVideo: HTMLVideoElement | null = null;
private hasStartedLoading: boolean = false;
protected renderTileContent(): TemplateResult {
return html`
<div class="video-wrapper">
${this.poster ? html`
<img class="poster-image" src="${this.poster}" alt="" />
` : this.thumbnailCaptured ? html`
<canvas></canvas>
` : html`
<div style="width: 100%; height: 100%; background: #000;"></div>
`}
${this.isHovering && this.src ? html`
<video
class="video-hover-preview ${this.isHovering ? 'active' : ''}"
.src=${this.src}
muted
playsinline
@loadeddata=${this.handleHoverVideoLoaded}
></video>
` : ''}
</div>
${this.duration > 0 ? html`
<div class="duration-badge">${this.formatTime(this.duration)}</div>
` : ''}
${!this.isHovering ? html`
<div class="play-overlay">
<dees-icon icon="lucide:Play"></dees-icon>
</div>
` : ''}
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:Play"></dees-icon>
<span>Play Video</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
src: this.src,
poster: this.poster,
duration: this.duration,
};
}
protected onBecameVisible(): void {
if (!this.hasStartedLoading && this.src) {
this.hasStartedLoading = true;
this.captureFirstFrame();
}
}
private async captureFirstFrame(): Promise<void> {
if (this.poster) {
// If poster is provided, just load duration
this.loadDuration();
return;
}
this.loading = true;
try {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.preload = 'metadata';
await new Promise<void>((resolve, reject) => {
video.addEventListener('loadeddata', () => {
this.duration = video.duration;
// Capture the first frame
video.currentTime = 0.1; // Slightly after start for better frame
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0);
this.thumbnailCanvas = canvas;
this.thumbnailCaptured = true;
}
// Clean up
video.src = '';
video.load();
resolve();
}, { once: true });
}, { once: true });
video.addEventListener('error', () => reject(new Error('Failed to load video')), { once: true });
video.src = this.src;
});
this.loading = false;
// Copy thumbnail to shadow DOM canvas
await this.updateComplete;
this.copyThumbnailToCanvas();
} catch {
this.loading = false;
// Don't set error for thumbnail failure
this.loadDuration();
}
}
private loadDuration(): void {
const video = document.createElement('video');
video.preload = 'metadata';
video.addEventListener('loadedmetadata', () => {
this.duration = video.duration;
video.src = '';
video.load();
});
video.src = this.src;
}
private copyThumbnailToCanvas(): void {
if (!this.thumbnailCanvas) return;
const canvas = this.shadowRoot?.querySelector('.video-wrapper canvas') as HTMLCanvasElement;
if (!canvas) return;
canvas.width = this.thumbnailCanvas.width;
canvas.height = this.thumbnailCanvas.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(this.thumbnailCanvas, 0, 0);
}
}
protected onTileMouseEnter(): void {
this.isHovering = true;
}
protected onTileMouseLeave(): void {
this.isHovering = false;
// The video element will be removed from DOM by the template
this.hoverVideo = null;
}
private handleHoverVideoLoaded(e: Event): void {
this.hoverVideo = e.target as HTMLVideoElement;
this.hoverVideo.play().catch(() => {
// Autoplay may be blocked
});
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('src') && this.src && this.isVisible) {
this.hasStartedLoading = true;
this.thumbnailCaptured = false;
this.duration = 0;
this.captureFirstFrame();
}
if (changedProperties.has('thumbnailCaptured') && this.thumbnailCaptured) {
await this.updateComplete;
this.copyThumbnailToCanvas();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.hoverVideo) {
this.hoverVideo.pause();
this.hoverVideo.src = '';
this.hoverVideo = null;
}
this.thumbnailCanvas = null;
}
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')}`;
}
}