Files
bearwave-mac/Sources/BearWaveCore/Stores/StationLibrary.swift
2026-06-25 09:21:42 +02:00

335 lines
11 KiB
Swift

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
}
}