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