Files
2026-02-11 17:51:48 +09:00

137 lines
4.9 KiB
Swift

@preconcurrency import AVKit
import Combine
import HaishinKit
@preconcurrency import Logboard
import SwiftUI
@MainActor
final class PlaybackViewModel: ObservableObject {
@Published private(set) var readyState: StreamSessionReadyState = .closed
@Published private(set) var error: Error?
@Published var hasError = false
var friendlyErrorMessage: String {
guard let error else {
return "Something went wrong. Please check your connection and try again."
}
let errorString = String(describing: error).lowercased()
if errorString.contains("unsupportedcommand") || errorString.contains("error 1") {
return "This server doesn't support watching streams directly. Most streaming servers (like Owncast) require you to watch via a web browser instead."
} else if errorString.contains("timeout") || errorString.contains("timedout") {
return "Connection timed out. The server may be offline or the stream URL might be incorrect."
} else if errorString.contains("invalidstate") {
return "Unable to connect. Please check that a stream is currently live."
} else if errorString.contains("connection") {
return "Couldn't reach the server. Check your internet connection and verify the stream URL in Preferences."
} else {
return "Unable to play this stream. The server may not support direct playback, or no stream is currently live."
}
}
func dismissError() {
hasError = false
error = nil
}
private var view: PiPHKView?
private var session: (any StreamSession)?
private let audioPlayer = AudioPlayer(audioEngine: AVAudioEngine())
private var pictureInPictureController: AVPictureInPictureController?
func start() async {
guard let session else {
return
}
do {
try await session.connect {
Task { @MainActor in
self.hasError = true
}
}
} catch {
self.error = error
self.hasError = true
}
}
func stop() async {
do {
try await session?.close()
} catch {
logger.error(error)
}
}
func makeSession() async {
do {
session = try await StreamSessionBuilderFactory.shared.make(Preference.default.makeURL())
.setMode(.playback)
.build()
await session?.setMaxRetryCount(0)
guard let session else {
return
}
if let view {
await session.stream.addOutput(view)
}
await session.stream.attachAudioPlayer(audioPlayer)
Task {
for await readyState in await session.readyState {
self.readyState = readyState
switch readyState {
case .open:
UIApplication.shared.isIdleTimerDisabled = false
default:
UIApplication.shared.isIdleTimerDisabled = true
}
}
}
} catch {
logger.error(error)
}
}
}
extension PlaybackViewModel: MTHKViewRepresentable.PreviewSource {
// MARK: MTHKViewRepresentable.PreviewSource
nonisolated func connect(to view: MTHKView) {
Task { @MainActor in
}
}
}
extension PlaybackViewModel: PiPHKViewRepresentable.PreviewSource {
// MARK: PiPHKSwiftUiView.PreviewSource
nonisolated func connect(to view: HaishinKit.PiPHKView) {
Task { @MainActor in
self.view = view
if pictureInPictureController == nil {
pictureInPictureController = AVPictureInPictureController(contentSource: .init(sampleBufferDisplayLayer: view.layer, playbackDelegate: PlaybackDelegate()))
}
}
}
}
final class PlaybackDelegate: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate {
// MARK: AVPictureInPictureControllerDelegate
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
}
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
return CMTimeRange(start: .zero, duration: .positiveInfinity)
}
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
completionHandler()
}
}