Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors

This commit is contained in:
2026-03-06 04:47:07 +01:00
parent 2d84fbd91a
commit fe2347b54e
65 changed files with 3593 additions and 1030 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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')}`;
}
}