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,58 @@
import Foundation
enum PlaybackError: Equatable, Sendable {
case connectionTimeout
case stationOffline
case httpError(Int)
case codecNotSupported
case streamInterrupted
case dnsResolutionFailed
case unknown(String)
var title: String {
switch self {
case .connectionTimeout:
String(localized: "Connection timed out")
case .stationOffline:
String(localized: "Station is offline")
case .httpError:
String(localized: "Server error")
case .codecNotSupported:
String(localized: "Codec not supported")
case .streamInterrupted:
String(localized: "Stream interrupted")
case .dnsResolutionFailed:
String(localized: "Could not resolve host")
case .unknown:
String(localized: "Playback failed")
}
}
var detail: String {
switch self {
case .connectionTimeout:
String(localized: "The station did not respond in time. It may be overloaded or temporarily unavailable.")
case .stationOffline:
String(localized: "The stream URL is no longer available. The station may have shut down or changed its address.")
case let .httpError(code):
String(localized: "The server responded with HTTP \(code). The stream may be temporarily unavailable.")
case .codecNotSupported:
String(localized: "macOS cannot decode this stream format. Try a different station.")
case .streamInterrupted:
String(localized: "The connection was lost. The station may have ended the broadcast.")
case .dnsResolutionFailed:
String(localized: "The stream server address could not be found. Check your internet connection.")
case let .unknown(message):
message
}
}
var isRetryable: Bool {
switch self {
case .connectionTimeout, .stationOffline, .httpError, .streamInterrupted:
true
case .codecNotSupported, .dnsResolutionFailed, .unknown:
false
}
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
enum PlaybackStatus: Equatable {
case stopped
case connecting
case buffering
case playing
case paused
case failed(PlaybackError)
var isPlaying: Bool {
self == .playing || self == .connecting || self == .buffering
}
var title: String {
switch self {
case .stopped:
String(localized: "Stopped")
case .connecting:
String(localized: "Connecting...")
case .buffering:
String(localized: "Buffering...")
case .playing:
String(localized: "Playing")
case .paused:
String(localized: "Paused")
case let .failed(error):
error.title
}
}
var detail: String? {
if case let .failed(error) = self {
return error.detail
}
return nil
}
}

View File

@@ -0,0 +1,136 @@
import Foundation
public struct RadioStation: Codable, Hashable, Identifiable, Sendable {
public var uuid: String
public var name: String
public var url: String
public var resolvedURL: String
public var homepage: String
public var favicon: String
public var country: String
public var tags: String
public var codec: String
public var bitrate: Int
public var votes: Int
public var isOnline: Bool
public var isFavorite: Bool
public var isManual: Bool
public var id: String {
if !uuid.isEmpty { return uuid }
if !resolvedURL.isEmpty { return resolvedURL }
if !url.isEmpty { return url }
return name
}
public var playbackURLString: String {
resolvedURL.isEmpty ? url : resolvedURL
}
public var searchableText: String {
[name, tags, country, codec].joined(separator: " ").lowercased()
}
public init(
uuid: String = "",
name: String,
url: String = "",
resolvedURL: String = "",
homepage: String = "",
favicon: String = "",
country: String = "",
tags: String = "",
codec: String = "",
bitrate: Int = 0,
votes: Int = 0,
isOnline: Bool = true,
isFavorite: Bool = false,
isManual: Bool = false
) {
self.uuid = uuid
self.name = name
self.url = url
self.resolvedURL = resolvedURL
self.homepage = homepage
self.favicon = favicon
self.country = country
self.tags = tags
self.codec = codec
self.bitrate = bitrate
self.votes = votes
self.isOnline = isOnline
self.isFavorite = isFavorite
self.isManual = isManual
}
enum CodingKeys: String, CodingKey {
case uuid = "stationuuid"
case legacyUUID = "uuid"
case name
case url
case resolvedURL = "url_resolved"
case legacyResolvedURL = "urlResolved"
case homepage
case favicon
case country
case tags
case codec
case bitrate
case votes
case lastCheckOK = "lastcheckok"
case isFavorite
case isManual
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
uuid = try container.decodeIfPresent(String.self, forKey: .uuid)
?? container.decodeIfPresent(String.self, forKey: .legacyUUID)
?? ""
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
url = try container.decodeIfPresent(String.self, forKey: .url) ?? ""
resolvedURL = try container.decodeIfPresent(String.self, forKey: .resolvedURL)
?? container.decodeIfPresent(String.self, forKey: .legacyResolvedURL)
?? ""
homepage = try container.decodeIfPresent(String.self, forKey: .homepage) ?? ""
favicon = try container.decodeIfPresent(String.self, forKey: .favicon) ?? ""
country = try container.decodeIfPresent(String.self, forKey: .country) ?? ""
tags = try container.decodeIfPresent(String.self, forKey: .tags) ?? ""
codec = try container.decodeIfPresent(String.self, forKey: .codec) ?? ""
bitrate = try container.decodeFlexibleInt(forKey: .bitrate) ?? 0
votes = try container.decodeFlexibleInt(forKey: .votes) ?? 0
let lastCheckOK = try container.decodeFlexibleInt(forKey: .lastCheckOK)
isOnline = lastCheckOK.map { $0 > 0 } ?? true
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
isManual = try container.decodeIfPresent(Bool.self, forKey: .isManual) ?? false
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(uuid, forKey: .legacyUUID)
try container.encode(name, forKey: .name)
try container.encode(url, forKey: .url)
try container.encode(resolvedURL, forKey: .legacyResolvedURL)
try container.encode(homepage, forKey: .homepage)
try container.encode(favicon, forKey: .favicon)
try container.encode(country, forKey: .country)
try container.encode(tags, forKey: .tags)
try container.encode(codec, forKey: .codec)
try container.encode(bitrate, forKey: .bitrate)
try container.encode(votes, forKey: .votes)
try container.encode(isFavorite, forKey: .isFavorite)
try container.encode(isManual, forKey: .isManual)
}
}
extension KeyedDecodingContainer {
func decodeFlexibleInt(forKey key: Key) throws -> Int? {
if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
return intValue
}
if let stringValue = try? decodeIfPresent(String.self, forKey: key) {
return Int(stringValue)
}
return nil
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
public struct StationGroup: Codable, Hashable, Identifiable, Sendable {
public var id: String
public var name: String
public var stationIDs: [String]
public var sortOrder: Int
public init(id: String = UUID().uuidString, name: String, stationIDs: [String] = [], sortOrder: Int = 0) {
self.id = id
self.name = name
self.stationIDs = stationIDs
self.sortOrder = sortOrder
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
enum StationPage: String, CaseIterable, Identifiable, Codable {
case top
case germany
case netherlands
case world
case search
case favorites
case recent
case manual
case group
var id: String { rawValue }
var title: String {
switch self {
case .top: String(localized: "Top")
case .germany: String(localized: "Germany")
case .netherlands: String(localized: "Netherlands")
case .world: String(localized: "World")
case .search: String(localized: "Search Results")
case .favorites: String(localized: "Favorites")
case .recent: String(localized: "Recent")
case .manual: String(localized: "Manual")
case .group: ""
}
}
var systemImage: String {
switch self {
case .top: "chart.bar.fill"
case .germany: "flag.fill"
case .netherlands: "flag"
case .world: "globe.europe.africa.fill"
case .search: "magnifyingglass"
case .favorites: "star.fill"
case .recent: "clock.arrow.circlepath"
case .manual: "plus.circle.fill"
case .group: "folder.fill"
}
}
}
enum StationSortMode: String, CaseIterable, Identifiable, Codable {
case name
case bitrate
case votes
var id: String { rawValue }
var title: String {
switch self {
case .name: String(localized: "Name")
case .bitrate: String(localized: "Bitrate")
case .votes: String(localized: "Votes")
}
}
}