Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
No EOL
3 KiB
Swift
92 lines
No EOL
3 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// Occasional ping + bandwidth probes while Stream sourcing is selected.
|
|
@Observable
|
|
@MainActor
|
|
public final class StreamabilityMonitor {
|
|
public private(set) var sample = StreamabilitySample.idle
|
|
public private(set) var isRunning = false
|
|
|
|
public var episodeDuration: Double?
|
|
public var fileBytes: Int64?
|
|
|
|
private weak var player: PlayerController?
|
|
private var task: Task<Void, Never>?
|
|
private let interval: TimeInterval = 45
|
|
|
|
public init() {}
|
|
|
|
public func attach(player: PlayerController) { self.player = player }
|
|
|
|
public func syncFromPlayer() {
|
|
guard let player else { return }
|
|
episodeDuration = player.activeSnapshot.status.duration
|
|
}
|
|
|
|
/// Start or stop based on whether Stream mode is active.
|
|
public func refreshActivation() {
|
|
guard shouldMonitor else {
|
|
stop()
|
|
sample = .idle
|
|
return
|
|
}
|
|
start()
|
|
}
|
|
|
|
public func start() {
|
|
guard task == nil else { return }
|
|
isRunning = true
|
|
sample = .checking
|
|
task = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.probeLoop()
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
task?.cancel()
|
|
task = nil
|
|
isRunning = false
|
|
}
|
|
|
|
private var shouldMonitor: Bool {
|
|
guard let player else { return false }
|
|
return player.playbackMode == .stream
|
|
}
|
|
|
|
private func probeLoop() async {
|
|
while !Task.isCancelled {
|
|
await tick()
|
|
try? await Task.sleep(for: .seconds(interval))
|
|
}
|
|
}
|
|
|
|
private func tick() async {
|
|
guard shouldMonitor else {
|
|
stop()
|
|
sample = .idle
|
|
return
|
|
}
|
|
sample = .checking
|
|
let policy = DevicesConfig.localStreamPolicy()
|
|
let episode = episodeDuration ?? Double(StreamPolicy.typicalEpisodeSeconds)
|
|
let buffer = policy.effectiveBufferSeconds(episodeDuration: episodeDuration)
|
|
let required = StreamabilityAssessor.requiredKBps(
|
|
bufferSeconds: buffer, episodeSeconds: episode, fileBytes: fileBytes)
|
|
let hosts = DevicesConfig.storageSSHEndpoints()
|
|
let bytes = fileBytes
|
|
let result = await Task.detached {
|
|
StreamabilityProbe.run(hosts: hosts, episodeSeconds: episode,
|
|
bufferSeconds: buffer, fileBytes: bytes)
|
|
}.value
|
|
let level = StreamabilityAssessor.assess(
|
|
pingMs: result.pingMs, bandwidthKBps: result.bandwidthKBps, requiredKBps: required)
|
|
let detail = StreamabilityAssessor.detail(
|
|
level: level, pingMs: result.pingMs, bandwidthKBps: result.bandwidthKBps,
|
|
requiredKBps: required, bufferSeconds: buffer)
|
|
sample = StreamabilitySample(
|
|
level: level, pingMs: result.pingMs, bandwidthKBps: result.bandwidthKBps,
|
|
requiredKBps: required, bufferSeconds: buffer, checkedAt: Date(), detail: detail)
|
|
}
|
|
} |