import { LitElement, html, css } from 'lit'; import type { PropertyValues, PropertyDeclaration } from 'lit'; import { Engine } from '../engine'; export class VideoPlayer extends LitElement { static styles = css` .video-player { position: relative; width: 100%; max-width: 800px; margin: 0 auto; overflow: hidden; border-radius: 4px; background-color: #000; } video { width: 100%; display: block; cursor: pointer; } .controls-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 10px; background: linear-gradient( 0deg, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0) 100% ); transition: opacity 0.3s ease, transform 0.3s ease; opacity: 0; transform: translateY(100%); z-index: 10; } .show-controls { opacity: 1; transform: translateY(0); } .controls-container { display: flex; align-items: center; justify-content: space-between; padding: 5px 0; color: white; } .controls-left, .controls-right { display: flex; align-items: center; gap: 15px; } .control-button { background: transparent; border: none; color: white; padding: 5px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; border-radius: 4px; } .control-button:hover { background-color: rgba(255, 255, 255, 0.2); } .bili-icon { font-size: 14px; display: inline-flex; align-items: center; justify-content: center; } .time-display { font-size: 12px; color: white; } .timeline { position: relative; height: 3px; background: rgba(255, 255, 255, 0.3); border-radius: 1.5px; cursor: pointer; overflow: hidden; margin-bottom: 10px; transition: height 0.2s ease; } .timeline-hover { height: 5px; } .timeline-buffer { position: absolute; height: 100%; background-color: rgba(255, 255, 255, 0.5); width: 0; pointer-events: none; } .timeline-progress { position: absolute; height: 100%; background-color: #fb7299; width: 0; pointer-events: none; } .timeline-handle { position: absolute; height: 8px; width: 8px; border-radius: 50%; background: #fb7299; top: 50%; transform: translate(-50%, -50%) scale(0.8); pointer-events: none; transition: transform 0.2s ease, height 0.2s ease, width 0.2s ease, box-shadow 0.2s ease; } .timeline-handle-hover { transform: translate(-50%, -50%) scale(1); height: 12px; width: 12px; box-shadow: 0 0 0 4px rgba(251, 114, 153, 0.2); cursor: grab; pointer-events: auto; } /* Playback Rate Control */ .playback-rate-control { position: relative; } .playback-rate-menu { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; padding: 5px; margin-bottom: 5px; display: flex; flex-direction: column; gap: 5px; z-index: 100; } .playback-rate-option { background: transparent; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; padding: 5px 10px; cursor: pointer; font-size: 12px; color: white; transition: all 0.2s; width: 100%; text-align: center; } .playback-rate-option:hover { background-color: rgba(255, 255, 255, 0.2); } .playback-rate-option.active { background-color: #fb7299; color: white; border-color: #fb7299; } /* Volume Control - Fixed vertical implementation */ .volume-control { position: relative; z-index: 101; /* Ensure this is above other controls for mouseleave detection */ } .volume-slider-container { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; padding: 15px 10px; margin-bottom: 5px; z-index: 100; } /* Add a pseudo-element to bridge the gap between button and slider container */ .volume-control:hover .volume-slider-container::after { content: ""; position: absolute; bottom: -15px; /* Match margin-bottom of slider container */ left: 0; width: 100%; height: 15px; background: transparent; } .volume-slider { position: relative; height: 80px; width: 10px; cursor: pointer; user-select: none; } .volume-slider-track { position: absolute; height: 100%; width: 4px; background: rgba(255, 255, 255, 0.3); border-radius: 2px; left: 3px; } .volume-slider-fill { position: absolute; width: 4px; background: #fb7299; border-radius: 2px; left: 3px; bottom: 0; } .volume-slider-thumb { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: #fb7299; left: -1px; transform: translateY(50%); cursor: pointer; } .icon-play, .icon-pause, .icon-forward, .icon-backward, .icon-volume-high, .icon-volume-low, .icon-volume-mute, .icon-fullscreen, .icon-fullscreen-exit { font-style: normal; display: inline-flex; align-items: center; justify-content: center; color: white; } /* Volume icon custom styles */ .icon-volume-mute::before { content: ""; display: inline-block; width: 20px; height: 20px; background: white; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 101.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12A4.5 4.5 0 0014 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24z'/%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 101.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12A4.5 4.5 0 0014 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24z'/%3E%3C/svg%3E") no-repeat 50% 50%; } .icon-volume-low::before { content: ""; display: inline-block; width: 20px; height: 20px; background: white; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 9v6h4l5 5V4l-5 5H7z'/%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 9v6h4l5 5V4l-5 5H7z'/%3E%3C/svg%3E") no-repeat 50% 50%; } .icon-volume-high::before { content: ""; display: inline-block; width: 20px; height: 20px; background: white; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z'/%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z'/%3E%3C/svg%3E") no-repeat 50% 50%; } /* Fullscreen styles */ .video-player:fullscreen { width: 100%; max-width: none; } .video-player:fullscreen video { height: 100vh; object-fit: contain; } /* Loading overlay styles */ .loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } .loading-spinner { display: flex; flex-direction: column; align-items: center; gap: 12px; } .spinner { width: 40px; height: 40px; border: 3px solid rgba(255, 255, 255, 0.3); border-top: 3px solid #ffffff; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .loading-text { color: #ffffff; font-size: 14px; font-weight: 500; text-align: center; } `; static properties = { src: { type: String }, debug: { type: Boolean }, isDragging: { type: Boolean }, isHovering: { type: Boolean }, playbackRate: { type: Number }, isPlaying: { type: Boolean }, currentPosition: { type: Number }, totalDuration: { type: Number }, showControls: { type: Boolean }, volume: { type: Number }, isMuted: { type: Boolean }, showPlaybackRateMenu: { type: Boolean }, showVolumeSlider: { type: Boolean }, isFullscreen: { type: Boolean }, isVolumeDragging: { type: Boolean }, singleFmp4: { type: Boolean }, isWideScreen: { type: Boolean }, isLoading: { type: Boolean }, isUserPaused: { type: Boolean }, }; // Property declarations declare src?: string; declare debug: boolean; declare isDragging: boolean; declare isHovering: boolean; declare playbackRate: number; declare isPlaying: boolean; declare currentPosition: number; declare totalDuration: number; declare showControls: boolean; declare volume: number; declare isMuted: boolean; declare showPlaybackRateMenu: boolean; declare showVolumeSlider: boolean; declare isFullscreen: boolean; declare isVolumeDragging: boolean; declare singleFmp4: boolean; declare isWideScreen: boolean; declare isLoading: boolean; declare isUserPaused: boolean; constructor() { super(); this.debug = false; this.isDragging = false; this.isHovering = false; this.playbackRate = 1; this.isPlaying = false; this.currentPosition = 0; this.totalDuration = 0; this.showControls = false; this.volume = 1; this.isMuted = false; this.showPlaybackRateMenu = false; this.showVolumeSlider = false; this.isFullscreen = false; this.isVolumeDragging = false; this.singleFmp4 = false; this.isWideScreen = true; this.isLoading = false; this.isUserPaused = false; } private _video?: HTMLVideoElement; private _timelineRef?: HTMLDivElement; private _progressRef?: HTMLDivElement; private _bufferRef?: HTMLDivElement; private _playerRef?: HTMLDivElement; private engine?: Engine; private hideControlsTimeoutId: number | null = null; private playbackRates = [0.5, 1, 1.5, 2, 3, 4]; // Getters for DOM references get video(): HTMLVideoElement { if (!this._video) { this._video = this.renderRoot.querySelector('video') as HTMLVideoElement; } return this._video; } get timelineRef(): HTMLDivElement { if (!this._timelineRef) { this._timelineRef = this.renderRoot.querySelector('.timeline') as HTMLDivElement; } return this._timelineRef; } get progressRef(): HTMLDivElement { if (!this._progressRef) { this._progressRef = this.renderRoot.querySelector('.timeline-progress') as HTMLDivElement; } return this._progressRef; } get bufferRef(): HTMLDivElement { if (!this._bufferRef) { this._bufferRef = this.renderRoot.querySelector('.timeline-buffer') as HTMLDivElement; } return this._bufferRef; } get playerRef(): HTMLDivElement { if (!this._playerRef) { this._playerRef = this.renderRoot.querySelector('.video-player') as HTMLDivElement; } return this._playerRef; } // Lifecycle methods connectedCallback() { super.connectedCallback(); this.addEventListener('keydown', this.handleKeyDown); } disconnectedCallback() { super.disconnectedCallback(); if (this.engine) { this.engine.destroy(); } if (this.hideControlsTimeoutId !== null) { window.clearTimeout(this.hideControlsTimeoutId); } this.video?.removeEventListener('timeupdate', this.updateTimelineUI); } firstUpdated() { // Setup video element and events after first render if (this.video) { // Listen to timeupdate event to update timeline UI this.video.addEventListener('timeupdate', this.updateTimelineUI); // Listen for play and pause events to update state this.video.addEventListener('play', () => { this.isPlaying = true; // 当用户主动播放时,隐藏 loading if (!this.isUserPaused) { this.isLoading = false; } }); this.video.addEventListener('pause', () => { this.isPlaying = false; // 当视频暂停时,如果不是用户主动暂停,可能需要显示 loading }); // 监听视频的 waiting 事件(缓冲中) this.video.addEventListener('waiting', () => { // 只有在非用户主动暂停状态下才显示 loading if (!this.isUserPaused) { this.isLoading = true; } }); // 监听视频的 canplay 事件(可以播放) this.video.addEventListener('canplay', () => { // 当视频可以播放时,隐藏 loading this.isLoading = false; }); // 监听视频的 playing 事件(正在播放) this.video.addEventListener('playing', () => { // 当视频开始播放时,隐藏 loading this.isLoading = false; }); // 监听视频的 error 事件(timeline 出错时) this.video.addEventListener('error', (e) => { console.error('Video error occurred:', e); // timeline 出错时也要隐藏 loading this.isLoading = false; }); // Initialize volume this.video.volume = this.volume; } // Set up ResizeObserver to check screen width if (this.playerRef) { const resizeObserver = new ResizeObserver(() => { this.checkScreenWidth(); }); resizeObserver.observe(this.playerRef); // Initial check this.checkScreenWidth(); } // Set up the timeline if src is provided if (this.src) { this.setupEngine(); } } updated(changedProperties: PropertyValues) { if (changedProperties.has('src')) { this.setupEngine(); } } // Helper methods private formatTime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const pad = (num: number) => num.toString().padStart(2, '0'); if (hours > 0) { return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; } else { return `${pad(minutes)}:${pad(secs)}`; } } private checkScreenWidth() { if (!this.playerRef) return; this.isWideScreen = this.playerRef.offsetWidth >= 400; // Set threshold for "wide" screen } private setupEngine() { if (!this.video || !this.src) return; if (this.engine) { this.engine.destroy(); } this.isLoading = true; this.currentPosition = 0; this.totalDuration = 0; this.engine = new Engine(this.video, { debug: this.debug, autoPlay: false }); this.engine.load(this.src).then(() => { this.totalDuration = this.engine!.totalDuration; this.isLoading = false; }).catch((error) => { console.error('Failed to load video:', error); this.isLoading = false; }); } private updateTimelineUI = () => { if (!this.engine || !this.progressRef || !this.bufferRef) return; this.currentPosition = this.engine.position; if (this.totalDuration !== this.engine.totalDuration) { this.totalDuration = this.engine.totalDuration; } const percentage = (this.engine.position / this.totalDuration) * 100; this.progressRef.style.width = `${percentage}%`; const bufferPercentage = (this.engine.bufferedLength / this.totalDuration) * 100; this.bufferRef.style.width = `${bufferPercentage}%`; }; private handleTimelineClick(event: MouseEvent) { if (!this.timelineRef || !this.engine) return; const rect = this.timelineRef.getBoundingClientRect(); const clickPosition = (event.clientX - rect.left) / rect.width; const seekTime = clickPosition * this.totalDuration; this.engine.seek(seekTime); this.currentPosition = seekTime; } private togglePlay() { if (!this.video || !this.engine) return; if (this.engine.isPlaying) { this.engine.pause(); } else { this.engine.play(); } } private changePlaybackRate(rate: number) { if (!this.video || !this.engine) return; this.playbackRate = rate; this.video.playbackRate = rate; this.showPlaybackRateMenu = false; } private togglePlaybackRateMenu() { this.showPlaybackRateMenu = !this.showPlaybackRateMenu; } private toggleMute() { if (!this.video) return; this.isMuted = !this.isMuted; this.video.muted = this.isMuted; } private handleVolumeChange(event: MouseEvent) { if (!this.video) return; // Get the volume slider element and its dimensions const slider = event.currentTarget as HTMLElement; const rect = slider.getBoundingClientRect(); this.updateVolumeFromPosition(event.clientY, rect); } private updateVolumeFromPosition(clientY: number, rect: DOMRect) { // Calculate volume: 1 at top (minimum clientY), 0 at bottom (maximum clientY) const sliderHeight = rect.height; const volumePercentage = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / sliderHeight)); const clampedVolume = Math.max(0, Math.min(1, volumePercentage)); this.volume = clampedVolume; if (this.video) { this.video.volume = clampedVolume; // If volume is set to 0, consider it as muted this.isMuted = clampedVolume === 0; this.video.muted = clampedVolume === 0; } } private startVolumeDrag(event: MouseEvent) { event.preventDefault(); this.isVolumeDragging = true; // Store the slider reference for use during drag const slider = event.currentTarget as HTMLElement; const sliderRect = slider.getBoundingClientRect(); // Initial volume update this.updateVolumeFromPosition(event.clientY, sliderRect); // Create a handler that uses the stored slider reference const handleDrag = (e: MouseEvent) => { if (this.isVolumeDragging) { this.updateVolumeFromPosition(e.clientY, sliderRect); } }; // Add global event listeners for drag tracking document.addEventListener('mousemove', handleDrag); const stopDrag = () => { this.isVolumeDragging = false; document.removeEventListener('mousemove', handleDrag); document.removeEventListener('mouseup', stopDrag); // Allow the slider to hide if mouse is not over the volume control setTimeout(() => { if (!document.querySelector('.volume-control:hover')) { this.showVolumeSlider = false; } }, 500); }; document.addEventListener('mouseup', stopDrag); } private seekForward() { if (!this.engine) return; const newTime = Math.min(this.engine.position + 10, this.totalDuration); this.engine.seek(newTime); this.currentPosition = newTime; } private seekBackward() { if (!this.engine) return; const newTime = Math.max(this.engine.position - 10, 0); this.engine.seek(newTime); this.currentPosition = newTime; } private handleMouseEnter() { this.showControls = true; if (this.hideControlsTimeoutId !== null) { window.clearTimeout(this.hideControlsTimeoutId); this.hideControlsTimeoutId = null; } } private handleMouseLeave() { if (this.isDragging) return; this.hideControlsTimeoutId = window.setTimeout(() => { this.showControls = false; }, 2000); } private handleMouseMove() { this.handleMouseEnter(); } private toggleFullscreen() { if (!this.playerRef) return; if (!document.fullscreenElement) { this.playerRef .requestFullscreen() .then(() => { this.isFullscreen = true; }) .catch((err) => { console.error(`Error attempting to enable fullscreen: ${err.message}`); }); } else { document .exitFullscreen() .then(() => { this.isFullscreen = false; }) .catch((err) => { console.error(`Error attempting to exit fullscreen: ${err.message}`); }); } } private handleKeyDown = (event: KeyboardEvent) => { // Handle keyboard shortcuts switch (event.key) { case ' ': case 'k': this.togglePlay(); event.preventDefault(); break; case 'ArrowRight': this.seekForward(); event.preventDefault(); break; case 'ArrowLeft': this.seekBackward(); event.preventDefault(); break; case 'f': this.toggleFullscreen(); event.preventDefault(); break; case 'm': this.toggleMute(); event.preventDefault(); break; } }; private onTimelineMouseEnter() { this.isHovering = true; } private onTimelineMouseLeave() { this.isHovering = false; } // Public methods seek(time: number) { if (this.engine) { this.engine.seek(time); this.currentPosition = time; } } // Render methods render() { const formattedCurrentTime = this.formatTime(this.currentPosition); const formattedTotalDuration = this.formatTime(this.totalDuration); return html`