mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Android Studio couldn't build the iOS app: resolving the videolan/vlckit Swift package fails inside it (Xcode's source control service reports "Communication with the service was interrupted"), ending in "Missing package product 'VLCKit'". VLCKit's README only documents CocoaPods and Carthage anyway. - Podfile with MobileVLCKit 3.7.5 (iOS-only build, 233 MB instead of the 922 MB cross-platform VLCKit 4 binary); build iosApp.xcworkspace. - VlcPlayerEngine ported to the MobileVLCKit 3 API: it has a real "ended" state, so the stop bookkeeping and the post-resume seek workaround needed for VLCKit 4 are gone. Buffering/ES-added notifications keep arriving while playing, so "playing" is reported whenever VLC is playing. - NativePlayerListener.onBuffered removed: MobileVLCKit has no buffering progress callback. - Pods/ is ignored; AGENTS.md notes the pod install + workspace setup.
206 lines
6.5 KiB
Swift
206 lines
6.5 KiB
Swift
// Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
//
|
|
// This program 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.
|
|
//
|
|
// This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import AVFoundation
|
|
import ComposeApp
|
|
import Foundation
|
|
import MobileVLCKit
|
|
|
|
/// Registers the MobileVLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first
|
|
/// `ComposeView` is created.
|
|
func registerNativePlayerEngine() {
|
|
NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory()
|
|
}
|
|
|
|
final class VlcPlayerEngineFactory: NSObject, NativePlayerEngineFactory {
|
|
func create() -> NativePlayerEngine {
|
|
VlcPlayerEngine()
|
|
}
|
|
}
|
|
|
|
/// Plays one URL at a time with MobileVLCKit. The Kotlin `AudioPlayer` owns the playlist; see
|
|
/// `NativePlayerEngine.kt` for the contract.
|
|
///
|
|
/// Every VLC call is made on the main queue, in the order the Kotlin side issued it.
|
|
final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate {
|
|
private let player = VLCMediaPlayer()
|
|
private var listener: NativePlayerListener?
|
|
private var released = false
|
|
|
|
private var rate: Float = 1
|
|
private var volumePercent: Int32 = 100
|
|
private var lastReportedDurationMs: Int64 = 0
|
|
private var lastReportedState: NativePlayerState?
|
|
|
|
override init() {
|
|
super.init()
|
|
player.delegate = self
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
}
|
|
|
|
// MARK: NativePlayerEngine
|
|
|
|
func setListener(listener: NativePlayerListener?) {
|
|
onMain { self.listener = listener }
|
|
}
|
|
|
|
func load(url: String, autoPlay: Bool, startPositionMs: Int64) {
|
|
onMain {
|
|
guard !self.released else { return }
|
|
guard let mediaUrl = URL(string: url) else {
|
|
self.listener?.onError(message: "Invalid URL: \(url)")
|
|
return
|
|
}
|
|
|
|
let media = VLCMedia(url: mediaUrl)
|
|
if startPositionMs > 0 {
|
|
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
|
|
}
|
|
self.lastReportedDurationMs = 0
|
|
self.player.media = media
|
|
self.report(.opening)
|
|
if autoPlay { self.startPlayback() }
|
|
}
|
|
}
|
|
|
|
func play() {
|
|
onMain {
|
|
guard !self.released else { return }
|
|
self.startPlayback()
|
|
}
|
|
}
|
|
|
|
func pause() {
|
|
onMain {
|
|
guard !self.released, self.player.isPlaying else { return }
|
|
self.player.pause()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
onMain {
|
|
guard !self.released else { return }
|
|
self.player.stop()
|
|
}
|
|
}
|
|
|
|
func seekTo(positionMs: Int64) {
|
|
onMain {
|
|
guard !self.released, self.player.isSeekable else { return }
|
|
self.player.time = VLCTime(number: NSNumber(value: positionMs))
|
|
}
|
|
}
|
|
|
|
func setVolume(volume: Float) {
|
|
onMain {
|
|
self.volumePercent = Int32((min(max(volume, 0), 1) * 100).rounded())
|
|
self.player.audio?.volume = self.volumePercent
|
|
}
|
|
}
|
|
|
|
func setRate(rate: Float) {
|
|
onMain {
|
|
self.rate = rate
|
|
self.player.rate = rate
|
|
}
|
|
}
|
|
|
|
func destroy() {
|
|
onMain {
|
|
guard !self.released else { return }
|
|
self.released = true
|
|
self.listener = nil
|
|
self.player.delegate = nil
|
|
self.player.stop()
|
|
self.player.media = nil
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
}
|
|
|
|
// MARK: VLCMediaPlayerDelegate
|
|
|
|
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
onMain {
|
|
guard !self.released else { return }
|
|
switch self.player.state {
|
|
case .opening:
|
|
self.report(.opening)
|
|
case .buffering, .esAdded:
|
|
// MobileVLCKit keeps sending these while it plays; only a stall counts as buffering.
|
|
self.report(self.player.isPlaying ? .playing : .buffering)
|
|
case .playing:
|
|
self.report(.playing)
|
|
case .paused:
|
|
self.report(.paused)
|
|
case .ended:
|
|
self.report(.ended)
|
|
case .stopped:
|
|
self.report(.idle)
|
|
case .error:
|
|
self.listener?.onError(message: "VLC could not play this media")
|
|
self.report(.idle)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
onMain {
|
|
guard !self.released else { return }
|
|
// Time only advances while playing, whatever the last state notification said.
|
|
if self.player.isPlaying { self.report(.playing) }
|
|
self.reportDuration()
|
|
if let ms = self.player.time.value?.int64Value, ms >= 0 {
|
|
self.listener?.onTime(positionMs: ms)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Internals
|
|
|
|
private func startPlayback() {
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
player.play()
|
|
}
|
|
|
|
/// Sends [state] to Kotlin unless it was the last one sent.
|
|
private func report(_ state: NativePlayerState) {
|
|
guard state != lastReportedState else { return }
|
|
lastReportedState = state
|
|
if state == .playing {
|
|
applyPlaybackSettings()
|
|
reportDuration()
|
|
}
|
|
listener?.onState(state: state)
|
|
}
|
|
|
|
private func applyPlaybackSettings() {
|
|
player.audio?.volume = volumePercent
|
|
if player.rate != rate { player.rate = rate }
|
|
}
|
|
|
|
/// MobileVLCKit has no length callback, so the length is read from the media as it plays.
|
|
private func reportDuration() {
|
|
guard let ms = player.media?.length.value?.int64Value, ms > 0, ms != lastReportedDurationMs else { return }
|
|
lastReportedDurationMs = ms
|
|
listener?.onDuration(durationMs: ms)
|
|
}
|
|
|
|
private func onMain(_ work: @escaping () -> Void) {
|
|
DispatchQueue.main.async(execute: work)
|
|
}
|
|
}
|