import Foundation /// Item ids for the CarPlay tree. Containers are `kind:key`; playable rows carry the /// container they were picked from, so choosing a song queues the rest of its list after it. /// The container goes last because search queries can contain anything. enum CarIds { static let downloads = "tab:downloads" static let playlists = "tab:playlists" static let albums = "tab:albums" static let artists = "tab:artists" static let tabs = [downloads, playlists, albums, artists] static func playlist(_ key: String) -> String { "playlist:\(key)" } static func album(_ key: String) -> String { "album:\(key)" } static func artist(_ key: String) -> String { "artist:\(key)" } static func albumLetter(_ l: String) -> String { "albums:\(l)" } static func artistLetter(_ l: String) -> String { "artists:\(l)" } static func search(_ q: String) -> String { "search:\(q)" } static func play(_ container: String, _ trackKey: String) -> String { "play|\(trackKey)|\(container)" } static func shuffle(_ container: String) -> String { "shuffle|\(container)" } enum Pick: Equatable { case song(container: String, trackKey: String) case shuffle(container: String) var container: String { switch self { case .song(let c, _), .shuffle(let c): c } } } static func parsePick(_ id: String) -> Pick? { if id.hasPrefix("play|") { let rest = id.dropFirst(5) guard let bar = rest.firstIndex(of: "|"), bar != rest.startIndex, rest.index(after: bar) != rest.endIndex else { return nil } return .song(container: String(rest[rest.index(after: bar)...]), trackKey: String(rest[.. (String, String)? { guard let c = id.firstIndex(of: ":"), c != id.startIndex else { return nil } return (String(id[.. String { let folded = sortTitle.trimmingCharacters(in: .whitespaces).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil) guard let c = folded.unicodeScalars.first(where: { CharacterSet.alphanumerics.contains($0) }) else { return "#" } let u = String(c).uppercased() return u.count == 1 && ("A"..."Z").contains(u) ? u : "#" } /// Items by first letter, "#" first then A–Z, each keeping the incoming order. static func buckets(_ items: [T], _ sortTitle: (T) -> String) -> [(String, [T])] { var groups: [String: [T]] = [:] for it in items { groups[letterOf(sortTitle(it)), default: []].append(it) } return groups.sorted { ($0.key == "#" ? "" : $0.key) < ($1.key == "#" ? "" : $1.key) }.map { ($0.key, $0.value) } } static func countLabel(_ n: Int, _ one: String, _ many: String) -> String { "\(n) \(n == 1 ? one : many)" } /// Loose title match for voice requests: case, accents and punctuation don't matter. static func norm(_ s: String) -> String { let folded = s.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil).lowercased() let kept = folded.unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) || $0 == " " } return String(String.UnicodeScalarView(kept)).split(separator: " ").joined(separator: " ") } static func matches(_ title: String, _ query: String) -> Bool { let t = norm(title), q = norm(query) func noThe(_ s: String) -> String { s.hasPrefix("the ") ? String(s.dropFirst(4)) : s } return !q.isEmpty && (t == q || noThe(t) == noThe(q)) } }