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