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,84 @@
import AppKit
import BearWaveCore
import SwiftUI
@main
struct BearWaveApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var model = AppModel()
@State private var isMiniPlayer = false
private static var windowWidth: Double {
let screen = NSScreen.main ?? NSScreen.screens.first
let width = screen?.visibleFrame.width ?? 1440
if width >= 1920 { return 1600 }
return 1255
}
var body: some Scene {
WindowGroup("BearWave", id: "main") {
ContentView(isMiniPlayer: $isMiniPlayer)
.environment(model)
.frame(
minWidth: isMiniPlayer ? 555 : 1255,
minHeight: isMiniPlayer ? 110 : 650
)
.onChange(of: isMiniPlayer) { _, newValue in
resizeWindow(toMini: newValue)
}
.task {
await model.start()
}
}
.defaultSize(width: Self.windowWidth, height: 720)
.commands {
CommandGroup(after: .appTermination) {
Button("Play/Pause") {
model.playback.togglePlayPause()
}
.keyboardShortcut(.space, modifiers: [])
}
CommandGroup(after: .newItem) {
Button(isMiniPlayer ? "Show Full Window" : "Mini Player") {
isMiniPlayer.toggle()
}
.keyboardShortcut("m", modifiers: .command)
}
}
MenuBarExtra("BearWave", systemImage: model.playback.isPlaying ? "radio.fill" : "radio") {
MenuBarContentView()
.environment(model)
}
Settings {
SettingsView()
.environment(model)
.frame(width: 520)
}
}
private func resizeWindow(toMini: Bool) {
let targetSize = toMini ? CGSize(width: 555, height: 110) : CGSize(width: Self.windowWidth, height: 720)
Task { @MainActor in
guard let window = NSApp.mainWindow ?? NSApp.windows.first else { return }
let currentFrame = window.frame
var newFrame = currentFrame
newFrame.size = targetSize
newFrame.origin.y = currentFrame.maxY - targetSize.height
window.minSize = targetSize
window.setFrame(newFrame, display: true, animate: true)
}
}
}
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
}

View File

@@ -0,0 +1,76 @@
import AppKit
import Foundation
import Observation
@Observable
@MainActor
public final class AppModel {
public let settings: SettingsStore
public let library: StationLibrary
public let playback: PlaybackController
let artwork: StationArtworkLoader
private let nowPlayingArtwork: NowPlayingArtworkResolver
public convenience init() {
self.init(settings: SettingsStore(), persistence: PersistenceStore())
}
init(
settings: SettingsStore = SettingsStore(),
persistence: PersistenceStore = PersistenceStore()
) {
self.settings = settings
let client = RadioBrowserClient(baseURL: settings.baseURL, cacheDirectory: persistence.apiCacheDirectory)
library = StationLibrary(client: client, persistence: persistence)
let artworkLoader = StationArtworkLoader(cacheDirectory: persistence.cacheDirectory)
artwork = artworkLoader
nowPlayingArtwork = NowPlayingArtworkResolver(cacheDirectory: persistence.cacheDirectory, faviconLoader: artworkLoader)
playback = PlaybackController(artworkProvider: { [weak nowPlayingArtwork] station, artist, title in
nowPlayingArtwork?.resolve(artist: artist, title: title, station: station) ?? BearWaveAssets.image(named: "bearwave") ?? NSImage()
})
nowPlayingArtwork.onArtworkLoaded = { [weak playback] in
playback?.refreshNowPlaying()
}
playback.onMetadataChanged = { [weak nowPlayingArtwork] artist, title, station in
nowPlayingArtwork?.fetchArtwork(artist: artist, title: title, station: station)
}
playback.volume = library.volume
library.updateBaseURL(settings.baseURL)
}
public func start() async {
await library.load(.top)
if settings.resumeOnLaunch, let station = library.resumeStation() {
play(station)
}
}
public func play(_ station: RadioStation) {
library.recordPlayback(station)
playback.play(station)
nowPlayingArtwork.fetchArtwork(
artist: playback.currentTrackArtist.isEmpty ? nil : playback.currentTrackArtist,
title: playback.currentTrackTitle.isEmpty ? nil : playback.currentTrackTitle,
station: station
)
}
public func resumeLastStation() {
guard let station = library.resumeStation() else { return }
play(station)
}
public func setVolume(_ value: Double) {
playback.volume = value
library.saveVolume(value)
}
public func applySettings() {
library.updateBaseURL(settings.baseURL)
}
public func quit() {
playback.stop()
nowPlayingArtwork.fetchArtwork(artist: nil, title: nil, station: nil)
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,90 @@
"Add" = "Hinzufügen";
"Add Favorite" = "Zu Favoriten hinzufügen";
"Add Station" = "Sender hinzufügen";
"Add to Folder" = "Zu Ordner hinzufügen";
"Add to \(group.name)" = "Zu \(group.name) hinzufügen";
"Artist" = "Artist";
"Bitrate" = "Bitrate";
"Codec" = "Codec";
"Connection timed out" = "Verbindung abgelaufen";
"The station did not respond in time. It may be overloaded or temporarily unavailable." = "Der Sender hat nicht rechtzeitig geantwortet. Er könnte überlastet oder vorübergehend nicht verfügbar sein.";
"Copy Stream URL" = "Stream-URL kopieren";
"Could not resolve host" = "Host konnte nicht aufgelöst werden";
"The stream server address could not be found. Check your internet connection." = "Die Serveradresse des Streams konnte nicht gefunden werden. Prüfe deine Internetverbindung.";
"Create new folder" = "Neuen Ordner erstellen";
"Details" = "Details";
"Folder name" = "Ordnername";
"Folders" = "Ordner";
"Mini Player" = "Mini-Player";
"Show Full Window" = "Vollständiges Fenster anzeigen";
"New Folder" = "Neuer Ordner";
"No stream metadata yet" = "Noch keine Stream-Metadaten";
"Now Playing" = "Jetzt läuft";
"Open Homepage" = "Homepage öffnen";
"Rename folder" = "Ordner umbenennen";
"Remove from \(group.name)" = "Aus \(group.name) entfernen";
"Server error" = "Serverfehler";
"The server responded with HTTP \(code). The stream may be temporarily unavailable." = "Der Server antwortete mit HTTP \(code). Der Stream könnte vorübergehend nicht verfügbar sein.";
"Station is offline" = "Sender ist offline";
"The stream URL is no longer available. The station may have shut down or changed its address." = "Die Stream-URL ist nicht mehr verfügbar. Der Sender könnte abgeschaltet oder die Adresse geändert haben.";
"Status" = "Status";
"Stream interrupted" = "Stream unterbrochen";
"The connection was lost. The station may have ended the broadcast." = "Die Verbindung wurde getrennt. Der Sender könnte die Übertragung beendet haben.";
"Tags" = "Tags";
"Title" = "Titel";
"Codec not supported" = "Codec nicht unterstützt";
"macOS cannot decode this stream format. Try a different station." = "macOS kann dieses Stream-Format nicht decodieren. Versuche einen anderen Sender.";
"Playback failed" = "Wiedergabe fehlgeschlagen";
"Add station manually" = "Sender manuell hinzufügen";
"Base URL" = "Basis-URL";
"BearWave" = "BearWave";
"Bitrate" = "Bitrate";
"Cancel" = "Abbrechen";
"Country (optional)" = "Land (optional)";
"Create" = "Erstellen";
"Delete" = "Löschen";
"Favorites" = "Favoriten";
"Germany" = "Deutschland";
"Last played" = "Zuletzt gespielt";
"Library" = "Mediathek";
"Live radio" = "Live-Radio";
"Load a station page or search worldwide." = "Lade eine Senderseite oder suche weltweit.";
"Manual" = "Manuell";
"Menu Bar" = "Menüleiste";
"Name" = "Name";
"Netherlands" = "Niederlande";
"No station selected" = "Kein Sender ausgewählt";
"No stations loaded yet" = "Noch keine Sender geladen";
"Pause" = "Pause";
"Paused" = "Pausiert";
"Play" = "Wiedergabe";
"Play/Pause" = "Wiedergabe/Pause";
"Playback" = "Wiedergabe";
"Playing" = "Wiedergabe läuft";
"Quit BearWave" = "BearWave beenden";
"Radio Browser" = "Radio Browser";
"Recent" = "Zuletzt";
"Remove Favorite" = "Aus Favoriten entfernen";
"Rename" = "Umbenennen";
"Resume" = "Fortsetzen";
"Resume last station on launch" = "Letzten Sender beim Start fortsetzen";
"Search" = "Suche";
"Search Results" = "Suchergebnisse";
"Search stations, genre, country" = "Sender, Genre, Land suchen";
"Show BearWave" = "BearWave zeigen";
"Show playback details in menu bar" = "Wiedergabedetails in der Menüleiste zeigen";
"Buffering..." = "Puffert...";
"Connecting..." = "Verbindet...";
"Failed" = "Fehlgeschlagen";
"Stopped" = "Gestoppt";
"Stream failed" = "Stream fehlgeschlagen";
"Unsupported stream state" = "Nicht unterstützter Stream-Zustand";
"Quick" = "Schnellzugriff";
"Sort" = "Sortierung";
"Stations" = "Sender";
"Stop" = "Stopp";
"Stream URL (http/https)" = "Stream-URL (http/https)";
"Top" = "Top";
"Votes" = "Stimmen";
"World" = "Welt";
"Unknown" = "Unbekannt";

View File

@@ -0,0 +1,68 @@
"Add" = "Add";
"Add Favorite" = "Add Favorite";
"Add Station" = "Add Station";
"Artist" = "Artist";
"Bitrate" = "Bitrate";
"Codec" = "Codec";
"Copy Stream URL" = "Copy Stream URL";
"Details" = "Details";
"No stream metadata yet" = "No stream metadata yet";
"Now Playing" = "Now Playing";
"Open Homepage" = "Open Homepage";
"Status" = "Status";
"Tags" = "Tags";
"Title" = "Title";
"Add station manually" = "Add station manually";
"Base URL" = "Base URL";
"BearWave" = "BearWave";
"Bitrate" = "Bitrate";
"Cancel" = "Cancel";
"Country (optional)" = "Country (optional)";
"Favorites" = "Favorites";
"Germany" = "Germany";
"Folder name" = "Folder name";
"Folders" = "Folders";
"Last played" = "Last played";
"Library" = "Library";
"Live radio" = "Live radio";
"Load a station page or search worldwide." = "Load a station page or search worldwide.";
"Manual" = "Manual";
"Menu Bar" = "Menu Bar";
"Name" = "Name";
"Netherlands" = "Netherlands";
"No station selected" = "No station selected";
"No stations loaded yet" = "No stations loaded yet";
"Pause" = "Pause";
"Paused" = "Paused";
"Play" = "Play";
"Play/Pause" = "Play/Pause";
"Playback" = "Playback";
"Playing" = "Playing";
"Quit BearWave" = "Quit BearWave";
"Radio Browser" = "Radio Browser";
"Recent" = "Recent";
"Rename folder" = "Rename folder";
"Remove Favorite" = "Remove Favorite";
"Resume" = "Resume";
"Resume last station on launch" = "Resume last station on launch";
"Search" = "Search";
"Search Results" = "Search Results";
"Search stations, genre, country" = "Search stations, genre, country";
"Show BearWave" = "Show BearWave";
"Show playback details in menu bar" = "Show playback details in menu bar";
"Buffering..." = "Buffering...";
"Connecting..." = "Connecting...";
"Failed" = "Failed";
"Stopped" = "Stopped";
"Stream failed" = "Stream failed";
"Unsupported stream state" = "Unsupported stream state";
"Sort" = "Sort";
"Stations" = "Stations";
"Stop" = "Stop";
"Stream URL (http/https)" = "Stream URL (http/https)";
"Top" = "Top";
"Votes" = "Votes";
"World" = "World";
"New Folder" = "New Folder";
"Create new folder" = "Create new folder";
"Add to Folder" = "Add to Folder";

View File

@@ -0,0 +1,158 @@
import AppKit
import CryptoKit
import Foundation
import os
import Observation
@Observable
@MainActor
final class NowPlayingArtworkResolver {
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Artwork")
private let cacheDirectory: URL
private var memoryCache: [String: NSImage] = [:]
private var inFlight: Set<String> = []
private let faviconLoader: StationArtworkLoader
var onArtworkLoaded: (() -> Void)?
init(cacheDirectory: URL, faviconLoader: StationArtworkLoader) {
self.cacheDirectory = cacheDirectory.appendingPathComponent("now_playing_artwork")
self.faviconLoader = faviconLoader
}
func resolve(artist: String?, title: String?, station: RadioStation?) -> NSImage {
let cacheKey = artworkCacheKey(artist: artist, title: title, station: station)
if let cached = memoryCache[cacheKey] {
return cached
}
if let diskImage = NSImage(contentsOf: diskCacheURL(for: cacheKey)) {
memoryCache[cacheKey] = diskImage
return diskImage
}
if let station {
let favicon = faviconLoader.image(for: station) ?? BearWaveAssets.image(named: "bearwave") ?? NSImage()
memoryCache[cacheKey] = favicon
return favicon
}
return BearWaveAssets.image(named: "bearwave") ?? NSImage()
}
func fetchArtwork(artist: String?, title: String?, station: RadioStation?) {
logger.info("fetchArtwork called: artist='\(artist ?? "nil")', title='\(title ?? "nil")'")
guard let artist, let title, !artist.isEmpty, !title.isEmpty else {
logger.info("guard failed: empty artist or title")
return
}
let cacheKey = artworkCacheKey(artist: artist, title: title, station: station)
guard !inFlight.contains(cacheKey) else {
logger.info("already in flight for key: \(cacheKey)")
return
}
inFlight.insert(cacheKey)
Task {
defer {
Task { @MainActor in self.inFlight.remove(cacheKey) }
}
let query = "\(artist) \(title)".trimmingCharacters(in: .whitespacesAndNewlines)
logger.info("iTunes search query: '\(query)'")
let searchURL = Self.buildITunesSearchURL(query: query)
guard let searchURL else {
logger.info("failed to build search URL")
return
}
do {
var request = URLRequest(url: searchURL)
request.timeoutInterval = 8
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
logger.info("search request failed: \(response)")
return
}
let decoder = JSONDecoder()
let itunesResponse = try decoder.decode(ITunesResponse.self, from: data)
logger.info("iTunes resultCount: \(itunesResponse.results.count)")
guard let result = itunesResponse.results.first,
var artworkURL = URL(string: result.artworkUrl100) else {
logger.info("no artwork URL in response")
return
}
logger.info("artwork URL: \(artworkURL.absoluteString)")
artworkURL = Self.highResArtworkURL(artworkURL)
var imageRequest = URLRequest(url: artworkURL)
imageRequest.timeoutInterval = 8
let (imageData, imageResponse) = try await URLSession.shared.data(for: imageRequest)
guard let httpImage = imageResponse as? HTTPURLResponse,
(200..<300).contains(httpImage.statusCode) else {
logger.info("image download failed: \(imageResponse)")
return
}
guard let image = NSImage(data: imageData) else {
logger.info("NSImage(data:) failed, data size: \(imageData.count)")
return
}
logger.info("image loaded, size: \(String(describing: image.size))")
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try? imageData.write(to: diskCacheURL(for: cacheKey), options: .atomic)
await MainActor.run {
memoryCache[cacheKey] = image
logger.info("artwork cached and callback triggered")
self.onArtworkLoaded?()
}
} catch {
logger.info("error: \(error)")
return
}
}
}
private func artworkCacheKey(artist: String?, title: String?, station: RadioStation?) -> String {
let artistPart = artist?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let titlePart = title?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let key = "\(artistPart)|\(titlePart)"
logger.info("cacheKey: '\(key)'")
return key
}
private func diskCacheURL(for key: String) -> URL {
let digest = SHA256.hash(data: Data(key.utf8))
let hash = digest.map { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent("\(hash).image")
}
private static func buildITunesSearchURL(query: String) -> URL? {
var components = URLComponents(string: "https://itunes.apple.com/search")
components?.queryItems = [
URLQueryItem(name: "term", value: query),
URLQueryItem(name: "media", value: "music"),
URLQueryItem(name: "limit", value: "1")
]
return components?.url
}
private static func highResArtworkURL(_ url: URL) -> URL {
var urlString = url.absoluteString
urlString = urlString.replacingOccurrences(of: "100x100", with: "600x600")
return URL(string: urlString) ?? url
}
}
private struct ITunesResponse: Codable {
struct Result: Codable {
let artworkUrl100: String
}
let results: [Result]
}

View File

@@ -0,0 +1,466 @@
import AppKit
import AVFoundation
import Combine
@preconcurrency import MediaPlayer
import Observation
import os
@Observable
@MainActor
public final class PlaybackController {
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Playback")
private let player = AVPlayer()
private let artworkProvider: (RadioStation?, String?, String?) -> NSImage
var onMetadataChanged: ((String?, String?, RadioStation?) -> Void)?
private var retryTask: Task<Void, Never>?
private var retryAttempts = 0
private var lastURL: URL?
private var lastStation: RadioStation?
private var cancellables: Set<AnyCancellable> = []
private var itemCancellables: Set<AnyCancellable> = []
private var metadataHandler: MetadataHandler?
var state: PlaybackStatus = .stopped
public var isPlaying: Bool { state.isPlaying }
var currentStation: RadioStation?
var currentTrackTitle = ""
var currentTrackArtist = ""
var currentArtwork: NSImage = BearWaveAssets.image(named: "bearwave") ?? NSImage()
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
var volume: Double = 0.55 {
didSet {
player.volume = Float(min(max(volume, 0), 1))
updateNowPlaying()
}
}
init(artworkProvider: @escaping (RadioStation?, String?, String?) -> NSImage = { station, _, _ in
BearWaveAssets.image(named: "bearwave") ?? NSImage()
}) {
self.artworkProvider = artworkProvider
player.volume = Float(volume)
configureRemoteCommands()
observePlaybackNotifications()
observePlayer()
configureTimeObserver()
}
func play(_ station: RadioStation) {
guard let url = URL(string: station.playbackURLString) else { return }
retryTask?.cancel()
retryAttempts = 0
lastURL = url
lastStation = station
currentStation = station
currentTrackTitle = ""
currentTrackArtist = ""
state = .connecting
itemCancellables.removeAll()
let item = AVPlayerItem(url: url)
let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil)
let handler = MetadataHandler { [weak self] artist, title in
Task { @MainActor [weak self] in
guard let self else { return }
var changed = false
if let title, title != self.currentTrackTitle {
self.currentTrackTitle = title
changed = true
}
if let artist, artist != self.currentTrackArtist {
self.currentTrackArtist = artist
changed = true
}
if changed {
self.onMetadataChanged?(self.currentTrackArtist, self.currentTrackTitle, self.currentStation)
self.updateNowPlaying()
}
}
}
metadataOutput.setDelegate(handler, queue: .main)
item.add(metadataOutput)
metadataHandler = handler
observeItem(item)
player.replaceCurrentItem(with: item)
player.play()
setInitialArtwork()
updateNowPlaying()
}
func resume() {
if let station = lastStation {
play(station)
} else {
player.play()
state = .connecting
updateNowPlaying()
}
}
func pause() {
player.pause()
state = .paused
updateNowPlaying()
}
func stop() {
retryTask?.cancel()
player.pause()
player.replaceCurrentItem(with: nil)
state = .stopped
currentStation = nil
currentTrackTitle = ""
currentTrackArtist = ""
currentArtwork = BearWaveAssets.image(named: "bearwave") ?? NSImage()
currentTime = 0
duration = 0
itemCancellables.removeAll()
metadataHandler = nil
onMetadataChanged?(nil, nil, nil)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
MPNowPlayingInfoCenter.default().playbackState = .stopped
}
public func togglePlayPause() {
isPlaying ? pause() : resume()
}
private var timeObserverToken: Any?
func configureTimeObserver() {
let interval = CMTime(seconds: 1, preferredTimescale: 1)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
Task { @MainActor in
guard let self else { return }
let currentSeconds = time.seconds
guard currentSeconds.isFinite, currentSeconds >= 0 else { return }
self.currentTime = currentSeconds
let itemDuration = self.player.currentItem?.duration
if let itemDuration, itemDuration.timescale > 0, itemDuration.value > 0 {
let dur = CMTimeGetSeconds(itemDuration)
if dur.isFinite, dur > 0 {
self.duration = dur
}
}
}
}
}
private func observePlaybackNotifications() {
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: nil,
queue: .main
) { [weak self] notification in
let error = (notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError)
Task { @MainActor in
guard let self else { return }
let resolvedError = error ?? self.player.currentItem?.error as NSError?
let playbackError = Self.classifyError(resolvedError)
self.logger.info("playback failed: \(playbackError.title) \(playbackError.detail)")
self.state = .failed(playbackError)
if playbackError.isRetryable {
self.scheduleRetry()
}
}
}
NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
self.state = .buffering
self.scheduleRetry()
}
}
}
private func observePlayer() {
player.publisher(for: \.timeControlStatus)
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
Task { @MainActor in self?.handleTimeControlStatus(status) }
}
.store(in: &cancellables)
}
private func observeItem(_ item: AVPlayerItem) {
item.publisher(for: \.status)
.receive(on: DispatchQueue.main)
.sink { [weak self, weak item] status in
Task { @MainActor in self?.handleItemStatus(status, item: item) }
}
.store(in: &itemCancellables)
}
private func handleItemStatus(_ status: AVPlayerItem.Status, item: AVPlayerItem?) {
switch status {
case .unknown:
state = .connecting
case .readyToPlay:
if player.timeControlStatus == .playing {
state = .playing
} else {
state = .buffering
}
case .failed:
let error = Self.classifyError(item?.error as NSError?)
logger.info("item failed: \(error.title) \(error.detail)")
state = .failed(error)
if error.isRetryable {
scheduleRetry()
}
@unknown default:
state = .failed(.unknown(String(localized: "Unsupported stream state")))
}
updateNowPlaying()
}
private func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
guard currentStation != nil else { return }
switch status {
case .paused:
if state != .stopped, !isFailed {
state = .paused
}
case .waitingToPlayAtSpecifiedRate:
if !isFailed {
state = player.currentItem?.status == .unknown ? .connecting : .buffering
}
case .playing:
state = .playing
@unknown default:
break
}
updateNowPlaying()
}
private func scheduleRetry() {
guard retryAttempts < 2, let url = lastURL else { return }
if case let .failed(error) = state, !error.isRetryable {
logger.info("skipping retry: error is not retryable")
return
}
retryAttempts += 1
logger.info("scheduling retry attempt \(self.retryAttempts)")
retryTask?.cancel()
retryTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(1200))
await MainActor.run {
guard let self else { return }
let item = AVPlayerItem(url: url)
self.itemCancellables.removeAll()
self.observeItem(item)
self.state = .connecting
self.player.replaceCurrentItem(with: item)
self.player.play()
self.updateNowPlaying()
}
}
}
private func configureRemoteCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.resume() }
return .success
}
commandCenter.pauseCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.pause() }
return .success
}
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.togglePlayPause() }
return .success
}
commandCenter.stopCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.stop() }
return .success
}
}
func refreshNowPlaying() {
logger.info("refreshNowPlaying called")
currentArtwork = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
updateNowPlaying()
}
public func updateNowPlayingExternally() {
updateNowPlaying()
}
private func setInitialArtwork() {
currentArtwork = artworkProvider(currentStation, nil, nil)
}
private func updateNowPlaying() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo()
MPNowPlayingInfoCenter.default().playbackState = state == .playing ? .playing : .paused
}
private func nowPlayingInfo() -> [String: Any] {
var info: [String: Any] = [
MPMediaItemPropertyTitle: currentTrackTitle.isEmpty ? currentStation?.name ?? "BearWave" : currentTrackTitle,
MPMediaItemPropertyAlbumTitle: currentStation?.name ?? "BearWave",
MPNowPlayingInfoPropertyPlaybackRate: state == .playing ? 1.0 : 0.0
]
if !currentTrackArtist.isEmpty {
info[MPMediaItemPropertyArtist] = currentTrackArtist
}
if let artwork = nowPlayingArtwork() {
info[MPMediaItemPropertyArtwork] = artwork
}
return info
}
private func nowPlayingArtwork() -> MPMediaItemArtwork? {
let image = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let size = image.size
return MPMediaItemArtwork(boundsSize: size, requestHandler: { @Sendable requestedSize in
NSImage(cgImage: cgImage, size: requestedSize)
})
}
private var isFailed: Bool {
if case .failed = state { return true }
return false
}
private static func classifyError(_ error: NSError?) -> PlaybackError {
guard let error else { return .unknown(String(localized: "Stream failed")) }
let domain = error.domain
let code = error.code
if domain == NSURLErrorDomain {
switch code {
case NSURLErrorTimedOut:
return .connectionTimeout
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
return .dnsResolutionFailed
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost:
return .stationOffline
case NSURLErrorUnsupportedURL, NSURLErrorFileDoesNotExist:
return .codecNotSupported
default:
if (500...599).contains(code) {
return .httpError(code)
}
return .unknown(error.localizedDescription)
}
}
if domain == "CoreMediaErrorDomain" {
switch code {
case -12785:
return .streamInterrupted
case -12935, -16042, -15697:
return .codecNotSupported
default:
return .unknown(error.localizedDescription)
}
}
if domain == "AVFoundationErrorDomain" {
switch code {
case -11800, -11828, -11838:
return .codecNotSupported
default:
return .unknown(error.localizedDescription)
}
}
if (400...599).contains(code) {
return .httpError(code)
}
return .unknown(error.localizedDescription)
}
}
final class MetadataHandler: NSObject, AVPlayerItemMetadataOutputPushDelegate {
private let onMetadata: @Sendable (String?, String?) -> Void
init(onMetadata: @escaping @Sendable (String?, String?) -> Void) {
self.onMetadata = onMetadata
}
func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) {
guard let items = groups.first?.items else { return }
let parsed = parseMetadataItems(items)
onMetadata(parsed.artist, parsed.title)
}
}
private func parseMetadataItems(_ items: [AVMetadataItem]) -> (artist: String?, title: String?) {
var artist: String?
var title: String?
for item in items {
let key = metadataKey(for: item)
let value = metadataStringValue(for: item)
guard let value, !value.isEmpty else { continue }
if key.contains("artist") || key.contains("author") {
artist = value
} else if key.contains("title") || key.contains("song") {
if let split = splitArtistTitle(value) {
artist = artist ?? split.artist
title = split.title
} else {
title = value
}
} else if artist == nil && title == nil, let split = splitArtistTitle(value) {
artist = split.artist
title = split.title
}
}
return (artist, title)
}
private func metadataKey(for item: AVMetadataItem) -> String {
let common = item.commonKey?.rawValue ?? ""
let identifier = item.identifier?.rawValue ?? ""
let key = item.key as? String ?? ""
return [common, identifier, key].joined(separator: " ").lowercased()
}
private func metadataStringValue(for item: AVMetadataItem) -> String? {
if let stringValue = item.stringValue {
return cleanMetadataString(stringValue)
}
if let value = item.value as? String {
return cleanMetadataString(value)
}
return nil
}
private func cleanMetadataString(_ value: String) -> String {
value
.replacingOccurrences(of: "StreamTitle=", with: "")
.trimmingCharacters(in: CharacterSet(charactersIn: " ';\""))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func splitArtistTitle(_ value: String) -> (artist: String, title: String)? {
let separators = [" - ", " ", ""]
for separator in separators {
let parts = value.components(separatedBy: separator)
guard parts.count >= 2 else { continue }
let artist = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let title = parts.dropFirst().joined(separator: separator).trimmingCharacters(in: .whitespacesAndNewlines)
if !artist.isEmpty, !title.isEmpty {
return (artist, title)
}
}
return nil
}

View File

@@ -0,0 +1,204 @@
import CryptoKit
import Foundation
struct RadioBrowserClient: Sendable {
var baseURL: URL
var cacheDirectory: URL
var session: URLSessionProtocol
init(
baseURL: URL = URL(string: "https://de1.api.radio-browser.info/json")!,
cacheDirectory: URL,
session: URLSessionProtocol = URLSession.shared
) {
self.baseURL = baseURL
self.cacheDirectory = cacheDirectory
self.session = session
}
func topStations(count: Int = 100) async throws -> [RadioStation] {
try await request("/stations/topvote/\(count)")
}
func germanStations() async throws -> [RadioStation] {
try await request("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true")
}
func dutchStations() async throws -> [RadioStation] {
try await request("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true")
}
func worldStations(count: Int = 200) async throws -> [RadioStation] {
try await request("/stations?hidebroken=true&limit=\(count)&order=votes&reverse=true")
}
func stations(tag: String) async throws -> [RadioStation] {
try await request("/stations/bytag/\(Self.percentEncodePathComponent(tag))")
}
func stations(countryCode: String) async throws -> [RadioStation] {
try await request("/stations/bycountrycodeexact/\(countryCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased())")
}
func search(_ query: String) async throws -> [RadioStation] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let candidates = searchCandidates(for: trimmed)
var firstError: Error?
var combined: [RadioStation] = []
var seenIDs = Set<String>()
for candidate in candidates {
do {
let stations = try await request(searchEndpoint(name: candidate))
for station in stations {
if seenIDs.insert(station.id).inserted {
combined.append(station)
}
}
} catch {
if firstError == nil {
firstError = error
}
}
}
let ranked = rankSearchResults(combined, query: trimmed)
if !ranked.isEmpty {
return ranked
}
if let firstError {
throw firstError
}
return []
}
func request(_ endpoint: String) async throws -> [RadioStation] {
let cacheFile = cacheURL(for: endpoint)
let requestURL = URL(string: baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + endpoint)!
do {
var request = URLRequest(url: requestURL)
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) {
throw URLError(.badServerResponse)
}
let stations = try Self.decodeStations(from: data)
if !stations.isEmpty {
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try data.write(to: cacheFile, options: .atomic)
}
return stations
} catch {
if let cachedData = try? Data(contentsOf: cacheFile) {
return try Self.decodeStations(from: cachedData)
}
throw error
}
}
func cacheURL(for endpoint: String) -> URL {
let digest = Insecure.MD5.hash(data: Data(endpoint.utf8))
let hash = digest.map { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent("\(hash).json")
}
static func decodeStations(from data: Data) throws -> [RadioStation] {
let decoder = JSONDecoder()
return try decoder.decode([RadioStation].self, from: data)
.filter { !$0.name.isEmpty && !$0.playbackURLString.isEmpty }
}
static func percentEncodePathComponent(_ value: String) -> String {
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "/?#[]@!$&'()*+,;=")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
private func searchEndpoint(name: String) -> String {
var components = URLComponents()
components.path = "/stations/search"
components.queryItems = [
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "hidebroken", value: "true"),
URLQueryItem(name: "limit", value: "200"),
URLQueryItem(name: "order", value: "votes"),
URLQueryItem(name: "reverse", value: "true")
]
return components.string ?? "/stations/byname/\(Self.percentEncodePathComponent(name))"
}
private func searchCandidates(for query: String) -> [String] {
guard !query.isEmpty else { return [] }
var candidates = [query]
let punctuation = CharacterSet.punctuationCharacters
if query.rangeOfCharacter(from: punctuation) == nil {
candidates.insert("\(query)!", at: 0)
candidates.append("\(query).")
}
var seen = Set<String>()
return candidates.filter { candidate in
let key = candidate.lowercased()
if seen.contains(key) { return false }
seen.insert(key)
return true
}
}
private func rankSearchResults(_ stations: [RadioStation], query: String) -> [RadioStation] {
let normalizedQuery = Self.normalizedSearchText(query)
guard !normalizedQuery.isEmpty else { return stations }
return stations
.map { station -> (RadioStation, Int) in
let normalizedName = Self.normalizedSearchText(station.name)
let nameTokens = Self.normalizedSearchTokens(station.name)
let allTokens = Self.normalizedSearchTokens(station.searchableText)
let score: Int
if normalizedName == normalizedQuery {
score = 0
} else if nameTokens.contains(normalizedQuery) {
score = 1
} else if nameTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
score = 2
} else if allTokens.contains(normalizedQuery) {
score = 3
} else if allTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
score = 4
} else {
score = 5
}
return (station, score)
}
.filter { $0.1 < 5 }
.sorted {
if $0.1 != $1.1 { return $0.1 < $1.1 }
if $0.0.votes != $1.0.votes { return $0.0.votes > $1.0.votes }
return $0.0.name.localizedCaseInsensitiveCompare($1.0.name) == .orderedAscending
}
.map(\.0)
}
static func normalizedSearchText(_ value: String) -> String {
value
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.joined()
}
static func normalizedSearchTokens(_ value: String) -> [String] {
value
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
}
}
protocol URLSessionProtocol: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}

View File

@@ -0,0 +1,78 @@
import AppKit
import CryptoKit
import Foundation
import Observation
@Observable
@MainActor
final class StationArtworkLoader {
private let cacheDirectory: URL
private var memoryCache: [String: NSImage] = [:]
private var inFlight: Set<String> = []
init(cacheDirectory: URL) {
self.cacheDirectory = cacheDirectory.appendingPathComponent("station_artwork")
}
func image(for station: RadioStation?) -> NSImage? {
guard let urlString = normalizedArtworkURLString(for: station) else {
return BearWaveAssets.image(named: "bearwave")
}
if let cached = memoryCache[urlString] {
return cached
}
if let diskImage = NSImage(contentsOf: cacheURL(for: urlString)) {
memoryCache[urlString] = diskImage
return diskImage
}
load(urlString)
return BearWaveAssets.image(named: "bearwave")
}
private func load(_ urlString: String) {
guard !inFlight.contains(urlString), let url = URL(string: urlString) else { return }
inFlight.insert(urlString)
Task {
defer {
Task { @MainActor in self.inFlight.remove(urlString) }
}
do {
var request = URLRequest(url: url)
request.timeoutInterval = 10
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let image = NSImage(data: data) else { return }
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try? data.write(to: cacheURL(for: urlString), options: .atomic)
await MainActor.run {
memoryCache[urlString] = image
}
} catch {
return
}
}
}
private func normalizedArtworkURLString(for station: RadioStation?) -> String? {
guard let station else { return nil }
let raw = station.favicon.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { return nil }
if raw.hasPrefix("//") {
return "https:\(raw)"
}
if raw.hasPrefix("http://") || raw.hasPrefix("https://") {
return raw
}
return nil
}
private func cacheURL(for urlString: String) -> URL {
let digest = SHA256.hash(data: Data(urlString.utf8))
let hash = digest.map { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent("\(hash).image")
}
}

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

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

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

View File

@@ -0,0 +1,13 @@
import AppKit
import Foundation
enum BearWaveAssets {
static func imageURL(named name: String, extension fileExtension: String = "png") -> URL? {
Bundle.module.url(forResource: name, withExtension: fileExtension)
}
static func image(named name: String, extension fileExtension: String = "png") -> NSImage? {
guard let url = imageURL(named: name, extension: fileExtension) else { return nil }
return NSImage(contentsOf: url)
}
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
public struct ContentView: View {
@Environment(AppModel.self) private var model
@Environment(\.openWindow) private var openWindow
@State private var searchText = ""
@State private var lastSubmittedSearch = ""
@State private var pendingSearchTask: Task<Void, Never>?
@State private var showingManualSheet = false
@State private var showingNewFolderSheet = false
@Binding var isMiniPlayer: Bool
public init(isMiniPlayer: Binding<Bool> = .constant(false)) {
_isMiniPlayer = isMiniPlayer
}
public var body: some View {
Group {
if isMiniPlayer {
MiniPlayerView(isMiniPlayer: $isMiniPlayer)
} else {
fullView
}
}
.navigationTitle("BearWave")
.onChange(of: searchText) { _, newValue in
scheduleSearch(newValue)
}
}
private var fullView: some View {
NavigationSplitView {
SidebarView()
.frame(minWidth: 220, idealWidth: 220, maxWidth: 280)
} detail: {
VStack(spacing: 0) {
VStack(spacing: 0) {
toolbar
Divider()
HSplitView {
StationListView()
.frame(minWidth: 520)
StationDetailView()
.frame(minWidth: 280, idealWidth: 320, maxWidth: 380)
}
}
Divider()
PlayerBarView()
}
}
.navigationSplitViewStyle(.automatic)
.toolbar {
ToolbarItemGroup {
Button {
showingManualSheet = true
} label: {
Label("Add Station", systemImage: "plus")
}
Button {
model.resumeLastStation()
} label: {
Label("Resume", systemImage: "arrow.clockwise")
}
.disabled(!model.library.canResume)
Button {
showingNewFolderSheet = true
} label: {
Label("New Folder", systemImage: "folder.badge.plus")
}
Button {
isMiniPlayer = true
} label: {
Label("Mini Player", systemImage: "arrow.down.left.and.arrow.up.right")
}
.keyboardShortcut("m", modifiers: .command)
}
}
.sheet(isPresented: $showingManualSheet) {
ManualStationView()
.environment(model)
}
.sheet(isPresented: $showingNewFolderSheet) {
NewGroupView()
.environment(model)
}
}
private var toolbar: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
HStack(spacing: 9) {
Image(nsImage: BearWaveAssets.image(named: "bearwave_line") ?? NSImage())
.resizable()
.scaledToFit()
.frame(width: 112, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text("BearWave")
.font(.headline.weight(.semibold))
Text("Internet Radio")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(width: 220, alignment: .leading)
Divider()
.frame(height: 32)
TextField("Search stations, genre, country", text: Binding(
get: { searchText },
set: { newValue in
searchText = newValue
}
))
.textFieldStyle(.roundedBorder)
.onSubmit {
Task { await submitSearch(searchText) }
}
.frame(minWidth: 280, idealWidth: 420, maxWidth: .infinity)
Button {
Task { await submitSearch(searchText) }
} label: {
Label("Search", systemImage: "magnifyingglass")
}
.disabled(searchText.trimmingCharacters(in: .whitespacesAndNewlines).count < 2)
.keyboardShortcut(.defaultAction)
Button {
searchText = ""
lastSubmittedSearch = ""
pendingSearchTask?.cancel()
model.library.filterQuery = ""
Task { await model.library.loadSelectedPage() }
} label: {
Label("Clear", systemImage: "xmark.circle")
}
.disabled(searchText.isEmpty)
if model.library.isLoading {
ProgressView()
.controlSize(.small)
}
}
HStack(spacing: 8) {
Text("Quick")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ForEach(["rock", "news", "jazz"], id: \.self) { tag in
Button(tag.capitalized) {
Task { await model.library.loadTag(tag) }
}
}
Divider().frame(height: 20)
ForEach(["US", "GB", "FR"], id: \.self) { country in
Button(country) {
Task { await model.library.loadCountry(country) }
}
}
Spacer()
Text("Sort")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Picker("", selection: Binding(
get: { model.library.sortMode },
set: { model.library.sortMode = $0 }
)) {
ForEach(StationSortMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.frame(width: 230)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.regularMaterial)
}
private func submitSearch(_ rawQuery: String) async {
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else { return }
if query == lastSubmittedSearch, !model.library.visibleStations.isEmpty {
return
}
lastSubmittedSearch = query
await model.library.search(query)
}
private func scheduleSearch(_ rawQuery: String) {
pendingSearchTask?.cancel()
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 3 else { return }
pendingSearchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await submitSearch(query)
}
}
}

View File

@@ -0,0 +1,61 @@
import SwiftUI
struct GroupRowView: View {
@Environment(AppModel.self) private var model
let group: StationGroup
@State private var showingRenameSheet = false
var body: some View {
HStack {
Label(group.name, systemImage: "folder.fill")
Spacer()
Text(group.stationIDs.count, format: .number)
.font(.caption)
.foregroundStyle(.secondary)
}
.tag(group.id)
.contextMenu {
Button("Rename") {
showingRenameSheet = true
}
Button("Delete") {
model.library.deleteGroup(group)
}
}
.sheet(isPresented: $showingRenameSheet) {
RenameGroupView(group: group)
.environment(model)
}
}
}
struct RenameGroupView: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
let group: StationGroup
@State private var name = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Rename folder")
.font(.title2.bold())
TextField("Folder name", text: $name)
.textFieldStyle(.roundedBorder)
.onAppear { name = group.name }
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Rename") {
model.library.renameGroup(group, to: name)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 340)
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct ManualStationView: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var url = ""
@State private var country = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Add station manually")
.font(.title2.bold())
TextField("Name", text: $name)
TextField("Stream URL (http/https)", text: $url)
TextField("Country (optional)", text: $country)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Add") {
model.library.addManualStation(name: name, url: url, country: country)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || URL(string: url) == nil)
}
}
.textFieldStyle(.roundedBorder)
.padding(24)
.frame(width: 430)
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
public struct MenuBarContentView: View {
@Environment(AppModel.self) private var model
@Environment(\.openWindow) private var openWindow
public init() {}
public var body: some View {
Button {
openWindow(id: "main")
NSApp.activate(ignoringOtherApps: true)
} label: {
Label("Show BearWave", systemImage: "macwindow")
}
Divider()
if model.settings.showMenuBarExtraDetails {
Text(model.playback.currentStation?.name ?? "BearWave")
Text(model.playback.state.title)
}
Button(model.playback.isPlaying ? "Pause" : "Play") {
model.playback.togglePlayPause()
}
.disabled(model.playback.currentStation == nil)
Button("Resume") {
model.resumeLastStation()
}
.disabled(!model.library.canResume)
if !model.library.favorites.isEmpty {
Divider()
ForEach(model.library.favorites.prefix(6)) { station in
Button(station.name) {
model.play(station)
}
}
}
Divider()
Button("Quit BearWave") {
model.quit()
NSApp.terminate(nil)
}
.keyboardShortcut("q")
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
struct MiniPlayerView: View {
@Environment(AppModel.self) private var model
@Binding var isMiniPlayer: Bool
var body: some View {
HStack(spacing: 12) {
Image(nsImage: model.playback.currentArtwork)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(model.playback.currentStation?.name ?? String(localized: "No station selected"))
.font(.headline)
.lineLimit(1)
Text(miniPlayerText)
.font(.caption)
.foregroundStyle(playerDetailColor)
.lineLimit(1)
}
Spacer()
Button {
model.playback.togglePlayPause()
} label: {
Image(systemName: model.playback.isPlaying ? "pause.fill" : "play.fill")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(model.playback.currentStation == nil)
Button {
model.playback.stop()
} label: {
Image(systemName: "stop.fill")
}
.buttonStyle(.bordered)
.controlSize(.small)
HStack(spacing: 4) {
Image(systemName: "speaker.wave.1")
.font(.caption)
Slider(value: Binding(
get: { model.playback.volume },
set: { model.setVolume($0) }
), in: 0...1)
.frame(width: 80)
Image(systemName: "speaker.wave.3")
.font(.caption)
}
.foregroundStyle(.secondary)
Button {
isMiniPlayer = false
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Show full window")
}
.padding(12)
.frame(height: 110)
.background(.bar)
}
private var miniPlayerText: String {
if let detail = model.playback.state.detail {
return "\(model.playback.state.title): \(detail)"
}
switch model.playback.state {
case .connecting, .buffering, .failed:
return model.playback.state.title
default:
break
}
if !model.playback.currentTrackTitle.isEmpty && !model.playback.currentTrackArtist.isEmpty {
return "\(model.playback.currentTrackArtist) - \(model.playback.currentTrackTitle)"
}
if !model.playback.currentTrackTitle.isEmpty {
return model.playback.currentTrackTitle
}
if !model.playback.currentTrackArtist.isEmpty {
return model.playback.currentTrackArtist
}
if let station = model.playback.currentStation, !station.tags.isEmpty {
return station.tags
}
return String(localized: "Live radio")
}
private var playerDetailColor: Color {
if case .failed = model.playback.state {
return .red
}
if case .connecting = model.playback.state {
return .secondary
}
if case .buffering = model.playback.state {
return .orange
}
return .secondary
}
}

View File

@@ -0,0 +1,32 @@
import SwiftUI
struct NewGroupView: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@State private var name = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Create new folder")
.font(.title2.bold())
TextField("Folder name", text: $name)
.textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
Button("Create") {
model.library.createGroup(name: name)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 340)
}
}

View File

@@ -0,0 +1,99 @@
import SwiftUI
struct PlayerBarView: View {
@Environment(AppModel.self) private var model
var body: some View {
HStack(spacing: 14) {
Image(nsImage: model.playback.currentArtwork)
.resizable()
.scaledToFit()
.frame(width: 46, height: 46)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(model.playback.currentStation?.name ?? model.library.selectedStation?.name ?? String(localized: "No station selected"))
.font(.headline)
.lineLimit(1)
Text(nowPlayingText)
.font(.subheadline)
.foregroundStyle(playerDetailColor)
.lineLimit(1)
.help(nowPlayingText)
}
Spacer()
radioControls
HStack {
Image(systemName: "speaker.wave.1")
Slider(value: Binding(
get: { model.playback.volume },
set: { model.setVolume($0) }
), in: 0...1)
.frame(width: 150)
Image(systemName: "speaker.wave.3")
}
.foregroundStyle(.secondary)
}
.padding(12)
.background(.bar)
}
private var radioControls: some View {
HStack(spacing: 8) {
Button {
model.playback.togglePlayPause()
} label: {
Label(model.playback.isPlaying ? "Pause" : "Play", systemImage: model.playback.isPlaying ? "pause.fill" : "play.fill")
}
.disabled(model.playback.currentStation == nil)
Button {
model.playback.stop()
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
}
private var nowPlayingText: String {
if let detail = model.playback.state.detail {
return "\(model.playback.state.title): \(detail)"
}
switch model.playback.state {
case .connecting, .buffering, .failed:
return model.playback.state.title
default:
break
}
if !model.playback.currentTrackTitle.isEmpty && !model.playback.currentTrackArtist.isEmpty {
return "\(model.playback.currentTrackArtist) - \(model.playback.currentTrackTitle)"
}
if !model.playback.currentTrackTitle.isEmpty {
return model.playback.currentTrackTitle
}
if !model.playback.currentTrackArtist.isEmpty {
return model.playback.currentTrackArtist
}
if let station = model.playback.currentStation, !station.tags.isEmpty {
return station.tags
}
return String(localized: "Live radio")
}
private var playerDetailColor: Color {
if case .failed = model.playback.state {
return .red
}
if case .connecting = model.playback.state {
return .secondary
}
if case .buffering = model.playback.state {
return .orange
}
return .secondary
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
public struct SettingsView: View {
@Environment(AppModel.self) private var model
public init() {}
public var body: some View {
Form {
Section("Radio Browser") {
TextField("Base URL", text: Binding(
get: { model.settings.radioBrowserBaseURL },
set: {
model.settings.radioBrowserBaseURL = $0
model.applySettings()
}
))
.textFieldStyle(.roundedBorder)
}
Section("Playback") {
Toggle("Resume last station on launch", isOn: Binding(
get: { model.settings.resumeOnLaunch },
set: { model.settings.resumeOnLaunch = $0 }
))
}
Section("Menu Bar") {
Toggle("Show playback details in menu bar", isOn: Binding(
get: { model.settings.showMenuBarExtraDetails },
set: { model.settings.showMenuBarExtraDetails = $0 }
))
}
}
.formStyle(.grouped)
.padding(20)
}
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
struct SidebarView: View {
@Environment(AppModel.self) private var model
@State private var showingNewFolderSheet = false
@State private var selection: String = ""
var body: some View {
List(selection: $selection) {
Section("Stations") {
pageRow(.top)
pageRow(.germany)
pageRow(.netherlands)
pageRow(.world)
pageRow(.search, badge: model.library.selectedPage == .search ? model.library.stations.count : nil)
}
Section("Library") {
pageRow(.favorites, badge: model.library.favorites.count)
pageRow(.recent, badge: model.library.recentStations.count)
pageRow(.manual, badge: model.library.manualStations.count)
}
if !model.library.groups.isEmpty {
Section("Folders") {
ForEach(model.library.groups.sorted { $0.sortOrder < $1.sortOrder }) { group in
GroupRowView(group: group)
}
}
}
}
.onAppear {
updateSelection()
}
.onChange(of: model.library.selectedPage) { _, _ in updateSelection() }
.onChange(of: model.library.selectedGroupID) { _, _ in updateSelection() }
.onChange(of: selection) { _, newValue in
handleSelectionChange(newValue)
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 280)
.contextMenu {
Button("New Folder") {
showingNewFolderSheet = true
}
}
.sheet(isPresented: $showingNewFolderSheet) {
NewGroupView()
.environment(model)
}
}
private func updateSelection() {
if model.library.selectedPage == .group {
selection = model.library.selectedGroupID ?? ""
} else {
selection = model.library.selectedPage.rawValue
}
}
private func handleSelectionChange(_ value: String) {
if let group = model.library.groups.first(where: { $0.id == value }) {
model.library.selectGroup(group)
} else if let page = StationPage(rawValue: value) {
Task { await model.library.load(page) }
}
}
private func pageRow(_ page: StationPage, badge: Int? = nil) -> some View {
HStack {
Label(page.title, systemImage: page.systemImage)
Spacer()
if let badge, badge > 0 {
Text(badge, format: .number)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.tag(page.rawValue)
}
}

View File

@@ -0,0 +1,177 @@
import AppKit
import SwiftUI
struct StationDetailView: View {
@Environment(AppModel.self) private var model
@Environment(\.openURL) private var openURL
var body: some View {
Group {
if let station = model.library.selectedStation {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header(station)
actions(station)
if model.playback.currentStation?.id == station.id {
nowPlayingSection
}
technicalSection(station)
linksSection(station)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
ContentUnavailableView("No station selected", systemImage: "radio")
}
}
.background(.regularMaterial)
}
private func header(_ station: RadioStation) -> some View {
let artworkImage: NSImage
if model.playback.currentStation?.id == station.id {
artworkImage = model.playback.currentArtwork
} else {
artworkImage = model.artwork.image(for: station) ?? NSImage()
}
return VStack(alignment: .leading, spacing: 10) {
Image(nsImage: artworkImage)
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 4) {
Text(station.name)
.font(.title3.weight(.semibold))
.lineLimit(2)
if !station.country.isEmpty {
Text(station.country)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
private func actions(_ station: RadioStation) -> some View {
HStack(spacing: 8) {
Button {
model.play(station)
} label: {
Label("Play", systemImage: "play.fill")
}
Button {
model.library.toggleFavorite(station)
} label: {
Label(model.library.isFavorite(station) ? "Remove Favorite" : "Add Favorite", systemImage: model.library.isFavorite(station) ? "star.fill" : "star")
}
if !model.library.groups.isEmpty {
Menu {
ForEach(model.library.groups) { group in
Button {
if model.library.isStationInGroup(station, group: group) {
model.library.removeStationFromGroup(station, group: group)
} else {
model.library.addStationToGroup(station, group: group)
}
} label: {
Label(
model.library.isStationInGroup(station, group: group) ? "Remove from \(group.name)" : "Add to \(group.name)",
systemImage: model.library.isStationInGroup(station, group: group) ? "checkmark" : "plus"
)
}
}
} label: {
Label("Folders", systemImage: "folder.badge.plus")
}
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
private var nowPlayingSection: some View {
DetailSection(title: "Now Playing") {
LabeledContent("Status", value: model.playback.state.title)
if case let .failed(error) = model.playback.state {
Text(error.detail)
.foregroundStyle(.red)
.font(.caption)
}
if !model.playback.currentTrackArtist.isEmpty {
LabeledContent("Artist", value: model.playback.currentTrackArtist)
}
if !model.playback.currentTrackTitle.isEmpty {
LabeledContent("Title", value: model.playback.currentTrackTitle)
}
if model.playback.currentTrackArtist.isEmpty && model.playback.currentTrackTitle.isEmpty, !model.playback.state.isPlaying, model.playback.state != .stopped {
Text("No stream metadata yet")
.foregroundStyle(.secondary)
}
}
}
private func technicalSection(_ station: RadioStation) -> some View {
DetailSection(title: "Details") {
if !station.codec.isEmpty {
LabeledContent("Codec", value: station.codec.uppercased())
}
if station.bitrate > 0 {
LabeledContent("Bitrate", value: "\(station.bitrate) kbps")
}
if station.votes > 0 {
LabeledContent("Votes", value: station.votes.formatted())
}
if !station.tags.isEmpty {
LabeledContent("Tags") {
Text(station.tags)
.lineLimit(4)
.multilineTextAlignment(.trailing)
}
}
}
}
private func linksSection(_ station: RadioStation) -> some View {
DetailSection(title: "Links") {
if let homepage = URL(string: station.homepage), !station.homepage.isEmpty {
Button {
openURL(homepage)
} label: {
Label("Open Homepage", systemImage: "safari")
}
}
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(station.playbackURLString, forType: .string)
} label: {
Label("Copy Stream URL", systemImage: "doc.on.doc")
}
.disabled(station.playbackURLString.isEmpty)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
private struct DetailSection<Content: View>: View {
let title: LocalizedStringKey
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
VStack(alignment: .leading, spacing: 6) {
content
}
.font(.subheadline)
}
}
}

View File

@@ -0,0 +1,140 @@
import SwiftUI
struct StationListView: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(spacing: 0) {
if let error = model.library.lastError {
Text(error)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.red.opacity(0.08))
}
if model.library.visibleStations.isEmpty && !model.library.isLoading {
ContentUnavailableView(
"No stations loaded yet",
systemImage: "radio",
description: Text("Load a station page or search worldwide.")
)
} else {
List(selection: Binding(
get: { model.library.selectedStationID },
set: { model.library.selectedStationID = $0 }
)) {
ForEach(model.library.visibleStations) { station in
StationRowView(station: station)
.tag(station.id)
.contentShape(Rectangle())
.onTapGesture {
model.library.select(station)
}
.contextMenu {
Button(model.library.isFavorite(station) ? "Remove Favorite" : "Add Favorite") {
model.library.toggleFavorite(station)
}
if !model.library.groups.isEmpty {
Menu("Add to Folder") {
ForEach(model.library.groups) { group in
Button(group.name) {
model.library.addStationToGroup(station, group: group)
}
}
}
}
Button("Play") {
model.play(station)
}
}
}
}
}
}
}
}
struct StationRowView: View {
@Environment(AppModel.self) private var model
let station: RadioStation
var body: some View {
HStack(spacing: 12) {
Image(nsImage: model.artwork.image(for: station) ?? NSImage())
.resizable()
.scaledToFill()
.frame(width: 34, height: 34)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .bottomTrailing) {
Image(systemName: station.isOnline ? "dot.radiowaves.left.and.right" : "exclamationmark.triangle")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(station.isOnline ? Color.secondary : Color.orange)
.padding(2)
.background(.regularMaterial, in: Circle())
.offset(x: 4, y: 4)
}
VStack(alignment: .leading, spacing: 4) {
Text(station.name)
.font(.headline)
.lineLimit(1)
HStack(spacing: 8) {
if isCurrentStation {
Text(model.playback.state.title)
.foregroundStyle(statusColor)
}
if !station.country.isEmpty {
Text(station.country)
}
if !station.codec.isEmpty {
Text(station.codec.uppercased())
}
if station.bitrate > 0 {
Text("\(station.bitrate) kbps")
}
if station.votes > 0 {
Text("\(station.votes) votes")
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Button {
model.library.toggleFavorite(station)
} label: {
Image(systemName: model.library.isFavorite(station) ? "star.fill" : "star")
}
.buttonStyle(.plain)
.foregroundStyle(model.library.isFavorite(station) ? .yellow : .secondary)
Button {
model.play(station)
} label: {
Image(systemName: "play.fill")
}
.buttonStyle(.bordered)
}
.padding(.vertical, 5)
}
private var isCurrentStation: Bool {
model.playback.currentStation?.id == station.id
}
private var statusColor: Color {
if case .failed = model.playback.state {
return .red
}
if case .buffering = model.playback.state {
return .orange
}
return .secondary
}
}