Create BearWave macOS radio-first app
This commit is contained in:
58
Sources/BearWaveCore/Models/PlaybackError.swift
Normal file
58
Sources/BearWaveCore/Models/PlaybackError.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Sources/BearWaveCore/Models/PlaybackStatus.swift
Normal file
38
Sources/BearWaveCore/Models/PlaybackStatus.swift
Normal 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
|
||||
}
|
||||
}
|
||||
136
Sources/BearWaveCore/Models/RadioStation.swift
Normal file
136
Sources/BearWaveCore/Models/RadioStation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
15
Sources/BearWaveCore/Models/StationGroup.swift
Normal file
15
Sources/BearWaveCore/Models/StationGroup.swift
Normal 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
|
||||
}
|
||||
}
|
||||
59
Sources/BearWaveCore/Models/StationPage.swift
Normal file
59
Sources/BearWaveCore/Models/StationPage.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user