You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
LeCountdown/LeCountdown/Conductor.swift

413 lines
14 KiB

//
// AppEnvironment.swift
// LeCountdown
//
// Created by Laurent Morvillier on 31/01/2023.
//
import Foundation
import BackgroundTasks
import SwiftUI
import Intents
import AudioToolbox
import ActivityKit
enum BGTaskIdentifier : String {
case refresh = "com.staxriver.lecountdown.refresh"
}
fileprivate enum Const: String {
case confirmationSound = "PVP_Stab_Oneshot_Bleep_Em.wav"
}
class Conductor: ObservableObject {
static let maestro: Conductor = Conductor()
@Published var soundPlayer: SoundPlayer? = nil
fileprivate var _delayedSoundPlayers: [TimerID : DelaySoundPlayer] = [:]
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : DateInterval]
@UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : Date]
@Published private (set) var liveTimers: [LiveTimer] = []
init() {
self.currentCountdowns = Conductor.savedCountdowns
self.currentStopwatches = Conductor.savedStopwatches
}
@Published var cancelledCountdowns: [String] = []
@Published var currentCountdowns: [String : DateInterval] = [:] {
didSet {
Conductor.savedCountdowns = currentCountdowns
// Logger.log("**** currentCountdowns didSet, count = \(currentCountdowns.count)")
withAnimation {
self._buildLiveTimers()
}
}
}
@Published var currentStopwatches: [String : Date] = [:] {
didSet {
Conductor.savedStopwatches = currentStopwatches
withAnimation {
self._buildLiveTimers()
}
}
}
func removeLiveTimer(id: String) {
Logger.log("removeLiveTimer")
self.liveTimers.removeAll(where: { $0.id == id })
self.cancelledCountdowns.removeAll(where: { $0 == id })
if let soundPlayer = self._delayedSoundPlayers[id] {
soundPlayer.stop()
}
}
fileprivate func _buildLiveTimers() {
Logger.log("_buildLiveTimers")
let liveCountdowns = self.currentCountdowns.map {
return LiveTimer(id: $0, date: $1.end)
}
// add countdown if not present
for liveCountdown in liveCountdowns {
if let index = self.liveTimers.firstIndex(where: { $0.id == liveCountdown.id }) {
self.liveTimers.remove(at: index)
self.liveTimers.insert(liveCountdown, at: index)
} else {
self.liveTimers.append(liveCountdown)
}
}
let liveStopwatches = self.currentStopwatches.map {
return LiveTimer(id: $0, date: $1)
}
for liveStopwatch in liveStopwatches {
if let index = self.liveTimers.firstIndex(where: { $0.id == liveStopwatch.id }) {
self.liveTimers.remove(at: index)
self.liveTimers.insert(liveStopwatch, at: index)
} else {
self.liveTimers.append(liveStopwatch)
}
}
}
func notifyUser(countdownId: String) {
// self._playSound(timerId: countdownId)
self._endCountdown(countdownId: countdownId, cancel: false)
}
fileprivate func _recordActivity(countdownId: String) {
let context = PersistenceController.shared.container.viewContext
if let countdown = context.object(stringId: countdownId) as? Countdown,
let dateInterval = self.currentCountdowns[countdownId] {
do {
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval)
} catch {
Logger.error(error)
// TODO: show error to user
}
}
}
// MARK: - Countdown
func startCountdown(_ date: Date, countdown: Countdown) {
// DispatchQueue.main.async {
Logger.log("Starts countdown: \(countdown.displayName)")
// cleanup existing countdowns
self.removeLiveTimer(id: countdown.stringId)
// self._cleanupTimers.removeValue(forKey: countdown.stringId)
let dateInterval = DateInterval(start: Date(), end: date)
self.currentCountdowns[countdown.stringId] = dateInterval
// self._launchLiveActivity(countdown: countdown, endDate: date)
// self._createTimerIntent(countdown)
// Logger.log("countdowns count = \(self.currentCountdowns.count)")
// }
}
func cancelCountdown(id: TimerID) {
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.cancelSoundPlayer(id: id)
self.cancelledCountdowns.append(id)
self._endCountdown(countdownId: id, cancel: true)
}
fileprivate func _endCountdown(countdownId: String, cancel: Bool) {
DispatchQueue.main.async {
if !cancel {
self._recordActivity(countdownId: countdownId)
}
self.currentCountdowns.removeValue(forKey: countdownId)
}
}
func prepareAlarm(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
DispatchQueue.main.async {
do {
let date = Date(timeIntervalSinceNow: countdown.duration)
self.startCountdown(date, countdown: countdown)
let soundFile = try SoundFile(fullName: countdown.soundName)
let soundPlayer = try DelaySoundPlayer(timerID: countdown.stringId, soundFile: soundFile)
self._delayedSoundPlayers[countdown.stringId] = soundPlayer
try soundPlayer.start(in: countdown.duration,
repeatCount: Int(countdown.repeatCount))
if Preferences.playConfirmationSound {
self._playSound(Const.confirmationSound.rawValue)
}
handler(.success(date))
} catch {
Logger.error(error)
handler(.failure(error))
}
}
}
// MARK: - Stopwatch
func startStopwatch(_ stopwatch: Stopwatch) {
DispatchQueue.main.async {
let now = Date()
Conductor.maestro.currentStopwatches[stopwatch.stringId] = now
if Preferences.playConfirmationSound {
self._playSound(Const.confirmationSound.rawValue)
}
self._launchLiveActivity(stopwatch: stopwatch, start: now)
// self._createTimerIntent(stopwatch)
}
}
func stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) {
if let start = Conductor.maestro.currentStopwatches[stopwatch.stringId] {
Conductor.maestro.currentStopwatches.removeValue(forKey: stopwatch.stringId)
do {
try CoreDataRequests.recordActivity(timer: stopwatch, dateInterval: DateInterval(start: start, end: end ?? Date()))
} catch {
Logger.error(error)
}
self._endLiveActivity(timerId: stopwatch.stringId)
}
}
func restore() {
for (countdownId, interval) in self.currentCountdowns {
if !self._delayedSoundPlayers.contains(where: { $0.key == countdownId }) {
let context = PersistenceController.shared.container.viewContext
if let countdown = context.object(stringId: countdownId) as? Countdown {
do {
let soundFile = try SoundFile(fullName: countdown.soundName)
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, soundFile: soundFile)
self._delayedSoundPlayers[countdown.stringId] = soundPlayer
try soundPlayer.restore(for: interval.end, repeatCount: Int(countdown.repeatCount))
} catch {
Logger.error(error)
}
}
}
}
}
// MARK: - Cleanup
func cleanup() {
self._cleanupCountdowns()
self._buildLiveTimers()
}
fileprivate func _cleanupCountdowns() {
let now = Date()
for (key, value) in self.currentCountdowns {
if value.end < now || self.cancelledCountdowns.contains(key) {
self._endCountdown(countdownId: key, cancel: false)
}
}
}
// MARK: - Sound
fileprivate func _playSound(timerId: String) {
let context = PersistenceController.shared.container.viewContext
var coolSound: Sound? = nil
let timer = context.object(stringId: timerId)
switch timer {
case let cd as Countdown:
coolSound = cd.someSound
case let sw as Stopwatch:
coolSound = sw.coolSound
default:
break
}
if let coolSound {
self.playSound(coolSound)
} else {
print("No sound to play!")
}
AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate))
}
func playSound(_ sound: Sound) {
self._playSound(sound.fileName)
}
func playSound(_ sound: Sound, duration: TimeInterval) {
self._playSound(sound.fileName, duration: duration)
}
fileprivate func _playSound(_ filename: String, duration: TimeInterval? = nil) {
do {
let soundFile = try SoundFile(fullName: filename)
let soundPlayer = SoundPlayer()
self.soundPlayer = soundPlayer
if let duration {
try soundPlayer.play(soundFile: soundFile, for: duration)
} else {
try soundPlayer.playSound(soundFile: soundFile)
}
} catch {
Logger.error(error)
// TODO: manage error
}
}
func cancelSoundPlayer(id: TimerID) {
if let soundPlayer = self._delayedSoundPlayers[id] {
soundPlayer.stop()
self._delayedSoundPlayers.removeValue(forKey: id)
}
}
func stopMainPlayersIfPossible() {
self.soundPlayer?.stop()
self.soundPlayer = nil
}
// MARK: - Intent
// fileprivate func _createTimerIntent(_ timer: AbstractTimer) {
// let intent = LaunchTimerIntent()
//
// let invocationPhrase = String(format: NSLocalizedString("Launch %@", comment: ""), timer.displayName)
// intent.suggestedInvocationPhrase = String(format: invocationPhrase, timer.displayName)
// intent.timer = TimerIdentifier(identifier: timer.stringId, display: timer.displayName)
//
// let interaction = INInteraction(intent: intent, response: nil)
// interaction.donate()
// }
fileprivate func _scheduleAppRefresh(countdown: Countdown) {
let request = BGAppRefreshTaskRequest(identifier: BGTaskIdentifier.refresh.rawValue)
request.earliestBeginDate = Date(timeIntervalSinceNow: countdown.duration)
do {
try BGTaskScheduler.shared.submit(request)
print("request submitted with date: \(String(describing: request.earliestBeginDate))")
} catch {
Logger.error(error)
}
}
// MARK: - Live Activity
fileprivate func _launchLiveActivity(stopwatch: Stopwatch, start: Date) {
if #available(iOS 16.2, *) {
if ActivityAuthorizationInfo().areActivitiesEnabled {
let contentState = LaunchWidgetAttributes.ContentState(ended: false)
let attributes = LaunchWidgetAttributes(id: stopwatch.stringId, name: stopwatch.displayName, date: start)
let activityContent = ActivityContent(state: contentState, staleDate: nil)
do {
let liveActivity = try ActivityKit.Activity.request(attributes: attributes, content: activityContent)
print("Requested a Live Activity: \(String(describing: liveActivity.id)).")
} catch (let error) {
Logger.error(error)
}
}
} else {
// Fallback on earlier versions
}
}
fileprivate func _liveActivity(timerId: String) -> ActivityKit.Activity<LaunchWidgetAttributes>? {
return ActivityKit.Activity<LaunchWidgetAttributes>.activities.first(where: { $0.attributes.id == timerId } )
}
func updateLiveActivities() {
print("update live activity...")
for (countdownId, interval) in self.currentCountdowns {
if interval.end < Date() {
self._endLiveActivity(timerId: countdownId)
}
// if let activity = self._liveActivity(countdownId: countdownId) {
//
// Task {
//
// if ended {
// self._endLiveActivity(countdownId: countdownId)
// }
//
//// let state = LaunchWidgetAttributes.ContentState(ended: ended)
//// let content = ActivityContent(state: state, staleDate: interval.end)
//// await activity.update(content)
//// print("Ending the Live Activity: \(activity.id)")
// }
// }
}
}
fileprivate func _endLiveActivity(timerId: String) {
if #available(iOS 16.2, *) {
print("Try to end the Live Activity: \(timerId)")
if let activity = self._liveActivity(timerId: timerId) {
Task {
let state = LaunchWidgetAttributes.ContentState(ended: true)
let content = ActivityContent(state: state, staleDate: Date())
await activity.end(content, dismissalPolicy: .immediate)
print("Ending the Live Activity: \(activity.id)")
}
}
}
}
}