/* * 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 . */ 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 = { low: VideoQuality.LOW, medium: VideoQuality.MEDIUM, high: VideoQuality.HIGH, }; export class VideoSubscriptionManager { private room: Room | null = null; private states = new Map(); 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}); } }