fluxer/fluxer_app/src/stores/voice/VideoSubscriptionManager.tsx
2026-02-17 12:22:36 +00:00

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