ShoutKit is a native SwiftUI internet-radio client for iOS and iPadOS 26+, with companion apps for watchOS and tvOS. It ships with real, keyless station discovery out of the box via Radio-Browser — a free, open-source community radio directory — plus an Apple Music-style persistent player: a Liquid Glass mini-player docked above the tab bar, a full-screen Now Playing surface with live ICY track metadata, lock-screen/Control Center controls, favorites and recents backed by SwiftData, and Siri/Shortcuts support ("Play KEXP on ShoutKit"). On iPad the tab bar becomes a sidebar and browsing surfaces flow into adaptive multi-column layouts, including Split View and Stage Manager.
- Xcode 27 with the iOS 27 SDK (to build — the MediaSession path needs the iOS 27 SDK to compile, even though it only runs on iOS 27 devices)
- Swift 6 strict concurrency
- iOS / iPadOS 26.0+ deployment target; watchOS 26.0+ and tvOS 26.0+ for the companion apps
If your active developer directory points at Command Line Tools, build with:
DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcodebuild -workspace ShoutKit.xcworkspace -scheme ShoutKit -destination 'generic/platform=iOS Simulator' buildDiscovery works with zero configuration: AppDependencies defaults to
RadioBrowserDirectoryClient, which talks to Radio-Browser's DNS-load-balanced community mirrors
(all.api.radio-browser.info, with named-mirror fallback). Search, genre browsing, and popular
stations all come from there — no API key. Per Radio-Browser etiquette the app sends a descriptive
User-Agent and reports plays (/json/url/{stationuuid}) so the community directory can rank
popularity; only Radio-Browser-sourced stations (UUID ids) are reported.
Every directory is wrapped in PreferredRadioDirectory, so curated stations are guaranteed even
when a directory source omits them — KEXP is bundled using its official 160K/64K AAC stream URLs.
PreviewRadioDirectory is reserved for previews and tests.
Discovery content is cached in two tiers by CachingRadioDirectory. A 60-second in-memory window
coalesces the discovery fetches issued around launch, and every successful fetch is also
written to a small JSON snapshot in Application Support. Landing surfaces paint that snapshot
first, so a launch shows stations immediately rather than a spinner, and within a six-hour
stability window the snapshot is the answer — no directory request at all, and the same list as
the previous launch instead of a reshuffled top-click ranking. Pull-to-refresh and the four-hourly
BGAppRefreshTask both bypass the window and rewrite the snapshot.
The live calls (topStations, genres) never serve the snapshot; the saved copy is reachable only
through the DirectoryDiscoveryCaching seam, so a surface always knows whether it's showing live
or saved content. When the directory can't be reached, saved stations stay on screen with a note
rather than being replaced by an error — the error state is reserved for having nothing to show.
Snapshots are scoped by directory source and geo filter, so travelling (or toggling the
geo-stations flag) refetches instead of serving another region's stations. Favorites and recents
are separately offline-capable via SwiftData; search results and per-genre lists are user-driven
and deliberately not persisted.
To use SHOUTcast's own directory instead of Radio-Browser, supply a developer key. The app reads
SHOUTCAST_DEV_KEY from build settings into Info.plist; do not hard-code this value.
- Copy
ShoutKitApp/Config/Secrets.xcconfig.templatetoShoutKitApp/Config/Secrets.xcconfig. - Set:
SHOUTCAST_DEV_KEY = your_key_here
Secrets.xcconfig is intentionally ignored by Git. With a key present,
AppDependencies creates ShoutcastDirectoryClient, which fetches live genre/top/search data from
api.shoutcast.com/legacy/... and resolves station streams through
yp.shoutcast.com/sbin/tunein-station.pls.
ShoutKitApp: thin SwiftUI app targets — the iPhone/iPad app keeps app-level wiring inAppDependencies.bootstrap()(shared between the scene and App Intents), while the watch app carries a separate minimal service graph for native watch playback. The phone app provides the 3-tab root shell (Listen Now · Search · Favorites) with the persistent mini-player and Siri/Shortcuts (PlayStationIntentwith a station entity resolved from favorites, recents, curated stations, and live search); the watch companion focuses on now playing, recent stations, and a complication quick-start path.Packages/DesignSystem: Liquid Glass-aware reusable SwiftUI surfaces (station rows/cards/ carousel, artwork, playing indicator) and design tokens, with Reduce Transparency/Increase Contrast fallbacks.Packages/RadioDirectory: domain models, theRadioDirectoryProvidingboundary, the Radio-Browser JSON client (default), the SHOUTcast XML client (optional, key-gated), and the curated/bundled directories.Packages/Playback:PlaybackController(app-wide observable playback state), theRadioPlaybackEngineseam it drives, ICY metadata parsing, the sleep timer, and aNowPlayingPresentingbridge that targets eitherMPNowPlayingInfoCenter/MPRemoteCommandCenter(iOS 26) or the iOS 27NowPlaying/MediaSessionframework, selected at runtime. Ships no engine itself, which is what keeps it free of a codec dependency.Packages/PlaybackEngineAudioStreaming: the productionRadioPlaybackEngine—AudioStreamingPlaybackEngine, AVAudioEngine-backed via the MIT-licensed AudioStreaming library — with audio-session ownership (interruptions, route changes, media-services reset) and the equalizer attach point. iOS-only and linked by the app target alone: AudioStreaming pulls the ogg/vorbis xcframeworks, which have no watchOS slice, and SwiftPM fetches binary artifacts regardless of platform conditions. The watch app supplies its ownAVPlayer-backed engine.Packages/Persistence: SwiftData models andLibraryStorefor favorites and recents.Packages/LiveActivity: Live Activity attributes and theNowPlayingActivityCoordinatordriving the lock screen / Dynamic Island now-playing surface, including staging downsampled artwork into a shared App Group container for the widget extension to render.Packages/ImageIODownsample: a small leaf module wrapping ImageIO downsampling, shared byDesignSystem's artwork pipeline and Live Activity artwork staging so decoded images never exceed the pixel size their surface actually needs.Packages/Features/*: one package per tab surface.Packages/BrowseFeatureCore,SearchFeatureCore,PlayerFeatureCore: the platform-free half of those surfaces — view-model logic and decision rules, split out because the feature packages depend onDesignSystem, which declares.iOS(.v26)alone and so doesn't build for the mac host, meaning nothing inside them can be reached byswift test. This is where their tests live.
Dependency wiring across these packages goes through Factory
(Container-based DI) rather than direct instantiation, so tests and previews can substitute fakes
without touching production call sites. Debug builds also link an app-side DebugSupport package
(#if DEBUG-only Pulse network inspection); it's never declared
by the reusable packages and is compiled out of Release entirely. See
THIRD_PARTY_LICENSES.md for the full dependency list.
The app uses SwiftUI, Observation, SwiftData, async/await, and local Swift packages. View state
lives in @Observable @MainActor models, while networking, playback, and persistence
implementation details stay behind protocol or actor boundaries.
The phone app Info.plist declares UIBackgroundModes = audio for streaming playback and
NSSupportsLiveActivities for the lock screen / Dynamic Island now-playing Live Activity (the
ShoutKitWidgets extension target, driven by NowPlayingActivityCoordinator from playback
state, with synced album/station artwork). The watch app adds a native watchOS now-playing +
recents surface plus a one-tap "Play Last" complication that deep-links into watch playback, and
ships embedded in the phone app's bundle. Apple TV (ShoutKitTVApp, tvOS 26+) is a separate
submission with its own bundle id: Recent and Popular station shelves built for the Siri Remote,
over the same AudioStreamingPlaybackEngine the phone uses, so live ICY track titles appear on
the big screen too. App Intents power Siri/Shortcuts with headless background playback (no app
foregrounding);
StationEntity also conforms to IndexedEntity so favorited, curated, and recently-played
stations land in Spotlight's semantic index, letting Siri resolve "play ⟨station⟩" for a station
from a previous session. shoutkit://station?... deep links open the phone app to a station for
promos, notifications, and other launch entry points; the app also publishes an NSUserActivity
for the current station so Handoff can resume it on another signed-in device; and long-pressing
Now Playing artwork
surfaces a "View in Apple Music" link when a track match is found. A small/medium Home Screen
widget plays a chosen favorite in one tap, and CarPlay ships as a CPListTemplate of
favorites and recents over CPNowPlayingTemplate, driven by the same PlaybackController (the
com.apple.developer.carplay-audio entitlement is declared in
ShoutKitApp/ShoutKitApp.entitlements). What's still ahead is in
docs/ROADMAP.md.
ShoutKit collects nothing and tracks nothing. There are no analytics, no ads, and no accounts.
The app's privacy manifest (PrivacyInfo.xcprivacy) declares zero collected data types and no
tracking. The complete list of network traffic the app produces:
- Directory queries to Radio-Browser community mirrors
(or
api.shoutcast.comif you opt into a SHOUTcast key): search terms, genre names, and station lookups — the same requests any client of those public directories makes. - Play reports to Radio-Browser (
/json/url/{stationuuid}) when you play a Radio-Browser-sourced station, per that project's etiquette, so the community directory can rank station popularity. Only the station's public UUID is sent — nothing about you or your device beyond a genericShoutKit/x.yUser-Agent. - Stream and artwork fetches directly from the stations you choose to play.
Favorites and recents live in a local SwiftData store on your device, alongside a small snapshot of the public station list the landing surfaces last fetched (so they can render without a network round trip). Nothing about you is stored in either, and nothing leaves the device.
Release plans live in docs/releases/*.md; the sprint-by-sprint sequencing across releases
is in docs/ROADMAP.md.
All package test suites (RadioDirectory, Playback, Persistence) run on the mac host with
swift test — the iOS-only playback types are canImport(UIKit)-gated so the controller tests
execute against fakes anywhere. See CONTRIBUTING.md for details and a local
codesign workaround.
| Component | License |
|---|---|
App target (ShoutKitApp, incl. the debug-only DebugSupport package), feature packages (Packages/Features/* and their *FeatureCore counterparts), Packages/LiveActivity |
GPL-3.0 |
Packages/RadioDirectory, Packages/Playback, Packages/PlaybackEngineAudioStreaming, Packages/Persistence, Packages/DesignSystem, Packages/FeatureFlags, Packages/ImageIODownsample |
MIT (per-package LICENSE files) |
The rule, if you're adding a package: a per-package LICENSE file makes it MIT and means it
must stay adoptable — no app-specific or heavyweight dependencies. Everything else inherits
GPL-3.0 from the root LICENSE.
That rule is enforced, not just documented: CI fails if an MIT package takes a dependency on a
GPL-3.0 one (.github/workflows/ci.yml, the license-boundary job). ImageIODownsample is MIT
for exactly this reason — Playback and DesignSystem both link it, so a GPL-3.0
ImageIODownsample would have made those two packages undistributable under MIT.
DocC-generated API reference for the six MIT packages above is published at cascadiacollections.github.io/shoutkit/api.
The reusable infrastructure packages are MIT so they can be adopted anywhere; the app itself is GPL-3.0 so distributed forks must remain open source. The ShoutKit name and branding are not covered by the code licenses — see TRADEMARK.md. Contributions require a DCO sign-off; see CONTRIBUTING.md.
ShoutKit is free software built in the open. If it's useful to you, support development via the
funding links in .github/FUNDING.yml (GitHub's Sponsor button). The app
itself contains no paywalls — everything works whether or not you donate.