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