commit b47b591e03a48f03771d1e4e395be79a6d38af4d
Author: Sebastian Palencsar <118485377+spalencsar@users.noreply.github.com>
Date: Thu Jun 25 09:21:42 2026 +0200
Create BearWave macOS radio-first app
diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml
new file mode 100644
index 0000000..486ff26
--- /dev/null
+++ b/.codex/environments/environment.toml
@@ -0,0 +1,11 @@
+# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
+version = 1
+name = "BearWave"
+
+[setup]
+script = ""
+
+[[actions]]
+name = "Run"
+icon = "run"
+command = "./script/build_and_run.sh"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e93b09e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.build/
+dist/
+.DS_Store
+._*
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d612116
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,108 @@
+# AGENTS.md
+
+This repository is the native macOS SwiftUI port of BearWave, originally a
+Linux/KDE internet radio app.
+
+Do not treat it as a Qt compatibility port. The current repo is a native macOS
+desktop app focused on internet radio.
+
+## What This Repo Is
+
+- SwiftPM macOS app targeting `macOS 26`
+- SwiftUI desktop UI
+- `AVPlayer`-based playback for radio streams
+- Radio Browser as the station source
+- Local persistence for favorites, recents, manual stations, station folders, and resume state
+
+## Architecture Overview
+
+- `Sources/BearWaveApp/BearWaveApp.swift`
+ App scene entrypoint, window sizing, commands, menu bar extra, settings scene
+- `Sources/BearWaveCore/App/AppModel.swift`
+ Top-level app coordinator; owns `StationLibrary`, `PlaybackController`, and artwork services
+- `Sources/BearWaveCore/Stores/StationLibrary.swift`
+ Source of truth for station pages, search results, favorites, recents, manual entries, and station folders
+- `Sources/BearWaveCore/Stores/PersistenceStore.swift`
+ JSON-based persistence for radio state
+- `Sources/BearWaveCore/Services/RadioBrowserClient.swift`
+ Radio Browser API wrapper plus local search normalization and ranking
+- `Sources/BearWaveCore/Services/PlaybackController.swift`
+ Shared `AVPlayer` wrapper with explicit playback states, retries, stream metadata via `AVPlayerItemMetadataOutput`, and Now Playing updates
+- `Sources/BearWaveCore/Services/StationArtworkLoader.swift`
+ Favicon and artwork fetch with disk cache
+- `Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift`
+ Artwork resolution for current playback state
+- `Sources/BearWaveCore/Views/`
+ Sidebar, station list, station detail, player bar, settings, menu bar, mini player
+
+## Current Product Behavior
+
+- Dedicated pages:
+ `Top`, `Germany`, `Netherlands`, `World`, `Search Results`, `Favorites`, `Recent`, `Manual`
+- Search is global and stays independent from the current browse page
+- Search ranking intentionally filters naive mid-word matches such as `slam` matching `Islamabad`
+- Playback UI exposes explicit states: connecting, buffering, playing, paused, stopped, failed
+- A right-side detail panel shows station metadata and current stream metadata when available
+- Player controls remain radio-focused: play/pause, stop, volume
+- Mini Player is available via `Cmd+M`
+- Mini Player target size is `555x110`
+- Station folders support create, rename, delete, and station assignment
+- Menu bar extra remains active while playback continues
+
+## Build and Run Conventions
+
+Use these commands unless there is a strong reason not to:
+
+```bash
+swift build --disable-index-store
+CLANG_MODULE_CACHE_PATH=/private/tmp/bearwave-clang-cache swift run --disable-index-store BearWaveChecks
+./script/build_and_run.sh
+```
+
+The run script:
+
+- kills the previous process
+- builds via SwiftPM
+- stages a local `.app` into `dist/`
+- copies SwiftPM resource bundles manually
+- copies `.lproj` localization directories into the bundle
+- launches the app bundle
+
+Do not remove the manual resource-bundle copy step from `script/build_and_run.sh`
+unless the bundle pipeline is replaced deliberately.
+
+## Important Constraints
+
+- Prefer the internal SSD workspace:
+ `/Users/sebastian/Development/radioappmac`
+- Do not assume the mounted share copy is current.
+- The staged app bundle uses ATS exceptions so that `http://` radio streams can play.
+- Debug signing and sandbox behavior are intentionally pragmatic and not yet release-ready.
+- `PlaybackController` uses `AVPlayerItemMetadataOutput` for stream metadata.
+- `MediaPlayer` is imported with `@preconcurrency`.
+- `MPMediaItemArtwork` is built from a `CGImage`-backed request handler.
+- Main window launch width is dynamic: `1255` on smaller displays, `1600` on wide displays.
+- Mini Player target size is `555x110`.
+
+## What To Preserve
+
+- Keep the project native-first for macOS.
+- Keep search global and ranking strict.
+- Keep explicit playback states; do not regress to a plain boolean model.
+- Keep the current split between `BearWaveApp` and `BearWaveCore`.
+- Keep the build/run script compatible with local staged app execution.
+
+## Good Next Steps
+
+- Improve release packaging, signing, and notarization
+- Add more playback diagnostics per stream failure mode
+- Expand tests around playback-state transitions and search ranking
+- Add cache eviction for memory and disk caches
+- Keep folders clearly positioned as lightweight saved-station organization
+
+## Bad Changes
+
+- Reintroducing page-coupled search behavior
+- Replacing explicit playback states with only `isPlaying`
+- Moving back toward Qt/KDE abstractions in this repo
+- Assuming every stream will provide usable metadata or artwork
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..d74a00c
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,178 @@
+# Architecture
+
+This repository is a native macOS 26 SwiftPM internet radio app.
+
+The app is split into a small app target and a larger shared core target.
+
+## Top-Level Structure
+
+- `Package.swift`
+ Defines:
+ - `BearWaveCore`
+ - `BearWave`
+ - `BearWaveChecks`
+- `Sources/BearWaveApp`
+ App entrypoint and scene wiring
+- `Sources/BearWaveCore`
+ Application logic, models, services, stores, views, and resources
+
+## Main Runtime Objects
+
+### `BearWaveApp`
+
+File:
+- [Sources/BearWaveApp/BearWaveApp.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveApp/BearWaveApp.swift)
+
+Responsibilities:
+- create the root `AppModel`
+- host the main `WindowGroup`
+- host `MenuBarExtra`
+- host `Settings`
+- manage window sizing and mini-player mode
+- define app commands such as play/pause and `Cmd+M`
+
+This file should stay thin. It is scene setup, not business logic.
+
+### `AppModel`
+
+File:
+- [Sources/BearWaveCore/App/AppModel.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/App/AppModel.swift)
+
+Responsibilities:
+- own the major runtime subsystems
+- coordinate radio, playback, settings, and artwork
+- restore launch state
+- route user actions like `play(_:)`, `setVolume(_:)`, and `quit()`
+
+Owned objects:
+- `SettingsStore`
+- `StationLibrary`
+- `PlaybackController`
+- `StationArtworkLoader`
+- `NowPlayingArtworkResolver`
+
+`AppModel` is the top-level coordinator. It should not become a dumping ground for unrelated view logic.
+
+## Domain Store
+
+### `StationLibrary`
+
+File:
+- [Sources/BearWaveCore/Stores/StationLibrary.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Stores/StationLibrary.swift)
+
+Responsibilities:
+- load and store browse pages
+- manage global search
+- manage favorites
+- manage recents
+- manage manual stations
+- manage station folders
+- persist radio-related state
+
+Important behaviors:
+- search is global, not page-local
+- search results live on a dedicated `Search Results` page
+- search ranking is stricter than raw Radio Browser substring matching
+- folders are persisted entities and not just transient UI state
+
+## Playback and Metadata
+
+### `PlaybackController`
+
+File:
+- [Sources/BearWaveCore/Services/PlaybackController.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Services/PlaybackController.swift)
+
+Responsibilities:
+- own the shared `AVPlayer`
+- manage playback state transitions
+- play radio streams
+- expose explicit playback states
+- read timed metadata through `AVPlayerItemMetadataOutput`
+- update `MPNowPlayingInfoCenter`
+- handle artwork updates
+
+Important invariants:
+- do not collapse playback state to only `isPlaying`
+- metadata handling must tolerate streams that provide nothing useful
+
+## Network Services
+
+### `RadioBrowserClient`
+
+File:
+- [Sources/BearWaveCore/Services/RadioBrowserClient.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Services/RadioBrowserClient.swift)
+
+Responsibilities:
+- call Radio Browser endpoints
+- decode station payloads
+- provide search support
+- cache API responses on disk
+
+### Artwork Services
+
+Files:
+- [Sources/BearWaveCore/Services/StationArtworkLoader.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Services/StationArtworkLoader.swift)
+- [Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift)
+
+Responsibilities:
+- load and cache station favicons
+- resolve playback artwork for Now Playing
+- fall back through available artwork sources when metadata is incomplete
+
+## Persistence
+
+### `PersistenceStore`
+
+File:
+- [Sources/BearWaveCore/Stores/PersistenceStore.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Stores/PersistenceStore.swift)
+
+Responsibilities:
+- JSON persistence for:
+ - favorites
+ - manual stations
+ - groups
+ - playback state
+- manage application support and cache directories
+- import legacy Linux config files on first run where applicable
+
+## UI Structure
+
+Main views:
+- [Sources/BearWaveCore/Views/ContentView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/ContentView.swift)
+- [Sources/BearWaveCore/Views/SidebarView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/SidebarView.swift)
+- [Sources/BearWaveCore/Views/StationListView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/StationListView.swift)
+- [Sources/BearWaveCore/Views/StationDetailView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/StationDetailView.swift)
+- [Sources/BearWaveCore/Views/PlayerBarView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/PlayerBarView.swift)
+- [Sources/BearWaveCore/Views/MiniPlayerView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/MiniPlayerView.swift)
+- [Sources/BearWaveCore/Views/MenuBarContentView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/MenuBarContentView.swift)
+- [Sources/BearWaveCore/Views/SettingsView.swift](/Users/sebastian/Development/radioappmac/Sources/BearWaveCore/Views/SettingsView.swift)
+
+High-level behavior:
+- `ContentView` switches between full app UI and mini-player mode
+- sidebar chooses radio pages and folders
+- station list and station detail cover browse, search, favorites, recents, manual stations, and folders
+- `PlayerBarView` stays focused on radio playback controls
+
+## Data Flow
+
+1. User selects a page or search query
+2. `StationLibrary` loads data through `RadioBrowserClient`
+3. UI renders visible stations
+4. User starts playback
+5. `AppModel` records playback and forwards to `PlaybackController`
+6. `PlaybackController` updates playback state, metadata, artwork, and Now Playing
+
+## Build and Packaging Notes
+
+- The current app bundle is staged by `script/build_and_run.sh`
+- SwiftPM resource bundles are copied manually into `dist/BearWave.app`
+- Localization directories are copied manually as part of staging
+- This is good enough for local development but not yet a polished release pipeline
+
+## Architectural Guardrails
+
+- Keep the app native-first for macOS
+- Keep search global and independently modeled
+- Keep explicit playback states
+- Keep `BearWaveApp` thin
+- Do not move product behavior into ad hoc view code when it belongs in a store or service
diff --git a/AppIcon.icns b/AppIcon.icns
new file mode 100644
index 0000000..5a78672
Binary files /dev/null and b/AppIcon.icns differ
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..695349e
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,36 @@
+// swift-tools-version: 6.2
+
+import PackageDescription
+
+let package = Package(
+ name: "BearWave",
+ defaultLocalization: "en",
+ platforms: [
+ .macOS(.v26)
+ ],
+ products: [
+ .executable(name: "BearWave", targets: ["BearWave"]),
+ .executable(name: "BearWaveChecks", targets: ["BearWaveChecks"])
+ ],
+ targets: [
+ .target(
+ name: "BearWaveCore",
+ resources: [
+ .process("Resources")
+ ]
+ ),
+ .executableTarget(
+ name: "BearWave",
+ dependencies: ["BearWaveCore"],
+ path: "Sources/BearWaveApp"
+ ),
+ .executableTarget(
+ name: "BearWaveChecks",
+ dependencies: ["BearWaveCore"],
+ path: "Tests/BearWaveChecks",
+ resources: [
+ .process("Fixtures")
+ ]
+ )
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ef2f630
--- /dev/null
+++ b/README.md
@@ -0,0 +1,120 @@
+# BearWave for macOS
+
+Native macOS 26 port of the Linux/KDE app BearWave.
+
+This repository is the current SwiftUI + AVFoundation version built as a
+SwiftPM app. The product scope is focused internet radio on macOS.
+
+## Current Scope
+
+- Radio Browser integration for Top, Germany, Netherlands, World, search, favorites, recents, and manual stations
+- Native SwiftUI macOS UI with sidebar, list, detail panel, player bar, settings, and menu bar extra
+- Station folders for organizing favorites and saved stations
+- Stream playback through `AVPlayer`
+- Playback states: connecting, buffering, playing, paused, stopped, failed
+- Stream metadata display via `AVPlayerItemMetadataOutput`
+- Station favicons/artwork with local disk cache
+- Now Playing artwork resolution with fallback chain: metadata lookup, station favicon, app default artwork
+- Mini Player with `Cmd+M`
+
+## Project Layout
+
+- `Package.swift`
+ SwiftPM package with:
+ - `BearWaveCore` shared app logic and resources
+ - `BearWave` app executable
+ - `BearWaveChecks` lightweight verification executable
+- `Sources/BearWaveApp`
+ App entrypoint and scene setup
+- `Sources/BearWaveCore`
+ App logic split into:
+ - `App/` high-level app coordinator (`AppModel.swift`)
+ - `Models/` domain models (`RadioStation`, `StationGroup`, `PlaybackStatus`, `PlaybackError`, `StationPage`)
+ - `Services/` Radio Browser client, playback controller, artwork loading, Now Playing artwork resolution
+ - `Stores/` persistence, settings, station library
+ - `Views/` SwiftUI UI (sidebar, list, detail, player bar, settings, menu bar, mini player)
+ - `Support/` resource helpers
+ - `Resources/` localized strings, assets, entitlements placeholder
+- `Tests/BearWaveChecks`
+ Executable smoke checks for decoding, search normalization, filtering, and library behavior
+- `script/build_and_run.sh`
+ Local build, bundle, and launch entrypoint
+
+## Build and Run
+
+```bash
+./script/build_and_run.sh
+```
+
+Useful modes:
+
+```bash
+./script/build_and_run.sh --verify
+./script/build_and_run.sh --debug
+./script/build_and_run.sh --logs
+./script/build_and_run.sh --telemetry
+```
+
+## Development Notes
+
+- Target platform is `macOS 26`
+- Preferred workspace is:
+ `/Users/sebastian/Development/radioappmac`
+- Build with:
+ `swift build --disable-index-store`
+- Lightweight verification:
+ `CLANG_MODULE_CACHE_PATH=/private/tmp/bearwave-clang-cache swift run --disable-index-store BearWaveChecks`
+- The generated app bundle lives in:
+ `./dist/BearWave.app`
+- The run script stages SwiftPM resource bundles and localization directories manually into the app bundle
+
+## Playback and Metadata Notes
+
+- Audio playback uses `AVPlayer`
+- Stream metadata is read through `AVPlayerItemMetadataOutput`
+- `PlaybackController` exposes explicit playback states instead of a simple play/pause boolean
+- Some radio streams provide current-song metadata; many do not
+- `MediaPlayer` is imported with `@preconcurrency` because the app updates `MPNowPlayingInfoCenter`
+- `MPMediaItemArtwork` is created from a stable `CGImage` request handler
+- ATS is explicitly opened for media and `http://` streams in the generated app `Info.plist`, otherwise many radio streams do not play
+
+## Persistence
+
+Data is stored under native macOS locations:
+
+- `Application Support/BearWave/`
+ - `favorites.json`
+ - `manual-stations.json`
+ - `groups.json`
+ - `state.json`
+
+- `Caches/BearWave/`
+ - `api_cache/`
+ - artwork cache files
+
+The app also tries to import old Linux-style files on first run if present:
+
+- `~/.config/bearwave/favorites.json`
+- `~/.config/bearwave/state.json`
+
+## Current UX Behavior
+
+- Search is global against Radio Browser and independent from the current browse page
+- Search results appear on a dedicated `Search Results` page
+- Search ranking intentionally filters naive mid-word matches such as `slam` matching `Islamabad`
+- A right-side detail panel shows station metadata and current stream metadata when available
+- German localization is active across the app
+
+## Known Technical Edges
+
+- The app is currently a SwiftPM-staged `.app`, not an Xcode-managed release app
+- Debug signing and sandbox behavior are intentionally pragmatic, not release-ready
+- Resource handling is functional but still tied to the custom staging script
+
+## Suggested Next Work
+
+- Improve packaging, signing, and notarization
+- Add more targeted playback diagnostics per stream failure mode
+- Expand tests around playback-state transitions and search ranking
+- Add cache eviction for memory and disk caches
+- Keep station folders lightweight and radio-focused
diff --git a/Sources/BearWaveApp/BearWaveApp.swift b/Sources/BearWaveApp/BearWaveApp.swift
new file mode 100644
index 0000000..b6d596f
--- /dev/null
+++ b/Sources/BearWaveApp/BearWaveApp.swift
@@ -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
+ }
+}
diff --git a/Sources/BearWaveCore/App/AppModel.swift b/Sources/BearWaveCore/App/AppModel.swift
new file mode 100644
index 0000000..2d6b87f
--- /dev/null
+++ b/Sources/BearWaveCore/App/AppModel.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Models/PlaybackError.swift b/Sources/BearWaveCore/Models/PlaybackError.swift
new file mode 100644
index 0000000..1eb1156
--- /dev/null
+++ b/Sources/BearWaveCore/Models/PlaybackError.swift
@@ -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
+ }
+ }
+}
diff --git a/Sources/BearWaveCore/Models/PlaybackStatus.swift b/Sources/BearWaveCore/Models/PlaybackStatus.swift
new file mode 100644
index 0000000..a24322b
--- /dev/null
+++ b/Sources/BearWaveCore/Models/PlaybackStatus.swift
@@ -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
+ }
+}
diff --git a/Sources/BearWaveCore/Models/RadioStation.swift b/Sources/BearWaveCore/Models/RadioStation.swift
new file mode 100644
index 0000000..ff7d2e6
--- /dev/null
+++ b/Sources/BearWaveCore/Models/RadioStation.swift
@@ -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
+ }
+}
diff --git a/Sources/BearWaveCore/Models/StationGroup.swift b/Sources/BearWaveCore/Models/StationGroup.swift
new file mode 100644
index 0000000..28ebba7
--- /dev/null
+++ b/Sources/BearWaveCore/Models/StationGroup.swift
@@ -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
+ }
+}
diff --git a/Sources/BearWaveCore/Models/StationPage.swift b/Sources/BearWaveCore/Models/StationPage.swift
new file mode 100644
index 0000000..77dfb9b
--- /dev/null
+++ b/Sources/BearWaveCore/Models/StationPage.swift
@@ -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")
+ }
+ }
+}
diff --git a/Sources/BearWaveCore/Resources/Assets/bearwave.png b/Sources/BearWaveCore/Resources/Assets/bearwave.png
new file mode 100755
index 0000000..15b1335
Binary files /dev/null and b/Sources/BearWaveCore/Resources/Assets/bearwave.png differ
diff --git a/Sources/BearWaveCore/Resources/Assets/bearwave_line.png b/Sources/BearWaveCore/Resources/Assets/bearwave_line.png
new file mode 100755
index 0000000..cb4f961
Binary files /dev/null and b/Sources/BearWaveCore/Resources/Assets/bearwave_line.png differ
diff --git a/Sources/BearWaveCore/Resources/BearWave.entitlements b/Sources/BearWaveCore/Resources/BearWave.entitlements
new file mode 100644
index 0000000..a8c1072
--- /dev/null
+++ b/Sources/BearWaveCore/Resources/BearWave.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+
+
diff --git a/Sources/BearWaveCore/Resources/de.lproj/Localizable.strings b/Sources/BearWaveCore/Resources/de.lproj/Localizable.strings
new file mode 100644
index 0000000..c5040b3
--- /dev/null
+++ b/Sources/BearWaveCore/Resources/de.lproj/Localizable.strings
@@ -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";
diff --git a/Sources/BearWaveCore/Resources/en.lproj/Localizable.strings b/Sources/BearWaveCore/Resources/en.lproj/Localizable.strings
new file mode 100644
index 0000000..49c6186
--- /dev/null
+++ b/Sources/BearWaveCore/Resources/en.lproj/Localizable.strings
@@ -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";
diff --git a/Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift b/Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift
new file mode 100644
index 0000000..1834581
--- /dev/null
+++ b/Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift
@@ -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 = []
+ 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]
+}
diff --git a/Sources/BearWaveCore/Services/PlaybackController.swift b/Sources/BearWaveCore/Services/PlaybackController.swift
new file mode 100644
index 0000000..35641f2
--- /dev/null
+++ b/Sources/BearWaveCore/Services/PlaybackController.swift
@@ -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?
+ private var retryAttempts = 0
+ private var lastURL: URL?
+ private var lastStation: RadioStation?
+ private var cancellables: Set = []
+ private var itemCancellables: Set = []
+ 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
+}
diff --git a/Sources/BearWaveCore/Services/RadioBrowserClient.swift b/Sources/BearWaveCore/Services/RadioBrowserClient.swift
new file mode 100644
index 0000000..0e418aa
--- /dev/null
+++ b/Sources/BearWaveCore/Services/RadioBrowserClient.swift
@@ -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()
+
+ 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()
+ 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 {}
diff --git a/Sources/BearWaveCore/Services/StationArtworkLoader.swift b/Sources/BearWaveCore/Services/StationArtworkLoader.swift
new file mode 100644
index 0000000..50ccfb1
--- /dev/null
+++ b/Sources/BearWaveCore/Services/StationArtworkLoader.swift
@@ -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 = []
+
+ 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")
+ }
+}
diff --git a/Sources/BearWaveCore/Stores/PersistenceStore.swift b/Sources/BearWaveCore/Stores/PersistenceStore.swift
new file mode 100644
index 0000000..49ad7bb
--- /dev/null
+++ b/Sources/BearWaveCore/Stores/PersistenceStore.swift
@@ -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(_ 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(_ 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
+ }
+}
diff --git a/Sources/BearWaveCore/Stores/SettingsStore.swift b/Sources/BearWaveCore/Stores/SettingsStore.swift
new file mode 100644
index 0000000..ef1ca41
--- /dev/null
+++ b/Sources/BearWaveCore/Stores/SettingsStore.swift
@@ -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"
+ }
+}
diff --git a/Sources/BearWaveCore/Stores/StationLibrary.swift b/Sources/BearWaveCore/Stores/StationLibrary.swift
new file mode 100644
index 0000000..320d4ed
--- /dev/null
+++ b/Sources/BearWaveCore/Stores/StationLibrary.swift
@@ -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()
+ 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
+ }
+}
diff --git a/Sources/BearWaveCore/Support/BearWaveAssets.swift b/Sources/BearWaveCore/Support/BearWaveAssets.swift
new file mode 100644
index 0000000..2214693
--- /dev/null
+++ b/Sources/BearWaveCore/Support/BearWaveAssets.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Views/ContentView.swift b/Sources/BearWaveCore/Views/ContentView.swift
new file mode 100644
index 0000000..1541930
--- /dev/null
+++ b/Sources/BearWaveCore/Views/ContentView.swift
@@ -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?
+ @State private var showingManualSheet = false
+ @State private var showingNewFolderSheet = false
+ @Binding var isMiniPlayer: Bool
+
+ public init(isMiniPlayer: Binding = .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)
+ }
+ }
+}
diff --git a/Sources/BearWaveCore/Views/GroupRowView.swift b/Sources/BearWaveCore/Views/GroupRowView.swift
new file mode 100644
index 0000000..ae2178a
--- /dev/null
+++ b/Sources/BearWaveCore/Views/GroupRowView.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Views/ManualStationView.swift b/Sources/BearWaveCore/Views/ManualStationView.swift
new file mode 100644
index 0000000..521d20b
--- /dev/null
+++ b/Sources/BearWaveCore/Views/ManualStationView.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Views/MenuBarContentView.swift b/Sources/BearWaveCore/Views/MenuBarContentView.swift
new file mode 100644
index 0000000..4e07280
--- /dev/null
+++ b/Sources/BearWaveCore/Views/MenuBarContentView.swift
@@ -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")
+ }
+}
diff --git a/Sources/BearWaveCore/Views/MiniPlayerView.swift b/Sources/BearWaveCore/Views/MiniPlayerView.swift
new file mode 100644
index 0000000..986b814
--- /dev/null
+++ b/Sources/BearWaveCore/Views/MiniPlayerView.swift
@@ -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
+ }
+}
diff --git a/Sources/BearWaveCore/Views/NewGroupView.swift b/Sources/BearWaveCore/Views/NewGroupView.swift
new file mode 100644
index 0000000..78e1390
--- /dev/null
+++ b/Sources/BearWaveCore/Views/NewGroupView.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Views/PlayerBarView.swift b/Sources/BearWaveCore/Views/PlayerBarView.swift
new file mode 100644
index 0000000..6525552
--- /dev/null
+++ b/Sources/BearWaveCore/Views/PlayerBarView.swift
@@ -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
+ }
+}
diff --git a/Sources/BearWaveCore/Views/SettingsView.swift b/Sources/BearWaveCore/Views/SettingsView.swift
new file mode 100644
index 0000000..b7fa789
--- /dev/null
+++ b/Sources/BearWaveCore/Views/SettingsView.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Views/SidebarView.swift b/Sources/BearWaveCore/Views/SidebarView.swift
new file mode 100644
index 0000000..93a52e7
--- /dev/null
+++ b/Sources/BearWaveCore/Views/SidebarView.swift
@@ -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)
+ }
+}
diff --git a/Sources/BearWaveCore/Views/StationDetailView.swift b/Sources/BearWaveCore/Views/StationDetailView.swift
new file mode 100644
index 0000000..c419641
--- /dev/null
+++ b/Sources/BearWaveCore/Views/StationDetailView.swift
@@ -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: 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)
+ }
+ }
+}
diff --git a/Sources/BearWaveCore/Views/StationListView.swift b/Sources/BearWaveCore/Views/StationListView.swift
new file mode 100644
index 0000000..e3c672e
--- /dev/null
+++ b/Sources/BearWaveCore/Views/StationListView.swift
@@ -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
+ }
+}
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..f5ca441
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,50 @@
+# TODO
+
+This file tracks the next practical work items for BearWave on macOS.
+
+## High Priority
+
+- Harden packaging and distribution
+ - move beyond the current SwiftPM-staged debug-style app bundle
+ - clarify signing, entitlements, and notarization path
+
+- Expand playback diagnostics
+ - expose clearer failure reasons per stream
+ - improve visibility into buffering, retry, offline, and codec issues
+
+- Add more verification around playback behavior
+ - playback state transitions
+ - metadata updates
+ - retry behavior
+
+## Medium Priority
+
+- Improve cache management
+ - add eviction for API cache
+ - add eviction for artwork cache
+ - review memory growth during long sessions
+
+- Refine artwork behavior
+ - make fallback ordering more explicit
+ - verify radio artwork behavior across edge cases
+
+- Tighten documentation drift
+ - keep `README.md`, `AGENTS.md`, and `ARCHITECTURE.md` aligned with code changes
+
+## Lower Priority
+
+- More advanced favorites/folder workflows
+- Additional menu bar workflows
+- More targeted UI polish for empty/loading/error states
+
+## Technical Debt
+
+- `AppModel` should stay under control as the app grows; watch for coordinator bloat
+- `script/build_and_run.sh` is central to local development and currently carries packaging assumptions that should eventually move into a cleaner release pipeline
+- The share-folder copy of the repo should not be treated as the current source of truth
+
+## Things Not To Regress
+
+- Global search must stay independent from the current browse page
+- Search ranking must stay stricter than raw API substring results
+- Explicit playback states must remain visible in the model and UI
diff --git a/Tests/BearWaveChecks/Fixtures/stations.json b/Tests/BearWaveChecks/Fixtures/stations.json
new file mode 100644
index 0000000..cdcc260
--- /dev/null
+++ b/Tests/BearWaveChecks/Fixtures/stations.json
@@ -0,0 +1,31 @@
+[
+ {
+ "stationuuid": "alpha",
+ "name": "Alpha Radio",
+ "url": "https://example.test/alpha",
+ "url_resolved": "https://example.test/alpha-resolved",
+ "country": "Germany",
+ "tags": "rock,pop",
+ "codec": "MP3",
+ "bitrate": 128,
+ "votes": 25,
+ "lastcheckok": 1
+ },
+ {
+ "stationuuid": "broken",
+ "name": "",
+ "url_resolved": "",
+ "lastcheckok": 0
+ },
+ {
+ "stationuuid": "beta",
+ "name": "Beta News",
+ "url_resolved": "https://example.test/beta",
+ "country": "United States",
+ "tags": "news",
+ "codec": "AAC",
+ "bitrate": "96",
+ "votes": "50",
+ "lastcheckok": 1
+ }
+]
diff --git a/Tests/BearWaveChecks/main.swift b/Tests/BearWaveChecks/main.swift
new file mode 100644
index 0000000..870aa8c
--- /dev/null
+++ b/Tests/BearWaveChecks/main.swift
@@ -0,0 +1,82 @@
+import Foundation
+@testable import BearWaveCore
+
+try checkRadioBrowserDecoding()
+checkPercentEncoding()
+checkNormalizedSearch()
+await checkStationLibrary()
+print("BearWave checks passed")
+
+private func checkRadioBrowserDecoding() throws {
+ let url = Bundle.module.url(forResource: "stations", withExtension: "json")!
+ let stations = try RadioBrowserClient.decodeStations(from: Data(contentsOf: url))
+ expect(stations.count == 2, "decode should filter invalid stations")
+ expect(stations[0].id == "alpha", "station id should prefer UUID")
+ expect(stations[0].playbackURLString == "https://example.test/alpha-resolved", "resolved URL should be playback URL")
+ expect(stations[1].bitrate == 96, "bitrate should decode from string")
+ expect(stations[1].votes == 50, "votes should decode from string")
+}
+
+private func checkPercentEncoding() {
+ expect(RadioBrowserClient.percentEncodePathComponent("classic rock") == "classic%20rock", "path values should be percent encoded")
+}
+
+private func checkNormalizedSearch() {
+ expect(RadioBrowserClient.normalizedSearchText("SLAM!") == "slam", "normalization should ignore punctuation")
+ expect(RadioBrowserClient.normalizedSearchText("SLÁM Radio") == "slamradio", "normalization should fold accents and separators")
+ expect(RadioBrowserClient.normalizedSearchTokens("Radio Islamabad").contains("slam") == false, "tokens should not match inside another word")
+ expect(RadioBrowserClient.normalizedSearchTokens("SLAM! Radio").contains("slam"), "tokens should match punctuation-separated station names")
+}
+
+@MainActor
+private func checkStationLibrary() {
+ let library = makeLibrary()
+ let favorite = RadioStation(uuid: "one", name: "One", resolvedURL: "https://example.test/one")
+ library.toggleFavorite(favorite)
+ library.toggleFavorite(favorite)
+ expect(library.favorites.isEmpty, "favorites should deduplicate by UUID")
+
+ for index in 0..<25 {
+ library.recordPlayback(RadioStation(uuid: "\(index)", name: "Station \(index)", resolvedURL: "https://example.test/\(index)"))
+ }
+ library.recordPlayback(RadioStation(uuid: "24", name: "Station 24", resolvedURL: "https://example.test/24"))
+ expect(library.recentStations.count == 20, "recent stations should be limited to 20")
+ expect(library.recentStations.first?.uuid == "24", "recent stations should move duplicates to the front")
+ expect(Set(library.recentStations.map(\.uuid)).count == 20, "recent stations should stay unique")
+
+ library.stations = [
+ RadioStation(name: "Zoo News", resolvedURL: "https://example.test/zoo", tags: "news", bitrate: 96, votes: 3),
+ RadioStation(name: "Alpha Rock", resolvedURL: "https://example.test/alpha", tags: "rock", bitrate: 192, votes: 1)
+ ]
+ library.filterQuery = "rock"
+ expect(library.visibleStations.map(\.name) == ["Alpha Rock"], "filter should match tags")
+ library.filterQuery = ""
+ library.sortMode = .bitrate
+ expect(library.visibleStations.map(\.name) == ["Alpha Rock", "Zoo News"], "bitrate sort should be descending")
+}
+
+@MainActor
+private func makeLibrary() -> StationLibrary {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let persistence = PersistenceStore(
+ applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport"),
+ cacheDirectory: root.appendingPathComponent("Caches"),
+ linuxConfigDirectory: root.appendingPathComponent("Linux")
+ )
+ let client = RadioBrowserClient(cacheDirectory: persistence.apiCacheDirectory, session: MockSession())
+ return StationLibrary(client: client, persistence: persistence)
+}
+
+private func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
+ if !condition() {
+ fatalError(message)
+ }
+}
+
+struct MockSession: URLSessionProtocol {
+ func data(for request: URLRequest) async throws -> (Data, URLResponse) {
+ let data = "[]".data(using: .utf8)!
+ let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return (data, response)
+ }
+}
diff --git a/script/build_and_run.sh b/script/build_and_run.sh
new file mode 100755
index 0000000..3aec544
--- /dev/null
+++ b/script/build_and_run.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+MODE="${1:-run}"
+APP_NAME="BearWave"
+BUNDLE_ID="com.spalencsar.BearWave"
+MIN_SYSTEM_VERSION="26.0"
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DIST_DIR="$ROOT_DIR/dist"
+APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
+APP_CONTENTS="$APP_BUNDLE/Contents"
+APP_MACOS="$APP_CONTENTS/MacOS"
+APP_RESOURCES="$APP_CONTENTS/Resources"
+APP_BINARY="$APP_MACOS/$APP_NAME"
+INFO_PLIST="$APP_CONTENTS/Info.plist"
+ENTITLEMENTS="$ROOT_DIR/Sources/BearWaveCore/Resources/BearWave.entitlements"
+
+pkill -x "$APP_NAME" >/dev/null 2>&1 || true
+
+swift build --disable-index-store
+BUILD_BINARY="$(swift build --disable-index-store --show-bin-path)/$APP_NAME"
+BUILD_BIN_DIR="$(dirname "$BUILD_BINARY")"
+
+rm -rf "$APP_BUNDLE"
+mkdir -p "$APP_MACOS" "$APP_RESOURCES"
+cp "$BUILD_BINARY" "$APP_BINARY"
+chmod +x "$APP_BINARY"
+
+cp "$ROOT_DIR/Sources/BearWaveCore/Resources/Assets/bearwave.png" "$APP_RESOURCES/BearWave.png"
+cp "$ROOT_DIR/Sources/BearWaveCore/Resources/Assets/bearwave.png" "$APP_RESOURCES/bearwave.png"
+cp "$ROOT_DIR/Sources/BearWaveCore/Resources/Assets/bearwave_line.png" "$APP_RESOURCES/bearwave_line.png"
+cp "$ROOT_DIR/AppIcon.icns" "$APP_RESOURCES/AppIcon.icns"
+for lproj in "$ROOT_DIR"/Sources/BearWaveCore/Resources/*.lproj; do
+ if [ -d "$lproj" ]; then
+ cp -R "$lproj" "$APP_RESOURCES/"
+ fi
+done
+if [ -d "$BUILD_BIN_DIR/BearWave_BearWaveCore.bundle" ]; then
+ cp -R "$BUILD_BIN_DIR/BearWave_BearWaveCore.bundle" "$APP_BUNDLE/BearWave_BearWaveCore.bundle"
+fi
+
+cat >"$INFO_PLIST" <
+
+
+
+ CFBundleExecutable
+ $APP_NAME
+ CFBundleIdentifier
+ $BUNDLE_ID
+ CFBundleName
+ $APP_NAME
+ CFBundleDisplayName
+ BearWave
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ $MIN_SYSTEM_VERSION
+ NSPrincipalClass
+ NSApplication
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsArbitraryLoadsForMedia
+
+
+ CFBundleLocalizations
+
+ en
+ de
+
+ CFBundleIconFile
+ AppIcon
+
+
+PLIST
+
+open_app() {
+ /usr/bin/open -n "$APP_BUNDLE"
+}
+
+case "$MODE" in
+ run)
+ open_app
+ ;;
+ --debug|debug)
+ lldb -- "$APP_BINARY"
+ ;;
+ --logs|logs)
+ open_app
+ /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\""
+ ;;
+ --telemetry|telemetry)
+ open_app
+ /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\""
+ ;;
+ --verify|verify)
+ open_app
+ sleep 1
+ pgrep -x "$APP_NAME" >/dev/null
+ ;;
+ *)
+ echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2
+ exit 2
+ ;;
+esac