mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-09-10 20:57:21 -04:00
Live activities count down
This commit is contained in:
parent
78c6a06c2e
commit
d396d43daf
@ -51,6 +51,10 @@ public struct DownloadActivityAttributes: ActivityAttributes {
|
|||||||
return first.description
|
return first.description
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var estimatedTimeLeft: TimeInterval {
|
||||||
|
items.map(\.timeRemaining).max() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
public var progress: Double {
|
public var progress: Double {
|
||||||
progressFor(items: items).fractionCompleted
|
progressFor(items: items).fractionCompleted
|
||||||
}
|
}
|
||||||
@ -65,6 +69,7 @@ public struct DownloadActivityAttributes: ActivityAttributes {
|
|||||||
let description: String
|
let description: String
|
||||||
let downloaded: Int64
|
let downloaded: Int64
|
||||||
let total: Int64
|
let total: Int64
|
||||||
|
let timeRemaining: TimeInterval
|
||||||
var progress: Double {
|
var progress: Double {
|
||||||
progressFor(items: [self]).fractionCompleted
|
progressFor(items: [self]).fractionCompleted
|
||||||
}
|
}
|
||||||
@ -72,11 +77,12 @@ public struct DownloadActivityAttributes: ActivityAttributes {
|
|||||||
progressFor(items: [self]).localizedAdditionalDescription
|
progressFor(items: [self]).localizedAdditionalDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(uuid: UUID, description: String, downloaded: Int64, total: Int64) {
|
public init(uuid: UUID, description: String, downloaded: Int64, total: Int64, timeRemaining: TimeInterval) {
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.description = description
|
self.description = description
|
||||||
self.downloaded = downloaded
|
self.downloaded = downloaded
|
||||||
self.total = total
|
self.total = total
|
||||||
|
self.timeRemaining = timeRemaining
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
93
Model/Utilities/DownloadTime.swift
Normal file
93
Model/Utilities/DownloadTime.swift
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// This file is part of Kiwix for iOS & macOS.
|
||||||
|
//
|
||||||
|
// Kiwix is free software; you can redistribute it and/or modify it
|
||||||
|
// under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation; either version 3 of the License, or
|
||||||
|
// any later version.
|
||||||
|
//
|
||||||
|
// Kiwix 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
|
||||||
|
// General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||||
|
import Foundation
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DownloadTime {
|
||||||
|
|
||||||
|
/// Only consider these last seconds, when calculating the average speed, hence the remaining time
|
||||||
|
private let considerLastSeconds: Double
|
||||||
|
/// sampled data: seconds to % of download
|
||||||
|
private var samples: [CFTimeInterval: Int64] = [:]
|
||||||
|
private let totalAmount: Int64
|
||||||
|
|
||||||
|
init(considerLastSeconds: Double = 2, total: Int64) {
|
||||||
|
assert(considerLastSeconds > 0)
|
||||||
|
assert(total > 0)
|
||||||
|
self.considerLastSeconds = considerLastSeconds
|
||||||
|
self.totalAmount = total
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(downloaded: Int64, now: CFTimeInterval = CACurrentMediaTime()) {
|
||||||
|
filterOutSamples(now: now)
|
||||||
|
samples[now] = downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
func remainingTime(now: CFTimeInterval = CACurrentMediaTime()) -> CFTimeInterval {
|
||||||
|
filterOutSamples(now: now)
|
||||||
|
guard samples.count > 1, let (latestTime, latestAmount) = latestSample() else {
|
||||||
|
return .infinity
|
||||||
|
}
|
||||||
|
let average = averagePerSecond()
|
||||||
|
let remainingAmount = totalAmount - latestAmount
|
||||||
|
let remaingTime = Double(remainingAmount) / average - (now - latestTime)
|
||||||
|
guard remaingTime > 0 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return remaingTime
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterOutSamples(now: CFTimeInterval) {
|
||||||
|
samples = samples.filter { time, _ in
|
||||||
|
time + considerLastSeconds > now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func averagePerSecond() -> Double {
|
||||||
|
var time: CFTimeInterval?
|
||||||
|
var amount: Int64?
|
||||||
|
var averages: [Double] = []
|
||||||
|
for key in samples.keys.sorted() {
|
||||||
|
let value = samples[key]!
|
||||||
|
if let time, let amount {
|
||||||
|
let took = key - time
|
||||||
|
let downloaded = value - amount
|
||||||
|
if took > 0, downloaded > 0 {
|
||||||
|
averages.append(Double(downloaded) / took)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time = key
|
||||||
|
amount = value
|
||||||
|
}
|
||||||
|
return mean(averages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func latestSample() -> (CFTimeInterval, Int64)? {
|
||||||
|
guard let lastTime = samples.keys.sorted().reversed().first,
|
||||||
|
let lastAmount = samples[lastTime] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (lastTime, lastAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mean(_ values: [Double]) -> Double {
|
||||||
|
let sum = values.reduce(0) { partialResult, value in
|
||||||
|
partialResult + value
|
||||||
|
}
|
||||||
|
let average = sum / Double(values.count)
|
||||||
|
return average
|
||||||
|
}
|
||||||
|
}
|
@ -26,17 +26,22 @@ final class ActivityService {
|
|||||||
private var activity: Activity<DownloadActivityAttributes>?
|
private var activity: Activity<DownloadActivityAttributes>?
|
||||||
private var lastUpdate = CACurrentMediaTime()
|
private var lastUpdate = CACurrentMediaTime()
|
||||||
private let updateFrequency: Double
|
private let updateFrequency: Double
|
||||||
|
private let averageDownloadSpeedFromLastSeconds: Double
|
||||||
private let publisher: @MainActor () -> CurrentValueSubject<[UUID: DownloadState], Never>
|
private let publisher: @MainActor () -> CurrentValueSubject<[UUID: DownloadState], Never>
|
||||||
private var isStarted: Bool = false
|
private var isStarted: Bool = false
|
||||||
|
private var downloadTimes: [UUID: DownloadTime] = [:]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = {
|
publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = {
|
||||||
DownloadService.shared.progress.publisher
|
DownloadService.shared.progress.publisher
|
||||||
},
|
},
|
||||||
updateFrequency: Double = 1
|
updateFrequency: Double = 1,
|
||||||
|
averageDownloadSpeedFromLastSeconds: Double = 30
|
||||||
) {
|
) {
|
||||||
assert(updateFrequency > 0)
|
assert(updateFrequency > 0)
|
||||||
|
assert(averageDownloadSpeedFromLastSeconds > 0)
|
||||||
self.updateFrequency = updateFrequency
|
self.updateFrequency = updateFrequency
|
||||||
|
self.averageDownloadSpeedFromLastSeconds = averageDownloadSpeedFromLastSeconds
|
||||||
self.publisher = publisher
|
self.publisher = publisher
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,9 +56,9 @@ final class ActivityService {
|
|||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start(with state: [UUID: DownloadState]) {
|
private func start(with state: [UUID: DownloadState], downloadTimes: [UUID: CFTimeInterval]) {
|
||||||
Task {
|
Task {
|
||||||
let activityState = await activityState(from: state)
|
let activityState = await activityState(from: state, downloadTimes: downloadTimes)
|
||||||
let content = ActivityContent(
|
let content = ActivityContent(
|
||||||
state: activityState,
|
state: activityState,
|
||||||
staleDate: nil,
|
staleDate: nil,
|
||||||
@ -81,9 +86,10 @@ final class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func update(state: [UUID: DownloadState]) {
|
private func update(state: [UUID: DownloadState]) {
|
||||||
|
let downloadTimes: [UUID: CFTimeInterval] = updatedDownloadTimes(from: state)
|
||||||
guard isStarted else {
|
guard isStarted else {
|
||||||
isStarted = true
|
isStarted = true
|
||||||
start(with: state)
|
start(with: state, downloadTimes: downloadTimes)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
@ -92,7 +98,7 @@ final class ActivityService {
|
|||||||
}
|
}
|
||||||
lastUpdate = now
|
lastUpdate = now
|
||||||
Task {
|
Task {
|
||||||
let activityState = await activityState(from: state)
|
let activityState = await activityState(from: state, downloadTimes: downloadTimes)
|
||||||
await activity.update(
|
await activity.update(
|
||||||
ActivityContent<DownloadActivityAttributes.ContentState>(
|
ActivityContent<DownloadActivityAttributes.ContentState>(
|
||||||
state: activityState,
|
state: activityState,
|
||||||
@ -102,11 +108,36 @@ final class ActivityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updatedDownloadTimes(from states: [UUID: DownloadState]) -> [UUID: CFTimeInterval] {
|
||||||
|
// remove the ones we should no longer track
|
||||||
|
downloadTimes = downloadTimes.filter({ key, _ in
|
||||||
|
states.keys.contains(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
let now = CACurrentMediaTime()
|
||||||
|
for (key, state) in states {
|
||||||
|
if downloadTimes[key] == nil {
|
||||||
|
debugPrint("Creating new downloadTimes for \(key)")
|
||||||
|
}
|
||||||
|
let downloadTime: DownloadTime = downloadTimes[key] ?? DownloadTime(
|
||||||
|
considerLastSeconds: averageDownloadSpeedFromLastSeconds,
|
||||||
|
total: state.total
|
||||||
|
)
|
||||||
|
downloadTime.update(downloaded: state.downloaded, now: now)
|
||||||
|
downloadTimes[key] = downloadTime
|
||||||
|
}
|
||||||
|
return downloadTimes.reduce(into: [:], { partialResult, time in
|
||||||
|
let (key, value) = time
|
||||||
|
partialResult.updateValue(value.remainingTime(now: now), forKey: key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private func stop() {
|
private func stop() {
|
||||||
Task {
|
Task {
|
||||||
await activity?.end(nil, dismissalPolicy: .immediate)
|
await activity?.end(nil, dismissalPolicy: .immediate)
|
||||||
activity = nil
|
activity = nil
|
||||||
isStarted = false
|
isStarted = false
|
||||||
|
downloadTimes = [:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +153,7 @@ final class ActivityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func activityState(from state: [UUID: DownloadState]) async -> DownloadActivityAttributes.ContentState {
|
private func activityState(from state: [UUID: DownloadState], downloadTimes: [UUID: CFTimeInterval]) async -> DownloadActivityAttributes.ContentState {
|
||||||
var titles: [UUID: String] = [:]
|
var titles: [UUID: String] = [:]
|
||||||
for key in state.keys {
|
for key in state.keys {
|
||||||
titles[key] = await getDownloadTitle(for: key)
|
titles[key] = await getDownloadTitle(for: key)
|
||||||
@ -135,7 +166,8 @@ final class ActivityService {
|
|||||||
uuid: key,
|
uuid: key,
|
||||||
description: titles[key] ?? key.uuidString,
|
description: titles[key] ?? key.uuidString,
|
||||||
downloaded: download.downloaded,
|
downloaded: download.downloaded,
|
||||||
total: download.total)
|
total: download.total,
|
||||||
|
timeRemaining: downloadTimes[key] ?? 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,6 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DownloadsLiveActivity: Widget {
|
struct DownloadsLiveActivity: Widget {
|
||||||
var isActivityFullScreen: Bool = false
|
|
||||||
// @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16
|
// @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16
|
||||||
// https://developer.apple.com/forums/thread/763594
|
// https://developer.apple.com/forums/thread/763594
|
||||||
|
|
||||||
@ -35,11 +34,18 @@ struct DownloadsLiveActivity: Widget {
|
|||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.bold()
|
.bold()
|
||||||
Text(context.state.progressDescription)
|
HStack {
|
||||||
.lineLimit(1)
|
Text(timerInterval: Date.now...Date(timeInterval: context.state.estimatedTimeLeft, since: .now))
|
||||||
.multilineTextAlignment(.leading)
|
.lineLimit(1)
|
||||||
.font(.caption)
|
.multilineTextAlignment(.leading)
|
||||||
.tint(.secondary)
|
.font(.caption)
|
||||||
|
.tint(.secondary)
|
||||||
|
Text(context.state.progressDescription)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.font(.caption)
|
||||||
|
.tint(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView(value: context.state.progress)
|
ProgressView(value: context.state.progress)
|
||||||
@ -111,13 +117,15 @@ extension DownloadActivityAttributes.ContentState {
|
|||||||
uuid: UUID(),
|
uuid: UUID(),
|
||||||
description: "First item",
|
description: "First item",
|
||||||
downloaded: 128,
|
downloaded: 128,
|
||||||
total: 256
|
total: 256,
|
||||||
|
timeRemaining: 3
|
||||||
),
|
),
|
||||||
DownloadActivityAttributes.DownloadItem(
|
DownloadActivityAttributes.DownloadItem(
|
||||||
uuid: UUID(),
|
uuid: UUID(),
|
||||||
description: "2nd item",
|
description: "2nd item",
|
||||||
downloaded: 90,
|
downloaded: 90,
|
||||||
total: 124
|
total: 124,
|
||||||
|
timeRemaining: 2
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -131,13 +139,15 @@ extension DownloadActivityAttributes.ContentState {
|
|||||||
uuid: UUID(),
|
uuid: UUID(),
|
||||||
description: "First item",
|
description: "First item",
|
||||||
downloaded: 256,
|
downloaded: 256,
|
||||||
total: 256
|
total: 256,
|
||||||
|
timeRemaining: 0
|
||||||
),
|
),
|
||||||
DownloadActivityAttributes.DownloadItem(
|
DownloadActivityAttributes.DownloadItem(
|
||||||
uuid: UUID(),
|
uuid: UUID(),
|
||||||
description: "2nd item",
|
description: "2nd item",
|
||||||
downloaded: 110,
|
downloaded: 110,
|
||||||
total: 124
|
total: 124,
|
||||||
|
timeRemaining: 2
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user