304 lines
7.6 KiB
TypeScript
304 lines
7.6 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;
|
|
}
|
|
|
|
|
|
.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="tile-badge-corner">${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')}`;
|
|
}
|
|
}
|