Create BearWave macOS radio-first app
This commit is contained in:
130
Sources/BearWaveCore/Stores/PersistenceStore.swift
Normal file
130
Sources/BearWaveCore/Stores/PersistenceStore.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
public struct PersistenceStore: Sendable {
|
||||
let applicationSupportDirectory: URL
|
||||
let cacheDirectory: URL
|
||||
let linuxConfigDirectory: URL
|
||||
|
||||
public init(
|
||||
applicationSupportDirectory: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("BearWave"),
|
||||
cacheDirectory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("BearWave"),
|
||||
linuxConfigDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/bearwave")
|
||||
) {
|
||||
self.applicationSupportDirectory = applicationSupportDirectory
|
||||
self.cacheDirectory = cacheDirectory
|
||||
self.linuxConfigDirectory = linuxConfigDirectory
|
||||
}
|
||||
|
||||
var apiCacheDirectory: URL {
|
||||
cacheDirectory.appendingPathComponent("api_cache")
|
||||
}
|
||||
|
||||
func bootstrapImportIfNeeded() {
|
||||
if !FileManager.default.fileExists(atPath: favoritesURL.path) {
|
||||
copyIfPresent(from: linuxConfigDirectory.appendingPathComponent("favorites.json"), to: favoritesURL)
|
||||
}
|
||||
if !FileManager.default.fileExists(atPath: stateURL.path) {
|
||||
copyIfPresent(from: linuxConfigDirectory.appendingPathComponent("state.json"), to: stateURL)
|
||||
}
|
||||
}
|
||||
|
||||
func loadFavorites() -> [RadioStation] {
|
||||
load([RadioStation].self, from: favoritesURL) ?? []
|
||||
}
|
||||
|
||||
func saveFavorites(_ stations: [RadioStation]) {
|
||||
save(stations, to: favoritesURL)
|
||||
}
|
||||
|
||||
func loadManualStations() -> [RadioStation] {
|
||||
load([RadioStation].self, from: manualURL) ?? []
|
||||
}
|
||||
|
||||
func saveManualStations(_ stations: [RadioStation]) {
|
||||
save(stations, to: manualURL)
|
||||
}
|
||||
|
||||
func loadGroups() -> [StationGroup] {
|
||||
load([StationGroup].self, from: groupsURL) ?? []
|
||||
}
|
||||
|
||||
func saveGroups(_ groups: [StationGroup]) {
|
||||
save(groups, to: groupsURL)
|
||||
}
|
||||
|
||||
func loadPlaybackState() -> PlaybackState {
|
||||
load(PlaybackState.self, from: stateURL) ?? PlaybackState()
|
||||
}
|
||||
|
||||
func savePlaybackState(_ state: PlaybackState) {
|
||||
save(state, to: stateURL)
|
||||
}
|
||||
|
||||
private var favoritesURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("favorites.json")
|
||||
}
|
||||
|
||||
private var manualURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("manual-stations.json")
|
||||
}
|
||||
|
||||
private var stateURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("state.json")
|
||||
}
|
||||
|
||||
private var groupsURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("groups.json")
|
||||
}
|
||||
|
||||
private func load<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
private func save<T: Encodable>(_ value: T, to url: URL) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: applicationSupportDirectory, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder.prettyBearWave.encode(value)
|
||||
try data.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
assertionFailure("Failed to save \(url.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func copyIfPresent(from source: URL, to destination: URL) {
|
||||
guard FileManager.default.fileExists(atPath: source.path) else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
} catch {
|
||||
assertionFailure("Failed to import \(source.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackState: Codable, Equatable, Sendable {
|
||||
var lastStationName: String
|
||||
var lastStationURL: String
|
||||
var volume: Double
|
||||
var recentStations: [RadioStation]
|
||||
|
||||
init(
|
||||
lastStationName: String = "",
|
||||
lastStationURL: String = "",
|
||||
volume: Double = 0.55,
|
||||
recentStations: [RadioStation] = []
|
||||
) {
|
||||
self.lastStationName = lastStationName
|
||||
self.lastStationURL = lastStationURL
|
||||
self.volume = volume
|
||||
self.recentStations = recentStations
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettyBearWave: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
37
Sources/BearWaveCore/Stores/SettingsStore.swift
Normal file
37
Sources/BearWaveCore/Stores/SettingsStore.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class SettingsStore {
|
||||
var radioBrowserBaseURL: String {
|
||||
didSet { defaults.set(radioBrowserBaseURL, forKey: Keys.radioBrowserBaseURL) }
|
||||
}
|
||||
|
||||
var resumeOnLaunch: Bool {
|
||||
didSet { defaults.set(resumeOnLaunch, forKey: Keys.resumeOnLaunch) }
|
||||
}
|
||||
|
||||
var showMenuBarExtraDetails: Bool {
|
||||
didSet { defaults.set(showMenuBarExtraDetails, forKey: Keys.showMenuBarExtraDetails) }
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
radioBrowserBaseURL = defaults.string(forKey: Keys.radioBrowserBaseURL) ?? "https://de1.api.radio-browser.info/json"
|
||||
resumeOnLaunch = defaults.object(forKey: Keys.resumeOnLaunch) as? Bool ?? false
|
||||
showMenuBarExtraDetails = defaults.object(forKey: Keys.showMenuBarExtraDetails) as? Bool ?? true
|
||||
}
|
||||
|
||||
var baseURL: URL {
|
||||
URL(string: radioBrowserBaseURL) ?? URL(string: "https://de1.api.radio-browser.info/json")!
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let radioBrowserBaseURL = "radioBrowserBaseURL"
|
||||
static let resumeOnLaunch = "resumeOnLaunch"
|
||||
static let showMenuBarExtraDetails = "showMenuBarExtraDetails"
|
||||
}
|
||||
}
|
||||
334
Sources/BearWaveCore/Stores/StationLibrary.swift
Normal file
334
Sources/BearWaveCore/Stores/StationLibrary.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class StationLibrary {
|
||||
var selectedPage: StationPage = .top
|
||||
var stations: [RadioStation] = []
|
||||
var favorites: [RadioStation] = []
|
||||
var manualStations: [RadioStation] = []
|
||||
var recentStations: [RadioStation] = []
|
||||
var groups: [StationGroup] = []
|
||||
var selectedGroupID: String?
|
||||
var selectedStationID: RadioStation.ID?
|
||||
var filterQuery: String = ""
|
||||
var sortMode: StationSortMode = .name
|
||||
var isLoading = false
|
||||
var lastError: String?
|
||||
var lastStationName = ""
|
||||
var lastStationURL = ""
|
||||
var volume: Double = 0.55
|
||||
|
||||
private var client: RadioBrowserClient
|
||||
private let persistence: PersistenceStore
|
||||
|
||||
init(client: RadioBrowserClient, persistence: PersistenceStore) {
|
||||
self.client = client
|
||||
self.persistence = persistence
|
||||
persistence.bootstrapImportIfNeeded()
|
||||
favorites = persistence.loadFavorites().map { station in
|
||||
var copy = station
|
||||
copy.isFavorite = true
|
||||
return copy
|
||||
}
|
||||
manualStations = persistence.loadManualStations()
|
||||
groups = persistence.loadGroups().sorted { $0.sortOrder < $1.sortOrder }
|
||||
let state = persistence.loadPlaybackState()
|
||||
lastStationName = state.lastStationName
|
||||
lastStationURL = state.lastStationURL
|
||||
volume = min(max(state.volume, 0), 1)
|
||||
recentStations = Array(state.recentStations.prefix(20))
|
||||
}
|
||||
|
||||
var visibleStations: [RadioStation] {
|
||||
let source: [RadioStation]
|
||||
switch selectedPage {
|
||||
case .favorites:
|
||||
source = favorites
|
||||
case .recent:
|
||||
source = recentStations
|
||||
case .manual:
|
||||
source = manualStations
|
||||
case .group:
|
||||
source = stationsForSelectedGroup()
|
||||
case .top, .germany, .netherlands, .world, .search:
|
||||
source = stations
|
||||
}
|
||||
|
||||
let filtered: [RadioStation]
|
||||
if filterQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
filtered = source
|
||||
} else {
|
||||
let query = filterQuery.lowercased()
|
||||
filtered = source.filter { $0.searchableText.contains(query) }
|
||||
}
|
||||
|
||||
return sorted(filtered)
|
||||
}
|
||||
|
||||
var selectedStation: RadioStation? {
|
||||
guard let selectedStationID else { return visibleStations.first }
|
||||
return visibleStations.first { $0.id == selectedStationID }
|
||||
}
|
||||
|
||||
var canResume: Bool {
|
||||
!lastStationURL.isEmpty
|
||||
}
|
||||
|
||||
func updateBaseURL(_ url: URL) {
|
||||
client.baseURL = url
|
||||
}
|
||||
|
||||
func loadSelectedPage() async {
|
||||
await load(selectedPage)
|
||||
}
|
||||
|
||||
func load(_ page: StationPage) async {
|
||||
selectedPage = page
|
||||
lastError = nil
|
||||
|
||||
switch page {
|
||||
case .favorites, .recent, .manual, .search, .group:
|
||||
selectedStationID = visibleStations.first?.id
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let loaded: [RadioStation]
|
||||
switch page {
|
||||
case .top:
|
||||
loaded = try await client.topStations()
|
||||
case .germany:
|
||||
loaded = try await client.germanStations()
|
||||
case .netherlands:
|
||||
loaded = try await client.dutchStations()
|
||||
case .world:
|
||||
loaded = try await client.worldStations()
|
||||
case .search, .favorites, .recent, .manual, .group:
|
||||
loaded = []
|
||||
}
|
||||
stations = markFavorites(loaded)
|
||||
selectedStationID = visibleStations.first?.id
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func search(_ query: String) async {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 2 else { return }
|
||||
selectedPage = .search
|
||||
filterQuery = ""
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
stations = markFavorites(try await client.search(trimmed))
|
||||
selectedStationID = visibleStations.first?.id
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadTag(_ tag: String) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
stations = markFavorites(try await client.stations(tag: tag))
|
||||
selectedPage = .world
|
||||
selectedStationID = visibleStations.first?.id
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadCountry(_ code: String) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
stations = markFavorites(try await client.stations(countryCode: code))
|
||||
selectedPage = .world
|
||||
selectedStationID = visibleStations.first?.id
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func select(_ station: RadioStation) {
|
||||
selectedStationID = station.id
|
||||
}
|
||||
|
||||
func toggleFavorite(_ station: RadioStation) {
|
||||
if let index = favorites.firstIndex(where: { matches($0, station) }) {
|
||||
favorites.remove(at: index)
|
||||
} else {
|
||||
var copy = station
|
||||
copy.isFavorite = true
|
||||
favorites.append(copy)
|
||||
}
|
||||
favorites = sorted(favorites).map { station in
|
||||
var copy = station
|
||||
copy.isFavorite = true
|
||||
return copy
|
||||
}
|
||||
persistence.saveFavorites(favorites)
|
||||
stations = markFavorites(stations)
|
||||
manualStations = markFavorites(manualStations)
|
||||
}
|
||||
|
||||
func isFavorite(_ station: RadioStation) -> Bool {
|
||||
favorites.contains { matches($0, station) }
|
||||
}
|
||||
|
||||
func addManualStation(name: String, url: String, country: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty, URL(string: cleanURL) != nil else { return }
|
||||
|
||||
let station = RadioStation(
|
||||
uuid: UUID().uuidString,
|
||||
name: cleanName,
|
||||
url: cleanURL,
|
||||
resolvedURL: cleanURL,
|
||||
country: country.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? String(localized: "Manual") : country,
|
||||
codec: "unknown",
|
||||
isManual: true
|
||||
)
|
||||
manualStations.insert(station, at: 0)
|
||||
persistence.saveManualStations(manualStations)
|
||||
selectedPage = .manual
|
||||
selectedStationID = station.id
|
||||
}
|
||||
|
||||
func recordPlayback(_ station: RadioStation) {
|
||||
lastStationName = station.name
|
||||
lastStationURL = station.playbackURLString
|
||||
var normalized = station
|
||||
normalized.isFavorite = isFavorite(station)
|
||||
recentStations.removeAll { matches($0, normalized) }
|
||||
recentStations.insert(normalized, at: 0)
|
||||
recentStations = Array(recentStations.prefix(20))
|
||||
savePlaybackState()
|
||||
}
|
||||
|
||||
func saveVolume(_ volume: Double) {
|
||||
self.volume = min(max(volume, 0), 1)
|
||||
savePlaybackState()
|
||||
}
|
||||
|
||||
func createGroup(name: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty else { return }
|
||||
let maxSort = groups.map { $0.sortOrder }.max() ?? 0
|
||||
let group = StationGroup(name: cleanName, sortOrder: maxSort + 1)
|
||||
groups.append(group)
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
|
||||
func deleteGroup(_ group: StationGroup) {
|
||||
groups.removeAll { $0.id == group.id }
|
||||
persistence.saveGroups(groups)
|
||||
if selectedGroupID == group.id {
|
||||
selectedGroupID = nil
|
||||
selectedPage = .top
|
||||
}
|
||||
}
|
||||
|
||||
func renameGroup(_ group: StationGroup, to name: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty else { return }
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].name = cleanName
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
}
|
||||
|
||||
func addStationToGroup(_ station: RadioStation, group: StationGroup) {
|
||||
guard !group.stationIDs.contains(station.id) else { return }
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].stationIDs.append(station.id)
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
}
|
||||
|
||||
func removeStationFromGroup(_ station: RadioStation, group: StationGroup) {
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].stationIDs.removeAll { $0 == station.id }
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
}
|
||||
|
||||
func isStationInGroup(_ station: RadioStation, group: StationGroup) -> Bool {
|
||||
group.stationIDs.contains(station.id)
|
||||
}
|
||||
|
||||
func selectGroup(_ group: StationGroup) {
|
||||
selectedPage = .group
|
||||
selectedGroupID = group.id
|
||||
filterQuery = ""
|
||||
selectedStationID = visibleStations.first?.id
|
||||
}
|
||||
|
||||
private func stationsForSelectedGroup() -> [RadioStation] {
|
||||
guard let group = groups.first(where: { $0.id == selectedGroupID }) else { return [] }
|
||||
let allStations = stations + favorites + manualStations + recentStations
|
||||
var seen = Set<String>()
|
||||
return group.stationIDs.compactMap { id in
|
||||
guard seen.insert(id).inserted else { return nil }
|
||||
return allStations.first { $0.id == id }
|
||||
}.compactMap { $0 }
|
||||
}
|
||||
|
||||
func resumeStation() -> RadioStation? {
|
||||
guard !lastStationURL.isEmpty else { return nil }
|
||||
if let known = (stations + favorites + manualStations + recentStations).first(where: { $0.playbackURLString == lastStationURL }) {
|
||||
return known
|
||||
}
|
||||
return RadioStation(name: lastStationName.isEmpty ? String(localized: "Last played") : lastStationName, url: lastStationURL, resolvedURL: lastStationURL)
|
||||
}
|
||||
|
||||
private func markFavorites(_ source: [RadioStation]) -> [RadioStation] {
|
||||
source.map { station in
|
||||
var copy = station
|
||||
copy.isFavorite = isFavorite(station)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
private func sorted(_ source: [RadioStation]) -> [RadioStation] {
|
||||
switch sortMode {
|
||||
case .name:
|
||||
source.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
case .bitrate:
|
||||
source.sorted { $0.bitrate > $1.bitrate }
|
||||
case .votes:
|
||||
source.sorted { $0.votes > $1.votes }
|
||||
}
|
||||
}
|
||||
|
||||
private func savePlaybackState() {
|
||||
persistence.savePlaybackState(
|
||||
PlaybackState(
|
||||
lastStationName: lastStationName,
|
||||
lastStationURL: lastStationURL,
|
||||
volume: volume,
|
||||
recentStations: recentStations
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func matches(_ lhs: RadioStation, _ rhs: RadioStation) -> Bool {
|
||||
if !lhs.uuid.isEmpty && !rhs.uuid.isEmpty {
|
||||
return lhs.uuid == rhs.uuid
|
||||
}
|
||||
return lhs.playbackURLString == rhs.playbackURLString
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user