Create BearWave macOS radio-first app
This commit is contained in:
11
.codex/environments/environment.toml
Normal file
11
.codex/environments/environment.toml
Normal file
@@ -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"
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.build/
|
||||
dist/
|
||||
.DS_Store
|
||||
._*
|
||||
108
AGENTS.md
Normal file
108
AGENTS.md
Normal file
@@ -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
|
||||
178
ARCHITECTURE.md
Normal file
178
ARCHITECTURE.md
Normal file
@@ -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
|
||||
BIN
AppIcon.icns
Normal file
BIN
AppIcon.icns
Normal file
Binary file not shown.
36
Package.swift
Normal file
36
Package.swift
Normal file
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
120
README.md
Normal file
120
README.md
Normal file
@@ -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
|
||||
84
Sources/BearWaveApp/BearWaveApp.swift
Normal file
84
Sources/BearWaveApp/BearWaveApp.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
import BearWaveCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BearWaveApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var model = AppModel()
|
||||
@State private var isMiniPlayer = false
|
||||
|
||||
private static var windowWidth: Double {
|
||||
let screen = NSScreen.main ?? NSScreen.screens.first
|
||||
let width = screen?.visibleFrame.width ?? 1440
|
||||
if width >= 1920 { return 1600 }
|
||||
return 1255
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("BearWave", id: "main") {
|
||||
ContentView(isMiniPlayer: $isMiniPlayer)
|
||||
.environment(model)
|
||||
.frame(
|
||||
minWidth: isMiniPlayer ? 555 : 1255,
|
||||
minHeight: isMiniPlayer ? 110 : 650
|
||||
)
|
||||
.onChange(of: isMiniPlayer) { _, newValue in
|
||||
resizeWindow(toMini: newValue)
|
||||
}
|
||||
.task {
|
||||
await model.start()
|
||||
}
|
||||
}
|
||||
.defaultSize(width: Self.windowWidth, height: 720)
|
||||
.commands {
|
||||
CommandGroup(after: .appTermination) {
|
||||
Button("Play/Pause") {
|
||||
model.playback.togglePlayPause()
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: [])
|
||||
}
|
||||
CommandGroup(after: .newItem) {
|
||||
Button(isMiniPlayer ? "Show Full Window" : "Mini Player") {
|
||||
isMiniPlayer.toggle()
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: .command)
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra("BearWave", systemImage: model.playback.isPlaying ? "radio.fill" : "radio") {
|
||||
MenuBarContentView()
|
||||
.environment(model)
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environment(model)
|
||||
.frame(width: 520)
|
||||
}
|
||||
}
|
||||
|
||||
private func resizeWindow(toMini: Bool) {
|
||||
let targetSize = toMini ? CGSize(width: 555, height: 110) : CGSize(width: Self.windowWidth, height: 720)
|
||||
Task { @MainActor in
|
||||
guard let window = NSApp.mainWindow ?? NSApp.windows.first else { return }
|
||||
let currentFrame = window.frame
|
||||
var newFrame = currentFrame
|
||||
newFrame.size = targetSize
|
||||
newFrame.origin.y = currentFrame.maxY - targetSize.height
|
||||
window.minSize = targetSize
|
||||
window.setFrame(newFrame, display: true, animate: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
76
Sources/BearWaveCore/App/AppModel.swift
Normal file
76
Sources/BearWaveCore/App/AppModel.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class AppModel {
|
||||
public let settings: SettingsStore
|
||||
public let library: StationLibrary
|
||||
public let playback: PlaybackController
|
||||
let artwork: StationArtworkLoader
|
||||
private let nowPlayingArtwork: NowPlayingArtworkResolver
|
||||
|
||||
public convenience init() {
|
||||
self.init(settings: SettingsStore(), persistence: PersistenceStore())
|
||||
}
|
||||
|
||||
init(
|
||||
settings: SettingsStore = SettingsStore(),
|
||||
persistence: PersistenceStore = PersistenceStore()
|
||||
) {
|
||||
self.settings = settings
|
||||
let client = RadioBrowserClient(baseURL: settings.baseURL, cacheDirectory: persistence.apiCacheDirectory)
|
||||
library = StationLibrary(client: client, persistence: persistence)
|
||||
let artworkLoader = StationArtworkLoader(cacheDirectory: persistence.cacheDirectory)
|
||||
artwork = artworkLoader
|
||||
nowPlayingArtwork = NowPlayingArtworkResolver(cacheDirectory: persistence.cacheDirectory, faviconLoader: artworkLoader)
|
||||
playback = PlaybackController(artworkProvider: { [weak nowPlayingArtwork] station, artist, title in
|
||||
nowPlayingArtwork?.resolve(artist: artist, title: title, station: station) ?? BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
})
|
||||
nowPlayingArtwork.onArtworkLoaded = { [weak playback] in
|
||||
playback?.refreshNowPlaying()
|
||||
}
|
||||
playback.onMetadataChanged = { [weak nowPlayingArtwork] artist, title, station in
|
||||
nowPlayingArtwork?.fetchArtwork(artist: artist, title: title, station: station)
|
||||
}
|
||||
playback.volume = library.volume
|
||||
library.updateBaseURL(settings.baseURL)
|
||||
}
|
||||
|
||||
public func start() async {
|
||||
await library.load(.top)
|
||||
if settings.resumeOnLaunch, let station = library.resumeStation() {
|
||||
play(station)
|
||||
}
|
||||
}
|
||||
|
||||
public func play(_ station: RadioStation) {
|
||||
library.recordPlayback(station)
|
||||
playback.play(station)
|
||||
nowPlayingArtwork.fetchArtwork(
|
||||
artist: playback.currentTrackArtist.isEmpty ? nil : playback.currentTrackArtist,
|
||||
title: playback.currentTrackTitle.isEmpty ? nil : playback.currentTrackTitle,
|
||||
station: station
|
||||
)
|
||||
}
|
||||
|
||||
public func resumeLastStation() {
|
||||
guard let station = library.resumeStation() else { return }
|
||||
play(station)
|
||||
}
|
||||
|
||||
public func setVolume(_ value: Double) {
|
||||
playback.volume = value
|
||||
library.saveVolume(value)
|
||||
}
|
||||
|
||||
public func applySettings() {
|
||||
library.updateBaseURL(settings.baseURL)
|
||||
}
|
||||
|
||||
public func quit() {
|
||||
playback.stop()
|
||||
nowPlayingArtwork.fetchArtwork(artist: nil, title: nil, station: nil)
|
||||
}
|
||||
}
|
||||
58
Sources/BearWaveCore/Models/PlaybackError.swift
Normal file
58
Sources/BearWaveCore/Models/PlaybackError.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
enum PlaybackError: Equatable, Sendable {
|
||||
case connectionTimeout
|
||||
case stationOffline
|
||||
case httpError(Int)
|
||||
case codecNotSupported
|
||||
case streamInterrupted
|
||||
case dnsResolutionFailed
|
||||
case unknown(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .connectionTimeout:
|
||||
String(localized: "Connection timed out")
|
||||
case .stationOffline:
|
||||
String(localized: "Station is offline")
|
||||
case .httpError:
|
||||
String(localized: "Server error")
|
||||
case .codecNotSupported:
|
||||
String(localized: "Codec not supported")
|
||||
case .streamInterrupted:
|
||||
String(localized: "Stream interrupted")
|
||||
case .dnsResolutionFailed:
|
||||
String(localized: "Could not resolve host")
|
||||
case .unknown:
|
||||
String(localized: "Playback failed")
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .connectionTimeout:
|
||||
String(localized: "The station did not respond in time. It may be overloaded or temporarily unavailable.")
|
||||
case .stationOffline:
|
||||
String(localized: "The stream URL is no longer available. The station may have shut down or changed its address.")
|
||||
case let .httpError(code):
|
||||
String(localized: "The server responded with HTTP \(code). The stream may be temporarily unavailable.")
|
||||
case .codecNotSupported:
|
||||
String(localized: "macOS cannot decode this stream format. Try a different station.")
|
||||
case .streamInterrupted:
|
||||
String(localized: "The connection was lost. The station may have ended the broadcast.")
|
||||
case .dnsResolutionFailed:
|
||||
String(localized: "The stream server address could not be found. Check your internet connection.")
|
||||
case let .unknown(message):
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
var isRetryable: Bool {
|
||||
switch self {
|
||||
case .connectionTimeout, .stationOffline, .httpError, .streamInterrupted:
|
||||
true
|
||||
case .codecNotSupported, .dnsResolutionFailed, .unknown:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Sources/BearWaveCore/Models/PlaybackStatus.swift
Normal file
38
Sources/BearWaveCore/Models/PlaybackStatus.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum PlaybackStatus: Equatable {
|
||||
case stopped
|
||||
case connecting
|
||||
case buffering
|
||||
case playing
|
||||
case paused
|
||||
case failed(PlaybackError)
|
||||
|
||||
var isPlaying: Bool {
|
||||
self == .playing || self == .connecting || self == .buffering
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .stopped:
|
||||
String(localized: "Stopped")
|
||||
case .connecting:
|
||||
String(localized: "Connecting...")
|
||||
case .buffering:
|
||||
String(localized: "Buffering...")
|
||||
case .playing:
|
||||
String(localized: "Playing")
|
||||
case .paused:
|
||||
String(localized: "Paused")
|
||||
case let .failed(error):
|
||||
error.title
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String? {
|
||||
if case let .failed(error) = self {
|
||||
return error.detail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
136
Sources/BearWaveCore/Models/RadioStation.swift
Normal file
136
Sources/BearWaveCore/Models/RadioStation.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
public struct RadioStation: Codable, Hashable, Identifiable, Sendable {
|
||||
public var uuid: String
|
||||
public var name: String
|
||||
public var url: String
|
||||
public var resolvedURL: String
|
||||
public var homepage: String
|
||||
public var favicon: String
|
||||
public var country: String
|
||||
public var tags: String
|
||||
public var codec: String
|
||||
public var bitrate: Int
|
||||
public var votes: Int
|
||||
public var isOnline: Bool
|
||||
public var isFavorite: Bool
|
||||
public var isManual: Bool
|
||||
|
||||
public var id: String {
|
||||
if !uuid.isEmpty { return uuid }
|
||||
if !resolvedURL.isEmpty { return resolvedURL }
|
||||
if !url.isEmpty { return url }
|
||||
return name
|
||||
}
|
||||
|
||||
public var playbackURLString: String {
|
||||
resolvedURL.isEmpty ? url : resolvedURL
|
||||
}
|
||||
|
||||
public var searchableText: String {
|
||||
[name, tags, country, codec].joined(separator: " ").lowercased()
|
||||
}
|
||||
|
||||
public init(
|
||||
uuid: String = "",
|
||||
name: String,
|
||||
url: String = "",
|
||||
resolvedURL: String = "",
|
||||
homepage: String = "",
|
||||
favicon: String = "",
|
||||
country: String = "",
|
||||
tags: String = "",
|
||||
codec: String = "",
|
||||
bitrate: Int = 0,
|
||||
votes: Int = 0,
|
||||
isOnline: Bool = true,
|
||||
isFavorite: Bool = false,
|
||||
isManual: Bool = false
|
||||
) {
|
||||
self.uuid = uuid
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.resolvedURL = resolvedURL
|
||||
self.homepage = homepage
|
||||
self.favicon = favicon
|
||||
self.country = country
|
||||
self.tags = tags
|
||||
self.codec = codec
|
||||
self.bitrate = bitrate
|
||||
self.votes = votes
|
||||
self.isOnline = isOnline
|
||||
self.isFavorite = isFavorite
|
||||
self.isManual = isManual
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uuid = "stationuuid"
|
||||
case legacyUUID = "uuid"
|
||||
case name
|
||||
case url
|
||||
case resolvedURL = "url_resolved"
|
||||
case legacyResolvedURL = "urlResolved"
|
||||
case homepage
|
||||
case favicon
|
||||
case country
|
||||
case tags
|
||||
case codec
|
||||
case bitrate
|
||||
case votes
|
||||
case lastCheckOK = "lastcheckok"
|
||||
case isFavorite
|
||||
case isManual
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
uuid = try container.decodeIfPresent(String.self, forKey: .uuid)
|
||||
?? container.decodeIfPresent(String.self, forKey: .legacyUUID)
|
||||
?? ""
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
url = try container.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
resolvedURL = try container.decodeIfPresent(String.self, forKey: .resolvedURL)
|
||||
?? container.decodeIfPresent(String.self, forKey: .legacyResolvedURL)
|
||||
?? ""
|
||||
homepage = try container.decodeIfPresent(String.self, forKey: .homepage) ?? ""
|
||||
favicon = try container.decodeIfPresent(String.self, forKey: .favicon) ?? ""
|
||||
country = try container.decodeIfPresent(String.self, forKey: .country) ?? ""
|
||||
tags = try container.decodeIfPresent(String.self, forKey: .tags) ?? ""
|
||||
codec = try container.decodeIfPresent(String.self, forKey: .codec) ?? ""
|
||||
bitrate = try container.decodeFlexibleInt(forKey: .bitrate) ?? 0
|
||||
votes = try container.decodeFlexibleInt(forKey: .votes) ?? 0
|
||||
let lastCheckOK = try container.decodeFlexibleInt(forKey: .lastCheckOK)
|
||||
isOnline = lastCheckOK.map { $0 > 0 } ?? true
|
||||
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
|
||||
isManual = try container.decodeIfPresent(Bool.self, forKey: .isManual) ?? false
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(uuid, forKey: .legacyUUID)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(url, forKey: .url)
|
||||
try container.encode(resolvedURL, forKey: .legacyResolvedURL)
|
||||
try container.encode(homepage, forKey: .homepage)
|
||||
try container.encode(favicon, forKey: .favicon)
|
||||
try container.encode(country, forKey: .country)
|
||||
try container.encode(tags, forKey: .tags)
|
||||
try container.encode(codec, forKey: .codec)
|
||||
try container.encode(bitrate, forKey: .bitrate)
|
||||
try container.encode(votes, forKey: .votes)
|
||||
try container.encode(isFavorite, forKey: .isFavorite)
|
||||
try container.encode(isManual, forKey: .isManual)
|
||||
}
|
||||
}
|
||||
|
||||
extension KeyedDecodingContainer {
|
||||
func decodeFlexibleInt(forKey key: Key) throws -> Int? {
|
||||
if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return intValue
|
||||
}
|
||||
if let stringValue = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return Int(stringValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
15
Sources/BearWaveCore/Models/StationGroup.swift
Normal file
15
Sources/BearWaveCore/Models/StationGroup.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
public struct StationGroup: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var stationIDs: [String]
|
||||
public var sortOrder: Int
|
||||
|
||||
public init(id: String = UUID().uuidString, name: String, stationIDs: [String] = [], sortOrder: Int = 0) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.stationIDs = stationIDs
|
||||
self.sortOrder = sortOrder
|
||||
}
|
||||
}
|
||||
59
Sources/BearWaveCore/Models/StationPage.swift
Normal file
59
Sources/BearWaveCore/Models/StationPage.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
enum StationPage: String, CaseIterable, Identifiable, Codable {
|
||||
case top
|
||||
case germany
|
||||
case netherlands
|
||||
case world
|
||||
case search
|
||||
case favorites
|
||||
case recent
|
||||
case manual
|
||||
case group
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .top: String(localized: "Top")
|
||||
case .germany: String(localized: "Germany")
|
||||
case .netherlands: String(localized: "Netherlands")
|
||||
case .world: String(localized: "World")
|
||||
case .search: String(localized: "Search Results")
|
||||
case .favorites: String(localized: "Favorites")
|
||||
case .recent: String(localized: "Recent")
|
||||
case .manual: String(localized: "Manual")
|
||||
case .group: ""
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .top: "chart.bar.fill"
|
||||
case .germany: "flag.fill"
|
||||
case .netherlands: "flag"
|
||||
case .world: "globe.europe.africa.fill"
|
||||
case .search: "magnifyingglass"
|
||||
case .favorites: "star.fill"
|
||||
case .recent: "clock.arrow.circlepath"
|
||||
case .manual: "plus.circle.fill"
|
||||
case .group: "folder.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StationSortMode: String, CaseIterable, Identifiable, Codable {
|
||||
case name
|
||||
case bitrate
|
||||
case votes
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .name: String(localized: "Name")
|
||||
case .bitrate: String(localized: "Bitrate")
|
||||
case .votes: String(localized: "Votes")
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Sources/BearWaveCore/Resources/Assets/bearwave.png
Executable file
BIN
Sources/BearWaveCore/Resources/Assets/bearwave.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
BIN
Sources/BearWaveCore/Resources/Assets/bearwave_line.png
Executable file
BIN
Sources/BearWaveCore/Resources/Assets/bearwave_line.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
10
Sources/BearWaveCore/Resources/BearWave.entitlements
Normal file
10
Sources/BearWaveCore/Resources/BearWave.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
90
Sources/BearWaveCore/Resources/de.lproj/Localizable.strings
Normal file
90
Sources/BearWaveCore/Resources/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,90 @@
|
||||
"Add" = "Hinzufügen";
|
||||
"Add Favorite" = "Zu Favoriten hinzufügen";
|
||||
"Add Station" = "Sender hinzufügen";
|
||||
"Add to Folder" = "Zu Ordner hinzufügen";
|
||||
"Add to \(group.name)" = "Zu \(group.name) hinzufügen";
|
||||
"Artist" = "Artist";
|
||||
"Bitrate" = "Bitrate";
|
||||
"Codec" = "Codec";
|
||||
"Connection timed out" = "Verbindung abgelaufen";
|
||||
"The station did not respond in time. It may be overloaded or temporarily unavailable." = "Der Sender hat nicht rechtzeitig geantwortet. Er könnte überlastet oder vorübergehend nicht verfügbar sein.";
|
||||
"Copy Stream URL" = "Stream-URL kopieren";
|
||||
"Could not resolve host" = "Host konnte nicht aufgelöst werden";
|
||||
"The stream server address could not be found. Check your internet connection." = "Die Serveradresse des Streams konnte nicht gefunden werden. Prüfe deine Internetverbindung.";
|
||||
"Create new folder" = "Neuen Ordner erstellen";
|
||||
"Details" = "Details";
|
||||
"Folder name" = "Ordnername";
|
||||
"Folders" = "Ordner";
|
||||
"Mini Player" = "Mini-Player";
|
||||
"Show Full Window" = "Vollständiges Fenster anzeigen";
|
||||
"New Folder" = "Neuer Ordner";
|
||||
"No stream metadata yet" = "Noch keine Stream-Metadaten";
|
||||
"Now Playing" = "Jetzt läuft";
|
||||
"Open Homepage" = "Homepage öffnen";
|
||||
"Rename folder" = "Ordner umbenennen";
|
||||
"Remove from \(group.name)" = "Aus \(group.name) entfernen";
|
||||
"Server error" = "Serverfehler";
|
||||
"The server responded with HTTP \(code). The stream may be temporarily unavailable." = "Der Server antwortete mit HTTP \(code). Der Stream könnte vorübergehend nicht verfügbar sein.";
|
||||
"Station is offline" = "Sender ist offline";
|
||||
"The stream URL is no longer available. The station may have shut down or changed its address." = "Die Stream-URL ist nicht mehr verfügbar. Der Sender könnte abgeschaltet oder die Adresse geändert haben.";
|
||||
"Status" = "Status";
|
||||
"Stream interrupted" = "Stream unterbrochen";
|
||||
"The connection was lost. The station may have ended the broadcast." = "Die Verbindung wurde getrennt. Der Sender könnte die Übertragung beendet haben.";
|
||||
"Tags" = "Tags";
|
||||
"Title" = "Titel";
|
||||
"Codec not supported" = "Codec nicht unterstützt";
|
||||
"macOS cannot decode this stream format. Try a different station." = "macOS kann dieses Stream-Format nicht decodieren. Versuche einen anderen Sender.";
|
||||
"Playback failed" = "Wiedergabe fehlgeschlagen";
|
||||
"Add station manually" = "Sender manuell hinzufügen";
|
||||
"Base URL" = "Basis-URL";
|
||||
"BearWave" = "BearWave";
|
||||
"Bitrate" = "Bitrate";
|
||||
"Cancel" = "Abbrechen";
|
||||
"Country (optional)" = "Land (optional)";
|
||||
"Create" = "Erstellen";
|
||||
"Delete" = "Löschen";
|
||||
"Favorites" = "Favoriten";
|
||||
"Germany" = "Deutschland";
|
||||
"Last played" = "Zuletzt gespielt";
|
||||
"Library" = "Mediathek";
|
||||
"Live radio" = "Live-Radio";
|
||||
"Load a station page or search worldwide." = "Lade eine Senderseite oder suche weltweit.";
|
||||
"Manual" = "Manuell";
|
||||
"Menu Bar" = "Menüleiste";
|
||||
"Name" = "Name";
|
||||
"Netherlands" = "Niederlande";
|
||||
"No station selected" = "Kein Sender ausgewählt";
|
||||
"No stations loaded yet" = "Noch keine Sender geladen";
|
||||
"Pause" = "Pause";
|
||||
"Paused" = "Pausiert";
|
||||
"Play" = "Wiedergabe";
|
||||
"Play/Pause" = "Wiedergabe/Pause";
|
||||
"Playback" = "Wiedergabe";
|
||||
"Playing" = "Wiedergabe läuft";
|
||||
"Quit BearWave" = "BearWave beenden";
|
||||
"Radio Browser" = "Radio Browser";
|
||||
"Recent" = "Zuletzt";
|
||||
"Remove Favorite" = "Aus Favoriten entfernen";
|
||||
"Rename" = "Umbenennen";
|
||||
"Resume" = "Fortsetzen";
|
||||
"Resume last station on launch" = "Letzten Sender beim Start fortsetzen";
|
||||
"Search" = "Suche";
|
||||
"Search Results" = "Suchergebnisse";
|
||||
"Search stations, genre, country" = "Sender, Genre, Land suchen";
|
||||
"Show BearWave" = "BearWave zeigen";
|
||||
"Show playback details in menu bar" = "Wiedergabedetails in der Menüleiste zeigen";
|
||||
"Buffering..." = "Puffert...";
|
||||
"Connecting..." = "Verbindet...";
|
||||
"Failed" = "Fehlgeschlagen";
|
||||
"Stopped" = "Gestoppt";
|
||||
"Stream failed" = "Stream fehlgeschlagen";
|
||||
"Unsupported stream state" = "Nicht unterstützter Stream-Zustand";
|
||||
"Quick" = "Schnellzugriff";
|
||||
"Sort" = "Sortierung";
|
||||
"Stations" = "Sender";
|
||||
"Stop" = "Stopp";
|
||||
"Stream URL (http/https)" = "Stream-URL (http/https)";
|
||||
"Top" = "Top";
|
||||
"Votes" = "Stimmen";
|
||||
"World" = "Welt";
|
||||
"Unknown" = "Unbekannt";
|
||||
68
Sources/BearWaveCore/Resources/en.lproj/Localizable.strings
Normal file
68
Sources/BearWaveCore/Resources/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,68 @@
|
||||
"Add" = "Add";
|
||||
"Add Favorite" = "Add Favorite";
|
||||
"Add Station" = "Add Station";
|
||||
"Artist" = "Artist";
|
||||
"Bitrate" = "Bitrate";
|
||||
"Codec" = "Codec";
|
||||
"Copy Stream URL" = "Copy Stream URL";
|
||||
"Details" = "Details";
|
||||
"No stream metadata yet" = "No stream metadata yet";
|
||||
"Now Playing" = "Now Playing";
|
||||
"Open Homepage" = "Open Homepage";
|
||||
"Status" = "Status";
|
||||
"Tags" = "Tags";
|
||||
"Title" = "Title";
|
||||
"Add station manually" = "Add station manually";
|
||||
"Base URL" = "Base URL";
|
||||
"BearWave" = "BearWave";
|
||||
"Bitrate" = "Bitrate";
|
||||
"Cancel" = "Cancel";
|
||||
"Country (optional)" = "Country (optional)";
|
||||
"Favorites" = "Favorites";
|
||||
"Germany" = "Germany";
|
||||
"Folder name" = "Folder name";
|
||||
"Folders" = "Folders";
|
||||
"Last played" = "Last played";
|
||||
"Library" = "Library";
|
||||
"Live radio" = "Live radio";
|
||||
"Load a station page or search worldwide." = "Load a station page or search worldwide.";
|
||||
"Manual" = "Manual";
|
||||
"Menu Bar" = "Menu Bar";
|
||||
"Name" = "Name";
|
||||
"Netherlands" = "Netherlands";
|
||||
"No station selected" = "No station selected";
|
||||
"No stations loaded yet" = "No stations loaded yet";
|
||||
"Pause" = "Pause";
|
||||
"Paused" = "Paused";
|
||||
"Play" = "Play";
|
||||
"Play/Pause" = "Play/Pause";
|
||||
"Playback" = "Playback";
|
||||
"Playing" = "Playing";
|
||||
"Quit BearWave" = "Quit BearWave";
|
||||
"Radio Browser" = "Radio Browser";
|
||||
"Recent" = "Recent";
|
||||
"Rename folder" = "Rename folder";
|
||||
"Remove Favorite" = "Remove Favorite";
|
||||
"Resume" = "Resume";
|
||||
"Resume last station on launch" = "Resume last station on launch";
|
||||
"Search" = "Search";
|
||||
"Search Results" = "Search Results";
|
||||
"Search stations, genre, country" = "Search stations, genre, country";
|
||||
"Show BearWave" = "Show BearWave";
|
||||
"Show playback details in menu bar" = "Show playback details in menu bar";
|
||||
"Buffering..." = "Buffering...";
|
||||
"Connecting..." = "Connecting...";
|
||||
"Failed" = "Failed";
|
||||
"Stopped" = "Stopped";
|
||||
"Stream failed" = "Stream failed";
|
||||
"Unsupported stream state" = "Unsupported stream state";
|
||||
"Sort" = "Sort";
|
||||
"Stations" = "Stations";
|
||||
"Stop" = "Stop";
|
||||
"Stream URL (http/https)" = "Stream URL (http/https)";
|
||||
"Top" = "Top";
|
||||
"Votes" = "Votes";
|
||||
"World" = "World";
|
||||
"New Folder" = "New Folder";
|
||||
"Create new folder" = "Create new folder";
|
||||
"Add to Folder" = "Add to Folder";
|
||||
158
Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift
Normal file
158
Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import os
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NowPlayingArtworkResolver {
|
||||
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Artwork")
|
||||
private let cacheDirectory: URL
|
||||
private var memoryCache: [String: NSImage] = [:]
|
||||
private var inFlight: Set<String> = []
|
||||
private let faviconLoader: StationArtworkLoader
|
||||
var onArtworkLoaded: (() -> Void)?
|
||||
|
||||
init(cacheDirectory: URL, faviconLoader: StationArtworkLoader) {
|
||||
self.cacheDirectory = cacheDirectory.appendingPathComponent("now_playing_artwork")
|
||||
self.faviconLoader = faviconLoader
|
||||
}
|
||||
|
||||
func resolve(artist: String?, title: String?, station: RadioStation?) -> NSImage {
|
||||
let cacheKey = artworkCacheKey(artist: artist, title: title, station: station)
|
||||
|
||||
if let cached = memoryCache[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
|
||||
if let diskImage = NSImage(contentsOf: diskCacheURL(for: cacheKey)) {
|
||||
memoryCache[cacheKey] = diskImage
|
||||
return diskImage
|
||||
}
|
||||
|
||||
if let station {
|
||||
let favicon = faviconLoader.image(for: station) ?? BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
memoryCache[cacheKey] = favicon
|
||||
return favicon
|
||||
}
|
||||
|
||||
return BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
}
|
||||
|
||||
func fetchArtwork(artist: String?, title: String?, station: RadioStation?) {
|
||||
logger.info("fetchArtwork called: artist='\(artist ?? "nil")', title='\(title ?? "nil")'")
|
||||
guard let artist, let title, !artist.isEmpty, !title.isEmpty else {
|
||||
logger.info("guard failed: empty artist or title")
|
||||
return
|
||||
}
|
||||
|
||||
let cacheKey = artworkCacheKey(artist: artist, title: title, station: station)
|
||||
guard !inFlight.contains(cacheKey) else {
|
||||
logger.info("already in flight for key: \(cacheKey)")
|
||||
return
|
||||
}
|
||||
inFlight.insert(cacheKey)
|
||||
|
||||
Task {
|
||||
defer {
|
||||
Task { @MainActor in self.inFlight.remove(cacheKey) }
|
||||
}
|
||||
|
||||
let query = "\(artist) \(title)".trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
logger.info("iTunes search query: '\(query)'")
|
||||
let searchURL = Self.buildITunesSearchURL(query: query)
|
||||
guard let searchURL else {
|
||||
logger.info("failed to build search URL")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.timeoutInterval = 8
|
||||
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||
logger.info("search request failed: \(response)")
|
||||
return
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let itunesResponse = try decoder.decode(ITunesResponse.self, from: data)
|
||||
logger.info("iTunes resultCount: \(itunesResponse.results.count)")
|
||||
guard let result = itunesResponse.results.first,
|
||||
var artworkURL = URL(string: result.artworkUrl100) else {
|
||||
logger.info("no artwork URL in response")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("artwork URL: \(artworkURL.absoluteString)")
|
||||
artworkURL = Self.highResArtworkURL(artworkURL)
|
||||
|
||||
var imageRequest = URLRequest(url: artworkURL)
|
||||
imageRequest.timeoutInterval = 8
|
||||
let (imageData, imageResponse) = try await URLSession.shared.data(for: imageRequest)
|
||||
guard let httpImage = imageResponse as? HTTPURLResponse,
|
||||
(200..<300).contains(httpImage.statusCode) else {
|
||||
logger.info("image download failed: \(imageResponse)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = NSImage(data: imageData) else {
|
||||
logger.info("NSImage(data:) failed, data size: \(imageData.count)")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("image loaded, size: \(String(describing: image.size))")
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try? imageData.write(to: diskCacheURL(for: cacheKey), options: .atomic)
|
||||
|
||||
await MainActor.run {
|
||||
memoryCache[cacheKey] = image
|
||||
logger.info("artwork cached and callback triggered")
|
||||
self.onArtworkLoaded?()
|
||||
}
|
||||
} catch {
|
||||
logger.info("error: \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func artworkCacheKey(artist: String?, title: String?, station: RadioStation?) -> String {
|
||||
let artistPart = artist?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let titlePart = title?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let key = "\(artistPart)|\(titlePart)"
|
||||
logger.info("cacheKey: '\(key)'")
|
||||
return key
|
||||
}
|
||||
|
||||
private func diskCacheURL(for key: String) -> URL {
|
||||
let digest = SHA256.hash(data: Data(key.utf8))
|
||||
let hash = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return cacheDirectory.appendingPathComponent("\(hash).image")
|
||||
}
|
||||
|
||||
private static func buildITunesSearchURL(query: String) -> URL? {
|
||||
var components = URLComponents(string: "https://itunes.apple.com/search")
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "term", value: query),
|
||||
URLQueryItem(name: "media", value: "music"),
|
||||
URLQueryItem(name: "limit", value: "1")
|
||||
]
|
||||
return components?.url
|
||||
}
|
||||
|
||||
private static func highResArtworkURL(_ url: URL) -> URL {
|
||||
var urlString = url.absoluteString
|
||||
urlString = urlString.replacingOccurrences(of: "100x100", with: "600x600")
|
||||
return URL(string: urlString) ?? url
|
||||
}
|
||||
}
|
||||
|
||||
private struct ITunesResponse: Codable {
|
||||
struct Result: Codable {
|
||||
let artworkUrl100: String
|
||||
}
|
||||
let results: [Result]
|
||||
}
|
||||
466
Sources/BearWaveCore/Services/PlaybackController.swift
Normal file
466
Sources/BearWaveCore/Services/PlaybackController.swift
Normal file
@@ -0,0 +1,466 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Combine
|
||||
@preconcurrency import MediaPlayer
|
||||
import Observation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class PlaybackController {
|
||||
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Playback")
|
||||
private let player = AVPlayer()
|
||||
private let artworkProvider: (RadioStation?, String?, String?) -> NSImage
|
||||
var onMetadataChanged: ((String?, String?, RadioStation?) -> Void)?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempts = 0
|
||||
private var lastURL: URL?
|
||||
private var lastStation: RadioStation?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
private var itemCancellables: Set<AnyCancellable> = []
|
||||
private var metadataHandler: MetadataHandler?
|
||||
|
||||
var state: PlaybackStatus = .stopped
|
||||
public var isPlaying: Bool { state.isPlaying }
|
||||
var currentStation: RadioStation?
|
||||
var currentTrackTitle = ""
|
||||
var currentTrackArtist = ""
|
||||
var currentArtwork: NSImage = BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
var currentTime: TimeInterval = 0
|
||||
var duration: TimeInterval = 0
|
||||
var volume: Double = 0.55 {
|
||||
didSet {
|
||||
player.volume = Float(min(max(volume, 0), 1))
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
init(artworkProvider: @escaping (RadioStation?, String?, String?) -> NSImage = { station, _, _ in
|
||||
BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
}) {
|
||||
self.artworkProvider = artworkProvider
|
||||
player.volume = Float(volume)
|
||||
configureRemoteCommands()
|
||||
observePlaybackNotifications()
|
||||
observePlayer()
|
||||
configureTimeObserver()
|
||||
}
|
||||
|
||||
func play(_ station: RadioStation) {
|
||||
guard let url = URL(string: station.playbackURLString) else { return }
|
||||
retryTask?.cancel()
|
||||
retryAttempts = 0
|
||||
lastURL = url
|
||||
lastStation = station
|
||||
currentStation = station
|
||||
currentTrackTitle = ""
|
||||
currentTrackArtist = ""
|
||||
state = .connecting
|
||||
itemCancellables.removeAll()
|
||||
|
||||
let item = AVPlayerItem(url: url)
|
||||
|
||||
let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil)
|
||||
let handler = MetadataHandler { [weak self] artist, title in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var changed = false
|
||||
if let title, title != self.currentTrackTitle {
|
||||
self.currentTrackTitle = title
|
||||
changed = true
|
||||
}
|
||||
if let artist, artist != self.currentTrackArtist {
|
||||
self.currentTrackArtist = artist
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
self.onMetadataChanged?(self.currentTrackArtist, self.currentTrackTitle, self.currentStation)
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
metadataOutput.setDelegate(handler, queue: .main)
|
||||
item.add(metadataOutput)
|
||||
metadataHandler = handler
|
||||
|
||||
observeItem(item)
|
||||
player.replaceCurrentItem(with: item)
|
||||
player.play()
|
||||
setInitialArtwork()
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
if let station = lastStation {
|
||||
play(station)
|
||||
} else {
|
||||
player.play()
|
||||
state = .connecting
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
state = .paused
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
retryTask?.cancel()
|
||||
player.pause()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
state = .stopped
|
||||
currentStation = nil
|
||||
currentTrackTitle = ""
|
||||
currentTrackArtist = ""
|
||||
currentArtwork = BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
itemCancellables.removeAll()
|
||||
metadataHandler = nil
|
||||
onMetadataChanged?(nil, nil, nil)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
||||
}
|
||||
|
||||
public func togglePlayPause() {
|
||||
isPlaying ? pause() : resume()
|
||||
}
|
||||
|
||||
private var timeObserverToken: Any?
|
||||
|
||||
func configureTimeObserver() {
|
||||
let interval = CMTime(seconds: 1, preferredTimescale: 1)
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let currentSeconds = time.seconds
|
||||
guard currentSeconds.isFinite, currentSeconds >= 0 else { return }
|
||||
|
||||
self.currentTime = currentSeconds
|
||||
let itemDuration = self.player.currentItem?.duration
|
||||
if let itemDuration, itemDuration.timescale > 0, itemDuration.value > 0 {
|
||||
let dur = CMTimeGetSeconds(itemDuration)
|
||||
if dur.isFinite, dur > 0 {
|
||||
self.duration = dur
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observePlaybackNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
let error = (notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError)
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let resolvedError = error ?? self.player.currentItem?.error as NSError?
|
||||
let playbackError = Self.classifyError(resolvedError)
|
||||
self.logger.info("playback failed: \(playbackError.title) – \(playbackError.detail)")
|
||||
self.state = .failed(playbackError)
|
||||
if playbackError.isRetryable {
|
||||
self.scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.state = .buffering
|
||||
self.scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observePlayer() {
|
||||
player.publisher(for: \.timeControlStatus)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
Task { @MainActor in self?.handleTimeControlStatus(status) }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func observeItem(_ item: AVPlayerItem) {
|
||||
item.publisher(for: \.status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self, weak item] status in
|
||||
Task { @MainActor in self?.handleItemStatus(status, item: item) }
|
||||
}
|
||||
.store(in: &itemCancellables)
|
||||
}
|
||||
|
||||
private func handleItemStatus(_ status: AVPlayerItem.Status, item: AVPlayerItem?) {
|
||||
switch status {
|
||||
case .unknown:
|
||||
state = .connecting
|
||||
case .readyToPlay:
|
||||
if player.timeControlStatus == .playing {
|
||||
state = .playing
|
||||
} else {
|
||||
state = .buffering
|
||||
}
|
||||
case .failed:
|
||||
let error = Self.classifyError(item?.error as NSError?)
|
||||
logger.info("item failed: \(error.title) – \(error.detail)")
|
||||
state = .failed(error)
|
||||
if error.isRetryable {
|
||||
scheduleRetry()
|
||||
}
|
||||
@unknown default:
|
||||
state = .failed(.unknown(String(localized: "Unsupported stream state")))
|
||||
}
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
|
||||
guard currentStation != nil else { return }
|
||||
switch status {
|
||||
case .paused:
|
||||
if state != .stopped, !isFailed {
|
||||
state = .paused
|
||||
}
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
if !isFailed {
|
||||
state = player.currentItem?.status == .unknown ? .connecting : .buffering
|
||||
}
|
||||
case .playing:
|
||||
state = .playing
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
guard retryAttempts < 2, let url = lastURL else { return }
|
||||
if case let .failed(error) = state, !error.isRetryable {
|
||||
logger.info("skipping retry: error is not retryable")
|
||||
return
|
||||
}
|
||||
retryAttempts += 1
|
||||
logger.info("scheduling retry attempt \(self.retryAttempts)")
|
||||
retryTask?.cancel()
|
||||
retryTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
let item = AVPlayerItem(url: url)
|
||||
self.itemCancellables.removeAll()
|
||||
self.observeItem(item)
|
||||
self.state = .connecting
|
||||
self.player.replaceCurrentItem(with: item)
|
||||
self.player.play()
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureRemoteCommands() {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.resume() }
|
||||
return .success
|
||||
}
|
||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.pause() }
|
||||
return .success
|
||||
}
|
||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.togglePlayPause() }
|
||||
return .success
|
||||
}
|
||||
commandCenter.stopCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.stop() }
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
func refreshNowPlaying() {
|
||||
logger.info("refreshNowPlaying called")
|
||||
currentArtwork = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
public func updateNowPlayingExternally() {
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func setInitialArtwork() {
|
||||
currentArtwork = artworkProvider(currentStation, nil, nil)
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo()
|
||||
MPNowPlayingInfoCenter.default().playbackState = state == .playing ? .playing : .paused
|
||||
}
|
||||
|
||||
private func nowPlayingInfo() -> [String: Any] {
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: currentTrackTitle.isEmpty ? currentStation?.name ?? "BearWave" : currentTrackTitle,
|
||||
MPMediaItemPropertyAlbumTitle: currentStation?.name ?? "BearWave",
|
||||
MPNowPlayingInfoPropertyPlaybackRate: state == .playing ? 1.0 : 0.0
|
||||
]
|
||||
if !currentTrackArtist.isEmpty {
|
||||
info[MPMediaItemPropertyArtist] = currentTrackArtist
|
||||
}
|
||||
if let artwork = nowPlayingArtwork() {
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
private func nowPlayingArtwork() -> MPMediaItemArtwork? {
|
||||
let image = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
|
||||
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
|
||||
let size = image.size
|
||||
return MPMediaItemArtwork(boundsSize: size, requestHandler: { @Sendable requestedSize in
|
||||
NSImage(cgImage: cgImage, size: requestedSize)
|
||||
})
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func classifyError(_ error: NSError?) -> PlaybackError {
|
||||
guard let error else { return .unknown(String(localized: "Stream failed")) }
|
||||
|
||||
let domain = error.domain
|
||||
let code = error.code
|
||||
|
||||
if domain == NSURLErrorDomain {
|
||||
switch code {
|
||||
case NSURLErrorTimedOut:
|
||||
return .connectionTimeout
|
||||
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
|
||||
return .dnsResolutionFailed
|
||||
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost:
|
||||
return .stationOffline
|
||||
case NSURLErrorUnsupportedURL, NSURLErrorFileDoesNotExist:
|
||||
return .codecNotSupported
|
||||
default:
|
||||
if (500...599).contains(code) {
|
||||
return .httpError(code)
|
||||
}
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
if domain == "CoreMediaErrorDomain" {
|
||||
switch code {
|
||||
case -12785:
|
||||
return .streamInterrupted
|
||||
case -12935, -16042, -15697:
|
||||
return .codecNotSupported
|
||||
default:
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
if domain == "AVFoundationErrorDomain" {
|
||||
switch code {
|
||||
case -11800, -11828, -11838:
|
||||
return .codecNotSupported
|
||||
default:
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
if (400...599).contains(code) {
|
||||
return .httpError(code)
|
||||
}
|
||||
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
final class MetadataHandler: NSObject, AVPlayerItemMetadataOutputPushDelegate {
|
||||
private let onMetadata: @Sendable (String?, String?) -> Void
|
||||
|
||||
init(onMetadata: @escaping @Sendable (String?, String?) -> Void) {
|
||||
self.onMetadata = onMetadata
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) {
|
||||
guard let items = groups.first?.items else { return }
|
||||
let parsed = parseMetadataItems(items)
|
||||
onMetadata(parsed.artist, parsed.title)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseMetadataItems(_ items: [AVMetadataItem]) -> (artist: String?, title: String?) {
|
||||
var artist: String?
|
||||
var title: String?
|
||||
|
||||
for item in items {
|
||||
let key = metadataKey(for: item)
|
||||
let value = metadataStringValue(for: item)
|
||||
guard let value, !value.isEmpty else { continue }
|
||||
|
||||
if key.contains("artist") || key.contains("author") {
|
||||
artist = value
|
||||
} else if key.contains("title") || key.contains("song") {
|
||||
if let split = splitArtistTitle(value) {
|
||||
artist = artist ?? split.artist
|
||||
title = split.title
|
||||
} else {
|
||||
title = value
|
||||
}
|
||||
} else if artist == nil && title == nil, let split = splitArtistTitle(value) {
|
||||
artist = split.artist
|
||||
title = split.title
|
||||
}
|
||||
}
|
||||
|
||||
return (artist, title)
|
||||
}
|
||||
|
||||
private func metadataKey(for item: AVMetadataItem) -> String {
|
||||
let common = item.commonKey?.rawValue ?? ""
|
||||
let identifier = item.identifier?.rawValue ?? ""
|
||||
let key = item.key as? String ?? ""
|
||||
return [common, identifier, key].joined(separator: " ").lowercased()
|
||||
}
|
||||
|
||||
private func metadataStringValue(for item: AVMetadataItem) -> String? {
|
||||
if let stringValue = item.stringValue {
|
||||
return cleanMetadataString(stringValue)
|
||||
}
|
||||
if let value = item.value as? String {
|
||||
return cleanMetadataString(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cleanMetadataString(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "StreamTitle=", with: "")
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " ';\""))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func splitArtistTitle(_ value: String) -> (artist: String, title: String)? {
|
||||
let separators = [" - ", " – ", " — "]
|
||||
for separator in separators {
|
||||
let parts = value.components(separatedBy: separator)
|
||||
guard parts.count >= 2 else { continue }
|
||||
let artist = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = parts.dropFirst().joined(separator: separator).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !artist.isEmpty, !title.isEmpty {
|
||||
return (artist, title)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
204
Sources/BearWaveCore/Services/RadioBrowserClient.swift
Normal file
204
Sources/BearWaveCore/Services/RadioBrowserClient.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct RadioBrowserClient: Sendable {
|
||||
var baseURL: URL
|
||||
var cacheDirectory: URL
|
||||
var session: URLSessionProtocol
|
||||
|
||||
init(
|
||||
baseURL: URL = URL(string: "https://de1.api.radio-browser.info/json")!,
|
||||
cacheDirectory: URL,
|
||||
session: URLSessionProtocol = URLSession.shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.cacheDirectory = cacheDirectory
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func topStations(count: Int = 100) async throws -> [RadioStation] {
|
||||
try await request("/stations/topvote/\(count)")
|
||||
}
|
||||
|
||||
func germanStations() async throws -> [RadioStation] {
|
||||
try await request("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true")
|
||||
}
|
||||
|
||||
func dutchStations() async throws -> [RadioStation] {
|
||||
try await request("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true")
|
||||
}
|
||||
|
||||
func worldStations(count: Int = 200) async throws -> [RadioStation] {
|
||||
try await request("/stations?hidebroken=true&limit=\(count)&order=votes&reverse=true")
|
||||
}
|
||||
|
||||
func stations(tag: String) async throws -> [RadioStation] {
|
||||
try await request("/stations/bytag/\(Self.percentEncodePathComponent(tag))")
|
||||
}
|
||||
|
||||
func stations(countryCode: String) async throws -> [RadioStation] {
|
||||
try await request("/stations/bycountrycodeexact/\(countryCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased())")
|
||||
}
|
||||
|
||||
func search(_ query: String) async throws -> [RadioStation] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidates = searchCandidates(for: trimmed)
|
||||
var firstError: Error?
|
||||
var combined: [RadioStation] = []
|
||||
var seenIDs = Set<String>()
|
||||
|
||||
for candidate in candidates {
|
||||
do {
|
||||
let stations = try await request(searchEndpoint(name: candidate))
|
||||
for station in stations {
|
||||
if seenIDs.insert(station.id).inserted {
|
||||
combined.append(station)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if firstError == nil {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ranked = rankSearchResults(combined, query: trimmed)
|
||||
if !ranked.isEmpty {
|
||||
return ranked
|
||||
}
|
||||
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
func request(_ endpoint: String) async throws -> [RadioStation] {
|
||||
let cacheFile = cacheURL(for: endpoint)
|
||||
let requestURL = URL(string: baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + endpoint)!
|
||||
|
||||
do {
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
let stations = try Self.decodeStations(from: data)
|
||||
if !stations.isEmpty {
|
||||
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try data.write(to: cacheFile, options: .atomic)
|
||||
}
|
||||
return stations
|
||||
} catch {
|
||||
if let cachedData = try? Data(contentsOf: cacheFile) {
|
||||
return try Self.decodeStations(from: cachedData)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func cacheURL(for endpoint: String) -> URL {
|
||||
let digest = Insecure.MD5.hash(data: Data(endpoint.utf8))
|
||||
let hash = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return cacheDirectory.appendingPathComponent("\(hash).json")
|
||||
}
|
||||
|
||||
static func decodeStations(from data: Data) throws -> [RadioStation] {
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode([RadioStation].self, from: data)
|
||||
.filter { !$0.name.isEmpty && !$0.playbackURLString.isEmpty }
|
||||
}
|
||||
|
||||
static func percentEncodePathComponent(_ value: String) -> String {
|
||||
var allowed = CharacterSet.urlPathAllowed
|
||||
allowed.remove(charactersIn: "/?#[]@!$&'()*+,;=")
|
||||
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
||||
}
|
||||
|
||||
private func searchEndpoint(name: String) -> String {
|
||||
var components = URLComponents()
|
||||
components.path = "/stations/search"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "name", value: name),
|
||||
URLQueryItem(name: "hidebroken", value: "true"),
|
||||
URLQueryItem(name: "limit", value: "200"),
|
||||
URLQueryItem(name: "order", value: "votes"),
|
||||
URLQueryItem(name: "reverse", value: "true")
|
||||
]
|
||||
return components.string ?? "/stations/byname/\(Self.percentEncodePathComponent(name))"
|
||||
}
|
||||
|
||||
private func searchCandidates(for query: String) -> [String] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
|
||||
var candidates = [query]
|
||||
let punctuation = CharacterSet.punctuationCharacters
|
||||
if query.rangeOfCharacter(from: punctuation) == nil {
|
||||
candidates.insert("\(query)!", at: 0)
|
||||
candidates.append("\(query).")
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return candidates.filter { candidate in
|
||||
let key = candidate.lowercased()
|
||||
if seen.contains(key) { return false }
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func rankSearchResults(_ stations: [RadioStation], query: String) -> [RadioStation] {
|
||||
let normalizedQuery = Self.normalizedSearchText(query)
|
||||
guard !normalizedQuery.isEmpty else { return stations }
|
||||
|
||||
return stations
|
||||
.map { station -> (RadioStation, Int) in
|
||||
let normalizedName = Self.normalizedSearchText(station.name)
|
||||
let nameTokens = Self.normalizedSearchTokens(station.name)
|
||||
let allTokens = Self.normalizedSearchTokens(station.searchableText)
|
||||
let score: Int
|
||||
if normalizedName == normalizedQuery {
|
||||
score = 0
|
||||
} else if nameTokens.contains(normalizedQuery) {
|
||||
score = 1
|
||||
} else if nameTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
|
||||
score = 2
|
||||
} else if allTokens.contains(normalizedQuery) {
|
||||
score = 3
|
||||
} else if allTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
|
||||
score = 4
|
||||
} else {
|
||||
score = 5
|
||||
}
|
||||
return (station, score)
|
||||
}
|
||||
.filter { $0.1 < 5 }
|
||||
.sorted {
|
||||
if $0.1 != $1.1 { return $0.1 < $1.1 }
|
||||
if $0.0.votes != $1.0.votes { return $0.0.votes > $1.0.votes }
|
||||
return $0.0.name.localizedCaseInsensitiveCompare($1.0.name) == .orderedAscending
|
||||
}
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
static func normalizedSearchText(_ value: String) -> String {
|
||||
value
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.joined()
|
||||
}
|
||||
|
||||
static func normalizedSearchTokens(_ value: String) -> [String] {
|
||||
value
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
protocol URLSessionProtocol: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: URLSessionProtocol {}
|
||||
78
Sources/BearWaveCore/Services/StationArtworkLoader.swift
Normal file
78
Sources/BearWaveCore/Services/StationArtworkLoader.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class StationArtworkLoader {
|
||||
private let cacheDirectory: URL
|
||||
private var memoryCache: [String: NSImage] = [:]
|
||||
private var inFlight: Set<String> = []
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
self.cacheDirectory = cacheDirectory.appendingPathComponent("station_artwork")
|
||||
}
|
||||
|
||||
func image(for station: RadioStation?) -> NSImage? {
|
||||
guard let urlString = normalizedArtworkURLString(for: station) else {
|
||||
return BearWaveAssets.image(named: "bearwave")
|
||||
}
|
||||
if let cached = memoryCache[urlString] {
|
||||
return cached
|
||||
}
|
||||
if let diskImage = NSImage(contentsOf: cacheURL(for: urlString)) {
|
||||
memoryCache[urlString] = diskImage
|
||||
return diskImage
|
||||
}
|
||||
load(urlString)
|
||||
return BearWaveAssets.image(named: "bearwave")
|
||||
}
|
||||
|
||||
private func load(_ urlString: String) {
|
||||
guard !inFlight.contains(urlString), let url = URL(string: urlString) else { return }
|
||||
inFlight.insert(urlString)
|
||||
|
||||
Task {
|
||||
defer {
|
||||
Task { @MainActor in self.inFlight.remove(urlString) }
|
||||
}
|
||||
do {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 10
|
||||
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
let image = NSImage(data: data) else { return }
|
||||
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try? data.write(to: cacheURL(for: urlString), options: .atomic)
|
||||
|
||||
await MainActor.run {
|
||||
memoryCache[urlString] = image
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedArtworkURLString(for station: RadioStation?) -> String? {
|
||||
guard let station else { return nil }
|
||||
let raw = station.favicon.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else { return nil }
|
||||
if raw.hasPrefix("//") {
|
||||
return "https:\(raw)"
|
||||
}
|
||||
if raw.hasPrefix("http://") || raw.hasPrefix("https://") {
|
||||
return raw
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cacheURL(for urlString: String) -> URL {
|
||||
let digest = SHA256.hash(data: Data(urlString.utf8))
|
||||
let hash = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return cacheDirectory.appendingPathComponent("\(hash).image")
|
||||
}
|
||||
}
|
||||
130
Sources/BearWaveCore/Stores/PersistenceStore.swift
Normal file
130
Sources/BearWaveCore/Stores/PersistenceStore.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
public struct PersistenceStore: Sendable {
|
||||
let applicationSupportDirectory: URL
|
||||
let cacheDirectory: URL
|
||||
let linuxConfigDirectory: URL
|
||||
|
||||
public init(
|
||||
applicationSupportDirectory: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("BearWave"),
|
||||
cacheDirectory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("BearWave"),
|
||||
linuxConfigDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/bearwave")
|
||||
) {
|
||||
self.applicationSupportDirectory = applicationSupportDirectory
|
||||
self.cacheDirectory = cacheDirectory
|
||||
self.linuxConfigDirectory = linuxConfigDirectory
|
||||
}
|
||||
|
||||
var apiCacheDirectory: URL {
|
||||
cacheDirectory.appendingPathComponent("api_cache")
|
||||
}
|
||||
|
||||
func bootstrapImportIfNeeded() {
|
||||
if !FileManager.default.fileExists(atPath: favoritesURL.path) {
|
||||
copyIfPresent(from: linuxConfigDirectory.appendingPathComponent("favorites.json"), to: favoritesURL)
|
||||
}
|
||||
if !FileManager.default.fileExists(atPath: stateURL.path) {
|
||||
copyIfPresent(from: linuxConfigDirectory.appendingPathComponent("state.json"), to: stateURL)
|
||||
}
|
||||
}
|
||||
|
||||
func loadFavorites() -> [RadioStation] {
|
||||
load([RadioStation].self, from: favoritesURL) ?? []
|
||||
}
|
||||
|
||||
func saveFavorites(_ stations: [RadioStation]) {
|
||||
save(stations, to: favoritesURL)
|
||||
}
|
||||
|
||||
func loadManualStations() -> [RadioStation] {
|
||||
load([RadioStation].self, from: manualURL) ?? []
|
||||
}
|
||||
|
||||
func saveManualStations(_ stations: [RadioStation]) {
|
||||
save(stations, to: manualURL)
|
||||
}
|
||||
|
||||
func loadGroups() -> [StationGroup] {
|
||||
load([StationGroup].self, from: groupsURL) ?? []
|
||||
}
|
||||
|
||||
func saveGroups(_ groups: [StationGroup]) {
|
||||
save(groups, to: groupsURL)
|
||||
}
|
||||
|
||||
func loadPlaybackState() -> PlaybackState {
|
||||
load(PlaybackState.self, from: stateURL) ?? PlaybackState()
|
||||
}
|
||||
|
||||
func savePlaybackState(_ state: PlaybackState) {
|
||||
save(state, to: stateURL)
|
||||
}
|
||||
|
||||
private var favoritesURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("favorites.json")
|
||||
}
|
||||
|
||||
private var manualURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("manual-stations.json")
|
||||
}
|
||||
|
||||
private var stateURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("state.json")
|
||||
}
|
||||
|
||||
private var groupsURL: URL {
|
||||
applicationSupportDirectory.appendingPathComponent("groups.json")
|
||||
}
|
||||
|
||||
private func load<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
private func save<T: Encodable>(_ value: T, to url: URL) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: applicationSupportDirectory, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder.prettyBearWave.encode(value)
|
||||
try data.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
assertionFailure("Failed to save \(url.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func copyIfPresent(from source: URL, to destination: URL) {
|
||||
guard FileManager.default.fileExists(atPath: source.path) else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
} catch {
|
||||
assertionFailure("Failed to import \(source.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackState: Codable, Equatable, Sendable {
|
||||
var lastStationName: String
|
||||
var lastStationURL: String
|
||||
var volume: Double
|
||||
var recentStations: [RadioStation]
|
||||
|
||||
init(
|
||||
lastStationName: String = "",
|
||||
lastStationURL: String = "",
|
||||
volume: Double = 0.55,
|
||||
recentStations: [RadioStation] = []
|
||||
) {
|
||||
self.lastStationName = lastStationName
|
||||
self.lastStationURL = lastStationURL
|
||||
self.volume = volume
|
||||
self.recentStations = recentStations
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettyBearWave: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
37
Sources/BearWaveCore/Stores/SettingsStore.swift
Normal file
37
Sources/BearWaveCore/Stores/SettingsStore.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class SettingsStore {
|
||||
var radioBrowserBaseURL: String {
|
||||
didSet { defaults.set(radioBrowserBaseURL, forKey: Keys.radioBrowserBaseURL) }
|
||||
}
|
||||
|
||||
var resumeOnLaunch: Bool {
|
||||
didSet { defaults.set(resumeOnLaunch, forKey: Keys.resumeOnLaunch) }
|
||||
}
|
||||
|
||||
var showMenuBarExtraDetails: Bool {
|
||||
didSet { defaults.set(showMenuBarExtraDetails, forKey: Keys.showMenuBarExtraDetails) }
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
radioBrowserBaseURL = defaults.string(forKey: Keys.radioBrowserBaseURL) ?? "https://de1.api.radio-browser.info/json"
|
||||
resumeOnLaunch = defaults.object(forKey: Keys.resumeOnLaunch) as? Bool ?? false
|
||||
showMenuBarExtraDetails = defaults.object(forKey: Keys.showMenuBarExtraDetails) as? Bool ?? true
|
||||
}
|
||||
|
||||
var baseURL: URL {
|
||||
URL(string: radioBrowserBaseURL) ?? URL(string: "https://de1.api.radio-browser.info/json")!
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let radioBrowserBaseURL = "radioBrowserBaseURL"
|
||||
static let resumeOnLaunch = "resumeOnLaunch"
|
||||
static let showMenuBarExtraDetails = "showMenuBarExtraDetails"
|
||||
}
|
||||
}
|
||||
334
Sources/BearWaveCore/Stores/StationLibrary.swift
Normal file
334
Sources/BearWaveCore/Stores/StationLibrary.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class StationLibrary {
|
||||
var selectedPage: StationPage = .top
|
||||
var stations: [RadioStation] = []
|
||||
var favorites: [RadioStation] = []
|
||||
var manualStations: [RadioStation] = []
|
||||
var recentStations: [RadioStation] = []
|
||||
var groups: [StationGroup] = []
|
||||
var selectedGroupID: String?
|
||||
var selectedStationID: RadioStation.ID?
|
||||
var filterQuery: String = ""
|
||||
var sortMode: StationSortMode = .name
|
||||
var isLoading = false
|
||||
var lastError: String?
|
||||
var lastStationName = ""
|
||||
var lastStationURL = ""
|
||||
var volume: Double = 0.55
|
||||
|
||||
private var client: RadioBrowserClient
|
||||
private let persistence: PersistenceStore
|
||||
|
||||
init(client: RadioBrowserClient, persistence: PersistenceStore) {
|
||||
self.client = client
|
||||
self.persistence = persistence
|
||||
persistence.bootstrapImportIfNeeded()
|
||||
favorites = persistence.loadFavorites().map { station in
|
||||
var copy = station
|
||||
copy.isFavorite = true
|
||||
return copy
|
||||
}
|
||||
manualStations = persistence.loadManualStations()
|
||||
groups = persistence.loadGroups().sorted { $0.sortOrder < $1.sortOrder }
|
||||
let state = persistence.loadPlaybackState()
|
||||
lastStationName = state.lastStationName
|
||||
lastStationURL = state.lastStationURL
|
||||
volume = min(max(state.volume, 0), 1)
|
||||
recentStations = Array(state.recentStations.prefix(20))
|
||||
}
|
||||
|
||||
var visibleStations: [RadioStation] {
|
||||
let source: [RadioStation]
|
||||
switch selectedPage {
|
||||
case .favorites:
|
||||
source = favorites
|
||||
case .recent:
|
||||
source = recentStations
|
||||
case .manual:
|
||||
source = manualStations
|
||||
case .group:
|
||||
source = stationsForSelectedGroup()
|
||||
case .top, .germany, .netherlands, .world, .search:
|
||||
source = stations
|
||||
}
|
||||
|
||||
let filtered: [RadioStation]
|
||||
if filterQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
filtered = source
|
||||
} else {
|
||||
let query = filterQuery.lowercased()
|
||||
filtered = source.filter { $0.searchableText.contains(query) }
|
||||
}
|
||||
|
||||
return sorted(filtered)
|
||||
}
|
||||
|
||||
var selectedStation: RadioStation? {
|
||||
guard let selectedStationID else { return visibleStations.first }
|
||||
return visibleStations.first { $0.id == selectedStationID }
|
||||
}
|
||||
|
||||
var canResume: Bool {
|
||||
!lastStationURL.isEmpty
|
||||
}
|
||||
|
||||
func updateBaseURL(_ url: URL) {
|
||||
client.baseURL = url
|
||||
}
|
||||
|
||||
func loadSelectedPage() async {
|
||||
await load(selectedPage)
|
||||
}
|
||||
|
||||
func load(_ page: StationPage) async {
|
||||
selectedPage = page
|
||||
lastError = nil
|
||||
|
||||
switch page {
|
||||
case .favorites, .recent, .manual, .search, .group:
|
||||
selectedStationID = visibleStations.first?.id
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let loaded: [RadioStation]
|
||||
switch page {
|
||||
case .top:
|
||||
loaded = try await client.topStations()
|
||||
case .germany:
|
||||
loaded = try await client.germanStations()
|
||||
case .netherlands:
|
||||
loaded = try await client.dutchStations()
|
||||
case .world:
|
||||
loaded = try await client.worldStations()
|
||||
case .search, .favorites, .recent, .manual, .group:
|
||||
loaded = []
|
||||
}
|
||||
stations = markFavorites(loaded)
|
||||
selectedStationID = visibleStations.first?.id
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func search(_ query: String) async {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 2 else { return }
|
||||
selectedPage = .search
|
||||
filterQuery = ""
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
stations = markFavorites(try await client.search(trimmed))
|
||||
selectedStationID = visibleStations.first?.id
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadTag(_ tag: String) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
stations = markFavorites(try await client.stations(tag: tag))
|
||||
selectedPage = .world
|
||||
selectedStationID = visibleStations.first?.id
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadCountry(_ code: String) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
stations = markFavorites(try await client.stations(countryCode: code))
|
||||
selectedPage = .world
|
||||
selectedStationID = visibleStations.first?.id
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func select(_ station: RadioStation) {
|
||||
selectedStationID = station.id
|
||||
}
|
||||
|
||||
func toggleFavorite(_ station: RadioStation) {
|
||||
if let index = favorites.firstIndex(where: { matches($0, station) }) {
|
||||
favorites.remove(at: index)
|
||||
} else {
|
||||
var copy = station
|
||||
copy.isFavorite = true
|
||||
favorites.append(copy)
|
||||
}
|
||||
favorites = sorted(favorites).map { station in
|
||||
var copy = station
|
||||
copy.isFavorite = true
|
||||
return copy
|
||||
}
|
||||
persistence.saveFavorites(favorites)
|
||||
stations = markFavorites(stations)
|
||||
manualStations = markFavorites(manualStations)
|
||||
}
|
||||
|
||||
func isFavorite(_ station: RadioStation) -> Bool {
|
||||
favorites.contains { matches($0, station) }
|
||||
}
|
||||
|
||||
func addManualStation(name: String, url: String, country: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty, URL(string: cleanURL) != nil else { return }
|
||||
|
||||
let station = RadioStation(
|
||||
uuid: UUID().uuidString,
|
||||
name: cleanName,
|
||||
url: cleanURL,
|
||||
resolvedURL: cleanURL,
|
||||
country: country.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? String(localized: "Manual") : country,
|
||||
codec: "unknown",
|
||||
isManual: true
|
||||
)
|
||||
manualStations.insert(station, at: 0)
|
||||
persistence.saveManualStations(manualStations)
|
||||
selectedPage = .manual
|
||||
selectedStationID = station.id
|
||||
}
|
||||
|
||||
func recordPlayback(_ station: RadioStation) {
|
||||
lastStationName = station.name
|
||||
lastStationURL = station.playbackURLString
|
||||
var normalized = station
|
||||
normalized.isFavorite = isFavorite(station)
|
||||
recentStations.removeAll { matches($0, normalized) }
|
||||
recentStations.insert(normalized, at: 0)
|
||||
recentStations = Array(recentStations.prefix(20))
|
||||
savePlaybackState()
|
||||
}
|
||||
|
||||
func saveVolume(_ volume: Double) {
|
||||
self.volume = min(max(volume, 0), 1)
|
||||
savePlaybackState()
|
||||
}
|
||||
|
||||
func createGroup(name: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty else { return }
|
||||
let maxSort = groups.map { $0.sortOrder }.max() ?? 0
|
||||
let group = StationGroup(name: cleanName, sortOrder: maxSort + 1)
|
||||
groups.append(group)
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
|
||||
func deleteGroup(_ group: StationGroup) {
|
||||
groups.removeAll { $0.id == group.id }
|
||||
persistence.saveGroups(groups)
|
||||
if selectedGroupID == group.id {
|
||||
selectedGroupID = nil
|
||||
selectedPage = .top
|
||||
}
|
||||
}
|
||||
|
||||
func renameGroup(_ group: StationGroup, to name: String) {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty else { return }
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].name = cleanName
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
}
|
||||
|
||||
func addStationToGroup(_ station: RadioStation, group: StationGroup) {
|
||||
guard !group.stationIDs.contains(station.id) else { return }
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].stationIDs.append(station.id)
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
}
|
||||
|
||||
func removeStationFromGroup(_ station: RadioStation, group: StationGroup) {
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].stationIDs.removeAll { $0 == station.id }
|
||||
persistence.saveGroups(groups)
|
||||
}
|
||||
}
|
||||
|
||||
func isStationInGroup(_ station: RadioStation, group: StationGroup) -> Bool {
|
||||
group.stationIDs.contains(station.id)
|
||||
}
|
||||
|
||||
func selectGroup(_ group: StationGroup) {
|
||||
selectedPage = .group
|
||||
selectedGroupID = group.id
|
||||
filterQuery = ""
|
||||
selectedStationID = visibleStations.first?.id
|
||||
}
|
||||
|
||||
private func stationsForSelectedGroup() -> [RadioStation] {
|
||||
guard let group = groups.first(where: { $0.id == selectedGroupID }) else { return [] }
|
||||
let allStations = stations + favorites + manualStations + recentStations
|
||||
var seen = Set<String>()
|
||||
return group.stationIDs.compactMap { id in
|
||||
guard seen.insert(id).inserted else { return nil }
|
||||
return allStations.first { $0.id == id }
|
||||
}.compactMap { $0 }
|
||||
}
|
||||
|
||||
func resumeStation() -> RadioStation? {
|
||||
guard !lastStationURL.isEmpty else { return nil }
|
||||
if let known = (stations + favorites + manualStations + recentStations).first(where: { $0.playbackURLString == lastStationURL }) {
|
||||
return known
|
||||
}
|
||||
return RadioStation(name: lastStationName.isEmpty ? String(localized: "Last played") : lastStationName, url: lastStationURL, resolvedURL: lastStationURL)
|
||||
}
|
||||
|
||||
private func markFavorites(_ source: [RadioStation]) -> [RadioStation] {
|
||||
source.map { station in
|
||||
var copy = station
|
||||
copy.isFavorite = isFavorite(station)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
private func sorted(_ source: [RadioStation]) -> [RadioStation] {
|
||||
switch sortMode {
|
||||
case .name:
|
||||
source.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
case .bitrate:
|
||||
source.sorted { $0.bitrate > $1.bitrate }
|
||||
case .votes:
|
||||
source.sorted { $0.votes > $1.votes }
|
||||
}
|
||||
}
|
||||
|
||||
private func savePlaybackState() {
|
||||
persistence.savePlaybackState(
|
||||
PlaybackState(
|
||||
lastStationName: lastStationName,
|
||||
lastStationURL: lastStationURL,
|
||||
volume: volume,
|
||||
recentStations: recentStations
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func matches(_ lhs: RadioStation, _ rhs: RadioStation) -> Bool {
|
||||
if !lhs.uuid.isEmpty && !rhs.uuid.isEmpty {
|
||||
return lhs.uuid == rhs.uuid
|
||||
}
|
||||
return lhs.playbackURLString == rhs.playbackURLString
|
||||
}
|
||||
}
|
||||
13
Sources/BearWaveCore/Support/BearWaveAssets.swift
Normal file
13
Sources/BearWaveCore/Support/BearWaveAssets.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum BearWaveAssets {
|
||||
static func imageURL(named name: String, extension fileExtension: String = "png") -> URL? {
|
||||
Bundle.module.url(forResource: name, withExtension: fileExtension)
|
||||
}
|
||||
|
||||
static func image(named name: String, extension fileExtension: String = "png") -> NSImage? {
|
||||
guard let url = imageURL(named: name, extension: fileExtension) else { return nil }
|
||||
return NSImage(contentsOf: url)
|
||||
}
|
||||
}
|
||||
206
Sources/BearWaveCore/Views/ContentView.swift
Normal file
206
Sources/BearWaveCore/Views/ContentView.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ContentView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var searchText = ""
|
||||
@State private var lastSubmittedSearch = ""
|
||||
@State private var pendingSearchTask: Task<Void, Never>?
|
||||
@State private var showingManualSheet = false
|
||||
@State private var showingNewFolderSheet = false
|
||||
@Binding var isMiniPlayer: Bool
|
||||
|
||||
public init(isMiniPlayer: Binding<Bool> = .constant(false)) {
|
||||
_isMiniPlayer = isMiniPlayer
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if isMiniPlayer {
|
||||
MiniPlayerView(isMiniPlayer: $isMiniPlayer)
|
||||
} else {
|
||||
fullView
|
||||
}
|
||||
}
|
||||
.navigationTitle("BearWave")
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
scheduleSearch(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var fullView: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
.frame(minWidth: 220, idealWidth: 220, maxWidth: 280)
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
HSplitView {
|
||||
StationListView()
|
||||
.frame(minWidth: 520)
|
||||
StationDetailView()
|
||||
.frame(minWidth: 280, idealWidth: 320, maxWidth: 380)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
PlayerBarView()
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.automatic)
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
Button {
|
||||
showingManualSheet = true
|
||||
} label: {
|
||||
Label("Add Station", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
model.resumeLastStation()
|
||||
} label: {
|
||||
Label("Resume", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!model.library.canResume)
|
||||
Button {
|
||||
showingNewFolderSheet = true
|
||||
} label: {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
Button {
|
||||
isMiniPlayer = true
|
||||
} label: {
|
||||
Label("Mini Player", systemImage: "arrow.down.left.and.arrow.up.right")
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingManualSheet) {
|
||||
ManualStationView()
|
||||
.environment(model)
|
||||
}
|
||||
.sheet(isPresented: $showingNewFolderSheet) {
|
||||
NewGroupView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 9) {
|
||||
Image(nsImage: BearWaveAssets.image(named: "bearwave_line") ?? NSImage())
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 112, height: 32)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("BearWave")
|
||||
.font(.headline.weight(.semibold))
|
||||
Text("Internet Radio")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 220, alignment: .leading)
|
||||
|
||||
Divider()
|
||||
.frame(height: 32)
|
||||
|
||||
TextField("Search stations, genre, country", text: Binding(
|
||||
get: { searchText },
|
||||
set: { newValue in
|
||||
searchText = newValue
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
Task { await submitSearch(searchText) }
|
||||
}
|
||||
.frame(minWidth: 280, idealWidth: 420, maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
Task { await submitSearch(searchText) }
|
||||
} label: {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.disabled(searchText.trimmingCharacters(in: .whitespacesAndNewlines).count < 2)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
lastSubmittedSearch = ""
|
||||
pendingSearchTask?.cancel()
|
||||
model.library.filterQuery = ""
|
||||
Task { await model.library.loadSelectedPage() }
|
||||
} label: {
|
||||
Label("Clear", systemImage: "xmark.circle")
|
||||
}
|
||||
.disabled(searchText.isEmpty)
|
||||
|
||||
if model.library.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Quick")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(["rock", "news", "jazz"], id: \.self) { tag in
|
||||
Button(tag.capitalized) {
|
||||
Task { await model.library.loadTag(tag) }
|
||||
}
|
||||
}
|
||||
Divider().frame(height: 20)
|
||||
ForEach(["US", "GB", "FR"], id: \.self) { country in
|
||||
Button(country) {
|
||||
Task { await model.library.loadCountry(country) }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Text("Sort")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("", selection: Binding(
|
||||
get: { model.library.sortMode },
|
||||
set: { model.library.sortMode = $0 }
|
||||
)) {
|
||||
ForEach(StationSortMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 230)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
private func submitSearch(_ rawQuery: String) async {
|
||||
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard query.count >= 2 else { return }
|
||||
if query == lastSubmittedSearch, !model.library.visibleStations.isEmpty {
|
||||
return
|
||||
}
|
||||
lastSubmittedSearch = query
|
||||
await model.library.search(query)
|
||||
}
|
||||
|
||||
private func scheduleSearch(_ rawQuery: String) {
|
||||
pendingSearchTask?.cancel()
|
||||
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard query.count >= 3 else { return }
|
||||
|
||||
pendingSearchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
await submitSearch(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Sources/BearWaveCore/Views/GroupRowView.swift
Normal file
61
Sources/BearWaveCore/Views/GroupRowView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GroupRowView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let group: StationGroup
|
||||
@State private var showingRenameSheet = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Label(group.name, systemImage: "folder.fill")
|
||||
Spacer()
|
||||
Text(group.stationIDs.count, format: .number)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(group.id)
|
||||
.contextMenu {
|
||||
Button("Rename") {
|
||||
showingRenameSheet = true
|
||||
}
|
||||
Button("Delete") {
|
||||
model.library.deleteGroup(group)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingRenameSheet) {
|
||||
RenameGroupView(group: group)
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RenameGroupView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let group: StationGroup
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Rename folder")
|
||||
.font(.title2.bold())
|
||||
|
||||
TextField("Folder name", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { name = group.name }
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Rename") {
|
||||
model.library.renameGroup(group, to: name)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 340)
|
||||
}
|
||||
}
|
||||
36
Sources/BearWaveCore/Views/ManualStationView.swift
Normal file
36
Sources/BearWaveCore/Views/ManualStationView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ManualStationView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var url = ""
|
||||
@State private var country = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add station manually")
|
||||
.font(.title2.bold())
|
||||
|
||||
TextField("Name", text: $name)
|
||||
TextField("Stream URL (http/https)", text: $url)
|
||||
TextField("Country (optional)", text: $country)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button("Add") {
|
||||
model.library.addManualStation(name: name, url: url, country: country)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || URL(string: url) == nil)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(24)
|
||||
.frame(width: 430)
|
||||
}
|
||||
}
|
||||
51
Sources/BearWaveCore/Views/MenuBarContentView.swift
Normal file
51
Sources/BearWaveCore/Views/MenuBarContentView.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct MenuBarContentView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
openWindow(id: "main")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} label: {
|
||||
Label("Show BearWave", systemImage: "macwindow")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if model.settings.showMenuBarExtraDetails {
|
||||
Text(model.playback.currentStation?.name ?? "BearWave")
|
||||
Text(model.playback.state.title)
|
||||
}
|
||||
|
||||
Button(model.playback.isPlaying ? "Pause" : "Play") {
|
||||
model.playback.togglePlayPause()
|
||||
}
|
||||
.disabled(model.playback.currentStation == nil)
|
||||
|
||||
Button("Resume") {
|
||||
model.resumeLastStation()
|
||||
}
|
||||
.disabled(!model.library.canResume)
|
||||
|
||||
if !model.library.favorites.isEmpty {
|
||||
Divider()
|
||||
ForEach(model.library.favorites.prefix(6)) { station in
|
||||
Button(station.name) {
|
||||
model.play(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit BearWave") {
|
||||
model.quit()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
109
Sources/BearWaveCore/Views/MiniPlayerView.swift
Normal file
109
Sources/BearWaveCore/Views/MiniPlayerView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MiniPlayerView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Binding var isMiniPlayer: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(nsImage: model.playback.currentArtwork)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.playback.currentStation?.name ?? String(localized: "No station selected"))
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(miniPlayerText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(playerDetailColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.playback.togglePlayPause()
|
||||
} label: {
|
||||
Image(systemName: model.playback.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(model.playback.currentStation == nil)
|
||||
|
||||
Button {
|
||||
model.playback.stop()
|
||||
} label: {
|
||||
Image(systemName: "stop.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "speaker.wave.1")
|
||||
.font(.caption)
|
||||
Slider(value: Binding(
|
||||
get: { model.playback.volume },
|
||||
set: { model.setVolume($0) }
|
||||
), in: 0...1)
|
||||
.frame(width: 80)
|
||||
Image(systemName: "speaker.wave.3")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
isMiniPlayer = false
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Show full window")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(height: 110)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var miniPlayerText: String {
|
||||
if let detail = model.playback.state.detail {
|
||||
return "\(model.playback.state.title): \(detail)"
|
||||
}
|
||||
switch model.playback.state {
|
||||
case .connecting, .buffering, .failed:
|
||||
return model.playback.state.title
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty && !model.playback.currentTrackArtist.isEmpty {
|
||||
return "\(model.playback.currentTrackArtist) - \(model.playback.currentTrackTitle)"
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty {
|
||||
return model.playback.currentTrackTitle
|
||||
}
|
||||
if !model.playback.currentTrackArtist.isEmpty {
|
||||
return model.playback.currentTrackArtist
|
||||
}
|
||||
if let station = model.playback.currentStation, !station.tags.isEmpty {
|
||||
return station.tags
|
||||
}
|
||||
return String(localized: "Live radio")
|
||||
}
|
||||
|
||||
private var playerDetailColor: Color {
|
||||
if case .failed = model.playback.state {
|
||||
return .red
|
||||
}
|
||||
if case .connecting = model.playback.state {
|
||||
return .secondary
|
||||
}
|
||||
if case .buffering = model.playback.state {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
32
Sources/BearWaveCore/Views/NewGroupView.swift
Normal file
32
Sources/BearWaveCore/Views/NewGroupView.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewGroupView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Create new folder")
|
||||
.font(.title2.bold())
|
||||
|
||||
TextField("Folder name", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button("Create") {
|
||||
model.library.createGroup(name: name)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 340)
|
||||
}
|
||||
}
|
||||
99
Sources/BearWaveCore/Views/PlayerBarView.swift
Normal file
99
Sources/BearWaveCore/Views/PlayerBarView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerBarView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(nsImage: model.playback.currentArtwork)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 46, height: 46)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.playback.currentStation?.name ?? model.library.selectedStation?.name ?? String(localized: "No station selected"))
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(nowPlayingText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(playerDetailColor)
|
||||
.lineLimit(1)
|
||||
.help(nowPlayingText)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
radioControls
|
||||
|
||||
HStack {
|
||||
Image(systemName: "speaker.wave.1")
|
||||
Slider(value: Binding(
|
||||
get: { model.playback.volume },
|
||||
set: { model.setVolume($0) }
|
||||
), in: 0...1)
|
||||
.frame(width: 150)
|
||||
Image(systemName: "speaker.wave.3")
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var radioControls: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
model.playback.togglePlayPause()
|
||||
} label: {
|
||||
Label(model.playback.isPlaying ? "Pause" : "Play", systemImage: model.playback.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.disabled(model.playback.currentStation == nil)
|
||||
|
||||
Button {
|
||||
model.playback.stop()
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var nowPlayingText: String {
|
||||
if let detail = model.playback.state.detail {
|
||||
return "\(model.playback.state.title): \(detail)"
|
||||
}
|
||||
switch model.playback.state {
|
||||
case .connecting, .buffering, .failed:
|
||||
return model.playback.state.title
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty && !model.playback.currentTrackArtist.isEmpty {
|
||||
return "\(model.playback.currentTrackArtist) - \(model.playback.currentTrackTitle)"
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty {
|
||||
return model.playback.currentTrackTitle
|
||||
}
|
||||
if !model.playback.currentTrackArtist.isEmpty {
|
||||
return model.playback.currentTrackArtist
|
||||
}
|
||||
if let station = model.playback.currentStation, !station.tags.isEmpty {
|
||||
return station.tags
|
||||
}
|
||||
return String(localized: "Live radio")
|
||||
}
|
||||
|
||||
private var playerDetailColor: Color {
|
||||
if case .failed = model.playback.state {
|
||||
return .red
|
||||
}
|
||||
if case .connecting = model.playback.state {
|
||||
return .secondary
|
||||
}
|
||||
if case .buffering = model.playback.state {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
38
Sources/BearWaveCore/Views/SettingsView.swift
Normal file
38
Sources/BearWaveCore/Views/SettingsView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct SettingsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Form {
|
||||
Section("Radio Browser") {
|
||||
TextField("Base URL", text: Binding(
|
||||
get: { model.settings.radioBrowserBaseURL },
|
||||
set: {
|
||||
model.settings.radioBrowserBaseURL = $0
|
||||
model.applySettings()
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
Section("Playback") {
|
||||
Toggle("Resume last station on launch", isOn: Binding(
|
||||
get: { model.settings.resumeOnLaunch },
|
||||
set: { model.settings.resumeOnLaunch = $0 }
|
||||
))
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Toggle("Show playback details in menu bar", isOn: Binding(
|
||||
get: { model.settings.showMenuBarExtraDetails },
|
||||
set: { model.settings.showMenuBarExtraDetails = $0 }
|
||||
))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
79
Sources/BearWaveCore/Views/SidebarView.swift
Normal file
79
Sources/BearWaveCore/Views/SidebarView.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var showingNewFolderSheet = false
|
||||
@State private var selection: String = ""
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Stations") {
|
||||
pageRow(.top)
|
||||
pageRow(.germany)
|
||||
pageRow(.netherlands)
|
||||
pageRow(.world)
|
||||
pageRow(.search, badge: model.library.selectedPage == .search ? model.library.stations.count : nil)
|
||||
}
|
||||
Section("Library") {
|
||||
pageRow(.favorites, badge: model.library.favorites.count)
|
||||
pageRow(.recent, badge: model.library.recentStations.count)
|
||||
pageRow(.manual, badge: model.library.manualStations.count)
|
||||
}
|
||||
if !model.library.groups.isEmpty {
|
||||
Section("Folders") {
|
||||
ForEach(model.library.groups.sorted { $0.sortOrder < $1.sortOrder }) { group in
|
||||
GroupRowView(group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: model.library.selectedPage) { _, _ in updateSelection() }
|
||||
.onChange(of: model.library.selectedGroupID) { _, _ in updateSelection() }
|
||||
.onChange(of: selection) { _, newValue in
|
||||
handleSelectionChange(newValue)
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 280)
|
||||
.contextMenu {
|
||||
Button("New Folder") {
|
||||
showingNewFolderSheet = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewFolderSheet) {
|
||||
NewGroupView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelection() {
|
||||
if model.library.selectedPage == .group {
|
||||
selection = model.library.selectedGroupID ?? ""
|
||||
} else {
|
||||
selection = model.library.selectedPage.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectionChange(_ value: String) {
|
||||
if let group = model.library.groups.first(where: { $0.id == value }) {
|
||||
model.library.selectGroup(group)
|
||||
} else if let page = StationPage(rawValue: value) {
|
||||
Task { await model.library.load(page) }
|
||||
}
|
||||
}
|
||||
|
||||
private func pageRow(_ page: StationPage, badge: Int? = nil) -> some View {
|
||||
HStack {
|
||||
Label(page.title, systemImage: page.systemImage)
|
||||
Spacer()
|
||||
if let badge, badge > 0 {
|
||||
Text(badge, format: .number)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(page.rawValue)
|
||||
}
|
||||
}
|
||||
177
Sources/BearWaveCore/Views/StationDetailView.swift
Normal file
177
Sources/BearWaveCore/Views/StationDetailView.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct StationDetailView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let station = model.library.selectedStation {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header(station)
|
||||
actions(station)
|
||||
if model.playback.currentStation?.id == station.id {
|
||||
nowPlayingSection
|
||||
}
|
||||
technicalSection(station)
|
||||
linksSection(station)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("No station selected", systemImage: "radio")
|
||||
}
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
private func header(_ station: RadioStation) -> some View {
|
||||
let artworkImage: NSImage
|
||||
if model.playback.currentStation?.id == station.id {
|
||||
artworkImage = model.playback.currentArtwork
|
||||
} else {
|
||||
artworkImage = model.artwork.image(for: station) ?? NSImage()
|
||||
}
|
||||
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Image(nsImage: artworkImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(station.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
if !station.country.isEmpty {
|
||||
Text(station.country)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actions(_ station: RadioStation) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
model.play(station)
|
||||
} label: {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
|
||||
Button {
|
||||
model.library.toggleFavorite(station)
|
||||
} label: {
|
||||
Label(model.library.isFavorite(station) ? "Remove Favorite" : "Add Favorite", systemImage: model.library.isFavorite(station) ? "star.fill" : "star")
|
||||
}
|
||||
|
||||
if !model.library.groups.isEmpty {
|
||||
Menu {
|
||||
ForEach(model.library.groups) { group in
|
||||
Button {
|
||||
if model.library.isStationInGroup(station, group: group) {
|
||||
model.library.removeStationFromGroup(station, group: group)
|
||||
} else {
|
||||
model.library.addStationToGroup(station, group: group)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
model.library.isStationInGroup(station, group: group) ? "Remove from \(group.name)" : "Add to \(group.name)",
|
||||
systemImage: model.library.isStationInGroup(station, group: group) ? "checkmark" : "plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Folders", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private var nowPlayingSection: some View {
|
||||
DetailSection(title: "Now Playing") {
|
||||
LabeledContent("Status", value: model.playback.state.title)
|
||||
if case let .failed(error) = model.playback.state {
|
||||
Text(error.detail)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
if !model.playback.currentTrackArtist.isEmpty {
|
||||
LabeledContent("Artist", value: model.playback.currentTrackArtist)
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty {
|
||||
LabeledContent("Title", value: model.playback.currentTrackTitle)
|
||||
}
|
||||
if model.playback.currentTrackArtist.isEmpty && model.playback.currentTrackTitle.isEmpty, !model.playback.state.isPlaying, model.playback.state != .stopped {
|
||||
Text("No stream metadata yet")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func technicalSection(_ station: RadioStation) -> some View {
|
||||
DetailSection(title: "Details") {
|
||||
if !station.codec.isEmpty {
|
||||
LabeledContent("Codec", value: station.codec.uppercased())
|
||||
}
|
||||
if station.bitrate > 0 {
|
||||
LabeledContent("Bitrate", value: "\(station.bitrate) kbps")
|
||||
}
|
||||
if station.votes > 0 {
|
||||
LabeledContent("Votes", value: station.votes.formatted())
|
||||
}
|
||||
if !station.tags.isEmpty {
|
||||
LabeledContent("Tags") {
|
||||
Text(station.tags)
|
||||
.lineLimit(4)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func linksSection(_ station: RadioStation) -> some View {
|
||||
DetailSection(title: "Links") {
|
||||
if let homepage = URL(string: station.homepage), !station.homepage.isEmpty {
|
||||
Button {
|
||||
openURL(homepage)
|
||||
} label: {
|
||||
Label("Open Homepage", systemImage: "safari")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(station.playbackURLString, forType: .string)
|
||||
} label: {
|
||||
Label("Copy Stream URL", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(station.playbackURLString.isEmpty)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailSection<Content: View>: View {
|
||||
let title: LocalizedStringKey
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
content
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Sources/BearWaveCore/Views/StationListView.swift
Normal file
140
Sources/BearWaveCore/Views/StationListView.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StationListView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let error = model.library.lastError {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.08))
|
||||
}
|
||||
|
||||
if model.library.visibleStations.isEmpty && !model.library.isLoading {
|
||||
ContentUnavailableView(
|
||||
"No stations loaded yet",
|
||||
systemImage: "radio",
|
||||
description: Text("Load a station page or search worldwide.")
|
||||
)
|
||||
} else {
|
||||
List(selection: Binding(
|
||||
get: { model.library.selectedStationID },
|
||||
set: { model.library.selectedStationID = $0 }
|
||||
)) {
|
||||
ForEach(model.library.visibleStations) { station in
|
||||
StationRowView(station: station)
|
||||
.tag(station.id)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
model.library.select(station)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(model.library.isFavorite(station) ? "Remove Favorite" : "Add Favorite") {
|
||||
model.library.toggleFavorite(station)
|
||||
}
|
||||
if !model.library.groups.isEmpty {
|
||||
Menu("Add to Folder") {
|
||||
ForEach(model.library.groups) { group in
|
||||
Button(group.name) {
|
||||
model.library.addStationToGroup(station, group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Play") {
|
||||
model.play(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StationRowView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let station: RadioStation
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(nsImage: model.artwork.image(for: station) ?? NSImage())
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 34, height: 34)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Image(systemName: station.isOnline ? "dot.radiowaves.left.and.right" : "exclamationmark.triangle")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(station.isOnline ? Color.secondary : Color.orange)
|
||||
.padding(2)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(station.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if isCurrentStation {
|
||||
Text(model.playback.state.title)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
if !station.country.isEmpty {
|
||||
Text(station.country)
|
||||
}
|
||||
if !station.codec.isEmpty {
|
||||
Text(station.codec.uppercased())
|
||||
}
|
||||
if station.bitrate > 0 {
|
||||
Text("\(station.bitrate) kbps")
|
||||
}
|
||||
if station.votes > 0 {
|
||||
Text("\(station.votes) votes")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.library.toggleFavorite(station)
|
||||
} label: {
|
||||
Image(systemName: model.library.isFavorite(station) ? "star.fill" : "star")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(model.library.isFavorite(station) ? .yellow : .secondary)
|
||||
|
||||
Button {
|
||||
model.play(station)
|
||||
} label: {
|
||||
Image(systemName: "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
|
||||
private var isCurrentStation: Bool {
|
||||
model.playback.currentStation?.id == station.id
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if case .failed = model.playback.state {
|
||||
return .red
|
||||
}
|
||||
if case .buffering = model.playback.state {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
50
TODO.md
Normal file
50
TODO.md
Normal file
@@ -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
|
||||
31
Tests/BearWaveChecks/Fixtures/stations.json
Normal file
31
Tests/BearWaveChecks/Fixtures/stations.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
82
Tests/BearWaveChecks/main.swift
Normal file
82
Tests/BearWaveChecks/main.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
112
script/build_and_run.sh
Executable file
112
script/build_and_run.sh
Executable file
@@ -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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>BearWave</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$MIN_SYSTEM_VERSION</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>de</string>
|
||||
</array>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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
|
||||
Reference in New Issue
Block a user