Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
<div class="audio-player-shell">
|
||||
<audio
|
||||
#audioEl
|
||||
[src]="src()"
|
||||
preload="metadata"
|
||||
(ended)="onPause()"
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(pause)="onPause()"
|
||||
(play)="onPlay()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(volumechange)="onVolumeChange()"
|
||||
></audio>
|
||||
|
||||
<div class="audio-top-bar">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
|
||||
@if (sizeLabel()) {
|
||||
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="audio-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save audio to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audio-body">
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePlayback()"
|
||||
class="audio-play-btn"
|
||||
[title]="isPlaying() ? 'Pause' : 'Play'"
|
||||
[attr.aria-label]="isPlaying() ? 'Pause audio' : 'Play audio'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="audio-main">
|
||||
<div
|
||||
class="audio-waveform-panel"
|
||||
[class.expanded]="waveformExpanded()"
|
||||
[attr.aria-hidden]="!waveformExpanded()"
|
||||
>
|
||||
<div class="audio-waveform-shell">
|
||||
<div
|
||||
#waveformContainer
|
||||
class="audio-waveform-container"
|
||||
[class.invisible]="waveformLoading() || waveformUnavailable()"
|
||||
></div>
|
||||
|
||||
@if (waveformLoading()) {
|
||||
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform…</div>
|
||||
} @else if (waveformUnavailable()) {
|
||||
<div class="audio-waveform-overlay text-muted-foreground">
|
||||
Couldn’t render a waveform preview for this file, but playback still works.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="durationSeconds() || 0"
|
||||
[value]="currentTimeSeconds()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.background]="seekTrackBackground()"
|
||||
aria-label="Seek audio"
|
||||
/>
|
||||
|
||||
<div class="audio-controls-row">
|
||||
<span class="audio-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
|
||||
|
||||
<div class="audio-actions-group">
|
||||
<div class="audio-volume-group">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
class="audio-control-btn"
|
||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||
[attr.aria-label]="isMuted() ? 'Unmute audio' : 'Mute audio'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="displayVolumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.background]="volumeTrackBackground()"
|
||||
aria-label="Audio volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleWaveform()"
|
||||
class="audio-control-btn"
|
||||
[title]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
|
||||
[attr.aria-label]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="waveformToggleIcon()"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,255 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.audio-player-shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background:
|
||||
radial-gradient(circle at top left, hsl(var(--primary) / 0.14), transparent 42%),
|
||||
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(var(--secondary) / 0.55) 100%);
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.audio-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0.875rem 0.625rem;
|
||||
}
|
||||
|
||||
.audio-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.875rem;
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
min-width: 3.25rem;
|
||||
border: 1px solid hsl(var(--primary) / 0.35);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: linear-gradient(180deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.76) 100%);
|
||||
box-shadow: 0 10px 22px rgb(0 0 0 / 16%);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.audio-play-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.audio-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audio-waveform-panel {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
margin-bottom 0.2s ease;
|
||||
}
|
||||
|
||||
.audio-waveform-panel.expanded {
|
||||
max-height: 5.5rem;
|
||||
opacity: 1;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-waveform-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 4.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 0.95rem;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--background) / 0.72) 0%, hsl(var(--secondary) / 0.28) 100%);
|
||||
}
|
||||
|
||||
.audio-waveform-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.audio-waveform-container.invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.audio-waveform-container ::part(wrapper) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audio-waveform-container ::part(cursor) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-waveform-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.audio-time-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audio-actions-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audio-volume-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audio-control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card) / 0.72);
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.audio-control-btn:hover {
|
||||
border-color: hsl(var(--primary) / 0.75);
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.seek-slider,
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 5.5rem;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-runnable-track,
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-thumb,
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-track,
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-thumb,
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
@keyframes audio-wave-motion {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.audio-body {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.audio-volume-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-waveform-shell {
|
||||
height: 3.75rem;
|
||||
}
|
||||
|
||||
.audio-controls-row {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import type WaveSurfer from 'wavesurfer.js';
|
||||
import {
|
||||
lucideChevronDown,
|
||||
lucideChevronUp,
|
||||
lucideDownload,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
const AUDIO_PLAYER_VOLUME_STORAGE_KEY = 'metoyou_audio_player_volume';
|
||||
const DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT = 50;
|
||||
|
||||
function getInitialSharedAudioPlayerVolume(): number {
|
||||
if (typeof window === 'undefined')
|
||||
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY);
|
||||
const parsed = Number(raw);
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 100)
|
||||
return parsed;
|
||||
} catch { /* ignore storage access issues */ }
|
||||
|
||||
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
|
||||
}
|
||||
|
||||
function persistSharedAudioPlayerVolume(volumePercent: number): void {
|
||||
if (typeof window === 'undefined')
|
||||
return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY, String(volumePercent));
|
||||
} catch { /* ignore storage access issues */ }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-audio-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideChevronDown,
|
||||
lucideChevronUp,
|
||||
lucideDownload,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-audio-player.component.html',
|
||||
styleUrl: './chat-audio-player.component.scss'
|
||||
})
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
downloadRequested = output<undefined>();
|
||||
|
||||
@ViewChild('audioEl') audioRef?: ElementRef<HTMLAudioElement>;
|
||||
@ViewChild('waveformContainer') waveformContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
isPlaying = signal(false);
|
||||
isMuted = signal(false);
|
||||
waveformExpanded = signal(false);
|
||||
waveformLoading = signal(false);
|
||||
waveformUnavailable = signal(false);
|
||||
currentTimeSeconds = signal(0);
|
||||
durationSeconds = signal(0);
|
||||
volumePercent = signal(getInitialSharedAudioPlayerVolume());
|
||||
private lastNonZeroVolume = signal(getInitialSharedAudioPlayerVolume());
|
||||
private waveSurfer: WaveSurfer | null = null;
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const duration = this.durationSeconds();
|
||||
|
||||
if (duration <= 0)
|
||||
return 0;
|
||||
|
||||
return (this.currentTimeSeconds() / duration) * 100;
|
||||
});
|
||||
seekTrackBackground = computed(() => this.buildSliderBackground(this.progressPercent()));
|
||||
waveformToggleIcon = computed(() => this.waveformExpanded() ? 'lucideChevronUp' : 'lucideChevronDown');
|
||||
displayVolumePercent = computed(() => this.isMuted() ? 0 : this.volumePercent());
|
||||
volumeTrackBackground = computed(() => {
|
||||
const volume = Math.max(0, Math.min(100, this.displayVolumePercent()));
|
||||
|
||||
return this.buildSliderBackground(volume);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
void this.src();
|
||||
const storedVolume = getInitialSharedAudioPlayerVolume();
|
||||
|
||||
this.destroyWaveSurfer();
|
||||
this.waveformExpanded.set(false);
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(false);
|
||||
this.currentTimeSeconds.set(0);
|
||||
this.durationSeconds.set(0);
|
||||
this.isPlaying.set(false);
|
||||
this.isMuted.set(false);
|
||||
this.volumePercent.set(storedVolume);
|
||||
this.lastNonZeroVolume.set(storedVolume);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyWaveSurfer();
|
||||
}
|
||||
|
||||
togglePlayback(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
if (audio.paused || audio.ended) {
|
||||
void audio.play().catch(() => {
|
||||
this.isPlaying.set(false);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
}
|
||||
|
||||
toggleWaveform(): void {
|
||||
const nextExpanded = !this.waveformExpanded();
|
||||
|
||||
this.waveformExpanded.set(nextExpanded);
|
||||
|
||||
if (nextExpanded) {
|
||||
requestAnimationFrame(() => {
|
||||
void this.ensureWaveformLoaded();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onLoadedMetadata(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
this.applyAudioVolume(this.volumePercent());
|
||||
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||
this.currentTimeSeconds.set(audio.currentTime || 0);
|
||||
}
|
||||
|
||||
onTimeUpdate(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
this.currentTimeSeconds.set(audio.currentTime || 0);
|
||||
}
|
||||
|
||||
onPlay(): void {
|
||||
this.isPlaying.set(true);
|
||||
}
|
||||
|
||||
onPause(): void {
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
|
||||
onSeek(event: Event): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
const nextTime = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
if (!Number.isFinite(nextTime))
|
||||
return;
|
||||
|
||||
audio.currentTime = nextTime;
|
||||
this.currentTimeSeconds.set(nextTime);
|
||||
}
|
||||
|
||||
onVolumeInput(event: Event): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
|
||||
|
||||
if (nextVolume <= 0) {
|
||||
audio.volume = 0;
|
||||
audio.muted = true;
|
||||
this.isMuted.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.volume = nextVolume / 100;
|
||||
audio.muted = false;
|
||||
|
||||
this.volumePercent.set(nextVolume);
|
||||
this.lastNonZeroVolume.set(nextVolume);
|
||||
this.isMuted.set(false);
|
||||
this.setSharedVolume(nextVolume);
|
||||
}
|
||||
|
||||
onVolumeChange(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
this.isMuted.set(audio.muted || audio.volume === 0);
|
||||
|
||||
if (!audio.muted && audio.volume > 0) {
|
||||
const volume = Math.round(audio.volume * 100);
|
||||
|
||||
this.volumePercent.set(volume);
|
||||
this.lastNonZeroVolume.set(volume);
|
||||
this.setSharedVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
if (audio.muted || audio.volume === 0) {
|
||||
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
|
||||
|
||||
audio.muted = false;
|
||||
audio.volume = restoredVolume / 100;
|
||||
this.volumePercent.set(restoredVolume);
|
||||
this.isMuted.set(false);
|
||||
this.setSharedVolume(restoredVolume);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.muted = true;
|
||||
this.isMuted.set(true);
|
||||
}
|
||||
|
||||
requestDownload(): void {
|
||||
this.downloadRequested.emit(undefined);
|
||||
}
|
||||
|
||||
private async ensureWaveformLoaded(): Promise<void> {
|
||||
if (this.waveformLoading() || this.waveSurfer || this.waveformUnavailable())
|
||||
return;
|
||||
|
||||
const source = this.src();
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
const waveformContainer = this.waveformContainer?.nativeElement;
|
||||
|
||||
if (!source || !audio || !waveformContainer)
|
||||
return;
|
||||
|
||||
this.waveformLoading.set(true);
|
||||
|
||||
try {
|
||||
const { default: WaveSurfer } = await import('wavesurfer.js');
|
||||
|
||||
this.waveSurfer = WaveSurfer.create({
|
||||
barGap: 2,
|
||||
barRadius: 999,
|
||||
barWidth: 3,
|
||||
container: waveformContainer,
|
||||
cursorWidth: 0,
|
||||
dragToSeek: true,
|
||||
height: 56,
|
||||
hideScrollbar: true,
|
||||
interact: true,
|
||||
media: audio,
|
||||
normalize: true,
|
||||
progressColor: this.resolveThemeColor('--primary', 'hsl(262 83% 58%)', 0.9),
|
||||
waveColor: this.resolveThemeColor('--foreground', 'hsl(215 16% 47%)', 0.22)
|
||||
});
|
||||
|
||||
this.waveSurfer.on('error', () => {
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(true);
|
||||
this.destroyWaveSurfer();
|
||||
});
|
||||
|
||||
this.waveSurfer.on('ready', () => {
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(false);
|
||||
});
|
||||
} catch {
|
||||
this.destroyWaveSurfer();
|
||||
this.waveformUnavailable.set(true);
|
||||
} finally {
|
||||
if (this.waveformUnavailable()) {
|
||||
this.waveformLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private destroyWaveSurfer(): void {
|
||||
if (!this.waveSurfer)
|
||||
return;
|
||||
|
||||
this.waveSurfer.destroy();
|
||||
this.waveSurfer = null;
|
||||
}
|
||||
|
||||
private resolveThemeColor(cssVarName: string, fallback: string, alpha: number): string {
|
||||
if (typeof window === 'undefined')
|
||||
return fallback;
|
||||
|
||||
const rawValue = window.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(cssVarName)
|
||||
.trim();
|
||||
|
||||
if (!rawValue)
|
||||
return fallback;
|
||||
|
||||
return `hsl(${rawValue} / ${alpha})`;
|
||||
}
|
||||
|
||||
private applyAudioVolume(volumePercent: number): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
audio.volume = this.isMuted() ? 0 : volumePercent / 100;
|
||||
audio.muted = this.isMuted();
|
||||
}
|
||||
|
||||
private setSharedVolume(volumePercent: number): void {
|
||||
persistSharedAudioPlayerVolume(volumePercent);
|
||||
}
|
||||
|
||||
private buildSliderBackground(fillPercent: number): string {
|
||||
return [
|
||||
'linear-gradient(90deg, ',
|
||||
'hsl(var(--primary)) 0%, ',
|
||||
`hsl(var(--primary)) ${fillPercent}%, `,
|
||||
`hsl(var(--secondary)) ${fillPercent}%, `,
|
||||
'hsl(var(--secondary)) 100%)'
|
||||
].join('');
|
||||
}
|
||||
|
||||
formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0)
|
||||
return '0:00';
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<div
|
||||
#playerRoot
|
||||
class="video-player-shell"
|
||||
[class.fullscreen]="isFullscreen()"
|
||||
[class.controls-hidden]="isFullscreen() && !controlsVisible()"
|
||||
(mousemove)="onPlayerMouseMove()"
|
||||
>
|
||||
@if (!isFullscreen()) {
|
||||
<div class="video-top-bar">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
|
||||
@if (sizeLabel()) {
|
||||
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="video-stage"
|
||||
(click)="onVideoClick()"
|
||||
(dblclick)="onVideoDoubleClick($event)"
|
||||
(keydown.enter)="onVideoClick()"
|
||||
(keydown.space)="onVideoClick(); $event.preventDefault()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Toggle video playback"
|
||||
>
|
||||
<video
|
||||
#videoEl
|
||||
[src]="src()"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="chat-video-element"
|
||||
(ended)="onPause()"
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(pause)="onPause()"
|
||||
(play)="onPlay()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(volumechange)="onVolumeChange()"
|
||||
></video>
|
||||
|
||||
@if (!isPlaying()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="onOverlayPlayClick($event)"
|
||||
class="video-play-overlay"
|
||||
title="Play video"
|
||||
aria-label="Play video"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
class="w-8 h-8"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="video-bottom-bar"
|
||||
[class.fullscreen-overlay]="isFullscreen()"
|
||||
[class.hidden-overlay]="isFullscreen() && !controlsVisible()"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="durationSeconds() || 0"
|
||||
[value]="currentTimeSeconds()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.background]="seekTrackBackground()"
|
||||
aria-label="Seek video"
|
||||
/>
|
||||
|
||||
<div class="video-controls-row">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePlayback()"
|
||||
class="video-control-btn"
|
||||
[title]="isPlaying() ? 'Pause' : 'Play'"
|
||||
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span class="video-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
|
||||
</div>
|
||||
|
||||
<div class="video-volume-group">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
class="video-control-btn"
|
||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="isMuted() ? 0 : volumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.background]="volumeTrackBackground()"
|
||||
aria-label="Video volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (isFullscreen()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleFullscreen()"
|
||||
class="video-control-btn"
|
||||
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isFullscreen() ? 'lucideMinimize' : 'lucideMaximize'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,243 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.video-player-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.16), transparent 38%),
|
||||
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(222deg 47% 8%) 100%);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.video-top-bar,
|
||||
.video-bottom-bar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.video-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0.875rem 0.625rem;
|
||||
background: linear-gradient(180deg, rgb(6 10 18 / 82%) 0%, rgb(6 10 18 / 30%) 100%);
|
||||
}
|
||||
|
||||
.video-stage {
|
||||
position: relative;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen .video-stage {
|
||||
height: 100vh;
|
||||
background: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen.controls-hidden .video-stage,
|
||||
.video-player-shell.fullscreen.controls-hidden .chat-video-element {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.chat-video-element {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: min(28rem, 70vh);
|
||||
background: rgb(0 0 0 / 85%);
|
||||
cursor: pointer;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen .chat-video-element {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.video-play-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: rgb(8 14 24 / 78%);
|
||||
box-shadow: 0 12px 24px rgb(0 0 0 / 35%);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.video-play-overlay:hover {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
background: rgb(12 18 30 / 88%);
|
||||
}
|
||||
|
||||
.video-bottom-bar {
|
||||
padding: 0.75rem 0.875rem 0.875rem;
|
||||
background: linear-gradient(180deg, rgb(6 10 18 / 38%) 0%, rgb(6 10 18 / 86%) 100%);
|
||||
}
|
||||
|
||||
.video-bottom-bar.fullscreen-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
padding-bottom: max(0.875rem, env(safe-area-inset-bottom));
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.video-bottom-bar.hidden-overlay {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.video-control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card) / 0.72);
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.video-control-btn:hover {
|
||||
border-color: hsl(var(--primary) / 0.75);
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.video-time-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-volume-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.seek-slider,
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--primary)) var(--value, 0%), hsl(var(--secondary)) var(--value, 0%), hsl(var(--secondary)) 100%);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 5.5rem;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-runnable-track,
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-thumb,
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-track,
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-thumb,
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.video-top-bar {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.video-bottom-bar {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.video-controls-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.video-volume-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-time-label {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideDownload,
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-video-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDownload,
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-video-player.component.html',
|
||||
styleUrl: './chat-video-player.component.scss'
|
||||
})
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
downloadRequested = output<undefined>();
|
||||
|
||||
private readonly SINGLE_CLICK_DELAY_MS = 300;
|
||||
private readonly FULLSCREEN_IDLE_MS = 2200;
|
||||
|
||||
@ViewChild('playerRoot') playerRoot?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('videoEl') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
|
||||
isPlaying = signal(false);
|
||||
isMuted = signal(false);
|
||||
isFullscreen = signal(false);
|
||||
controlsVisible = signal(true);
|
||||
currentTimeSeconds = signal(0);
|
||||
durationSeconds = signal(0);
|
||||
volumePercent = signal(100);
|
||||
private lastNonZeroVolume = signal(100);
|
||||
private singleClickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const duration = this.durationSeconds();
|
||||
|
||||
if (duration <= 0)
|
||||
return 0;
|
||||
|
||||
return (this.currentTimeSeconds() / duration) * 100;
|
||||
});
|
||||
seekTrackBackground = computed(() => {
|
||||
const progress = Math.max(0, Math.min(100, this.progressPercent()));
|
||||
|
||||
return this.buildSliderBackground(progress);
|
||||
});
|
||||
volumeTrackBackground = computed(() => {
|
||||
const volume = Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()));
|
||||
|
||||
return this.buildSliderBackground(volume);
|
||||
});
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
const player = this.playerRoot?.nativeElement;
|
||||
const isFullscreen = !!player && document.fullscreenElement === player;
|
||||
|
||||
this.isFullscreen.set(isFullscreen);
|
||||
|
||||
if (isFullscreen) {
|
||||
this.revealControlsTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearControlsHideTimer();
|
||||
this.clearSingleClickTimer();
|
||||
}
|
||||
|
||||
onPlayerMouseMove(): void {
|
||||
if (!this.isFullscreen())
|
||||
return;
|
||||
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onVideoClick(): void {
|
||||
this.clearSingleClickTimer();
|
||||
this.revealControlsTemporarily();
|
||||
this.singleClickTimer = setTimeout(() => {
|
||||
this.singleClickTimer = null;
|
||||
this.togglePlayback();
|
||||
}, this.SINGLE_CLICK_DELAY_MS);
|
||||
}
|
||||
|
||||
onVideoDoubleClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.clearSingleClickTimer();
|
||||
void this.toggleFullscreen();
|
||||
}
|
||||
|
||||
onOverlayPlayClick(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.togglePlayback();
|
||||
}
|
||||
|
||||
togglePlayback(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
if (video.paused || video.ended) {
|
||||
void video.play().catch(() => {
|
||||
this.isPlaying.set(false);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
video.pause();
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onLoadedMetadata(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
this.durationSeconds.set(Number.isFinite(video.duration) ? video.duration : 0);
|
||||
this.currentTimeSeconds.set(video.currentTime || 0);
|
||||
}
|
||||
|
||||
onTimeUpdate(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
this.currentTimeSeconds.set(video.currentTime || 0);
|
||||
}
|
||||
|
||||
onPlay(): void {
|
||||
this.isPlaying.set(true);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onPause(): void {
|
||||
this.isPlaying.set(false);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onSeek(event: Event): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
const nextTime = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
if (!Number.isFinite(nextTime))
|
||||
return;
|
||||
|
||||
video.currentTime = nextTime;
|
||||
this.currentTimeSeconds.set(nextTime);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onVolumeInput(event: Event): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
|
||||
|
||||
video.volume = nextVolume / 100;
|
||||
video.muted = nextVolume === 0;
|
||||
|
||||
if (nextVolume > 0)
|
||||
this.lastNonZeroVolume.set(nextVolume);
|
||||
|
||||
this.volumePercent.set(nextVolume);
|
||||
this.isMuted.set(video.muted);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onVolumeChange(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
this.isMuted.set(video.muted || video.volume === 0);
|
||||
|
||||
if (!video.muted && video.volume > 0) {
|
||||
const volume = Math.round(video.volume * 100);
|
||||
|
||||
this.volumePercent.set(volume);
|
||||
this.lastNonZeroVolume.set(volume);
|
||||
}
|
||||
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
if (video.muted || video.volume === 0) {
|
||||
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
|
||||
|
||||
video.muted = false;
|
||||
video.volume = restoredVolume / 100;
|
||||
this.volumePercent.set(restoredVolume);
|
||||
this.isMuted.set(false);
|
||||
this.revealControlsTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
video.muted = true;
|
||||
this.isMuted.set(true);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
const player = this.playerRoot?.nativeElement;
|
||||
|
||||
if (!player)
|
||||
return;
|
||||
|
||||
if (document.fullscreenElement === player) {
|
||||
await document.exitFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
await player.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
|
||||
requestDownload(): void {
|
||||
this.downloadRequested.emit(undefined);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
private buildSliderBackground(fillPercent: number): string {
|
||||
return [
|
||||
'linear-gradient(90deg, ',
|
||||
'hsl(var(--primary)) 0%, ',
|
||||
`hsl(var(--primary)) ${fillPercent}%, `,
|
||||
`hsl(var(--secondary)) ${fillPercent}%, `,
|
||||
'hsl(var(--secondary)) 100%)'
|
||||
].join('');
|
||||
}
|
||||
|
||||
private revealControlsTemporarily(): void {
|
||||
if (!this.isFullscreen()) {
|
||||
this.controlsVisible.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
this.controlsHideTimer = setTimeout(() => {
|
||||
this.controlsVisible.set(false);
|
||||
}, this.FULLSCREEN_IDLE_MS);
|
||||
}
|
||||
|
||||
private clearControlsHideTimer(): void {
|
||||
if (this.controlsHideTimer) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSingleClickTimer(): void {
|
||||
if (this.singleClickTimer) {
|
||||
clearTimeout(this.singleClickTimer);
|
||||
this.singleClickTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0)
|
||||
return '0:00';
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close dialog"
|
||||
></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||
[class]="widthClass()"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button
|
||||
(click)="cancelled.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
|
||||
>
|
||||
{{ cancelLabel() }}
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmed.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||
[class.bg-primary]="variant() === 'primary'"
|
||||
[class.text-primary-foreground]="variant() === 'primary'"
|
||||
[class.hover:bg-primary/90]="variant() === 'primary'"
|
||||
[class.bg-destructive]="variant() === 'danger'"
|
||||
[class.text-destructive-foreground]="variant() === 'danger'"
|
||||
[class.hover:bg-destructive/90]="variant() === 'danger'"
|
||||
>
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,51 +26,10 @@ import {
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close dialog"
|
||||
></div>
|
||||
<!-- Dialog -->
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||
[class]="widthClass()">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button
|
||||
(click)="cancelled.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
|
||||
>
|
||||
{{ cancelLabel() }}
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmed.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||
[class.bg-primary]="variant() === 'primary'"
|
||||
[class.text-primary-foreground]="variant() === 'primary'"
|
||||
[class.hover:bg-primary/90]="variant() === 'primary'"
|
||||
[class.bg-destructive]="variant() === 'danger'"
|
||||
[class.text-destructive-foreground]="variant() === 'danger'"
|
||||
[class.hover:bg-destructive/90]="variant() === 'danger'"
|
||||
>
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [':host { display: contents; }']
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
/** Dialog title. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close dialog"
|
||||
></div>
|
||||
<div
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[360px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card shadow-lg"
|
||||
>
|
||||
<div class="space-y-3 p-4">
|
||||
<h4 class="font-semibold text-foreground">Leave Server?</h4>
|
||||
<div class="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Leaving will remove
|
||||
<span class="font-medium text-foreground">{{ room().name }}</span>
|
||||
from your My Servers list.
|
||||
</p>
|
||||
|
||||
@if (isOwner()) {
|
||||
<div class="space-y-2 rounded-md border border-border/80 bg-secondary/20 p-3">
|
||||
<p class="text-foreground">You are the current owner of this server.</p>
|
||||
<p>You can optionally promote another member before leaving. If you skip this step, the server will continue without an owner.</p>
|
||||
|
||||
@if (ownerCandidates().length > 0) {
|
||||
<label class="block space-y-1">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> New owner </span>
|
||||
<select
|
||||
class="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[ngModel]="selectedOwnerKey()"
|
||||
(ngModelChange)="selectedOwnerKey.set($event || '')"
|
||||
>
|
||||
<option value="">Skip owner transfer</option>
|
||||
@for (member of ownerCandidates(); track roomMemberKey(member)) {
|
||||
<option [value]="roomMemberKey(member)">{{ member.displayName }} - {{ roleLabel(member) }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
} @else {
|
||||
<p>No other known members are available to promote right now.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 border-t border-border p-3">
|
||||
<button
|
||||
(click)="cancelled.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmLeave()"
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-destructive px-3 py-2 text-sm text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
User
|
||||
} from '../../../core/models';
|
||||
|
||||
export interface LeaveServerDialogResult {
|
||||
nextOwnerKey?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-leave-server-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './leave-server-dialog.component.html'
|
||||
})
|
||||
export class LeaveServerDialogComponent {
|
||||
room = input.required<Room>();
|
||||
currentUser = input<User | null>(null);
|
||||
confirmed = output<LeaveServerDialogResult>();
|
||||
cancelled = output<undefined>();
|
||||
|
||||
selectedOwnerKey = signal('');
|
||||
|
||||
isOwner = computed(() => {
|
||||
const room = this.room();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!room || !user)
|
||||
return false;
|
||||
|
||||
return room.hostId === user.id || room.hostId === user.oderId;
|
||||
});
|
||||
|
||||
ownerCandidates = computed(() => {
|
||||
const room = this.room();
|
||||
const user = this.currentUser();
|
||||
const userIds = new Set([user?.id, user?.oderId].filter((value): value is string => !!value));
|
||||
|
||||
return (room.members ?? []).filter((member) => !userIds.has(member.id) && !userIds.has(member.oderId || ''));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.room();
|
||||
this.currentUser();
|
||||
this.selectedOwnerKey.set('');
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.cancelled.emit(undefined);
|
||||
}
|
||||
|
||||
confirmLeave(): void {
|
||||
this.confirmed.emit(
|
||||
this.selectedOwnerKey()
|
||||
? { nextOwnerKey: this.selectedOwnerKey() }
|
||||
: {}
|
||||
);
|
||||
}
|
||||
|
||||
roomMemberKey(member: RoomMember): string {
|
||||
return member.oderId || member.id;
|
||||
}
|
||||
|
||||
roleLabel(member: RoomMember): string {
|
||||
switch (member.role) {
|
||||
case 'host':
|
||||
return 'Owner';
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'moderator':
|
||||
return 'Moderator';
|
||||
default:
|
||||
return 'Member';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@if (avatarUrl()) {
|
||||
<img
|
||||
[ngSrc]="avatarUrl()!"
|
||||
[width]="sizePx()"
|
||||
[height]="sizePx()"
|
||||
alt=""
|
||||
class="rounded-full object-cover"
|
||||
[class]="sizeClasses() + ' ' + ringClass()"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||
>
|
||||
{{ initial() }}
|
||||
</div>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
@@ -17,24 +18,11 @@ import { Component, input } from '@angular/core';
|
||||
@Component({
|
||||
selector: 'app-user-avatar',
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (avatarUrl()) {
|
||||
<img
|
||||
[src]="avatarUrl()"
|
||||
alt=""
|
||||
class="rounded-full object-cover"
|
||||
[class]="sizeClasses() + ' ' + ringClass()"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||
>
|
||||
{{ initial() }}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [':host { display: contents; }']
|
||||
imports: [NgOptimizedImage],
|
||||
templateUrl: './user-avatar.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class UserAvatarComponent {
|
||||
/** Display name - first character is used as fallback initial. */
|
||||
@@ -62,6 +50,16 @@ export class UserAvatarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map size token to explicit pixel dimensions for image optimisation. */
|
||||
sizePx(): number {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 28;
|
||||
case 'sm': return 32;
|
||||
case 'md': return 40;
|
||||
case 'lg': return 48;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map size token to text size for initials. */
|
||||
textClass(): string {
|
||||
switch (this.size()) {
|
||||
|
||||
Reference in New Issue
Block a user