Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// How many of an album's (or artist's) songs have stems.
|
||||
struct Coverage: Equatable {
|
||||
var total: Int
|
||||
var withStems: Int
|
||||
var none: Bool { total > 0 && withStems == 0 }
|
||||
var partial: Bool { withStems >= 1 && withStems < total }
|
||||
}
|
||||
|
||||
/// Which albums and artists have stems. Plex albums carry no file paths, so this keeps a slim
|
||||
/// copy of every track's path (fetched in pages, cached on disk, refreshed every few hours) and
|
||||
/// matches it against the stems index whenever that changes. Until the first full listing
|
||||
/// arrives [ready] is false and nothing is flagged.
|
||||
@Observable
|
||||
final class StemCoverage: @unchecked Sendable {
|
||||
private struct Slim: Codable { var k: String; var al: String?; var ar: String?; var f: String?; var d: Int64 = 0 }
|
||||
private struct Listing: Codable { var section: String; var at: Double; var tracks: [Slim] }
|
||||
|
||||
@ObservationIgnored private let plex: PlexClient
|
||||
@ObservationIgnored private let stems: StemServer
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private let lock = AsyncLock()
|
||||
@ObservationIgnored private var listing: Listing?
|
||||
private static let page = 1000
|
||||
private static let staleSeconds: Double = 6 * 3600
|
||||
|
||||
private(set) var albums: [String: Coverage] = [:]
|
||||
private(set) var artists: [String: Coverage] = [:]
|
||||
/// True once the whole library has been listed and matched against a non-empty stems index.
|
||||
private(set) var ready = false
|
||||
|
||||
init(plex: PlexClient, stems: StemServer, settings: Settings, dir: URL) {
|
||||
self.plex = plex
|
||||
self.stems = stems
|
||||
self.settings = settings
|
||||
file = dir.appendingPathComponent("library_tracks.json")
|
||||
listing = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(Listing.self, from: $0) }
|
||||
settings.onChange("musicSection") { [weak self] in self?.sectionChanged() }
|
||||
stems.onIndexChange { [weak self] in self?.recompute() }
|
||||
sectionChanged()
|
||||
recompute()
|
||||
}
|
||||
|
||||
private func sectionChanged() {
|
||||
let section = settings.musicSection
|
||||
guard !section.isEmpty else { return }
|
||||
if let l = listing, l.section == section, Date().timeIntervalSince1970 - l.at <= Self.staleSeconds { return }
|
||||
Task { await refresh() }
|
||||
}
|
||||
|
||||
/// Lists every track in the library again (every few hours).
|
||||
func refresh() async {
|
||||
await lock.run {
|
||||
let section = settings.musicSection
|
||||
guard !section.isEmpty, plex.signedIn else { return }
|
||||
var all: [Slim] = []
|
||||
do {
|
||||
var start = 0
|
||||
while true {
|
||||
let (page, total) = try await plex.tracksPage(start: start, size: Self.page)
|
||||
all += page.map { Slim(k: $0.ratingKey, al: $0.albumKey, ar: $0.artistKey, f: $0.partFile, d: $0.durationMs) }
|
||||
start += page.count
|
||||
if page.isEmpty || start >= total { break }
|
||||
}
|
||||
} catch { return }
|
||||
let l = Listing(section: section, at: Date().timeIntervalSince1970, tracks: all)
|
||||
if let data = try? JSONEncoder().encode(l) { try? data.write(to: file, options: .atomic) }
|
||||
await MainActor.run {
|
||||
self.listing = l
|
||||
self.recompute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recompute() {
|
||||
guard let l = listing, l.section == settings.musicSection, stems.index.size > 0 else { return }
|
||||
let tracks = l.tracks
|
||||
Task.detached { [self] in
|
||||
var al: [String: (Int, Int)] = [:]
|
||||
var ar: [String: (Int, Int)] = [:]
|
||||
for t in tracks {
|
||||
let has = stems.find(Track(ratingKey: t.k, title: "", artist: "", album: "", durationMs: t.d, partFile: t.f)) != nil
|
||||
if let k = t.al { let c = al[k] ?? (0, 0); al[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) }
|
||||
if let k = t.ar { let c = ar[k] ?? (0, 0); ar[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) }
|
||||
}
|
||||
let albums = al.mapValues { Coverage(total: $0.0, withStems: $0.1) }
|
||||
let artists = ar.mapValues { Coverage(total: $0.0, withStems: $0.1) }
|
||||
await MainActor.run {
|
||||
self.albums = albums
|
||||
self.artists = artists
|
||||
self.ready = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user