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.
637 lines
22 KiB
637 lines
22 KiB
//
|
|
// AppEnvironment.swift
|
|
// LeCountdown
|
|
//
|
|
// Created by Laurent Morvillier on 31/01/2023.
|
|
//
|
|
|
|
import Foundation
|
|
import BackgroundTasks
|
|
import SwiftUI
|
|
import Intents
|
|
import AudioToolbox
|
|
import ActivityKit
|
|
import AVFoundation
|
|
|
|
enum BGTaskIdentifier : String {
|
|
case refresh = "com.staxriver.lecountdown.refresh"
|
|
}
|
|
|
|
fileprivate enum Const: String {
|
|
case confirmationSound = "ESM_Ambient_Game_Menu_Soft_Wood_Confirm_1_Notification_Button_Settings_UI.wav"
|
|
case cancellationSound = "MRKRSTPHR_synth_one_shot_bleep_G.wav"
|
|
}
|
|
|
|
enum CountdownState {
|
|
case inprogress
|
|
case paused
|
|
case finished
|
|
case cancelled
|
|
}
|
|
|
|
class Conductor: ObservableObject {
|
|
|
|
static let maestro: Conductor = Conductor()
|
|
|
|
@ObservedObject var soundPlayer: SoundPlayer = SoundPlayer()
|
|
|
|
fileprivate var _delayedSoundPlayers: [TimerID : DelaySoundPlayer] = [:]
|
|
|
|
fileprivate var beats: Timer? = nil
|
|
|
|
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : CountdownSequence]
|
|
@UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : LiveStopWatch]
|
|
|
|
@Published private (set) var liveTimers: [LiveTimer] = []
|
|
|
|
@Published var memoryWarningReceived: Bool = false
|
|
|
|
init() {
|
|
self.currentCountdowns = Conductor.savedCountdowns
|
|
self.currentStopwatches = Conductor.savedStopwatches
|
|
|
|
self.beats = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { _ in
|
|
self._cleanupCountdowns()
|
|
self._buildLiveTimers()
|
|
})
|
|
}
|
|
|
|
@Published var cancelledCountdowns: [String] = []
|
|
|
|
@Published var currentCountdowns: [String : CountdownSequence] = [:] {
|
|
didSet {
|
|
Conductor.savedCountdowns = currentCountdowns
|
|
withAnimation {
|
|
self._buildLiveTimers()
|
|
}
|
|
}
|
|
}
|
|
|
|
@Published var currentStopwatches: [String : LiveStopWatch] = [:] {
|
|
didSet {
|
|
Conductor.savedStopwatches = currentStopwatches
|
|
withAnimation {
|
|
self._buildLiveTimers()
|
|
}
|
|
}
|
|
}
|
|
|
|
static let notificationIdSeparator: String = "||"
|
|
|
|
fileprivate func _buildLiveTimers() {
|
|
|
|
let liveCountdowns: [LiveTimer] = self.currentCountdowns.map { id, sequence in
|
|
let currentStep = sequence.currentStep
|
|
return LiveTimer(id: id, name: currentStep.label, date: currentStep.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: [LiveTimer] = self.currentStopwatches.map {
|
|
return LiveTimer(id: $0, date: $1.start, endDate: $1.end)
|
|
}
|
|
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 removeLiveTimer(id: TimerID) {
|
|
|
|
self.liveTimers.removeAll(where: { $0.id == id })
|
|
self.cancelledCountdowns.removeAll(where: { $0 == id })
|
|
self.currentStopwatches.removeValue(forKey: id)
|
|
|
|
if let soundPlayer = self._delayedSoundPlayers[id] {
|
|
FileLogger.log("Stop sound player: \(self._timerName(id))")
|
|
soundPlayer.stop()
|
|
}
|
|
}
|
|
|
|
fileprivate func _cleanupLiveTimers() {
|
|
self.liveTimers.removeAll()
|
|
}
|
|
|
|
func isCountdownCancelled(_ countdown: Countdown) -> Bool {
|
|
return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId })
|
|
}
|
|
|
|
fileprivate func _recordActivity(countdownId: String, cancelled: Bool) {
|
|
let context = PersistenceController.shared.container.viewContext
|
|
if let countdown: Countdown = context.object(stringId: countdownId),
|
|
let sequence: CountdownSequence = self.currentCountdowns[countdownId] {
|
|
do {
|
|
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: sequence.dateInterval, cancelled: cancelled)
|
|
} catch {
|
|
Logger.error(error)
|
|
// TODO: show error to user
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Countdown
|
|
|
|
func startCountdown(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
|
|
|
|
self.cancelCurrentNotifications(countdownId: countdown.stringId)
|
|
|
|
let countdownId = countdown.stringId
|
|
self._cleanupPreviousTimerIfNecessary(countdownId)
|
|
|
|
do {
|
|
|
|
let totalDuration = try self._schedulePlayers(countdown: countdown)
|
|
|
|
self._scheduleCountdownNotification(countdown: countdown, in: totalDuration, handler: handler)
|
|
|
|
let end = Date(timeIntervalSinceNow: totalDuration)
|
|
self._launchLiveActivity(timer: countdown, date: end)
|
|
|
|
if Preferences.playConfirmationSound {
|
|
self._playConfirmationSound(timer: countdown)
|
|
}
|
|
handler(.success(end))
|
|
} catch {
|
|
FileLogger.log("start error : \(error.localizedDescription)")
|
|
Logger.error(error)
|
|
handler(.failure(error))
|
|
}
|
|
|
|
}
|
|
|
|
fileprivate func _schedulePlayers(countdown: Countdown) throws -> TimeInterval {
|
|
|
|
var totalDuration: TimeInterval = 0.0
|
|
var spans: [CountdownStep] = []
|
|
let now = Date()
|
|
|
|
for i in 0..<countdown.loops {
|
|
for step in countdown.sortedSteps() {
|
|
|
|
let start = now.addingTimeInterval(totalDuration)
|
|
|
|
totalDuration += step.duration
|
|
let end = try self._scheduleSoundPlayer(countdown: countdown, step: step, in: totalDuration)
|
|
|
|
let dateInterval = DateInterval(start: start, end: end)
|
|
let span = CountdownStep(interval: dateInterval, name: step.name, index: i, loopCount: countdown.loops, stepId: step.stringId)
|
|
spans.append(span)
|
|
}
|
|
}
|
|
let sequence = CountdownSequence(steps: spans)
|
|
self.currentCountdowns[countdown.stringId] = sequence
|
|
return totalDuration
|
|
}
|
|
|
|
fileprivate let idSeparator = "=&="
|
|
|
|
fileprivate func _scheduleSoundPlayer(countdown: Countdown, step: Step, in interval: TimeInterval) throws -> Date {
|
|
let start = Date()
|
|
let end = start.addingTimeInterval(interval)
|
|
|
|
FileLogger.log("schedule countdown \(step.name ?? "''") at \(end)")
|
|
Logger.log("schedule countdown \(step.name ?? "''") at \(end)")
|
|
|
|
let sound = step.someSound ?? countdown.someSound ?? Sound.default
|
|
|
|
let idComponents = [countdown.stringId, step.stringId, interval.debugDescription]
|
|
let playerId = idComponents.joined(separator: idSeparator)
|
|
let soundPlayer = try DelaySoundPlayer(sound: sound)
|
|
self._delayedSoundPlayers[playerId] = soundPlayer
|
|
|
|
try soundPlayer.start(in: interval)
|
|
|
|
return end
|
|
}
|
|
|
|
fileprivate func _cleanupPreviousTimerIfNecessary(_ timerId: TimerID) {
|
|
self.removeLiveTimer(id: timerId)
|
|
if let player = self._delayedSoundPlayers[timerId] {
|
|
player.stop() // release resources
|
|
}
|
|
self._endLiveActivity(timerId: timerId)
|
|
}
|
|
|
|
func cancelCountdown(id: TimerID) {
|
|
|
|
FileLogger.log("Cancel \(self._timerName(id))")
|
|
self.cancelCurrentNotifications(countdownId: id)
|
|
self.currentCountdowns.removeValue(forKey: id)
|
|
|
|
self.removeLiveTimer(id: id)
|
|
self.cancelSoundPlayers(id: id)
|
|
|
|
self._recordAndRemoveCountdown(countdownId: id, cancel: true)
|
|
|
|
if Preferences.playCancellationSound {
|
|
self._playCancellationSound()
|
|
}
|
|
|
|
self._endLiveActivity(timerId: id)
|
|
}
|
|
|
|
func countdownState(_ countdown: Countdown) -> CountdownState {
|
|
let id = countdown.stringId
|
|
if self.cancelledCountdowns.contains(id) {
|
|
return .cancelled
|
|
} else if self.currentCountdowns[id]?.pauseDate != nil {
|
|
return .paused
|
|
} else if let end = self.currentCountdowns[id]?.end, end > Date() {
|
|
return .inprogress
|
|
} else {
|
|
return .finished
|
|
}
|
|
}
|
|
|
|
func remainingPausedCountdownTime(_ countdown: Countdown) -> TimeInterval? {
|
|
guard let sequence = self.currentCountdowns[countdown.stringId] else {
|
|
return nil
|
|
}
|
|
return sequence.remainingPausedCountdownTime()
|
|
}
|
|
|
|
func pauseCountdown(id: TimerID) {
|
|
Logger.log("Pause countdown")
|
|
guard let sequence = self.currentCountdowns[id] else {
|
|
return
|
|
}
|
|
sequence.pauseDate = Date()
|
|
|
|
// cancel stuff
|
|
self.cancelCurrentNotifications(countdownId: id)
|
|
self.cancelSoundPlayers(id: id)
|
|
self._endLiveActivity(timerId: id)
|
|
}
|
|
|
|
func resumeCountdown(id: TimerID) throws {
|
|
|
|
let context = PersistenceController.shared.container.viewContext
|
|
if let countdown: Countdown = context.object(stringId: id),
|
|
let sequence = self.currentCountdowns[countdown.stringId],
|
|
let pauseDate = sequence.pauseDate {
|
|
|
|
for countdownStep in sequence.steps {
|
|
if countdownStep.end > pauseDate, let step = countdownStep.step(context: context) {
|
|
do {
|
|
let remainingTime = countdownStep.end.timeIntervalSince(pauseDate)
|
|
let _ = try self._scheduleSoundPlayer(countdown: countdown, step: step, in: remainingTime)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
sequence.resume()
|
|
|
|
} else {
|
|
throw AppError.timerNotFound(id: id)
|
|
}
|
|
}
|
|
|
|
fileprivate func _recordAndRemoveCountdown(countdownId: String, cancel: Bool) {
|
|
DispatchQueue.main.async {
|
|
self._recordActivity(countdownId: countdownId, cancelled: cancel)
|
|
self.currentCountdowns.removeValue(forKey: countdownId)
|
|
self._endLiveActivity(timerId: countdownId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Stopwatch
|
|
|
|
func startStopwatch(_ stopwatchId: TimerID) {
|
|
DispatchQueue.main.async {
|
|
|
|
guard let stopWatch = IntentDataProvider.main.timer(id: stopwatchId) as? Stopwatch else {
|
|
return
|
|
}
|
|
|
|
let lsw: LiveStopWatch = LiveStopWatch(start: Date())
|
|
self.currentStopwatches[stopWatch.stringId] = lsw
|
|
|
|
if Preferences.playConfirmationSound {
|
|
self._playSound(Const.confirmationSound.rawValue)
|
|
}
|
|
|
|
self._endLiveActivity(timerId: stopWatch.stringId)
|
|
self._launchLiveActivity(timer: stopWatch, date: lsw.start)
|
|
}
|
|
}
|
|
|
|
func stopStopwatch(_ stopwatch: Stopwatch) {
|
|
if let lsw = Conductor.maestro.currentStopwatches[stopwatch.stringId] {
|
|
|
|
if lsw.end == nil {
|
|
let end = Date()
|
|
lsw.end = end
|
|
|
|
Conductor.maestro.currentStopwatches[stopwatch.stringId] = lsw
|
|
|
|
do {
|
|
try CoreDataRequests.recordActivity(timer: stopwatch, dateInterval: DateInterval(start: lsw.start, end: end), cancelled: false)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
self._endLiveActivity(timerId: stopwatch.stringId)
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
func restoreSoundPlayers() {
|
|
|
|
for (countdownId, sequence) in self.currentCountdowns {
|
|
if !self._delayedSoundPlayers.contains(where: { $0.key == countdownId }) {
|
|
|
|
let context = PersistenceController.shared.container.viewContext
|
|
if let countdown: Countdown = context.object(stringId: countdownId) {
|
|
|
|
let now = Date()
|
|
for countdownStep in sequence.steps {
|
|
|
|
if let step = countdownStep.step(context: context) {
|
|
|
|
let remainingTime = countdownStep.interval.end.timeIntervalSince(now)
|
|
if remainingTime > 0 {
|
|
do {
|
|
let _ = try self._scheduleSoundPlayer(countdown: countdown, step: step, in: remainingTime)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Cleanup
|
|
|
|
func cleanup() {
|
|
self._cleanupCountdowns()
|
|
withAnimation {
|
|
self._cleanupLiveTimers()
|
|
self._buildLiveTimers()
|
|
}
|
|
if #available(iOS 16.2, *) {
|
|
self.cleanupLiveActivities()
|
|
}
|
|
}
|
|
|
|
fileprivate func _cleanupCountdowns() {
|
|
let now = Date()
|
|
for (key, value) in self.currentCountdowns {
|
|
if (value.pauseDate == nil && value.end < now) || self.cancelledCountdowns.contains(key) {
|
|
self._recordAndRemoveCountdown(countdownId: key, cancel: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
fileprivate func _scheduleCountdownNotification(countdown: Countdown, in duration: TimeInterval, handler: @escaping (Result<Date?, Error>) -> Void) {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = NSLocalizedString("It's time!", comment: "")
|
|
|
|
let body: String
|
|
if let name = countdown.activity?.name {
|
|
let timesup = NSLocalizedString("Time's up for %@!", comment: "")
|
|
body = String(format: timesup, name)
|
|
} else {
|
|
let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "")
|
|
body = String(format: timesup, duration.hourMinuteSecond)
|
|
}
|
|
|
|
content.body = body
|
|
|
|
self._createNotification(countdown: countdown, in: duration, content: content, handler: handler)
|
|
|
|
// content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0)
|
|
// content.interruptionLevel = .critical
|
|
content.relevanceScore = 1.0
|
|
|
|
}
|
|
|
|
fileprivate func _createNotification(countdown: Countdown, in duration: TimeInterval, offset: TimeInterval = 0.0, content: UNMutableNotificationContent, handler: @escaping (Result<Date?, Error>) -> Void) {
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: duration + offset, repeats: false)
|
|
|
|
let identifier: String = [countdown.objectID.uriRepresentation().absoluteString, "\(offset)"].joined(separator: Conductor.notificationIdSeparator)
|
|
|
|
let request = UNNotificationRequest(identifier: identifier,
|
|
content: content,
|
|
trigger: trigger)
|
|
UNUserNotificationCenter.current().add(request) { error in
|
|
DispatchQueue.main.async {
|
|
if let error {
|
|
handler(.failure(error))
|
|
print("Scheduling error = \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func cancelCurrentNotifications(countdownId: String) {
|
|
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
|
|
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) }
|
|
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
fileprivate func _playConfirmationSound(timer: AbstractSoundTimer) {
|
|
let fileName: String
|
|
if let confirmationSound = timer.confirmationSounds.randomElement() {
|
|
fileName = confirmationSound.fileName
|
|
} else {
|
|
fileName = Const.confirmationSound.rawValue
|
|
}
|
|
self._playSound(fileName)
|
|
}
|
|
|
|
fileprivate func _playCancellationSound() {
|
|
self._playSound(Const.cancellationSound.rawValue)
|
|
}
|
|
|
|
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 {
|
|
try self.soundPlayer.playOrPauseSound(filename, duration: duration)
|
|
} catch {
|
|
Logger.error(error)
|
|
// TODO: manage error
|
|
}
|
|
}
|
|
|
|
fileprivate func _soundPlayers(id: TimerID) -> [TimerID : DelaySoundPlayer] {
|
|
return self._delayedSoundPlayers.filter { (key, value) in
|
|
key.starts(with: id)
|
|
}
|
|
}
|
|
|
|
func cancelSoundPlayers(id: TimerID) {
|
|
|
|
let players = self._soundPlayers(id: id)
|
|
for (key, player) in players {
|
|
player.stop()
|
|
self._delayedSoundPlayers.removeValue(forKey: key)
|
|
}
|
|
|
|
FileLogger.log("cancelled \(players.count) sound players for \(self._timerName(id))")
|
|
|
|
self.deactivateAudioSessionIfPossible()
|
|
}
|
|
|
|
func deactivateAudioSessionIfPossible() {
|
|
if self._delayedSoundPlayers.isEmpty {
|
|
// do {
|
|
// try AVAudioSession.sharedInstance().setActive(false)
|
|
// } catch {
|
|
// Logger.error(error)
|
|
// }
|
|
}
|
|
}
|
|
|
|
func stopMainPlayersIfPossible() {
|
|
self.soundPlayer.stop()
|
|
}
|
|
|
|
func activateAudioSession() {
|
|
do {
|
|
let audioSession: AVAudioSession = AVAudioSession.sharedInstance()
|
|
try audioSession.setCategory(.playback, options: .duckOthers)
|
|
try audioSession.setActive(true)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Live Activity
|
|
|
|
fileprivate func _launchLiveActivity(timer: AbstractTimer, date: Date) {
|
|
|
|
guard let sequence = self.currentCountdowns[timer.stringId] else { return }
|
|
|
|
if #available(iOS 16.2, *) {
|
|
|
|
if ActivityAuthorizationInfo().areActivitiesEnabled {
|
|
|
|
let contentState = LaunchWidgetAttributes.ContentState(ended: false, sequence: sequence)
|
|
let attributes = LaunchWidgetAttributes(id: timer.stringId, name: timer.displayName, date: date, isCountdown: timer is Countdown)
|
|
|
|
let activityContent = ActivityContent(state: contentState, staleDate: nil)
|
|
do {
|
|
let _ = try ActivityKit.Activity.request(attributes: attributes, content: activityContent)
|
|
// print("Requested a Live Activity: \(String(describing: liveActivity.id))")
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback on earlier versions
|
|
}
|
|
|
|
}
|
|
|
|
class func removeLiveActivities() {
|
|
print("Ending Live Activities")
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
Task.detached(priority: .high) {
|
|
print("Task")
|
|
for activity in ActivityKit.Activity<LaunchWidgetAttributes>.activities {
|
|
print("Ending Live Activity: \(activity.id)")
|
|
if #available(iOS 16.2, *) {
|
|
await activity.end(nil, dismissalPolicy: .immediate)
|
|
}
|
|
}
|
|
semaphore.signal()
|
|
}
|
|
semaphore.wait()
|
|
}
|
|
|
|
fileprivate func _liveActivity(timerId: String) -> [ActivityKit.Activity<LaunchWidgetAttributes>] {
|
|
return ActivityKit.Activity<LaunchWidgetAttributes>.activities.filter { $0.attributes.id == timerId }
|
|
}
|
|
|
|
fileprivate func _liveActivityIds() -> [String] {
|
|
let activities = ActivityKit.Activity<LaunchWidgetAttributes>.activities
|
|
return activities.map { $0.attributes.id }
|
|
}
|
|
|
|
func cleanupLiveActivities() {
|
|
for id in self._liveActivityIds() {
|
|
if self.liveTimers.first(where: { $0.id == id} ) == nil {
|
|
self._endLiveActivity(timerId: id)
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func _endLiveActivity(timerId: String) {
|
|
if #available(iOS 16.2, *) {
|
|
print("Try to end the Live Activity: \(timerId)")
|
|
for activity in 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)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func _timerName(_ id: TimerID) -> String {
|
|
return IntentDataProvider.main.timer(id: id)?.name ?? id
|
|
}
|
|
|
|
deinit {
|
|
self.beats?.invalidate()
|
|
self.beats = nil
|
|
}
|
|
|
|
}
|
|
|