Files
damus/damus/Shared/Media/Video/DamusVideoCoordinator.swift
Daniel D’Aquino f2870b9a38 Fix OS 26 build errors
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-22 10:39:35 -07:00

132 lines
6.5 KiB
Swift

//
// DamusVideoCoordinator.swift
// damus
//
// Created by Bryan Montz on 9/3/23.
//
import Combine
import Foundation
import SwiftUI
import AVFoundation
/// DamusVideoCoordinator is responsible for coordinating the various video players throughout the app, and providing a nicely orchestrated experience.
/// The goals of this object are to:
/// - ensure some video playing states (such as mute state and current time) are consistent across different video player view instances of the same video
/// - ensure only one video is playing at a time
/// - Provide global video playback controls to control the currently playing video
///
/// This is used as a singleton object (one global object per `DamusState`), which gets passed around to video players, which can then interact with the coordinator to ensure an app-wide coherent experience
///
/// A good analogy here is that video players and their models/states are like individual cars and their drivers, and this coordinator is like a traffic control person + traffic lights that ensures cars don't crash each other.
final class DamusVideoCoordinator: ObservableObject {
// MARK: - States
// MARK: State and information about each video
private var players: [URL: DamusVideoPlayer] = [:]
// MARK: Main stage requests from player views
// The stacks of video player views that have marked themselves as visible on the user screen.
//
// Because our visibility tracker cannot tell if a player is obscured by a view in front of it,
// we need to implement two stacks representing the different view layers:
// - Normal layer: For timelines, threads, etc
// - Full screen layer: For full screen views
private var normal_layer_main_stage_requests: [MainStageRequest] = []
private var full_screen_layer_stage_requests: [MainStageRequest] = []
// MARK: Coordinator state
// Members representing the state of the coordinator itself
private var full_screen_mode: Bool = false {
didSet {
self.select_focused_video()
}
}
/// The video currently in focus
/// This can only be chosen by the coordinator. To get a video in focus, use one of the instance methods that provide an interface for focus control.
@MainActor
@Published private(set) var focused_video: DamusVideoPlayer? {
didSet {
oldValue?.pause()
focused_video?.play()
Log.info("VIDEO_COORDINATOR: %s paused, playing %s", for: .video_coordination, oldValue?.url.absoluteString ?? "no video", focused_video?.url.absoluteString ?? "no video")
}
}
// MARK: - Interface to set and fetch information about each different video
@MainActor
func get_player(for url: URL) -> DamusVideoPlayer {
if let player = self.players[url] {
return player
}
let player = DamusVideoPlayer(url: url)
self.players[url] = player
return player
}
// MARK: - Interface for video players to come to the foreground
// This portion provides an interface for video players to signal their visibility changes,
// and implements some coordination logic to choose which video to play and pause at a given time.
func request_main_stage(_ request: MainStageRequest) {
Log.info("VIDEO_COORDINATOR: %s requested main stage", for: .video_coordination, request.requestor_id.uuidString)
switch request.layer_context {
case .normal_layer:
if normal_layer_main_stage_requests.first(where: { $0.requestor_id == request.requestor_id }) != nil { return } // Entry exists already
normal_layer_main_stage_requests.append(request)
case .full_screen_layer:
if full_screen_layer_stage_requests.first(where: { $0.requestor_id == request.requestor_id }) != nil { return } // Entry exists already
full_screen_layer_stage_requests.append(request)
}
self.select_focused_video()
}
func give_up_main_stage(request_id: UUID) {
Log.info("VIDEO_COORDINATOR: %s gave up the main stage", for: .video_coordination, request_id.uuidString)
normal_layer_main_stage_requests.removeAll(where: { $0.requestor_id == request_id })
full_screen_layer_stage_requests.removeAll(where: { $0.requestor_id == request_id })
self.select_focused_video()
}
// MARK: - Additional interface to help with video coordination
func set_full_screen_mode(_ is_full_screen: Bool) {
full_screen_mode = is_full_screen
}
// MARK: - Internal video coordination logic
private func select_focused_video() {
// This function may be called during a SwiftUI view update,
// so schedule this change for the next render pass to ensure state immutability/stability within a single render pass
DispatchQueue.main.async { [weak self] in // [weak self] to safeguard in cases this object is deallocated by the time we execute this task
guard let self else { return }
// The focused video will always be the last one that was inserted similar to a LIFO stack
// The reason is that:
// - both a LIFO stack and a FIFO queue are decent at selecting videos when scrolling on the Y axis (timeline),
// - The LIFO stack is better at selecting videos when navigating on the Z axis (e.g. opening and closing full screen covers or sheets), since those sheets operate like a stack as well
let winning_request = self.full_screen_mode ? self.full_screen_layer_stage_requests.last : self.normal_layer_main_stage_requests.last
self.focused_video = winning_request?.player
winning_request?.main_stage_granted?()
}
Log.info("VIDEO_COORDINATOR: fullscreen layer main stage request stack: %s", for: .video_coordination, full_screen_layer_stage_requests.map({ $0.requestor_id.uuidString }).debugDescription)
Log.info("VIDEO_COORDINATOR: normal layer main stage request stack: %s", for: .video_coordination, normal_layer_main_stage_requests.map({ $0.requestor_id.uuidString }).debugDescription)
Log.info("VIDEO_COORDINATOR: full_screen_mode: %s", for: .video_coordination, String(describing: self.full_screen_mode))
}
// MARK: - Helper structures
struct MainStageRequest {
var requestor_id: UUID
var layer_context: ViewLayerContext
var player: DamusVideoPlayer
var main_stage_granted: (() -> Void)?
}
}