Create BearWave macOS radio-first app

This commit is contained in:
Sebastian Palencsar
2026-06-25 09:21:42 +02:00
commit b47b591e03
42 changed files with 3814 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
import AppKit
import CryptoKit
import Foundation
import os
import Observation
@Observable
@MainActor
final class NowPlayingArtworkResolver {
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Artwork")
private let cacheDirectory: URL
private var memoryCache: [String: NSImage] = [:]
private var inFlight: Set<String> = []
private let faviconLoader: StationArtworkLoader
var onArtworkLoaded: (() -> Void)?
init(cacheDirectory: URL, faviconLoader: StationArtworkLoader) {
self.cacheDirectory = cacheDirectory.appendingPathComponent("now_playing_artwork")
self.faviconLoader = faviconLoader
}
func resolve(artist: String?, title: String?, station: RadioStation?) -> NSImage {
let cacheKey = artworkCacheKey(artist: artist, title: title, station: station)
if let cached = memoryCache[cacheKey] {
return cached
}
if let diskImage = NSImage(contentsOf: diskCacheURL(for: cacheKey)) {
memoryCache[cacheKey] = diskImage
return diskImage
}
if let station {
let favicon = faviconLoader.image(for: station) ?? BearWaveAssets.image(named: "bearwave") ?? NSImage()
memoryCache[cacheKey] = favicon
return favicon
}
return BearWaveAssets.image(named: "bearwave") ?? NSImage()
}
func fetchArtwork(artist: String?, title: String?, station: RadioStation?) {
logger.info("fetchArtwork called: artist='\(artist ?? "nil")', title='\(title ?? "nil")'")
guard let artist, let title, !artist.isEmpty, !title.isEmpty else {
logger.info("guard failed: empty artist or title")
return
}
let cacheKey = artworkCacheKey(artist: artist, title: title, station: station)
guard !inFlight.contains(cacheKey) else {
logger.info("already in flight for key: \(cacheKey)")
return
}
inFlight.insert(cacheKey)
Task {
defer {
Task { @MainActor in self.inFlight.remove(cacheKey) }
}
let query = "\(artist) \(title)".trimmingCharacters(in: .whitespacesAndNewlines)
logger.info("iTunes search query: '\(query)'")
let searchURL = Self.buildITunesSearchURL(query: query)
guard let searchURL else {
logger.info("failed to build search URL")
return
}
do {
var request = URLRequest(url: searchURL)
request.timeoutInterval = 8
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
logger.info("search request failed: \(response)")
return
}
let decoder = JSONDecoder()
let itunesResponse = try decoder.decode(ITunesResponse.self, from: data)
logger.info("iTunes resultCount: \(itunesResponse.results.count)")
guard let result = itunesResponse.results.first,
var artworkURL = URL(string: result.artworkUrl100) else {
logger.info("no artwork URL in response")
return
}
logger.info("artwork URL: \(artworkURL.absoluteString)")
artworkURL = Self.highResArtworkURL(artworkURL)
var imageRequest = URLRequest(url: artworkURL)
imageRequest.timeoutInterval = 8
let (imageData, imageResponse) = try await URLSession.shared.data(for: imageRequest)
guard let httpImage = imageResponse as? HTTPURLResponse,
(200..<300).contains(httpImage.statusCode) else {
logger.info("image download failed: \(imageResponse)")
return
}
guard let image = NSImage(data: imageData) else {
logger.info("NSImage(data:) failed, data size: \(imageData.count)")
return
}
logger.info("image loaded, size: \(String(describing: image.size))")
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try? imageData.write(to: diskCacheURL(for: cacheKey), options: .atomic)
await MainActor.run {
memoryCache[cacheKey] = image
logger.info("artwork cached and callback triggered")
self.onArtworkLoaded?()
}
} catch {
logger.info("error: \(error)")
return
}
}
}
private func artworkCacheKey(artist: String?, title: String?, station: RadioStation?) -> String {
let artistPart = artist?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let titlePart = title?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let key = "\(artistPart)|\(titlePart)"
logger.info("cacheKey: '\(key)'")
return key
}
private func diskCacheURL(for key: String) -> URL {
let digest = SHA256.hash(data: Data(key.utf8))
let hash = digest.map { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent("\(hash).image")
}
private static func buildITunesSearchURL(query: String) -> URL? {
var components = URLComponents(string: "https://itunes.apple.com/search")
components?.queryItems = [
URLQueryItem(name: "term", value: query),
URLQueryItem(name: "media", value: "music"),
URLQueryItem(name: "limit", value: "1")
]
return components?.url
}
private static func highResArtworkURL(_ url: URL) -> URL {
var urlString = url.absoluteString
urlString = urlString.replacingOccurrences(of: "100x100", with: "600x600")
return URL(string: urlString) ?? url
}
}
private struct ITunesResponse: Codable {
struct Result: Codable {
let artworkUrl100: String
}
let results: [Result]
}

View File

@@ -0,0 +1,466 @@
import AppKit
import AVFoundation
import Combine
@preconcurrency import MediaPlayer
import Observation
import os
@Observable
@MainActor
public final class PlaybackController {
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Playback")
private let player = AVPlayer()
private let artworkProvider: (RadioStation?, String?, String?) -> NSImage
var onMetadataChanged: ((String?, String?, RadioStation?) -> Void)?
private var retryTask: Task<Void, Never>?
private var retryAttempts = 0
private var lastURL: URL?
private var lastStation: RadioStation?
private var cancellables: Set<AnyCancellable> = []
private var itemCancellables: Set<AnyCancellable> = []
private var metadataHandler: MetadataHandler?
var state: PlaybackStatus = .stopped
public var isPlaying: Bool { state.isPlaying }
var currentStation: RadioStation?
var currentTrackTitle = ""
var currentTrackArtist = ""
var currentArtwork: NSImage = BearWaveAssets.image(named: "bearwave") ?? NSImage()
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
var volume: Double = 0.55 {
didSet {
player.volume = Float(min(max(volume, 0), 1))
updateNowPlaying()
}
}
init(artworkProvider: @escaping (RadioStation?, String?, String?) -> NSImage = { station, _, _ in
BearWaveAssets.image(named: "bearwave") ?? NSImage()
}) {
self.artworkProvider = artworkProvider
player.volume = Float(volume)
configureRemoteCommands()
observePlaybackNotifications()
observePlayer()
configureTimeObserver()
}
func play(_ station: RadioStation) {
guard let url = URL(string: station.playbackURLString) else { return }
retryTask?.cancel()
retryAttempts = 0
lastURL = url
lastStation = station
currentStation = station
currentTrackTitle = ""
currentTrackArtist = ""
state = .connecting
itemCancellables.removeAll()
let item = AVPlayerItem(url: url)
let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil)
let handler = MetadataHandler { [weak self] artist, title in
Task { @MainActor [weak self] in
guard let self else { return }
var changed = false
if let title, title != self.currentTrackTitle {
self.currentTrackTitle = title
changed = true
}
if let artist, artist != self.currentTrackArtist {
self.currentTrackArtist = artist
changed = true
}
if changed {
self.onMetadataChanged?(self.currentTrackArtist, self.currentTrackTitle, self.currentStation)
self.updateNowPlaying()
}
}
}
metadataOutput.setDelegate(handler, queue: .main)
item.add(metadataOutput)
metadataHandler = handler
observeItem(item)
player.replaceCurrentItem(with: item)
player.play()
setInitialArtwork()
updateNowPlaying()
}
func resume() {
if let station = lastStation {
play(station)
} else {
player.play()
state = .connecting
updateNowPlaying()
}
}
func pause() {
player.pause()
state = .paused
updateNowPlaying()
}
func stop() {
retryTask?.cancel()
player.pause()
player.replaceCurrentItem(with: nil)
state = .stopped
currentStation = nil
currentTrackTitle = ""
currentTrackArtist = ""
currentArtwork = BearWaveAssets.image(named: "bearwave") ?? NSImage()
currentTime = 0
duration = 0
itemCancellables.removeAll()
metadataHandler = nil
onMetadataChanged?(nil, nil, nil)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
MPNowPlayingInfoCenter.default().playbackState = .stopped
}
public func togglePlayPause() {
isPlaying ? pause() : resume()
}
private var timeObserverToken: Any?
func configureTimeObserver() {
let interval = CMTime(seconds: 1, preferredTimescale: 1)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
Task { @MainActor in
guard let self else { return }
let currentSeconds = time.seconds
guard currentSeconds.isFinite, currentSeconds >= 0 else { return }
self.currentTime = currentSeconds
let itemDuration = self.player.currentItem?.duration
if let itemDuration, itemDuration.timescale > 0, itemDuration.value > 0 {
let dur = CMTimeGetSeconds(itemDuration)
if dur.isFinite, dur > 0 {
self.duration = dur
}
}
}
}
}
private func observePlaybackNotifications() {
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: nil,
queue: .main
) { [weak self] notification in
let error = (notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError)
Task { @MainActor in
guard let self else { return }
let resolvedError = error ?? self.player.currentItem?.error as NSError?
let playbackError = Self.classifyError(resolvedError)
self.logger.info("playback failed: \(playbackError.title) \(playbackError.detail)")
self.state = .failed(playbackError)
if playbackError.isRetryable {
self.scheduleRetry()
}
}
}
NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
self.state = .buffering
self.scheduleRetry()
}
}
}
private func observePlayer() {
player.publisher(for: \.timeControlStatus)
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
Task { @MainActor in self?.handleTimeControlStatus(status) }
}
.store(in: &cancellables)
}
private func observeItem(_ item: AVPlayerItem) {
item.publisher(for: \.status)
.receive(on: DispatchQueue.main)
.sink { [weak self, weak item] status in
Task { @MainActor in self?.handleItemStatus(status, item: item) }
}
.store(in: &itemCancellables)
}
private func handleItemStatus(_ status: AVPlayerItem.Status, item: AVPlayerItem?) {
switch status {
case .unknown:
state = .connecting
case .readyToPlay:
if player.timeControlStatus == .playing {
state = .playing
} else {
state = .buffering
}
case .failed:
let error = Self.classifyError(item?.error as NSError?)
logger.info("item failed: \(error.title) \(error.detail)")
state = .failed(error)
if error.isRetryable {
scheduleRetry()
}
@unknown default:
state = .failed(.unknown(String(localized: "Unsupported stream state")))
}
updateNowPlaying()
}
private func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
guard currentStation != nil else { return }
switch status {
case .paused:
if state != .stopped, !isFailed {
state = .paused
}
case .waitingToPlayAtSpecifiedRate:
if !isFailed {
state = player.currentItem?.status == .unknown ? .connecting : .buffering
}
case .playing:
state = .playing
@unknown default:
break
}
updateNowPlaying()
}
private func scheduleRetry() {
guard retryAttempts < 2, let url = lastURL else { return }
if case let .failed(error) = state, !error.isRetryable {
logger.info("skipping retry: error is not retryable")
return
}
retryAttempts += 1
logger.info("scheduling retry attempt \(self.retryAttempts)")
retryTask?.cancel()
retryTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(1200))
await MainActor.run {
guard let self else { return }
let item = AVPlayerItem(url: url)
self.itemCancellables.removeAll()
self.observeItem(item)
self.state = .connecting
self.player.replaceCurrentItem(with: item)
self.player.play()
self.updateNowPlaying()
}
}
}
private func configureRemoteCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.resume() }
return .success
}
commandCenter.pauseCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.pause() }
return .success
}
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.togglePlayPause() }
return .success
}
commandCenter.stopCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.stop() }
return .success
}
}
func refreshNowPlaying() {
logger.info("refreshNowPlaying called")
currentArtwork = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
updateNowPlaying()
}
public func updateNowPlayingExternally() {
updateNowPlaying()
}
private func setInitialArtwork() {
currentArtwork = artworkProvider(currentStation, nil, nil)
}
private func updateNowPlaying() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo()
MPNowPlayingInfoCenter.default().playbackState = state == .playing ? .playing : .paused
}
private func nowPlayingInfo() -> [String: Any] {
var info: [String: Any] = [
MPMediaItemPropertyTitle: currentTrackTitle.isEmpty ? currentStation?.name ?? "BearWave" : currentTrackTitle,
MPMediaItemPropertyAlbumTitle: currentStation?.name ?? "BearWave",
MPNowPlayingInfoPropertyPlaybackRate: state == .playing ? 1.0 : 0.0
]
if !currentTrackArtist.isEmpty {
info[MPMediaItemPropertyArtist] = currentTrackArtist
}
if let artwork = nowPlayingArtwork() {
info[MPMediaItemPropertyArtwork] = artwork
}
return info
}
private func nowPlayingArtwork() -> MPMediaItemArtwork? {
let image = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let size = image.size
return MPMediaItemArtwork(boundsSize: size, requestHandler: { @Sendable requestedSize in
NSImage(cgImage: cgImage, size: requestedSize)
})
}
private var isFailed: Bool {
if case .failed = state { return true }
return false
}
private static func classifyError(_ error: NSError?) -> PlaybackError {
guard let error else { return .unknown(String(localized: "Stream failed")) }
let domain = error.domain
let code = error.code
if domain == NSURLErrorDomain {
switch code {
case NSURLErrorTimedOut:
return .connectionTimeout
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
return .dnsResolutionFailed
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost:
return .stationOffline
case NSURLErrorUnsupportedURL, NSURLErrorFileDoesNotExist:
return .codecNotSupported
default:
if (500...599).contains(code) {
return .httpError(code)
}
return .unknown(error.localizedDescription)
}
}
if domain == "CoreMediaErrorDomain" {
switch code {
case -12785:
return .streamInterrupted
case -12935, -16042, -15697:
return .codecNotSupported
default:
return .unknown(error.localizedDescription)
}
}
if domain == "AVFoundationErrorDomain" {
switch code {
case -11800, -11828, -11838:
return .codecNotSupported
default:
return .unknown(error.localizedDescription)
}
}
if (400...599).contains(code) {
return .httpError(code)
}
return .unknown(error.localizedDescription)
}
}
final class MetadataHandler: NSObject, AVPlayerItemMetadataOutputPushDelegate {
private let onMetadata: @Sendable (String?, String?) -> Void
init(onMetadata: @escaping @Sendable (String?, String?) -> Void) {
self.onMetadata = onMetadata
}
func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) {
guard let items = groups.first?.items else { return }
let parsed = parseMetadataItems(items)
onMetadata(parsed.artist, parsed.title)
}
}
private func parseMetadataItems(_ items: [AVMetadataItem]) -> (artist: String?, title: String?) {
var artist: String?
var title: String?
for item in items {
let key = metadataKey(for: item)
let value = metadataStringValue(for: item)
guard let value, !value.isEmpty else { continue }
if key.contains("artist") || key.contains("author") {
artist = value
} else if key.contains("title") || key.contains("song") {
if let split = splitArtistTitle(value) {
artist = artist ?? split.artist
title = split.title
} else {
title = value
}
} else if artist == nil && title == nil, let split = splitArtistTitle(value) {
artist = split.artist
title = split.title
}
}
return (artist, title)
}
private func metadataKey(for item: AVMetadataItem) -> String {
let common = item.commonKey?.rawValue ?? ""
let identifier = item.identifier?.rawValue ?? ""
let key = item.key as? String ?? ""
return [common, identifier, key].joined(separator: " ").lowercased()
}
private func metadataStringValue(for item: AVMetadataItem) -> String? {
if let stringValue = item.stringValue {
return cleanMetadataString(stringValue)
}
if let value = item.value as? String {
return cleanMetadataString(value)
}
return nil
}
private func cleanMetadataString(_ value: String) -> String {
value
.replacingOccurrences(of: "StreamTitle=", with: "")
.trimmingCharacters(in: CharacterSet(charactersIn: " ';\""))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func splitArtistTitle(_ value: String) -> (artist: String, title: String)? {
let separators = [" - ", " ", ""]
for separator in separators {
let parts = value.components(separatedBy: separator)
guard parts.count >= 2 else { continue }
let artist = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let title = parts.dropFirst().joined(separator: separator).trimmingCharacters(in: .whitespacesAndNewlines)
if !artist.isEmpty, !title.isEmpty {
return (artist, title)
}
}
return nil
}

View File

@@ -0,0 +1,204 @@
import CryptoKit
import Foundation
struct RadioBrowserClient: Sendable {
var baseURL: URL
var cacheDirectory: URL
var session: URLSessionProtocol
init(
baseURL: URL = URL(string: "https://de1.api.radio-browser.info/json")!,
cacheDirectory: URL,
session: URLSessionProtocol = URLSession.shared
) {
self.baseURL = baseURL
self.cacheDirectory = cacheDirectory
self.session = session
}
func topStations(count: Int = 100) async throws -> [RadioStation] {
try await request("/stations/topvote/\(count)")
}
func germanStations() async throws -> [RadioStation] {
try await request("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true")
}
func dutchStations() async throws -> [RadioStation] {
try await request("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true")
}
func worldStations(count: Int = 200) async throws -> [RadioStation] {
try await request("/stations?hidebroken=true&limit=\(count)&order=votes&reverse=true")
}
func stations(tag: String) async throws -> [RadioStation] {
try await request("/stations/bytag/\(Self.percentEncodePathComponent(tag))")
}
func stations(countryCode: String) async throws -> [RadioStation] {
try await request("/stations/bycountrycodeexact/\(countryCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased())")
}
func search(_ query: String) async throws -> [RadioStation] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let candidates = searchCandidates(for: trimmed)
var firstError: Error?
var combined: [RadioStation] = []
var seenIDs = Set<String>()
for candidate in candidates {
do {
let stations = try await request(searchEndpoint(name: candidate))
for station in stations {
if seenIDs.insert(station.id).inserted {
combined.append(station)
}
}
} catch {
if firstError == nil {
firstError = error
}
}
}
let ranked = rankSearchResults(combined, query: trimmed)
if !ranked.isEmpty {
return ranked
}
if let firstError {
throw firstError
}
return []
}
func request(_ endpoint: String) async throws -> [RadioStation] {
let cacheFile = cacheURL(for: endpoint)
let requestURL = URL(string: baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + endpoint)!
do {
var request = URLRequest(url: requestURL)
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) {
throw URLError(.badServerResponse)
}
let stations = try Self.decodeStations(from: data)
if !stations.isEmpty {
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try data.write(to: cacheFile, options: .atomic)
}
return stations
} catch {
if let cachedData = try? Data(contentsOf: cacheFile) {
return try Self.decodeStations(from: cachedData)
}
throw error
}
}
func cacheURL(for endpoint: String) -> URL {
let digest = Insecure.MD5.hash(data: Data(endpoint.utf8))
let hash = digest.map { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent("\(hash).json")
}
static func decodeStations(from data: Data) throws -> [RadioStation] {
let decoder = JSONDecoder()
return try decoder.decode([RadioStation].self, from: data)
.filter { !$0.name.isEmpty && !$0.playbackURLString.isEmpty }
}
static func percentEncodePathComponent(_ value: String) -> String {
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "/?#[]@!$&'()*+,;=")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
private func searchEndpoint(name: String) -> String {
var components = URLComponents()
components.path = "/stations/search"
components.queryItems = [
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "hidebroken", value: "true"),
URLQueryItem(name: "limit", value: "200"),
URLQueryItem(name: "order", value: "votes"),
URLQueryItem(name: "reverse", value: "true")
]
return components.string ?? "/stations/byname/\(Self.percentEncodePathComponent(name))"
}
private func searchCandidates(for query: String) -> [String] {
guard !query.isEmpty else { return [] }
var candidates = [query]
let punctuation = CharacterSet.punctuationCharacters
if query.rangeOfCharacter(from: punctuation) == nil {
candidates.insert("\(query)!", at: 0)
candidates.append("\(query).")
}
var seen = Set<String>()
return candidates.filter { candidate in
let key = candidate.lowercased()
if seen.contains(key) { return false }
seen.insert(key)
return true
}
}
private func rankSearchResults(_ stations: [RadioStation], query: String) -> [RadioStation] {
let normalizedQuery = Self.normalizedSearchText(query)
guard !normalizedQuery.isEmpty else { return stations }
return stations
.map { station -> (RadioStation, Int) in
let normalizedName = Self.normalizedSearchText(station.name)
let nameTokens = Self.normalizedSearchTokens(station.name)
let allTokens = Self.normalizedSearchTokens(station.searchableText)
let score: Int
if normalizedName == normalizedQuery {
score = 0
} else if nameTokens.contains(normalizedQuery) {
score = 1
} else if nameTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
score = 2
} else if allTokens.contains(normalizedQuery) {
score = 3
} else if allTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
score = 4
} else {
score = 5
}
return (station, score)
}
.filter { $0.1 < 5 }
.sorted {
if $0.1 != $1.1 { return $0.1 < $1.1 }
if $0.0.votes != $1.0.votes { return $0.0.votes > $1.0.votes }
return $0.0.name.localizedCaseInsensitiveCompare($1.0.name) == .orderedAscending
}
.map(\.0)
}
static func normalizedSearchText(_ value: String) -> String {
value
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.joined()
}
static func normalizedSearchTokens(_ value: String) -> [String] {
value
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
}
}
protocol URLSessionProtocol: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}

View File

@@ -0,0 +1,78 @@
import AppKit
import CryptoKit
import Foundation
import Observation
@Observable
@MainActor
final class StationArtworkLoader {
private let cacheDirectory: URL
private var memoryCache: [String: NSImage] = [:]
private var inFlight: Set<String> = []
init(cacheDirectory: URL) {
self.cacheDirectory = cacheDirectory.appendingPathComponent("station_artwork")
}
func image(for station: RadioStation?) -> NSImage? {
guard let urlString = normalizedArtworkURLString(for: station) else {
return BearWaveAssets.image(named: "bearwave")
}
if let cached = memoryCache[urlString] {
return cached
}
if let diskImage = NSImage(contentsOf: cacheURL(for: urlString)) {
memoryCache[urlString] = diskImage
return diskImage
}
load(urlString)
return BearWaveAssets.image(named: "bearwave")
}
private func load(_ urlString: String) {
guard !inFlight.contains(urlString), let url = URL(string: urlString) else { return }
inFlight.insert(urlString)
Task {
defer {
Task { @MainActor in self.inFlight.remove(urlString) }
}
do {
var request = URLRequest(url: url)
request.timeoutInterval = 10
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let image = NSImage(data: data) else { return }
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try? data.write(to: cacheURL(for: urlString), options: .atomic)
await MainActor.run {
memoryCache[urlString] = image
}
} catch {
return
}
}
}
private func normalizedArtworkURLString(for station: RadioStation?) -> String? {
guard let station else { return nil }
let raw = station.favicon.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { return nil }
if raw.hasPrefix("//") {
return "https:\(raw)"
}
if raw.hasPrefix("http://") || raw.hasPrefix("https://") {
return raw
}
return nil
}
private func cacheURL(for urlString: String) -> URL {
let digest = SHA256.hash(data: Data(urlString.utf8))
let hash = digest.map { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent("\(hash).image")
}
}