Started implementing video support.

This commit is contained in:
2026-04-10 09:15:37 +02:00
parent 7d9737e9c2
commit 2bcb6adbb3
13 changed files with 429 additions and 55 deletions

8
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.0.11", "@chatenium/chatenium-sdk": "^1.1.2",
"@ngx-translate/core": "^17.0.0", "@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0",
"@taiga-ui/addon-charts": "^5.1.0", "@taiga-ui/addon-charts": "^5.1.0",
@@ -988,9 +988,9 @@
} }
}, },
"node_modules/@chatenium/chatenium-sdk": { "node_modules/@chatenium/chatenium-sdk": {
"version": "1.0.11", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@chatenium/chatenium-sdk/-/chatenium-sdk-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@chatenium/chatenium-sdk/-/chatenium-sdk-1.1.2.tgz",
"integrity": "sha512-2vkN+W541bMEdWTStrXorsEmbQ9mva6drKOU11yFdVMzpPEsMJr9bvk5Lwc5COpOgNTIbnFSAkzPg+0USFruVQ==", "integrity": "sha512-MYUdi1zxcsSUlf1JADU7HNU6zxPejNuspbt+9P3iUBI2ecHWzhqSdcQRR+OMEe0UThl7QNIlZrt0yl15/4fjYQ==",
"dependencies": { "dependencies": {
"@faker-js/faker": "^10.4.0", "@faker-js/faker": "^10.4.0",
"axios": "^1.14.0", "axios": "^1.14.0",

View File

@@ -18,7 +18,7 @@
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.0.11", "@chatenium/chatenium-sdk": "^1.1.2",
"@ngx-translate/core": "^17.0.0", "@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0",
"@taiga-ui/addon-charts": "^5.1.0", "@taiga-ui/addon-charts": "^5.1.0",

View File

@@ -49,9 +49,10 @@ export class Dm implements OnInit {
fileId: file.fileId, fileId: file.fileId,
type: file.type, type: file.type,
format: file.extension, format: file.extension,
path: file.preview, path: file.blob,
height: file.height, height: file.height,
width: file.width, width: file.width,
localVideoThumbnail: file.videoThumbnail
}) })
}) })
@@ -82,46 +83,58 @@ export class Dm implements OnInit {
console.log(fileId, allChunks, chunksDone) console.log(fileId, allChunks, chunksDone)
} }
onWsListen(action: string, message: string) { // The chatid parameter ensures isolation
console.log(action, message) onWsListen(action: string, message: string, chatid: string) {
switch (action) { const data = JSON.parse(message);
case "newMessage": { if (data.chatid === chatid) {
this.store.messages.update(messages => [...messages, JSON.parse(message)]) const targetStore = this.serviceManager.dmServices()[chatid];
if (targetStore) {
switch (action) {
case "newMessage":
targetStore.messages.update(messages => [...messages, data]);
break;
}
} }
} }
} }
ngOnInit() { ngOnInit() {
this.route.params.subscribe(async params => { this.route.params.subscribe(async params => {
const chatid = params['chatid'] const chatid = params['chatid'];
this.chatid = chatid this.chatid = chatid;
console.log(this.serviceManager.chats())
const session = this.serviceManager.currentSession();
const chatData = this.serviceManager.chats().find(chat => chat.chatid == chatid)
// Setup storage const session = this.serviceManager.currentSession();
if (!this.serviceManager.dmServices()[chatid] && session != null && chatData != null) { const chatData = this.serviceManager.chats().find(c => c.chatid === chatid);
this.serviceManager.dmServices()[chatid] = {
service: new DMService( if (!session || !chatData) {
session.userData.userid, console.warn(`Initialization deferred for ${chatid}: Session or ChatData missing.`);
session.token, return;
chatid,
this.indexedDb.getApi(),
(action, data) => {
this.onWsListen(action, data)
}
),
chatData: signal<Chat>(chatData),
messages: signal<Message[]>([]),
messageBox: new MessageBoxViewModel(
(msg, files) => this.sendMessage(msg, files),
)
}
} }
this.store.messages.set(await this.serviceManager.dmServices()[chatid].service.get()) if (!this.serviceManager.dmServices()[chatid]) {
console.log(WebSocketHandler.getInstance().connId) const newStore = {
await this.store.service.joinWebSocketRoom() chatData: signal<Chat>(chatData),
}) messages: signal<Message[]>([]),
messageBox: new MessageBoxViewModel((msg, files) => this.sendMessage(msg, files)),
wsListener: (action, data) => this.onWsListen(action, data, chatid),
} as DmStorage;
newStore.service = new DMService(
session.userData.userid,
session.token,
chatid,
this.indexedDb.getApi(),
(action, data) => newStore.wsListener(action, data)
);
this.serviceManager.dmServices()[chatid] = newStore;
const currentStore = this.serviceManager.dmServices()[chatid];
const history = await currentStore.service.get();
currentStore.messages.set(history);
await currentStore.service.joinWebSocketRoom();
}
});
} }
} }

View File

@@ -17,7 +17,7 @@
<masonry [maxColSize]="2" style="overflow-y: scroll; height: 500px"> <masonry [maxColSize]="2" style="overflow-y: scroll; height: 500px">
@for (file of viewModel().files(); track file) { @for (file of viewModel().files(); track file) {
@if (file.type == "image") { @if (file.type == "image") {
<img [src]="file.preview" style="width: 100%; height: 100%; object-fit: fill;"/> <img [src]="file.blob" style="width: 100%; height: 100%; object-fit: fill;"/>
} }
} }
</masonry> </masonry>

View File

@@ -107,22 +107,24 @@ export class MessageBox {
for (let i = 0; i < fileList.length; i++) { for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]; const file = fileList[i];
const type = file.type.split("/").shift() ?? "" const type = file.type.split("/").shift() ?? ""
let preview = "" let blob = ""
let thumbnailBlob = undefined
let height = 0 let height = 0
let width = 0 let width = 0
console.log(type) console.log(type)
blob = URL.createObjectURL(file)
if (type == "image") { if (type == "image") {
try { const imgData = await fetchImageDimensions(file)
const imgData = await makePicturePreview(file) console.log(imgData)
console.log(imgData) height = imgData.height
preview = imgData.preview width = imgData.width
height = imgData.height } else if (type == "video") {
width = imgData.width const videoData = await processVideo(file)
} catch (error) { thumbnailBlob = videoData.thumbnailBlob
console.error(error) height = videoData.height
} width = videoData.width
} }
console.log("push") console.log("push")
@@ -132,14 +134,15 @@ export class MessageBox {
name: file.name, name: file.name,
type: type, type: type,
extension: file.name.split(".").pop() ?? "", extension: file.name.split(".").pop() ?? "",
preview: preview, blob: blob,
height: height, height: height,
width: width, width: width,
videoThumbnail: thumbnailBlob,
}) })
} }
this.viewModel().dialogOpen.set(true) this.viewModel().dialogOpen.set(true)
function makePicturePreview(file: File): Promise<{ preview: string, height: number, width: number }> { function fetchImageDimensions(file: File): Promise<{ height: number, width: number }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
@@ -147,7 +150,6 @@ export class MessageBox {
img.onload = () => { img.onload = () => {
console.log("Loaded image") console.log("Loaded image")
resolve({ resolve({
preview: objectUrl,
width: img.naturalWidth, width: img.naturalWidth,
height: img.naturalHeight, height: img.naturalHeight,
}); });
@@ -162,6 +164,37 @@ export class MessageBox {
img.src = objectUrl; img.src = objectUrl;
}); });
} }
async function processVideo(file: File): Promise<{ thumbnailBlob: string, height: number, width: number }> {
const objectUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.src = objectUrl;
video.crossOrigin = 'anonymous';
video.muted = true;
return new Promise((resolve) => {
video.addEventListener('loadeddata', async () => {
video.currentTime = 0;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx!.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnail = canvas.toDataURL('image/jpeg')
const thumbnailResp = await fetch(thumbnail);
const thumbnailBlob = await thumbnailResp.blob();
const thumbnailBlobUrl = URL.createObjectURL(thumbnailBlob);
resolve({
thumbnailBlob: thumbnailBlobUrl,
height: video.videoHeight,
width: video.videoWidth,
});
});
});
}
} }
protected readonly console = console; protected readonly console = console;
@@ -171,7 +204,8 @@ export class MessageBox {
* All the extra data for client-side rendering. (With finished messages these are generated on the server) * All the extra data for client-side rendering. (With finished messages these are generated on the server)
*/ */
export interface FileDataWithPreview extends FileData { export interface FileDataWithPreview extends FileData {
preview: string; blob: string;
videoThumbnail?: string;
height: number; height: number;
width: number; width: number;
} }

View File

@@ -16,6 +16,8 @@
@for (file of message.files; track file) { @for (file of message.files; track file) {
@if (file.type == "image") { @if (file.type == "image") {
<img [src]="file.path" style="width: 100%; height: 100%; max-height: 300px; object-fit: cover; border-radius: 25px"/> <img [src]="file.path" style="width: 100%; height: 100%; max-height: 300px; object-fit: cover; border-radius: 25px"/>
} @else if (file.type == "video") {
<video-player [src]="file.path"></video-player>
} }
} }
</masonry> </masonry>

View File

@@ -53,7 +53,7 @@
.bubble { .bubble {
background: var(--tui-background-neutral-2); background: var(--tui-background-neutral-2);
max-width: 350px; max-width: 50%;
min-width: 250px; min-width: 250px;
min-height: 40px; min-height: 40px;
display: flex; display: flex;

View File

@@ -4,12 +4,14 @@ import {Message as NetworkMessage} from '@chatenium/chatenium-sdk/domain/textCha
import {ServiceManager} from '../../../service-manager'; import {ServiceManager} from '../../../service-manager';
import {DatePipe} from '@angular/common'; import {DatePipe} from '@angular/common';
import {Masonry} from '../masonry/masonry'; import {Masonry} from '../masonry/masonry';
import {VideoPlayer} from '../video-player/video-player';
@Component({ @Component({
selector: 'messages', selector: 'messages',
imports: [ imports: [
DatePipe, DatePipe,
Masonry Masonry,
VideoPlayer
], ],
templateUrl: './messages.html', templateUrl: './messages.html',
styleUrl: './messages.scss', styleUrl: './messages.scss',

View File

@@ -0,0 +1,88 @@
@if (playerActive) {
<div id="player" [style]="'max-width:'+maxWidth+';max-height:'+maxHeight" (mouseover)="controlShowed = true"
(mouseleave)="controlShowed = false" #player>
<video (mouseout)="showVolRange = false" [class.upScale]="videoFullscreen"
[style]="'max-width:'+maxWidth+';height:'+maxHeight" (pause)="videoPlaying = false"
(play)="videoPlaying = true" #video (timeupdate)="watched = videoHMSFormat(video.currentTime)"
(loadedmetadata)="videoPlayer = video; video.style.display = 'block'; videoLoaded = true"
(click)="videoPlaying ? video.pause() : video.play(); videoPlaying = !videoPlaying"
(dblclick)="videoFullscreen ? exitFullScreen() : player.requestFullscreen(); videoFullscreen = !videoFullscreen"
[src]="src"></video>
<button tuiButton appearance="icon" class="picInPic" (click)="video.requestPictureInPicture();">
<tui-icon icon="@tui.picture-in-picture"></tui-icon>
</button>
<div id="controlsHolder">
<div id="controls">
<button tuiButton appearance="icon"
(click)="videoPlaying ? video.pause() : video.play();">
@if (videoPlaying) {
<tui-icon icon="@tui.pause"></tui-icon>
} @else {
<tui-icon icon="@tui.play"></tui-icon>
}
</button>
<span>{{ watched }}</span>
<progress class="timeProgress" (click)="jump($event)" [max]="video.duration" [value]="video.currentTime"
tuiProgressBar size="xs"></progress>
<div>
<!-- @if (showVolRange) { -->
<!-- } -->
<div class="volumeSetter" (mouseover)="showVolRange = true">
<button
tuiButton appearance="icon"
tuiDropdown
tuiDropdownAppearance="neutral"
tuiDropdownAuto
tuiDropdownDirection="top"
tuiDropdownLimitWidth="fixed"
tuiIconButton
>
@if (video.volume == 0) {
<tui-icon icon="@tui.volume-off"></tui-icon>
} @else if (video.volume > .75) {
<tui-icon icon="@tui.volume-2"></tui-icon>
} @else if (video.volume >= .50 && video.volume < .74) {
<tui-icon icon="@tui.volume-1"></tui-icon>
} @else {
<tui-icon icon="@tui.volume"></tui-icon>
}
<input
*tuiDropdown
tuiSlider
type="range"
value="100"
(input)="setVolume(volume)"
[(ngModel)]="volume"
/>
</button>
</div>
</div>
<button tuiButton appearance="icon"
(click)="videoFullscreen ? exitFullScreen() : player.requestFullscreen(); videoFullscreen = !videoFullscreen">
@if (videoFullscreen) {
<tui-icon icon="@tui.minimize"></tui-icon>
} @else {
<tui-icon icon="@tui.maximize"></tui-icon>
}
</button>
</div>
</div>
</div>
} @else {
<div class="player_preview">
@if (thumbnailOverwrite) {
<img [src]="thumbnailOverwrite" />
} @else {
<oimg [src]="src+'_thumbnail.png'"></oimg>
}
<button tuiButton (click)="playVideo()">
<tui-icon icon="@tui.play"></tui-icon>
</button>
</div>
}

View File

@@ -0,0 +1,110 @@
#player {
max-width: 400px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
tui-icon {
font-size: 20px;
}
.picInPic {
position: absolute;
top: 0;
right: 0;
}
.upScale {
min-height: 100%;
}
video {
max-width: 100% !important;
}
#controlsHolder {
position: absolute;
bottom: 10px;
left: 0;
width: 100%;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
#controls {
width: 95%;
height: 50px;
background: var(--tui-background-base-alt);
border: 2px solid var(--tui-border-normal);
padding: 10px;
border-radius: 20px;
gap: 5px;
display: grid;
grid-template-columns: 50px 50px 1fr 50px 50px;
align-items: center;
button {
height: 25px;
width: 50px;
}
.time {
font-size: 15px;
display: flex;
flex-direction: column;
gap: 0;
}
.volumeSetter {
display: flex;
gap: 5px;
align-items: center;
}
}
}
}
.player_preview {
max-width: 400px;
position: relative;
button {
width: 50px;
height: 50px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 50px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
}
tui-dropdown[data-appearance] {
min-block-size: 8rem;
background: var(--tui-background-base);
box-shadow: none;
border: none;
backdrop-filter: blur(1rem);
[tuiSlider] {
position: absolute;
inline-size: 7rem;
transform-origin: left;
transform: rotate(-90deg) translate(-100%, 1rem) translateY(12px);
&::-webkit-slider-thumb {
cursor: ns-resize;
}
&::-moz-range-thumb {
cursor: ns-resize;
}
}
}

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoPlayer } from './video-player';
describe('VideoPlayer', () => {
let component: VideoPlayer;
let fixture: ComponentFixture<VideoPlayer>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VideoPlayer],
}).compileComponents();
fixture = TestBed.createComponent(VideoPlayer);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,102 @@
import {Component, Input, ViewEncapsulation} from '@angular/core';
import {
TuiButton,
TuiDropdownDirective,
TuiDropdownOpen,
TuiDropdownOptionsDirective,
TuiIcon,
TuiSlider
} from '@taiga-ui/core';
import {FormsModule} from '@angular/forms';
import {Oimg} from '../oimg/oimg';
import {TuiInputDateRange, TuiProgressBar} from '@taiga-ui/kit';
@Component({
selector: 'video-player',
imports: [
TuiIcon,
FormsModule,
TuiButton,
Oimg,
TuiSlider,
TuiProgressBar,
TuiDropdownOptionsDirective,
TuiDropdownDirective,
TuiDropdownOpen,
TuiInputDateRange
],
templateUrl: './video-player.html',
styleUrl: './video-player.scss',
encapsulation: ViewEncapsulation.None,
})
export class VideoPlayer {
@Input() src: string = "";
@Input() maxWidth: string = "";
@Input() maxHeight: string = "";
@Input() thumbnailOverwrite: string | undefined = undefined;
playerActive = false
videoLoaded = false
videoPlayer: HTMLVideoElement | any = "";
videoPlaying: boolean = false;
videoFullscreen: boolean = false;
watched: string = "00:00";
controlShowed: boolean = false;
videoMuted: boolean = false;
videoVolBefMute: number = 0;
videoHMSFormat(time: any) {
let hours: any = Math.round(time / 3600)
let minutes: any = Math.round(time / 60 - (hours * 60));
let seconds: any = Math.round(time - (minutes * 60));
if (seconds < 0) seconds += 60;
if (hours < 10) hours = `0${hours}`;
if (minutes < 10) minutes = `0${minutes}`;
if (seconds < 10) seconds = `0${seconds}`;
return hours == 0 ? `${minutes}:${seconds}` : `${hours}:${minutes}:${seconds}`;
}
jump(e: any) {
let progressBar = e.target;
const clickX = e.clientX - progressBar.getBoundingClientRect().left;
const progressBarWidth = progressBar.clientWidth;
const progressValue = (clickX / progressBarWidth) * progressBar.max;
this.videoPlayer.currentTime = progressValue;
progressBar.value = progressValue;
}
volume = 50
showVolRange = false
setVolume(volume: number) {
this.videoPlayer.volume = volume / 100;
}
exitFullScreen() {
document.exitFullscreen();
}
handleMute(){
if(this.videoMuted) {
this.videoPlayer.volume = this.videoVolBefMute / 100;
this.videoMuted = false;
this.volume = this.videoVolBefMute
}else{
this.videoPlayer.volume = 0;
this.videoMuted = true;
this.videoVolBefMute = this.volume;
this.volume = 0
}
}
playVideo() {
this.playerActive = true
this.videoPlaying = true;
setTimeout(() => {
this.videoPlayer.play();
}, 100)
}
}

View File

@@ -40,4 +40,5 @@ export interface DmStorage {
messages: WritableSignal<Message[]> messages: WritableSignal<Message[]>
chatData: WritableSignal<Chat> chatData: WritableSignal<Chat>
messageBox: MessageBoxViewModel messageBox: MessageBoxViewModel
wsListener: (action: string, message: string) => void
} }