275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {Logger} from '@app/lib/Logger';
|
|
import type {VideoQualityLevel} from '@app/stores/voice/VoiceSubscriptionManager';
|
|
import type {RemoteParticipant, RemoteTrackPublication, Room} from 'livekit-client';
|
|
import {Track, VideoQuality} from 'livekit-client';
|
|
import {makeAutoObservable, runInAction} from 'mobx';
|
|
|
|
const logger = new Logger('VideoSubscriptionManager');
|
|
|
|
interface VideoSubscriptionState {
|
|
subscribed: boolean;
|
|
enabled: boolean;
|
|
quality: VideoQualityLevel;
|
|
isIntersecting: boolean;
|
|
observer: IntersectionObserver | null;
|
|
}
|
|
|
|
const qualityMap: Record<VideoQualityLevel, VideoQuality> = {
|
|
low: VideoQuality.LOW,
|
|
medium: VideoQuality.MEDIUM,
|
|
high: VideoQuality.HIGH,
|
|
};
|
|
|
|
export class VideoSubscriptionManager {
|
|
private room: Room | null = null;
|
|
private states = new Map<string, VideoSubscriptionState>();
|
|
private readonly intersectionOptions: IntersectionObserverInit = {
|
|
root: null,
|
|
rootMargin: '50px',
|
|
threshold: [0, 0.1],
|
|
};
|
|
|
|
constructor() {
|
|
makeAutoObservable(this, {}, {autoBind: true});
|
|
}
|
|
|
|
setRoom(room: Room | null): void {
|
|
this.room = room;
|
|
}
|
|
|
|
cleanup(): void {
|
|
for (const state of this.states.values()) {
|
|
state.observer?.disconnect();
|
|
}
|
|
runInAction(() => {
|
|
this.states.clear();
|
|
});
|
|
}
|
|
|
|
subscribe(participantIdentity: string, element: HTMLElement | null, initialQuality: VideoQualityLevel = 'low'): void {
|
|
if (!this.room) {
|
|
logger.warn('No room available');
|
|
return;
|
|
}
|
|
|
|
const participant = this.room.remoteParticipants.get(participantIdentity);
|
|
if (!participant) {
|
|
logger.warn('Participant not found', {participantIdentity});
|
|
return;
|
|
}
|
|
|
|
const existingState = this.states.get(participantIdentity);
|
|
if (existingState) {
|
|
logger.debug('Already subscribed', {participantIdentity});
|
|
if (element && element !== existingState.observer?.root) {
|
|
this.updateObserver(participantIdentity, element);
|
|
}
|
|
return;
|
|
}
|
|
|
|
logger.info('Subscribing to video', {participantIdentity, quality: initialQuality});
|
|
|
|
const cameraPublication = this.findCameraPublication(participant);
|
|
if (!cameraPublication) {
|
|
logger.debug('No camera publication found', {participantIdentity});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
cameraPublication.setSubscribed(true);
|
|
this.applyQuality(cameraPublication, initialQuality);
|
|
|
|
const observer = element ? this.createObserver(participantIdentity, element) : null;
|
|
|
|
runInAction(() => {
|
|
this.states.set(participantIdentity, {
|
|
subscribed: true,
|
|
enabled: false,
|
|
quality: initialQuality,
|
|
isIntersecting: false,
|
|
observer,
|
|
});
|
|
});
|
|
|
|
logger.debug('Video subscribed successfully', {participantIdentity});
|
|
} catch (error) {
|
|
logger.error('Failed to subscribe', {participantIdentity, error});
|
|
}
|
|
}
|
|
|
|
unsubscribe(participantIdentity: string): void {
|
|
const state = this.states.get(participantIdentity);
|
|
if (!state) {
|
|
logger.debug('Not subscribed', {participantIdentity});
|
|
return;
|
|
}
|
|
|
|
logger.info('Unsubscribing from video', {participantIdentity});
|
|
|
|
state.observer?.disconnect();
|
|
|
|
if (this.room) {
|
|
const participant = this.room.remoteParticipants.get(participantIdentity);
|
|
if (participant) {
|
|
const cameraPublication = this.findCameraPublication(participant);
|
|
if (cameraPublication) {
|
|
try {
|
|
cameraPublication.setSubscribed(false);
|
|
logger.debug('Track unsubscribed', {participantIdentity});
|
|
} catch (error) {
|
|
logger.error('Failed to unsubscribe', {participantIdentity, error});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
runInAction(() => {
|
|
this.states.delete(participantIdentity);
|
|
});
|
|
|
|
logger.info('Video unsubscribed successfully', {participantIdentity});
|
|
}
|
|
|
|
setEnabled(participantIdentity: string, enabled: boolean): void {
|
|
const state = this.states.get(participantIdentity);
|
|
if (!state) {
|
|
logger.debug('Not subscribed', {participantIdentity});
|
|
return;
|
|
}
|
|
|
|
if (state.enabled === enabled) {
|
|
return;
|
|
}
|
|
|
|
logger.debug('Setting video enabled state', {participantIdentity, enabled});
|
|
|
|
if (this.room) {
|
|
const participant = this.room.remoteParticipants.get(participantIdentity);
|
|
if (participant) {
|
|
const cameraPublication = this.findCameraPublication(participant);
|
|
if (cameraPublication) {
|
|
try {
|
|
cameraPublication.setEnabled(enabled);
|
|
runInAction(() => {
|
|
state.enabled = enabled;
|
|
});
|
|
logger.debug('Track enabled state updated', {participantIdentity, enabled});
|
|
} catch (error) {
|
|
logger.error('Failed to set enabled state', {participantIdentity, enabled, error});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
setQuality(participantIdentity: string, quality: VideoQualityLevel): void {
|
|
const state = this.states.get(participantIdentity);
|
|
if (!state) {
|
|
logger.debug('Not subscribed', {participantIdentity});
|
|
return;
|
|
}
|
|
|
|
if (state.quality === quality) {
|
|
return;
|
|
}
|
|
|
|
logger.debug('Setting video quality', {participantIdentity, quality});
|
|
|
|
if (this.room) {
|
|
const participant = this.room.remoteParticipants.get(participantIdentity);
|
|
if (participant) {
|
|
const cameraPublication = this.findCameraPublication(participant);
|
|
if (cameraPublication) {
|
|
this.applyQuality(cameraPublication, quality);
|
|
runInAction(() => {
|
|
state.quality = quality;
|
|
});
|
|
logger.debug('Quality updated', {participantIdentity, quality});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
isSubscribed(participantIdentity: string): boolean {
|
|
return this.states.get(participantIdentity)?.subscribed ?? false;
|
|
}
|
|
|
|
getQuality(participantIdentity: string): VideoQualityLevel | null {
|
|
return this.states.get(participantIdentity)?.quality ?? null;
|
|
}
|
|
|
|
private findCameraPublication(participant: RemoteParticipant): RemoteTrackPublication | undefined {
|
|
for (const pub of participant.videoTrackPublications.values()) {
|
|
if (pub.source === Track.Source.Camera) {
|
|
return pub;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private applyQuality(publication: RemoteTrackPublication, quality: VideoQualityLevel): void {
|
|
try {
|
|
const lkQuality = qualityMap[quality];
|
|
publication.setVideoQuality(lkQuality);
|
|
logger.debug('Quality set', {quality, lkQuality});
|
|
} catch (error) {
|
|
logger.error('Failed to set quality', {quality, error});
|
|
}
|
|
}
|
|
|
|
private createObserver(participantIdentity: string, element: HTMLElement): IntersectionObserver {
|
|
const observer = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const isIntersecting = entry.isIntersecting;
|
|
const state = this.states.get(participantIdentity);
|
|
if (!state) continue;
|
|
|
|
runInAction(() => {
|
|
state.isIntersecting = isIntersecting;
|
|
});
|
|
|
|
this.setEnabled(participantIdentity, isIntersecting);
|
|
|
|
logger.debug('Intersection changed', {participantIdentity, isIntersecting});
|
|
}
|
|
}, this.intersectionOptions);
|
|
|
|
observer.observe(element);
|
|
return observer;
|
|
}
|
|
|
|
private updateObserver(participantIdentity: string, element: HTMLElement): void {
|
|
const state = this.states.get(participantIdentity);
|
|
if (!state) return;
|
|
|
|
state.observer?.disconnect();
|
|
|
|
const observer = this.createObserver(participantIdentity, element);
|
|
|
|
runInAction(() => {
|
|
state.observer = observer;
|
|
});
|
|
|
|
logger.debug('Observer updated', {participantIdentity});
|
|
}
|
|
}
|