From 579da9d1a8ef8a43b4a33c7c7a7ed5616e833653 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 16:03:18 -0400 Subject: [PATCH 01/11] Migrate Swift Radio to SwiftUI and Swift 6 --- .gitignore | 5 + Documentation/SwiftUIParity.md | 58 ++ Packages/SwiftRadioCore/.gitignore | 2 + Packages/SwiftRadioCore/Package.resolved | 15 + Packages/SwiftRadioCore/Package.swift | 15 + Packages/SwiftRadioCore/README.md | 38 ++ .../Handoff/HandoffActivity.swift | 28 + .../SwiftRadioCore/Images/ArtworkLoader.swift | 113 ++++ .../Models/RadioStation+ArtworkSource.swift | 20 + .../SwiftRadioCore/Models/RadioStation.swift | 96 +++ .../Models/StationsResponse.swift | 15 + .../Playback/AudioSessionConfigurator.swift | 44 ++ .../Playback/MainActorDelivery.swift | 25 + .../Playback/NowPlayingInfoPublisher.swift | 46 ++ .../Playback/PlayerService+Readiness.swift | 16 + .../Playback/PlayerService+State.swift | 17 + .../Playback/PlayerService.swift | 438 +++++++++++++ .../Playback/RadioPlayerEvents.swift | 21 + .../Playback/RadioPlayerState.swift | 15 + .../Playback/RadioPlaying.swift | 25 + .../Playback/RemoteCommand.swift | 14 + .../Playback/RemoteCommandHandling.swift | 19 + .../Playback/RemoteCommandsController.swift | 88 +++ .../Playback/SystemRemoteCommandHandler.swift | 38 ++ .../Stations/BundleStationsLoader.swift | 28 + .../Stations/CatalogListContent.swift | 56 ++ .../Stations/RemoteStationsLoader.swift | 30 + .../Stations/StationsLoader.swift | 14 + .../Stations/StationsStore+LoadState.swift | 36 ++ .../Stations/StationsStore.swift | 123 ++++ .../ArtworkLoaderCoalescingTests.swift | 171 +++++ .../ArtworkLoaderTests.swift | 44 ++ .../AudioSessionConfiguratorTests.swift | 27 + .../CatalogListContentTests.swift | 72 +++ .../Fakes/ActivationGate.swift | 35 ++ .../Fakes/ControlledStationsLoader.swift | 30 + .../Fakes/FakeRadioPlayer.swift | 139 ++++ .../Fakes/FakeRemoteCommandHandler.swift | 30 + .../Fakes/FakeStationsLoader.swift | 24 + .../Fakes/ObservationCounter.swift | 19 + .../Fakes/StubURLProtocol.swift | 47 ++ .../Fixtures/placeholder.png | Bin 0 -> 54271 bytes .../SwiftRadioCoreTests/Fixtures/station.png | Bin 0 -> 20806 bytes .../Fixtures/stations.json | 52 ++ .../HandoffSearchURLTests.swift | 33 + .../NowPlayingInfoPublisherTests.swift | 75 +++ .../PlaybackInterruptionTests.swift | 151 +++++ .../PlayerArtworkTests.swift | 109 ++++ .../PlayerServiceTests.swift | 533 ++++++++++++++++ .../RadioStationTests.swift | 74 +++ .../RealEngineSeekTests.swift | 110 ++++ .../RemoteCommandsControllerTests.swift | 245 ++++++++ .../StationActivationTests.swift | 182 ++++++ .../StationsLoaderTests.swift | 33 + .../StationsRefreshTests.swift | 43 ++ .../StationsStoreTests.swift | 76 +++ README.md | 108 +++- SwiftRadio.xcodeproj/project.pbxproj | 595 ++++-------------- .../xcshareddata/swiftpm/Package.resolved | 24 +- SwiftRadio/App/AppDelegate.swift | 40 +- SwiftRadio/App/AppEnvironment.swift | 93 +++ SwiftRadio/App/ArtworkLoaderKey.swift | 24 + SwiftRadio/App/ConfiguredStationsLoader.swift | 24 + SwiftRadio/App/RootView.swift | 123 ++++ SwiftRadio/App/SceneDelegate.swift | 61 -- SwiftRadio/App/SwiftRadioApp.swift | 26 + SwiftRadio/App/UITestRadioPlayer.swift | 63 ++ SwiftRadio/CarPlay/CarPlayObservation.swift | 50 ++ SwiftRadio/CarPlay/CarPlaySceneDelegate.swift | 252 +++++--- SwiftRadio/Cells/NothingFoundCell.xib | 44 -- SwiftRadio/Cells/StationTableViewCell.swift | 337 ---------- SwiftRadio/Config/Config.swift | 10 +- SwiftRadio/Config/Content.swift | 15 +- SwiftRadio/Config/InfoSection.swift | 8 +- .../Coordinators/AboutCoordinator.swift | 78 --- SwiftRadio/Coordinators/Coordinator.swift | 22 - SwiftRadio/Coordinators/MainCoordinator.swift | 149 ----- SwiftRadio/Data/stations.json | 11 +- SwiftRadio/Features/About/AboutScreen.swift | 273 ++++++++ .../Features/About/ConfiguredRowID.swift | 27 + .../Features/About/ContributorsScreen.swift | 92 +++ .../Features/About/FeaturesScreen.swift | 45 ++ SwiftRadio/Features/About/GitHubClient.swift | 94 +++ .../Features/About/LibrariesScreen.swift | 130 ++++ .../Features/Loading/LoadingScreen.swift | 39 ++ .../NowPlaying/NowPlayingOptionsSheet.swift | 127 ++++ .../Features/NowPlaying/NowPlayingView.swift | 275 ++++++++ .../NowPlaying/PlaybackControls.swift | 214 +++++++ .../StationInfo/StationInfoScreen.swift | 93 +++ .../Features/Stations/EmptyStationsView.swift | 21 + .../Stations/NowPlayingIndicatorButton.swift | 35 ++ SwiftRadio/Features/Stations/StationRow.swift | 138 ++++ .../Features/Stations/StationsScreen.swift | 115 ++++ SwiftRadio/Helpers/BottomSheetHandler.swift | 42 -- SwiftRadio/Helpers/Handoffable.swift | 43 -- .../Helpers/NSLayoutConstraint+with.swift | 16 - SwiftRadio/Helpers/ShareActivity.swift | 44 -- SwiftRadio/Helpers/UIImage+Cache.swift | 23 - SwiftRadio/Helpers/UIImage+DropShadow.swift | 21 - SwiftRadio/Helpers/UIImageView+Cache.swift | 20 - .../UITableViewCell+reuseIdentifier.swift | 28 - .../Helpers/UIViewController+Email.swift | 38 -- SwiftRadio/Info-CarPlay.plist | 11 - SwiftRadio/Info.plist | 9 - SwiftRadio/Localizable.xcstrings | 186 +++++- SwiftRadio/Model/RadioStation.swift | 84 --- SwiftRadio/Model/StationsManager.swift | 223 ------- SwiftRadio/Networking/NetworkService.swift | 142 ----- SwiftRadio/Services/AudioSetupService.swift | 96 --- SwiftRadio/SwiftRadio.entitlements | 2 - SwiftRadio/UI/ActivityView.swift | 23 + SwiftRadio/UI/AirPlayButton.swift | 24 + SwiftRadio/UI/ArtworkView.swift | 59 ++ SwiftRadio/UI/CrossfadeImage.swift | 58 ++ SwiftRadio/UI/EqualizerBars.swift | 26 + SwiftRadio/UI/GradientBackground.swift | 42 ++ SwiftRadio/UI/LogoShareView.swift | 56 ++ SwiftRadio/UI/MailComposeView.swift | 59 ++ SwiftRadio/UI/MarqueeText.swift | 46 ++ SwiftRadio/UI/PlaybackActivityIndicator.swift | 81 +++ SwiftRadio/UI/PlaybackArtworkImage.swift | 136 ++++ SwiftRadio/UI/SafariView.swift | 23 + .../ViewControllers/AboutViewController.swift | 290 --------- .../ViewControllers/BaseController.swift | 34 - .../BottomSheetViewController.swift | 192 ------ .../ContributorsViewController.swift | 216 ------- .../FeaturesViewController.swift | 94 --- .../InfoDetailViewController.swift | 234 ------- .../LibrariesViewController.swift | 171 ----- .../ViewControllers/LoaderController.swift | 129 ---- .../NowPlayingViewController.swift | 358 ----------- .../StationsViewController.swift | 322 ---------- SwiftRadio/Views/AlbumArtworkView.swift | 129 ---- SwiftRadio/Views/ControlsView.swift | 411 ------------ SwiftRadio/Views/GradientBackgroundView.swift | 46 -- SwiftRadio/Views/LogoShareView.swift | 30 - SwiftRadio/Views/LogoShareView.xib | 79 --- SwiftRadioUITests/SwiftRadioUITests.swift | 389 +++++++++++- 138 files changed, 7622 insertions(+), 4890 deletions(-) create mode 100644 Documentation/SwiftUIParity.md create mode 100644 Packages/SwiftRadioCore/.gitignore create mode 100644 Packages/SwiftRadioCore/Package.resolved create mode 100644 Packages/SwiftRadioCore/Package.swift create mode 100644 Packages/SwiftRadioCore/README.md create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Handoff/HandoffActivity.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Images/ArtworkLoader.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation+ArtworkSource.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/StationsResponse.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/AudioSessionConfigurator.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/MainActorDelivery.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/NowPlayingInfoPublisher.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+Readiness.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+State.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerEvents.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerState.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlaying.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommand.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandHandling.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandsController.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/SystemRemoteCommandHandler.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/BundleStationsLoader.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/CatalogListContent.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/RemoteStationsLoader.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsLoader.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore+LoadState.swift create mode 100644 Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderCoalescingTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/AudioSessionConfiguratorTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/CatalogListContentTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ActivationGate.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ControlledStationsLoader.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRadioPlayer.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRemoteCommandHandler.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeStationsLoader.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ObservationCounter.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/StubURLProtocol.swift create mode 100755 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/placeholder.png create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/station.png create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/stations.json create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/HandoffSearchURLTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/NowPlayingInfoPublisherTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlaybackInterruptionTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerArtworkTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RadioStationTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RealEngineSeekTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RemoteCommandsControllerTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsLoaderTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift create mode 100644 Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsStoreTests.swift create mode 100644 SwiftRadio/App/AppEnvironment.swift create mode 100644 SwiftRadio/App/ArtworkLoaderKey.swift create mode 100644 SwiftRadio/App/ConfiguredStationsLoader.swift create mode 100644 SwiftRadio/App/RootView.swift delete mode 100644 SwiftRadio/App/SceneDelegate.swift create mode 100644 SwiftRadio/App/SwiftRadioApp.swift create mode 100644 SwiftRadio/App/UITestRadioPlayer.swift create mode 100644 SwiftRadio/CarPlay/CarPlayObservation.swift delete mode 100755 SwiftRadio/Cells/NothingFoundCell.xib delete mode 100755 SwiftRadio/Cells/StationTableViewCell.swift delete mode 100644 SwiftRadio/Coordinators/AboutCoordinator.swift delete mode 100755 SwiftRadio/Coordinators/Coordinator.swift delete mode 100755 SwiftRadio/Coordinators/MainCoordinator.swift create mode 100644 SwiftRadio/Features/About/AboutScreen.swift create mode 100644 SwiftRadio/Features/About/ConfiguredRowID.swift create mode 100644 SwiftRadio/Features/About/ContributorsScreen.swift create mode 100644 SwiftRadio/Features/About/FeaturesScreen.swift create mode 100644 SwiftRadio/Features/About/GitHubClient.swift create mode 100644 SwiftRadio/Features/About/LibrariesScreen.swift create mode 100644 SwiftRadio/Features/Loading/LoadingScreen.swift create mode 100644 SwiftRadio/Features/NowPlaying/NowPlayingOptionsSheet.swift create mode 100644 SwiftRadio/Features/NowPlaying/NowPlayingView.swift create mode 100644 SwiftRadio/Features/NowPlaying/PlaybackControls.swift create mode 100644 SwiftRadio/Features/StationInfo/StationInfoScreen.swift create mode 100644 SwiftRadio/Features/Stations/EmptyStationsView.swift create mode 100644 SwiftRadio/Features/Stations/NowPlayingIndicatorButton.swift create mode 100644 SwiftRadio/Features/Stations/StationRow.swift create mode 100644 SwiftRadio/Features/Stations/StationsScreen.swift delete mode 100644 SwiftRadio/Helpers/BottomSheetHandler.swift delete mode 100755 SwiftRadio/Helpers/Handoffable.swift delete mode 100644 SwiftRadio/Helpers/NSLayoutConstraint+with.swift delete mode 100755 SwiftRadio/Helpers/ShareActivity.swift delete mode 100755 SwiftRadio/Helpers/UIImage+Cache.swift delete mode 100755 SwiftRadio/Helpers/UIImage+DropShadow.swift delete mode 100755 SwiftRadio/Helpers/UIImageView+Cache.swift delete mode 100755 SwiftRadio/Helpers/UITableViewCell+reuseIdentifier.swift delete mode 100755 SwiftRadio/Helpers/UIViewController+Email.swift delete mode 100755 SwiftRadio/Model/RadioStation.swift delete mode 100755 SwiftRadio/Model/StationsManager.swift delete mode 100644 SwiftRadio/Networking/NetworkService.swift delete mode 100644 SwiftRadio/Services/AudioSetupService.swift create mode 100644 SwiftRadio/UI/ActivityView.swift create mode 100644 SwiftRadio/UI/AirPlayButton.swift create mode 100644 SwiftRadio/UI/ArtworkView.swift create mode 100644 SwiftRadio/UI/CrossfadeImage.swift create mode 100644 SwiftRadio/UI/EqualizerBars.swift create mode 100644 SwiftRadio/UI/GradientBackground.swift create mode 100644 SwiftRadio/UI/LogoShareView.swift create mode 100644 SwiftRadio/UI/MailComposeView.swift create mode 100644 SwiftRadio/UI/MarqueeText.swift create mode 100644 SwiftRadio/UI/PlaybackActivityIndicator.swift create mode 100644 SwiftRadio/UI/PlaybackArtworkImage.swift create mode 100644 SwiftRadio/UI/SafariView.swift delete mode 100755 SwiftRadio/ViewControllers/AboutViewController.swift delete mode 100755 SwiftRadio/ViewControllers/BaseController.swift delete mode 100644 SwiftRadio/ViewControllers/BottomSheetViewController.swift delete mode 100644 SwiftRadio/ViewControllers/ContributorsViewController.swift delete mode 100644 SwiftRadio/ViewControllers/FeaturesViewController.swift delete mode 100755 SwiftRadio/ViewControllers/InfoDetailViewController.swift delete mode 100644 SwiftRadio/ViewControllers/LibrariesViewController.swift delete mode 100755 SwiftRadio/ViewControllers/LoaderController.swift delete mode 100755 SwiftRadio/ViewControllers/NowPlayingViewController.swift delete mode 100755 SwiftRadio/ViewControllers/StationsViewController.swift delete mode 100755 SwiftRadio/Views/AlbumArtworkView.swift delete mode 100755 SwiftRadio/Views/ControlsView.swift delete mode 100644 SwiftRadio/Views/GradientBackgroundView.swift delete mode 100755 SwiftRadio/Views/LogoShareView.swift delete mode 100755 SwiftRadio/Views/LogoShareView.xib diff --git a/.gitignore b/.gitignore index 4ed09ca91..4fb5c16a6 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ *.xccheckout *.xcscmblueprint xcuserdata + +# Build and test artifacts +*.xcresult/ +*.log +DerivedData/ diff --git a/Documentation/SwiftUIParity.md b/Documentation/SwiftUIParity.md new file mode 100644 index 000000000..2339baf0a --- /dev/null +++ b/Documentation/SwiftUIParity.md @@ -0,0 +1,58 @@ +# SwiftUI visual parity + +The UIKit app is the visual and behavioral reference. A successful build alone is not parity approval. Compare both implementations on the same device, OS build, appearance, text size, language, station and playback state. Accessibility layouts should preserve all content and actions rather than copy clipping in a fixed-height reference. + +## Ownership and extension points + +- `AppEnvironment` creates one shared playback/catalog composition for the phone and CarPlay. Views must not create another engine or system remote-command registration. +- `SwiftRadioCore` owns playback state, station loading, artwork identity and system metadata. Its injected boundaries support network-free unit tests; see the package README. +- `RootView` owns navigation and popup presentation. An options action queues its destination, waits for the sheet's `onDismiss`, closes the popup, then navigates from the popup's completion callback. Do not substitute arbitrary delays or unstructured tasks for dismissal completion. +- Player sections receive the values they display. Playback time belongs in the transport/timeline subtree; avoid passing an aggregate model snapshot through every catalog row. +- The native seek slider is a small `UIViewRepresentable` boundary for UIKit's track/thumb appearance and adjustable accessibility. Selection and scrubbing state remain in SwiftUI. Decorative backgrounds must never determine foreground layout size. +- `MarqueeText` wraps the same MarqueeLabel version and configuration as UIKit: continuous motion at 30pt/s, 10pt edge fades, 30pt trailing buffer. Identical metadata must not restart it on every playback tick. The library owns window/app lifecycle; teardown shuts the label down. Reduce Motion and accessibility text sizes use wrapping text instead. +- Equalizer and buffering views use the reference NVActivityIndicatorView implementation. A native container starts them only after layout supplies a nonzero frame, and explicitly stops them on state changes and teardown; the toolbar is absent while paused/stopped. Never use a persistent `repeatForever` animation as a substitute for an engine with explicit cancellation. Check that the playing indicator is actually visible before accepting a stopped-state comparison. +- The row removes the equalizer's layout space when paused/stopped, matching a hidden UIKit stack arranged subview. Only the subdued selected-artwork ring remains; a paused row must not retain an empty subtitle indent. +- Artwork replacement cross-dissolves at 0.3s (foreground) and 0.5s (background); buffering fades at 0.3s. Reduce Motion disables these transitions. Native image containers accept the proposed size rather than propagating image intrinsic dimensions. +- Expanded artwork keeps a small native presentation boundary for UIKit's exact 0.5s/damping-0.7 scale animation, buffering overlay and fixed outer shadow. Async artwork resolution and playback state remain in SwiftUI/shared services. +- Sharing queues its payload on the player and presents only after Options dismisses. Cancelling must return directly to the player, including on repeated presentations; do not restore the old nested sheet stack. +- The diagonal background wraps `CAGradientLayer` with the reference's colors and locations. Matching normalized endpoints in `LinearGradient` did not produce the same tall-screen rendering in the simulator comparison. +- Station cards keep native `Button` activation/cancellation. Their feedback completes a short activation pulse as well as reflecting held presses, so quick taps do not depend on a transient pressed frame. Reduce Motion substitutes opacity feedback for scaling; do not add a zero-distance drag gesture that competes with list scrolling. +- The options sheet sizes itself to its measured list content (iOS 18+), so row and section metrics can differ between OS releases without a gap or clipping. iOS 17 uses an estimate from the scaled row height. +- CarPlay pages a catalog longer than the head unit's list limit: each page ends with a More row that pushes the next page. Four pages at most, so Now Playing still fits CarPlay's template depth; pushed pages follow a runtime limit change. +- Use `Config`, `Content`, `stations.json` and the asset catalog for branding/content. Changing station data or About sections must not require editing navigation or the playback engine. +- Retain the iOS 17 deployment floor. Newer presentation APIs need availability handling; changing the whole app's minimum version is not a styling fix. + +## Comparison protocol + +1. Build UIKit and SwiftUI separately, then install on separate simulators with matching hardware/runtime. They use the same bundle identifier, so do not install one over the other during a comparison. +2. Set dark appearance, English, default `large` content size and the same status-bar overrides. Capture raw screenshots without frames, resizing or retouching. +3. Compare initial catalog, selected/playing/buffering cards, collapsed popup, expanded live/file player, options, station info, About and its destinations. Check search, refresh, previous/next, seeking, close/reopen and sheet handoffs. +4. For file playback, pause and seek to the same elapsed time before pixel comparison. Exclude genuinely volatile OS/status-bar content from numerical comparison, not app-layout differences. +5. Repeat with accessibility text sizes and Reduce Motion. Inspect the accessibility hierarchy and actual hit targets, not screenshots alone. Small windows/landscape may scroll; every control must remain reachable. +6. Retain screenshots, runtime details and test results with the review. Record remaining differences explicitly rather than calling a close match pixel-perfect. + +The initial iPhone 18 Pro / stable iOS 27 comparison identified catalog spacing/materials, an overflowing full-screen player, options sizing, and About/info typography differences. The current work addresses those areas; final visual acceptance is still pending. + +## Regression checks + +From the repository root, using a simulator destination available on the machine: + +```sh +xcodebuild test -project SwiftRadio.xcodeproj -scheme SwiftRadio \ + -destination 'platform=iOS Simulator,name=iPhone 18 Pro,OS=27.0' \ + -only-testing:SwiftRadioUITests/SwiftRadioUITests/testStationsListRenders \ + -only-testing:SwiftRadioUITests/SwiftRadioUITests/testPlayerControlsFitAndOptionsNavigate \ + -only-testing:SwiftRadioUITests/SwiftRadioUITests/testAboutFeaturesNavigationAndDismissal +``` + +The layout/navigation test does not wait for an internet station to begin playback. The separate `testPopupNavigationAndRefresh` is a live-stream integration test and can fail because a third-party stream is unavailable. Run core tests from `Packages/SwiftRadioCore`, not the app project's generated package scheme. + +Run the full app suite (omit `-only-testing`) for deterministic Stop/resume, file Pause/seek, marquee scrolling after reopen and foreground, repeated Share/cancel, row cancellation/reselection, previous/next wraparound, website handoff and library-link coverage. These tests launch with `--ui-test-playback`: Debug builds inject a deterministic `RadioPlaying` implementation at the existing composition boundary; Release builds exclude it. The real catalog, stores, navigation and animation views still run. Keep a separate real-stream integration test: a fake engine cannot validate AVPlayer or audio routing. Swift Debug compilation explicitly defines `DEBUG`; a C preprocessor `DEBUG=1` alone does not enable Swift fixtures. + +Static screenshots do not prove that a title scrolls or that an equalizer animates, so the UI tests compare element screenshots over time: the toolbar equalizer must change while playing and disappear when paused or stopped, and the long fixture title must move after first open, after reopening the popup and after returning to the foreground. They do not check that identical metadata leaves a running marquee alone, or animation timing; use a recording for those. Core seek tests deliberately deliver stale time events before completion and out-of-order completions, and run against a fake with FRadioPlayer 0.4's independent seek and interruption-intent behavior. Adversarial late events separately test the app's transport guards; real-engine seek tests exercise the released package. + +Physical-device audio interruptions, route changes, lock-screen controls, CarPlay and the oldest supported OS remain separate acceptance gates. Core tests reproduce the engine's interruption and route-change handlers, but simulator and unit test success does not prove those behaviors on hardware. + +## Guidance + +The implementation follows Apple's SwiftUI guidance on [performance](https://developer.apple.com/documentation/xcode/understanding-and-improving-swiftui-performance) (small view sections, per-property Observation, stable list identity) and [localization](https://developer.apple.com/documentation/swiftui/preparing-views-for-localization). Presentation behavior is checked against Apple's [modal presentation documentation](https://developer.apple.com/documentation/swiftui/modal-presentations) and [custom detents](https://developer.apple.com/documentation/swiftui/custompresentationdetent). Native controls retain their accessibility semantics; accessibility content uses wrapping text instead of a continuously moving marquee. diff --git a/Packages/SwiftRadioCore/.gitignore b/Packages/SwiftRadioCore/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/Packages/SwiftRadioCore/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/Packages/SwiftRadioCore/Package.resolved b/Packages/SwiftRadioCore/Package.resolved new file mode 100644 index 000000000..4cae28fad --- /dev/null +++ b/Packages/SwiftRadioCore/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9906a01bbddc833a56f0a3b2b36623f626602dee062c60ecb36389398f83d03d", + "pins" : [ + { + "identity" : "fradioplayer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fethica/FRadioPlayer.git", + "state" : { + "revision" : "8fff8f5f220acb1640e6cca9b18f9ca61f45075a", + "version" : "0.4.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/SwiftRadioCore/Package.swift b/Packages/SwiftRadioCore/Package.swift new file mode 100644 index 000000000..efec959d5 --- /dev/null +++ b/Packages/SwiftRadioCore/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "SwiftRadioCore", + platforms: [.iOS(.v17)], + products: [.library(name: "SwiftRadioCore", targets: ["SwiftRadioCore"])], + dependencies: [.package(url: "https://github.com/fethica/FRadioPlayer.git", .upToNextMinor(from: "0.4.0"))], + targets: [ + .target(name: "SwiftRadioCore", dependencies: ["FRadioPlayer"], + swiftSettings: [.swiftLanguageMode(.v6)]), + .testTarget(name: "SwiftRadioCoreTests", dependencies: ["SwiftRadioCore"], + resources: [.copy("Fixtures")], swiftSettings: [.swiftLanguageMode(.v6)]) + ] +) diff --git a/Packages/SwiftRadioCore/README.md b/Packages/SwiftRadioCore/README.md new file mode 100644 index 000000000..ac8cb592b --- /dev/null +++ b/Packages/SwiftRadioCore/README.md @@ -0,0 +1,38 @@ +# SwiftRadioCore + +An iOS 17+ Swift 6 library containing the radio catalog, observable stores, playback boundary, artwork loading, and outgoing Handoff. The shipped app instantiates it: `AppEnvironment` builds one set of these objects at launch and the SwiftUI scene and the UIKit CarPlay scene delegate share that instance. + +Run the isolated Swift Testing suite from **this package directory**: + +```sh +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +xcodebuild test -scheme SwiftRadioCore \ + -destination 'platform=iOS Simulator,name=iPhone 18 Pro,OS=27.0' \ + -derivedDataPath ~/Library/Developer/Xcode/DerivedData/SwiftRadio-swiftui +``` + +The app project's generated package scheme does not include the package test action. Use the package-directory command above. This package imports UIKit and MediaPlayer; macOS `swift test` is not supported. + +## Composition and ownership + +Create one app-lifetime `PlayerService`, `StationsStore`, `ArtworkLoader`, `NowPlayingInfoPublisher`, and `RemoteCommandsController`. Phone and CarPlay must share them. Do not activate the audio session at launch or on scene activation: `PlayerService` activates a non-mixable `.playback` session itself when `load(url:)`, `play()` or `seek(to:)` is called, so opening the app never stops other apps' audio. Those calls stay synchronous for callers, but the engine only receives the stream, Play or seek after the activation has finished: the session applies its category when it activates. Activations run one at a time, and any later transport command supersedes a pending one, so Stop, Pause or a new selection during activation wins and the superseded request never reaches the engine (a stream stopped this way is loaded by the next `play()`). While a new station waits, the previous stream is stopped and the player reads idle and buffering. If activation fails, the player stops and reports `.failed`, never Playing; the next tap or Play command retries. The adapter sets `FRadioPlayer.configuresAudioSession = false` before first accessing `shared`; SwiftRadio alone owns category setup and activation. The category is re-applied on each activation. Pass `mixesWithOtherAudio: true` only if the app should play under other audio; a mixable session cannot own lock-screen, Control Center or CarPlay Now Playing. + +Inject the engine, publisher, and commands into `PlayerService`, then inject that service and a loader into `StationsStore`. Bind commands once with `bind(player:stations:)`: without a current station every command reports `.noActionableNowPlayingItem` and does nothing, so a stray headset press cannot start the radio. The controller removes registrations on `unbind()` or destruction. Do not run the legacy audio setup and new composition simultaneously: the vendor engine and system media centers are process-wide resources. + +`StationsStore.select` supplies station display fallbacks and starts loading the stream. When a reload drops the current station the store calls `PlayerService.unload()`, which stops transport, clears the engine's `radioURL`, and removes system Now Playing info (nil, not an empty-title media card). Late metadata callbacks cannot recreate that card without a selection. `play()` then does nothing until another station is selected, so a late remote Play cannot resume a removed stream. Keep the navigation shell alive after initial loading; refresh state is not navigation state. + +After awaiting station artwork, compare the current station ID and call `updateArtwork(_:for:artworkURL:)` with a nil URL. For track artwork, compare both station ID and the requested metadata URL, then pass that URL. The service rejects obsolete IDs/URLs and immediately reveals cached station artwork when the track URL becomes nil. FRadioPlayer 0.4 rejects superseded lookups. The app retains its own station/track identity checks for image downloads and injected late events, and keys accepted artwork to the station that was current when it arrived. `ArtworkLoader` shares one download per URL and cancels it when the last waiting caller is cancelled. Asset resolution uses the app bundle by default; tests can inject a bundle. + +All six engine events use application-owned values. The private adapter exists because FRadioPlayer's nonoptional duration, vendor metadata, and seek completion parameter cannot witness the `RadioPlaying` signatures directly. FRadioPlayer 0.4 guarantees main-actor observer delivery. The adapter retains `deliverOnMainActor` as a defensive boundary: it delivers inline on the main thread, preserving ordering, and hops for explicitly injected background events. Remote-command callbacks always return the action's real status. On the main thread the action runs inline; from any other thread the callback blocks on `DispatchQueue.main.sync` until the action has run. That cannot deadlock because nothing on the main actor waits for a MediaPlayer callback queue. Next and Previous report `.noSuchContent` when there is no other usable station. Their MediaPlayer target closure is explicitly `@Sendable`. + +`RadioPlaying.seek(to:completion:)` uses FRadioPlayer 0.4's exactly-once, asynchronous main-actor completion. The engine completes even when no item or duration is available, and seeking preserves a paused or stopped transport state. + +`PlayerService` still reads the current engine duration before entering the seek flow, so live streams do not seek or resume through a stale file duration. It publishes the requested position immediately and ignores intervening time events until completion. A generation token prevents an older completion from releasing a newer seek or resuming a different station; loading, pausing and stopping invalidate that token. **Resume-on-scrub is app policy:** only the still-current completion explicitly resumes playback. The `.vendor` test fake models the 0.4 contract; controlled stale completions and adversarial events verify the app's guards independently. Real-engine tests use the published dependency. + +FRadioPlayer handles interruptions and route changes. It resumes after an interruption only when playback was active at its beginning, the system allows resumption, and no intervening user command superseded it. An unmatched interruption end does nothing; losing an audio route pauses. The service retains its transport-intent guard so an unexpected Playing event cannot override an explicit Pause or Stop or reach the lock screen as playing. An engine-originated pause does not itself become a user pause. + +The package and app project both require the published `0.4.x` series (`.upToNextMinor(from: "0.4.0")`). Both lockfiles resolve 0.4.0 at `8fff8f5f220acb1640e6cca9b18f9ca61f45075a`. Validation uses this remote package, with LNPopupUI 4.0.1 and LNPopupController 4.5.12 in the app graph. + +Playback-clock ticks update the observable `elapsed` only. The system advances lock-screen elapsed time from the published rate, so a file that is rebuffering publishes rate 0 (and 1 again once ready) while its transport intent stays playing; live streams keep rate 1 because they publish no clock. For the same reason, the Now Playing dictionary is rebuilt only on transport, readiness, metadata, artwork and duration changes, and once on the first tick after a seek completes (the engine lands near, not on, the requested second). + +`CatalogListContent` maps the catalog to a size-limited list such as CarPlay's. A catalog longer than the item limit is split into pages: every page but the last holds `limit - 1` stations and a More row that opens the next one. The page count is capped by the caller (the CarPlay scene allows four list pages so Now Playing still fits the five-template depth); stations beyond that are reported in `hiddenCount`, as they are when the limit leaves no room for a More row. diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Handoff/HandoffActivity.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Handoff/HandoffActivity.swift new file mode 100644 index 000000000..344377dd7 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Handoff/HandoffActivity.swift @@ -0,0 +1,28 @@ +// +// HandoffActivity.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// The outgoing Handoff target: a web search for the playing artist and track. +/// +/// The app advertises it with SwiftUI's `userActivity` modifier, which ties the activity to +/// the scene that shows it, so this type only decides the URL. +public enum HandoffActivity { + /// `nil` unless both artist and track are known, so a partial search is never advertised. + public static func searchURL(track: String?, artist: String?) -> URL? { + guard let track, let artist, !track.isEmpty, !artist.isEmpty else { return nil } + var components = URLComponents() + components.scheme = "https" + components.host = "google.com" + components.path = "/search" + components.queryItems = [URLQueryItem(name: "q", value: "\(artist) \(track)")] + return components.url + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Images/ArtworkLoader.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Images/ArtworkLoader.swift new file mode 100644 index 000000000..a3fa2bfbe --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Images/ArtworkLoader.swift @@ -0,0 +1,113 @@ +// +// ArtworkLoader.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import UIKit + +/// Resolves station and track images with bounded decoded caching over URLSession's HTTP cache. +public actor ArtworkLoader { + private let session: URLSession + private let placeholderName: String + private let bundle: Bundle + private let cache = NSCache() + /// One download per URL while it runs. The lock screen, backdrop and foreground artwork ask + /// for the same track at once; sharing the request keeps them from resolving differently. + /// Each download counts its callers, and the last one to cancel cancels it. + private var inFlight: [String: Download] = [:] + private var nextDownloadID = 0 + + private struct Download { + let id: Int + let task: Task + var waiters: Int + } + + public init(session: URLSession = .shared, placeholderName: String = "stationImage", bundle: Bundle = .main) { + self.session = session + self.placeholderName = placeholderName + self.bundle = bundle + cache.countLimit = 100 + cache.totalCostLimit = 32 * 1_024 * 1_024 + } + + /// Callers must compare station.id with their current selection after awaiting the result. + public func image(for station: RadioStation) async -> UIImage { + switch station.artworkSource { + case .asset(let name): return asset(named: name) ?? placeholder() + case .remote(let url): return await trackArtwork(at: url) ?? placeholder() + case .placeholder: return placeholder() + } + } + + /// The URL is the request identity; a caller must discard results for obsolete metadata URLs. + /// Cancelling one caller leaves a shared download running for the others; cancelling the last + /// one cancels the download, so abandoned artwork does not keep loading until it times out. + public func trackArtwork(at url: URL) async -> UIImage? { + let key = "remote:\(url.absoluteString)" + if let image = cache.object(forKey: key as NSString) { return image } + let task: Task + let id: Int + if let pending = inFlight[key] { + (task, id) = (pending.task, pending.id) + inFlight[key]?.waiters += 1 + } else { + nextDownloadID += 1 + let newID = nextDownloadID + task = Task { [session] in + let image = await Self.download(url, session: session) + // Runs on the actor: a failure is not cached, so a later request retries. + if inFlight[key]?.id == newID { inFlight[key] = nil } + if let image { insert(image, for: key as NSString) } + return image + } + id = newID + inFlight[key] = Download(id: id, task: task, waiters: 1) + } + return await withTaskCancellationHandler { + await task.value + } onCancel: { + Task { await self.waiterCancelled(key: key, id: id) } + } + } + + private func waiterCancelled(key: String, id: Int) { + guard var download = inFlight[key], download.id == id else { return } + download.waiters -= 1 + if download.waiters > 0 { + inFlight[key] = download + } else { + inFlight[key] = nil + download.task.cancel() + } + } + + private static func download(_ url: URL, session: URLSession) async -> UIImage? { + guard let (data, response) = try? await session.data(from: url) else { return nil } + if let response = response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) { return nil } + return UIImage(data: data) + } + + private func asset(named name: String) -> UIImage? { + let key = "asset:\(name)" as NSString + if let image = cache.object(forKey: key) { return image } + guard let image = UIImage(named: name, in: bundle, compatibleWith: nil) else { return nil } + insert(image, for: key) + return image + } + + private func placeholder() -> UIImage { + asset(named: placeholderName) ?? UIImage(systemName: "radio") ?? UIImage() + } + + private func insert(_ image: UIImage, for key: NSString) { + let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0 + cache.setObject(image, forKey: key, cost: cost) + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation+ArtworkSource.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation+ArtworkSource.swift new file mode 100644 index 000000000..36b141d14 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation+ArtworkSource.swift @@ -0,0 +1,20 @@ +// +// RadioStation+ArtworkSource.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +extension RadioStation { + /// Identifies a bundled image, a remote image, or the app's station placeholder. + public enum ArtworkSource: Equatable, Sendable { + case asset(String) + case remote(URL) + case placeholder + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift new file mode 100644 index 000000000..00f347b6d --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift @@ -0,0 +1,96 @@ +// +// RadioStation.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// A station's catalog data, stable identity, and metadata-independent presentation helpers. +public struct RadioStation: Codable, Hashable, Sendable, Identifiable { + public var name: String + public var streamURL: String + public var imageURL: String + public var desc: String + public var longDesc: String + public var website: String? + public var id: String { streamURL + "|" + name } + + public init(name: String, website: String? = nil, streamURL: String, imageURL: String, + desc: String, longDesc: String = "") { + self.name = name + self.website = website + self.streamURL = streamURL + self.imageURL = imageURL + self.desc = desc + self.longDesc = longDesc + } + + private enum CodingKeys: String, CodingKey { + case name, streamURL, imageURL, desc, longDesc, website + } + + /// Hand-written so `longDesc` is genuinely optional in a fork's catalog: synthesized decoding + /// requires the nonoptional property and the initializer's default never applies. A missing + /// key becomes an empty string, which the station info screen already renders as its default. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + streamURL = try container.decode(String.self, forKey: .streamURL) + imageURL = try container.decode(String.self, forKey: .imageURL) + desc = try container.decode(String.self, forKey: .desc) + longDesc = try container.decodeIfPresent(String.self, forKey: .longDesc) ?? "" + website = try container.decodeIfPresent(String.self, forKey: .website) + } + + public var hasValidWebsite: Bool { + guard let website, let url = URL(string: website) else { return false } + return Self.isWebURL(url) + } + + public var shoutout: String { + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "" + return "I'm listening to \(name) via \(appName) app" + } + + public func musicSearchURL(track: String?, artist: String?) -> URL? { + guard let track, let artist else { return nil } + var components = URLComponents() + components.scheme = "https" + components.host = "music.apple.com" + components.path = "/search" + components.queryItems = [URLQueryItem(name: "term", value: "\(track) \(artist)")] + // A literal plus must survive servers that interpret '+' as form-encoded space. + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + return components.url + } + + public var artworkSource: ArtworkSource { + guard !imageURL.isEmpty else { return .placeholder } + if let url = URL(string: imageURL), Self.isWebURL(url) { return .remote(url) } + return .asset(imageURL) + } + + private static func isWebURL(_ url: URL) -> Bool { + ["http", "https"].contains(url.scheme?.lowercased() ?? "") && !(url.host ?? "").isEmpty + } + + // Preserve the UIKit catalog's unchanged-list behavior: website is deliberately ignored. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name && lhs.streamURL == rhs.streamURL && lhs.imageURL == rhs.imageURL + && lhs.desc == rhs.desc && lhs.longDesc == rhs.longDesc + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(streamURL) + hasher.combine(imageURL) + hasher.combine(desc) + hasher.combine(longDesc) + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/StationsResponse.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/StationsResponse.swift new file mode 100644 index 000000000..f0fd3b1ca --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/StationsResponse.swift @@ -0,0 +1,15 @@ +// +// StationsResponse.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +/// The JSON envelope used by bundled and remote station catalogs. +public struct StationsResponse: Codable, Sendable { + public var station: [RadioStation] + public init(station: [RadioStation]) { self.station = station } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/AudioSessionConfigurator.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/AudioSessionConfigurator.swift new file mode 100644 index 000000000..c6f7be406 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/AudioSessionConfigurator.swift @@ -0,0 +1,44 @@ +// +// AudioSessionConfigurator.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import AVFAudio +import OSLog + +/// Owns the playback category and activates the session only when audio is about to start. +public enum AudioSessionConfigurator { + /// Only a non-mixable session can own lock-screen, Control Center and CarPlay Now Playing and + /// receive headset buttons. Playback already supports AirPlay and Bluetooth A2DP; + /// explicit allowAirPlay is only valid for playAndRecord and is rejected on devices. + public static func categoryOptions(mixesWithOthers: Bool = false) -> AVAudioSession.CategoryOptions { + var options: AVAudioSession.CategoryOptions = [] + if mixesWithOthers { options.insert(.mixWithOthers) } + return options + } + + /// Activating at launch or on scene activation would stop other apps' audio just by opening + /// this one, so `PlayerService` awaits this before it lets the engine start. The category is + /// re-applied every time because the vendor engine sets its own when it is created. The work + /// runs off the main actor: `setActive(true)` blocks while the system arbitrates with other + /// sessions. Failures are logged and rethrown so the player can report them. + public static func activateForPlayback(mixesWithOthers: Bool = false) async throws { + let options = categoryOptions(mixesWithOthers: mixesWithOthers) + do { + try await Task.detached(priority: .userInitiated) { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default, options: options) + try session.setActive(true) + }.value + } catch { + Logger(subsystem: "SwiftRadioCore", category: "AudioSession") + .error("Audio activation failed: \(error.localizedDescription)") + throw error + } + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/MainActorDelivery.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/MainActorDelivery.swift new file mode 100644 index 000000000..29cbb49c9 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/MainActorDelivery.swift @@ -0,0 +1,25 @@ +// +// MainActorDelivery.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Delivers a vendor callback to the main actor without asserting where it came from. +/// +/// The supported FRadioPlayer 0.4 engine already delivers on the main actor. This defensive +/// boundary also supports explicitly injected background events without an actor assertion. +/// Main-thread delivery stays synchronous and ordered with transport commands; other threads +/// hop before touching application state. +func deliverOnMainActor(_ body: @escaping @MainActor @Sendable () -> Void) { + if Thread.isMainThread { + MainActor.assumeIsolated { body() } + } else { + Task { @MainActor in body() } + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/NowPlayingInfoPublisher.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/NowPlayingInfoPublisher.swift new file mode 100644 index 000000000..60aa97025 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/NowPlayingInfoPublisher.swift @@ -0,0 +1,46 @@ +// +// NowPlayingInfoPublisher.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer +import UIKit + +/// Replaces lock-screen metadata atomically, removing stale live/file timing keys. +@MainActor public final class NowPlayingInfoPublisher { + private let publish: @MainActor ([String: Any]?) -> Void + + public convenience init() { + self.init(publish: { MPNowPlayingInfoCenter.default().nowPlayingInfo = $0 }) + } + + init(publish: @escaping @MainActor ([String: Any]?) -> Void) { self.publish = publish } + + /// Removes the system media session instead of leaving an empty lock-screen card. + public func clear() { publish(nil) } + + public func update(title: String, artist: String, artwork: UIImage?, isLive: Bool, + elapsed: TimeInterval, duration: TimeInterval, rate: Float) { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: title, + MPMediaItemPropertyArtist: artist, + MPNowPlayingInfoPropertyIsLiveStream: isLive, + MPNowPlayingInfoPropertyPlaybackRate: rate + ] + if let artwork { + info[MPMediaItemPropertyArtwork] = // MediaPlayer calls this handler on its own queue: keep it nonisolated (@Sendable) and + // capture only the Sendable UIImage. + MPMediaItemArtwork(boundsSize: artwork.size) { @Sendable _ in artwork } + } + if !isLive { + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed + info[MPMediaItemPropertyPlaybackDuration] = duration + } + publish(info) + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+Readiness.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+Readiness.swift new file mode 100644 index 000000000..9de4d993d --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+Readiness.swift @@ -0,0 +1,16 @@ +// +// PlayerService+Readiness.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +extension PlayerService { + /// Whether the current media item is usable, even while transport is stopped. + public enum Readiness: Equatable, Sendable { + case idle, loading, ready + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+State.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+State.swift new file mode 100644 index 000000000..025203ecb --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService+State.swift @@ -0,0 +1,17 @@ +// +// PlayerService+State.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +extension PlayerService { + /// Playback transport, independent of the current item's loading progress. + public enum State: Equatable, Sendable { + case idle, playing, paused, stopped + case failed(String) + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift new file mode 100644 index 000000000..b8020fc0d --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift @@ -0,0 +1,438 @@ +// +// PlayerService.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import OSLog +import os +import Observation +import UIKit +import FRadioPlayer + +/// Owns observable playback and translates engine callbacks into application-owned state. +@MainActor @Observable public final class PlayerService: RadioPlayerEvents { + public private(set) var state: State = .idle + public private(set) var readiness: Readiness = .idle + public var isBuffering: Bool { readiness == .loading && state != .stopped } + public var isLive: Bool { duration == 0 } + public private(set) var track: String? + public private(set) var artist: String? + public private(set) var artworkURL: URL? + public private(set) var elapsed: TimeInterval = 0 + public private(set) var duration: TimeInterval = 0 + public private(set) var isSeeking = false + @ObservationIgnored private var seekGeneration = 0 + /// App-owned intent: late engine events must not override an explicit pause or stop. + @ObservationIgnored private var transportIntent: TransportIntent? + private enum TransportIntent { case playing, paused, stopped } + /// Set by seek completion: the engine lands near, not exactly on, the target. + @ObservationIgnored private var publishesNextTick = false + @ObservationIgnored private let player: any RadioPlaying + @ObservationIgnored private let nowPlaying: NowPlayingInfoPublisher + @ObservationIgnored private let remoteCommands: RemoteCommandsController + /// Awaited before the engine may start audio. `nil` means there is no session to prepare, so + /// commands reach the engine synchronously (engine-ordering tests use this). + @ObservationIgnored private let activateAudioSession: (@MainActor () async throws -> Void)? + /// Bumped by every transport command, so a newer command supersedes one still waiting for the + /// session: Stop right after Play must not start audio once activation finishes. + @ObservationIgnored private var startGeneration = 0 + /// Session arbitration can stall in the audio server; past this deadline the engine starts + /// anyway (the pre-ownership behaviour) rather than leaving Play dead. Tests shorten it. + @ObservationIgnored var activationDeadline: Duration = .seconds(4) + /// The latest activation; each one waits for the previous so session calls never overlap. + @ObservationIgnored private(set) var activation: Task? + /// A load, play or seek is waiting for the session and will start playback when it is active. + @ObservationIgnored private var hasPendingStart = false + /// A stream selected while the session was activating that the engine has not received yet. + @ObservationIgnored private var pendingURL: URL? + @ObservationIgnored private var station: RadioStation? + @ObservationIgnored private var stationArtwork: UIImage? + @ObservationIgnored private var trackArtwork: UIImage? + /// The station that was current when the engine reported `artworkURL`. + @ObservationIgnored private var artworkStationID: String? + + /// `mixesWithOtherAudio` keeps other apps playing underneath, at the cost of lock-screen, + /// Control Center and CarPlay Now Playing (see `AudioSessionConfigurator.categoryOptions`). + public convenience init(player: any RadioPlaying, nowPlaying: NowPlayingInfoPublisher, + remoteCommands: RemoteCommandsController, mixesWithOtherAudio: Bool = false) { + self.init(player: player, nowPlaying: nowPlaying, remoteCommands: remoteCommands, + activateAudioSession: { + try await AudioSessionConfigurator.activateForPlayback(mixesWithOthers: mixesWithOtherAudio) + }) + } + + /// `activateAudioSession` nil skips session ownership: for a fake engine there is no audio to + /// own, and gating a UI test on the simulator's audio server only adds a flake. + public init(player: any RadioPlaying, nowPlaying: NowPlayingInfoPublisher, remoteCommands: RemoteCommandsController, + activateAudioSession: (@MainActor () async throws -> Void)?) { + self.player = player + self.nowPlaying = nowPlaying + self.remoteCommands = remoteCommands + self.activateAudioSession = activateAudioSession + player.addObserver(self) + } + + /// Creates the vendor adapter; call once in the composition root. + public static func makeRadioPlayer() -> any RadioPlaying { Engine() } + + public func displayTitle(for station: RadioStation) -> String { track ?? station.name } + public func displayArtist(for station: RadioStation) -> String { artist ?? station.desc } + + /// Supplies shared station fallbacks without making the player depend on StationsStore. + public func updateStation(_ station: RadioStation?) { + if self.station?.id != station?.id { invalidateSeek() } + self.station = station + stationArtwork = nil + trackArtwork = nil + if station == nil { + transportIntent = .stopped + track = nil + artist = nil + artworkURL = nil + elapsed = 0 + updateDuration(0) + publish() + } + } + + /// Rejects artwork that finished after selection or track artwork identity changed. + public func updateArtwork(_ image: UIImage?, for stationID: String, artworkURL: URL?) { + guard station?.id == stationID else { return } + if let artworkURL { + guard self.artworkURL == artworkURL, artworkStationID == stationID else { return } + trackArtwork = image + } else { + stationArtwork = image + } + publish() + } + + public func load(url: URL) { + invalidateSeek() + transportIntent = .playing + // The previous stream would otherwise keep playing, and reporting its events under the new + // station, until the session is ready for the new one. + if activateAudioSession != nil, player.isPlaying { player.stop() } + state = .idle + readiness = .loading + track = nil + artist = nil + artworkURL = nil + trackArtwork = nil + elapsed = 0 + updateDuration(0) + pendingURL = url + publish() + afterActivation { service in + service.pendingURL = nil + service.player.radioURL = url // The production adapter enables autoplay. + service.publish() + } + } + + /// Detaches the engine from a stream that is no longer selectable. `stop()` alone leaves the + /// vendor's `radioURL` (and its player item) in place, so a later remote Play would resume a + /// station the catalog has dropped while the phone controls and CarPlay show no selection. + public func unload() { + supersedePendingStart() + pendingURL = nil + transportIntent = .stopped + player.stop() + player.radioURL = nil + state = .stopped + readiness = .idle + updateStation(nil) // Clears presentation and removes system Now Playing info. + } + + /// Starting playback requires a selected station: remote commands and CarPlay can arrive after + /// the catalog dropped the current one, and `unload()` leaves the engine with no stream. + public func play() { + guard station != nil else { return } + // A pending load, play or seek already starts playback once the session is active. + guard !hasPendingStart else { return } + transportIntent = .playing + if let url = pendingURL { return load(url: url) } // Stopped before the engine got it. + afterActivation { service in + service.player.play(); service.state = .playing; service.syncAndPublish() + } + } + public func pause() { + supersedePendingStart() + transportIntent = .paused + invalidateSeek() + player.pause(); state = .paused; syncAndPublish() + } + public func stop() { + supersedePendingStart() + transportIntent = .stopped + invalidateSeek() + player.stop(); state = .stopped; syncAndPublish() + } + public func togglePlayPause() { + updateDuration(player.duration ?? 0) + if player.isPlaying || hasPendingStart { + if isLive { stop() } else { pause() } + } else { play() } + } + public func seek(to seconds: TimeInterval) { + guard seconds.isFinite else { return } + // The engine duration can change during a reload. Read it before seeking so a live + // stream never enters the app's seek/resume flow, even when our cached duration lags. + updateDuration(player.duration ?? 0) + guard !isLive else { return publish() } + let time = min(max(0, seconds), duration) + // Releasing the scrubber resumes playback after the seek. A subsequent user command wins. + transportIntent = .playing + seekGeneration += 1 + let generation = seekGeneration + isSeeking = true + elapsed = time + syncAndPublish() + afterActivation { service in + service.player.seek(to: time) { [weak service] in + guard let service else { return } + service.restoreTransportIntentAfterSeek() + guard service.seekGeneration == generation else { return } + service.isSeeking = false + service.publishesNextTick = true + // Resume-on-scrub is app policy. FRadioPlayer 0.4 seeks without changing + // transport, so only this still-current completion may resume playback. + if service.transportIntent == .playing { + if !service.player.isPlaying { service.player.play() } + service.state = .playing + } + service.syncAndPublish() + } + } + } + + /// Hands `command` to the engine once the audio session is active. The session applies its + /// category only on activation, so starting the engine first could play under the wrong + /// category or without owning Now Playing. Only the latest transport command may run. + private func afterActivation(_ command: @escaping @MainActor (PlayerService) -> Void) { + startGeneration += 1 + guard let activateAudioSession else { return command(self) } + let generation = startGeneration + let previous = activation + hasPendingStart = true + activation = Task { @MainActor [weak self] in + await previous?.value + guard self?.startGeneration == generation else { return } + let failure: (any Error)? + let deadline = self?.activationDeadline ?? .seconds(4) + do { try await Self.awaitActivation(activateAudioSession, deadline: deadline); failure = nil } + catch { failure = error } + guard let self, self.startGeneration == generation else { return } + self.hasPendingStart = false + if let failure { self.activationFailed(failure) } else { command(self) } + } + } + + /// Races the activation against `deadline`. A timeout is not a failure: the engine starts + /// without confirmed ownership and the next command tries the session again. Awaiting a + /// blocked `setActive` cannot be cancelled, so the loser simply finds the continuation gone. + private static func awaitActivation(_ activate: @escaping @MainActor () async throws -> Void, + deadline: Duration) async throws { + let activated: Bool = try await withCheckedThrowingContinuation { continuation in + let slot = OSAllocatedUnfairLock?>(initialState: continuation) + let resume: @Sendable (Result) -> Void = { result in + slot.withLock { pending -> CheckedContinuation? in + defer { pending = nil } + return pending + }?.resume(with: result) + } + Task { @MainActor in + do { try await activate(); resume(.success(true)) } catch { resume(.failure(error)) } + } + Task { + try? await Task.sleep(for: deadline) + resume(.success(false)) + } + } + if !activated { + Logger(subsystem: "SwiftRadioCore", category: "AudioSession") + .error("Audio activation exceeded \(deadline); starting playback without it") + } + } + + private func supersedePendingStart() { + startGeneration += 1 + hasPendingStart = false + // A superseded load never reached the engine: nothing is loading until play() retries it. + if pendingURL != nil { readiness = .idle } + } + + /// Without an active session the engine must not play: report it as a player failure so no + /// surface keeps showing Playing, and let the next tap or Play command retry. + private func activationFailed(_ error: any Error) { + transportIntent = .stopped + invalidateSeek() + player.stop() + state = .failed(error.localizedDescription) + readiness = .idle + syncAndPublish() + } + + private func invalidateSeek() { + seekGeneration += 1 + isSeeking = false + publishesNextTick = false + } + + private func restoreTransportIntentAfterSeek() { + switch transportIntent { + case .paused: player.pause(); state = .paused + case .stopped: player.stop(); state = .stopped + case .playing, nil: break + } + } + + public func playerStateDidChange(_ event: RadioPlayerState) { + switch event { + case .urlNotSet: readiness = .idle; state = .idle + case .loading: readiness = .loading + case .readyToPlay, .loadingFinished: readiness = .ready + case .failed(let message): readiness = .idle; state = .failed(message) + } + syncAndPublish() + } + public func playbackStateDidChange(_ state: State) { + if state == .playing, transportIntent == .paused || transportIntent == .stopped { + restoreTransportIntentAfterSeek() + } else if state == .paused, transportIntent == .stopped { + // The vendor pauses on an interruption even after the user stopped; stopped stays stopped. + } else { + self.state = state + } + syncAndPublish() + } + public func metadataDidChange(artist: String?, track: String?) { + self.artist = artist + self.track = track + syncAndPublish() + } + /// FRadioPlayer 0.4 rejects superseded lookups. The app also requires track context before + /// accepting artwork and keys downloaded images to station/URL identity, so injected or late + /// application events cannot replace the current station's fallback. + public func artworkDidChange(_ url: URL?) { + if url != nil, track == nil, artist == nil { return } + artworkURL = url + artworkStationID = url == nil ? nil : station?.id + trackArtwork = nil // Nil immediately exposes station art, with no network dependency. + syncAndPublish() + } + public func durationDidChange(_ duration: TimeInterval) { updateDuration(duration); publish() } + /// The system advances lock-screen elapsed time from the published rate, so ticks only update + /// the observable clock. Rebuilding the dictionary (and its artwork) every 0.5 s adds nothing. + public func playTimeDidChange(_ elapsed: TimeInterval, duration: TimeInterval) { + if !isSeeking { self.elapsed = elapsed.isFinite ? max(0, elapsed) : 0 } + let durationChanged = Self.normalizedDuration(duration) != self.duration + guard durationChanged || (publishesNextTick && !isSeeking) else { return } + publishesNextTick = false + if durationChanged { updateDuration(duration) } + publish() + } + private func updateDuration(_ value: TimeInterval) { + duration = Self.normalizedDuration(value) + remoteCommands.updateLiveState(isLive: isLive) + } + private static func normalizedDuration(_ value: TimeInterval) -> TimeInterval { + value.isFinite && value > 0 ? value : 0 + } + private func syncAndPublish() { updateDuration(player.duration ?? 0); publish() } + /// The system extrapolates a file's elapsed time from the rate, so a rebuffering file publishes + /// 0 until it is ready again; transport intent is unchanged. A live stream publishes no clock, + /// and a 0 rate would only flip its lock-screen button to Play on every rebuffer. + private var isClockRunning: Bool { state == .playing && (isLive || readiness != .loading) } + + private func publish() { + guard let station else { + nowPlaying.clear() + return + } + nowPlaying.update(title: track ?? station.name, artist: artist ?? station.desc, + artwork: trackArtwork ?? stationArtwork, isLive: isLive, elapsed: elapsed, + duration: duration, rate: isClockRunning ? 1 : 0) + } +} + +extension PlayerService { + /// Adapts incompatible vendor duration/metadata signatures while keeping vendor types private. + @MainActor private final class Engine: RadioPlaying, FRadioPlayerObserver { + private let engine: FRadioPlayer + private weak var observer: (any RadioPlayerEvents)? + init() { + // SwiftRadio owns category setup and activation. Opt out before creating the singleton + // so startup leaves audio session setup to the app. + FRadioPlayer.configuresAudioSession = false + engine = FRadioPlayer.shared + engine.isAutoPlay = true + engine.enableArtwork = true + engine.artworkAPI = iTunesAPI(artworkSize: 600) + engine.addObserver(self) + } + isolated deinit { engine.removeObserver(self) } + var radioURL: URL? { + get { engine.radioURL } + set { engine.radioURL = newValue } + } + var isPlaying: Bool { engine.isPlaying } + var duration: TimeInterval? { engine.duration } + var currentMetadata: (artist: String?, track: String?)? { + engine.currentMetadata.map { ($0.artistName, $0.trackName) } + } + func play() { engine.play() } + func pause() { engine.pause() } + func stop() { engine.stop() } + func togglePlaying() { engine.togglePlaying() } + func seek(to seconds: TimeInterval, completion: @escaping @MainActor @Sendable () -> Void) { + engine.seek(to: seconds) { deliverOnMainActor(completion) } + } + func addObserver(_ observer: any RadioPlayerEvents) { self.observer = observer } + + // FRadioPlayer 0.4 guarantees main-actor observer delivery. Keep the defensive adapter + // boundary: production callbacks run inline, preserving ordering, while explicitly + // injected background callbacks hop before touching application state. Only application + // values cross that boundary; vendor metadata is reduced to strings here. + nonisolated func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) { + let event: RadioPlayerState + switch state { + case .urlNotSet: event = .urlNotSet + case .loading: event = .loading + case .readyToPlay: event = .readyToPlay + case .loadingFinished: event = .loadingFinished + case .error: event = .failed(state.description) + } + deliverOnMainActor { [weak self] in self?.observer?.playerStateDidChange(event) } + } + nonisolated func radioPlayer(_ player: FRadioPlayer, playbackStateDidChange state: FRadioPlayer.PlaybackState) { + let event: PlayerService.State + switch state { + case .playing: event = .playing + case .paused: event = .paused + case .stopped: event = .stopped + } + deliverOnMainActor { [weak self] in self?.observer?.playbackStateDidChange(event) } + } + nonisolated func radioPlayer(_ player: FRadioPlayer, metadataDidChange metadata: FRadioPlayer.Metadata?) { + let artist = metadata?.artistName + let track = metadata?.trackName + deliverOnMainActor { [weak self] in self?.observer?.metadataDidChange(artist: artist, track: track) } + } + nonisolated func radioPlayer(_ player: FRadioPlayer, artworkDidChange artworkURL: URL?) { + deliverOnMainActor { [weak self] in self?.observer?.artworkDidChange(artworkURL) } + } + nonisolated func radioPlayer(_ player: FRadioPlayer, durationDidChange duration: TimeInterval) { + deliverOnMainActor { [weak self] in self?.observer?.durationDidChange(duration) } + } + nonisolated func radioPlayer(_ player: FRadioPlayer, playTimeDidChange currentTime: TimeInterval, duration: TimeInterval) { + deliverOnMainActor { [weak self] in self?.observer?.playTimeDidChange(currentTime, duration: duration) } + } + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerEvents.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerEvents.swift new file mode 100644 index 000000000..85cb81803 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerEvents.swift @@ -0,0 +1,21 @@ +// +// RadioPlayerEvents.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Application-owned engine events, delivered synchronously on the main actor. +@MainActor public protocol RadioPlayerEvents: AnyObject { + func playerStateDidChange(_ state: RadioPlayerState) + func playbackStateDidChange(_ state: PlayerService.State) + func metadataDidChange(artist: String?, track: String?) + func artworkDidChange(_ url: URL?) + func durationDidChange(_ duration: TimeInterval) + func playTimeDidChange(_ elapsed: TimeInterval, duration: TimeInterval) +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerState.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerState.swift new file mode 100644 index 000000000..6891482a3 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlayerState.swift @@ -0,0 +1,15 @@ +// +// RadioPlayerState.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +/// Readiness events retain both ready signals so lock-screen publication can repeat. +public enum RadioPlayerState: Equatable, Sendable { + case urlNotSet, loading, readyToPlay, loadingFinished + case failed(String) +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlaying.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlaying.swift new file mode 100644 index 000000000..1ba2fc560 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RadioPlaying.swift @@ -0,0 +1,25 @@ +// +// RadioPlaying.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// The main-actor transport and event registration boundary implemented by engines and test fakes. +@MainActor public protocol RadioPlaying: AnyObject { + var radioURL: URL? { get set } + var isPlaying: Bool { get } + var duration: TimeInterval? { get } + var currentMetadata: (artist: String?, track: String?)? { get } + func play() + func pause() + func stop() + func togglePlaying() + func seek(to seconds: TimeInterval, completion: @escaping @MainActor @Sendable () -> Void) + func addObserver(_ observer: any RadioPlayerEvents) +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommand.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommand.swift new file mode 100644 index 000000000..b95720b4e --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommand.swift @@ -0,0 +1,14 @@ +// +// RemoteCommand.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +/// The six transport commands supported by the radio app. +enum RemoteCommand: CaseIterable, Sendable { + case play, pause, stop, toggle, next, previous +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandHandling.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandHandling.swift new file mode 100644 index 000000000..91215a6e6 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandHandling.swift @@ -0,0 +1,19 @@ +// +// RemoteCommandHandling.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer + +/// Allows remote registration to be tested without changing the system command center. +@MainActor protocol RemoteCommandHandling: AnyObject { + func setEnabled(_ enabled: Bool, for command: RemoteCommand) + func addTarget(for command: RemoteCommand, handler: @escaping @Sendable () -> MPRemoteCommandHandlerStatus) -> Any + func removeTarget(_ token: Any, for command: RemoteCommand) + func beginReceivingEvents() +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandsController.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandsController.swift new file mode 100644 index 000000000..400e3a2e3 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/RemoteCommandsController.swift @@ -0,0 +1,88 @@ +// +// RemoteCommandsController.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer + +/// Owns remote command registrations and runs their actions on the main actor, reporting each +/// action's real status to the system. +@MainActor public final class RemoteCommandsController { + private let handler: any RemoteCommandHandling + private var isLive = false + private var tokens: [(RemoteCommand, Any)] = [] + + public convenience init() { self.init(handler: SystemRemoteCommandHandler()) } + + init(handler: any RemoteCommandHandling) { + self.handler = handler + handler.setEnabled(true, for: .pause) + handler.setEnabled(false, for: .stop) + } + + isolated deinit { unbind() } + + /// Wires the six commands to the shared player and catalog. Without a selection every command + /// reports that there is nothing to act on instead of starting the radio from nothing, which + /// matches UIKit's `setNext`/`setPrevious` ignoring a missing current station. Next/Previous + /// with no other usable station report `.noSuchContent` rather than claiming a change. + public func bind(player: PlayerService, stations: StationsStore) { + func whenSelected(_ action: @escaping @MainActor @Sendable (PlayerService, StationsStore) -> Bool) + -> @MainActor @Sendable () -> MPRemoteCommandHandlerStatus { + { [weak player, weak stations] in + guard let player, let stations, stations.currentStation != nil else { return .noActionableNowPlayingItem } + return action(player, stations) ? .success : .noSuchContent + } + } + bind(play: whenSelected { player, _ in player.play(); return true }, + pause: whenSelected { player, _ in player.pause(); return true }, + stop: whenSelected { player, _ in player.stop(); return true }, + toggle: whenSelected { player, _ in player.togglePlayPause(); return true }, + next: whenSelected { _, stations in stations.selectNext() }, + previous: whenSelected { _, stations in stations.selectPrevious() }) + } + + /// Custom wiring: each action's return value is the status reported to the system. + public func bind(play: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus, + pause: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus, + stop: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus, + toggle: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus, + next: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus, + previous: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus) { + unbind() + let actions = [play, pause, stop, toggle, next, previous] + for (command, action) in zip(RemoteCommand.allCases, actions) { + let token = handler.addTarget(for: command) { Self.accept(action) } + tokens.append((command, token)) + handler.setEnabled(command != .stop, for: command) + } + updateLiveState(isLive: isLive) + handler.beginReceivingEvents() + } + + public func unbind() { + for (command, token) in tokens { handler.removeTarget(token, for: command) } + tokens.removeAll() + } + + public func updateLiveState(isLive: Bool) { + self.isLive = isLive + handler.setEnabled(!isLive, for: .pause) + handler.setEnabled(isLive, for: .stop) + } + + /// MediaPlayer calls targets on the main thread in practice, where the action runs inline. On + /// any other thread the callback blocks on `DispatchQueue.main.sync` for the real status. That + /// cannot deadlock: nothing on the main actor ever waits for a MediaPlayer callback queue, and + /// the actions only touch main-actor state (session activation is awaited asynchronously). + nonisolated private static func accept(_ action: @escaping @MainActor @Sendable () -> MPRemoteCommandHandlerStatus) + -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { return MainActor.assumeIsolated { action() } } + return DispatchQueue.main.sync { MainActor.assumeIsolated { action() } } + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/SystemRemoteCommandHandler.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/SystemRemoteCommandHandler.swift new file mode 100644 index 000000000..40edcf6b1 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/SystemRemoteCommandHandler.swift @@ -0,0 +1,38 @@ +// +// SystemRemoteCommandHandler.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer +import UIKit + +/// Connects application command identifiers to MediaPlayer's system commands. +@MainActor final class SystemRemoteCommandHandler: RemoteCommandHandling { + private let center = MPRemoteCommandCenter.shared() + func setEnabled(_ enabled: Bool, for command: RemoteCommand) { systemCommand(command).isEnabled = enabled } + func addTarget(for command: RemoteCommand, handler: @escaping @Sendable () -> MPRemoteCommandHandlerStatus) -> Any { + // The wrapper must be explicitly @Sendable: inside a @MainActor type it would otherwise be + // inferred main-actor-isolated, and MediaPlayer may invoke the target off the main thread. + // That inferred isolation traps on the executor check before the controller can enqueue its + // main-actor action; the handler itself reaches the main actor (RemoteCommandsController.accept). + systemCommand(command).addTarget { @Sendable _ in handler() } + } + func removeTarget(_ token: Any, for command: RemoteCommand) { systemCommand(command).removeTarget(token) } + func beginReceivingEvents() { UIApplication.shared.beginReceivingRemoteControlEvents() } + + private func systemCommand(_ command: RemoteCommand) -> MPRemoteCommand { + switch command { + case .play: center.playCommand + case .pause: center.pauseCommand + case .stop: center.stopCommand + case .toggle: center.togglePlayPauseCommand + case .next: center.nextTrackCommand + case .previous: center.previousTrackCommand + } + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/BundleStationsLoader.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/BundleStationsLoader.swift new file mode 100644 index 000000000..8bd38bcb3 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/BundleStationsLoader.swift @@ -0,0 +1,28 @@ +// +// BundleStationsLoader.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Decodes a station catalog from the supplied application or test bundle. +public struct BundleStationsLoader: StationsLoader { + public var fileName: String + public var bundle: Bundle + public init(fileName: String = "stations", bundle: Bundle = .main) { + self.fileName = fileName + self.bundle = bundle + } + + public func load() async throws -> [RadioStation] { + guard let url = bundle.url(forResource: fileName, withExtension: "json") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(StationsResponse.self, from: Data(contentsOf: url)).station + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/CatalogListContent.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/CatalogListContent.swift new file mode 100644 index 000000000..42cf68f7a --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/CatalogListContent.swift @@ -0,0 +1,56 @@ +// +// CatalogListContent.swift +// SwiftRadioCore +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// What a size-limited station list (the CarPlay list template) shows for the catalog's state. +/// Pure so the car's empty, failed and paged states are testable without a head unit. +public enum CatalogListContent: Equatable, Sendable { + case loading + case failed(String) + /// Loaded, but the catalog has no stations. + case empty + /// The catalog split into pages that each fit the list limit. Every page but the last ends + /// with a More row that opens the next one. `hiddenCount` stations did not fit even so. + case stations(pages: [[RadioStation]], hiddenCount: Int) + + /// - Parameter maximumPageCount: how many list templates may be stacked for the catalog. + public init(stations: [RadioStation], loadState: StationsStore.LoadState, maximumItemCount: Int, + maximumPageCount: Int = 1) { + guard stations.isEmpty else { + self = Self.paged(stations, limit: max(1, maximumItemCount), pageCount: max(1, maximumPageCount)) + return + } + switch loadState { + case .idle, .loading: self = .loading + case .failed(let message): self = .failed(message) + case .loaded: self = .empty + } + } + + /// Stations on the pages, in catalog order. + public var visibleStations: [RadioStation] { + guard case .stations(let pages, _) = self else { return [] } + return pages.flatMap { $0 } + } + + private static func paged(_ stations: [RadioStation], limit: Int, pageCount: Int) -> Self { + var pages: [[RadioStation]] = [] + var remaining = stations[...] + while !remaining.isEmpty { + // The last page allowed, or the rest fitting whole, needs no More row. A one-item + // limit has no room for one, so it truncates. + let isLast = pages.count == pageCount - 1 || remaining.count <= limit || limit < 2 + let page = remaining.prefix(isLast ? limit : limit - 1) + pages.append(Array(page)) + remaining = remaining.dropFirst(page.count) + if isLast { break } + } + return .stations(pages: pages, hiddenCount: remaining.count) + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/RemoteStationsLoader.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/RemoteStationsLoader.swift new file mode 100644 index 000000000..1c57f2863 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/RemoteStationsLoader.swift @@ -0,0 +1,30 @@ +// +// RemoteStationsLoader.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Fetches fresh station JSON, bypassing the local HTTP cache on each refresh. +public struct RemoteStationsLoader: StationsLoader { + public var url: URL + public var session: URLSession + public init(url: URL, session: URLSession = .shared) { + self.url = url + self.session = session + } + + public func load() async throws -> [RadioStation] { + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + let (data, response) = try await session.data(for: request) + if let response = response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) { + throw URLError(.badServerResponse) + } + return try JSONDecoder().decode(StationsResponse.self, from: data).station + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsLoader.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsLoader.swift new file mode 100644 index 000000000..e655bcd6f --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsLoader.swift @@ -0,0 +1,14 @@ +// +// StationsLoader.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +/// Loads a station catalog without tying observable state to its source. +public protocol StationsLoader: Sendable { + func load() async throws -> [RadioStation] +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore+LoadState.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore+LoadState.swift new file mode 100644 index 000000000..75f6f4345 --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore+LoadState.swift @@ -0,0 +1,36 @@ +// +// StationsStore+LoadState.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +extension StationsStore { + /// The latest catalog request's progress, independent of navigation lifetime. + public enum LoadState: Equatable, Sendable { + case idle, loading, loaded + case failed(String) + } + + /// Which surface a row tap came from; see `activate(_:on:)`. + public enum ActivationSurface: CaseIterable, Sendable { + case phone, carPlay + } + + /// Outcome of `activate(_:on:)`, so a caller can finish its UI flow without inspecting the player. + public enum Activation: Equatable, Sendable { + /// The station was selected and its stream loaded (autoplay starts it). + case selected + /// The paused or stopped current station was asked to play again. + case resumed + /// A phone tap paused or stopped the current station, or cancelled its pending start. + case paused + /// The station is already playing (or, on CarPlay, loading); nothing changed. + case alreadyActive + /// The station's stream URL is unusable; nothing changed. + case unavailable + } +} diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift new file mode 100644 index 000000000..a1462159c --- /dev/null +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift @@ -0,0 +1,123 @@ +// +// StationsStore.swift +// SwiftRadioCore +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Observation + +/// Owns catalog loading, filtering, and circular station selection for all app scenes. +@MainActor @Observable public final class StationsStore { + public private(set) var stations: [RadioStation] = [] + public private(set) var currentStation: RadioStation? + public private(set) var loadState: LoadState = .idle + public var searchText = "" + @ObservationIgnored private let loader: any StationsLoader + @ObservationIgnored private let player: PlayerService + @ObservationIgnored private var loadGeneration = 0 + + public init(loader: any StationsLoader, player: PlayerService) { + self.loader = loader + self.player = player + } + + public var filteredStations: [RadioStation] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty ? stations : stations.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + public func load() async { + loadGeneration += 1 + let generation = loadGeneration + let previousState = loadState + loadState = .loading + do { + let loaded = try await loader.load() + try Task.checkCancellation() + guard generation == loadGeneration else { return } + if loaded != stations { + stations = loaded + if let currentStation, !loaded.contains(currentStation) { + self.currentStation = nil + // Clearing selection must not leave audio playing behind hidden controls, nor + // leave the removed stream loaded and resumable from a remote Play command. + player.unload() + } + } + loadState = .loaded + } catch { + guard generation == loadGeneration else { return } + if error is CancellationError || (error as? URLError)?.code == .cancelled { + loadState = previousState == .loading ? .idle : previousState + } else { + loadState = .failed(error.localizedDescription) + } + } + } + + public func refresh() async { await load() } + + /// Returns `false`, leaving the current selection and stream untouched, when the station's + /// stream URL cannot be opened. Callers must not start playback on `false`: the engine would + /// resume the previously selected station under the new station's row. + @discardableResult public func select(_ station: RadioStation) -> Bool { + guard let url = Self.streamURL(for: station) else { return false } + currentStation = station + player.updateStation(station) + player.load(url: url) + return true + } + + /// Row-tap rule. A different (or failed) station reloads its stream on every surface; for the + /// current station the surfaces differ on purpose: + /// - `.phone` mirrors the UIKit reference: playing opens the player, anything else (paused, + /// stopped, or a load still waiting for the audio session) toggles. The vendor's autoplay + /// reports a buffering stream as playing, so that opens the player, as in UIKit. + /// - `.carPlay` never restarts or cancels: playing or loading is left alone, paused or stopped plays. + @discardableResult public func activate(_ station: RadioStation, on surface: ActivationSurface) -> Activation { + guard station == currentStation else { return select(station) ? .selected : .unavailable } + switch (player.state, surface) { + case (.playing, _): + return .alreadyActive + case (.failed, _): + return select(station) ? .selected : .unavailable + case (.idle, .carPlay) where player.isBuffering: + return .alreadyActive // The pending load starts once the session is active. + case (_, .carPlay): + player.play() + return .resumed + case (_, .phone): + player.togglePlayPause() + return player.state == .playing ? .resumed : .paused + } + } + + /// Returns whether a station was selected, so a remote command can report that it could not act. + @discardableResult public func selectNext() -> Bool { selectRelative(offset: 1) } + @discardableResult public func selectPrevious() -> Bool { selectRelative(offset: -1) } + + /// Steps over stations with unusable stream URLs so a single bad catalog entry cannot trap + /// Next/Previous on it. With every other entry unusable the current stream is left alone. + private func selectRelative(offset: Int) -> Bool { + let count = stations.count + guard count > 0 else { return false } + guard let currentStation, let index = stations.firstIndex(of: currentStation) else { + return stations.contains { select($0) } + } + for step in 1.. URL? { + guard let url = URL(string: station.streamURL), url.scheme != nil else { return nil } + return url + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderCoalescingTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderCoalescingTests.swift new file mode 100644 index 000000000..ed5e9587e --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderCoalescingTests.swift @@ -0,0 +1,171 @@ +// +// ArtworkLoaderCoalescingTests.swift +// SwiftRadioCoreTests +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import os +import Testing +import UIKit +@testable import SwiftRadioCore + +/// Concurrent requests for one artwork URL share a single download, so the lock screen, +/// the backdrop and the foreground artwork cannot resolve differently for the same track. +struct ArtworkLoaderCoalescingTests { + @Test func concurrentRequestsForOneURLShareOneDownload() async throws { + let url = CountingURLProtocol.url() + let loader = ArtworkLoader(session: CountingURLProtocol.session()) + async let first = loader.trackArtwork(at: url) + async let second = loader.trackArtwork(at: url) + async let third = loader.trackArtwork(at: url) + let images = await [first, second, third] + let image = try #require(images[0]) + #expect(images.allSatisfy { $0 === image }) + #expect(CountingURLProtocol.requestCount(for: url) == 1) + } + + @Test func failedDownloadIsNotSharedWithLaterRequests() async { + let url = CountingURLProtocol.url(failing: true) + let loader = ArtworkLoader(session: CountingURLProtocol.session()) + #expect(await loader.trackArtwork(at: url) == nil) + #expect(await loader.trackArtwork(at: url) == nil) + #expect(CountingURLProtocol.requestCount(for: url) == 2) + } + + /// Rapid station changes and CarPlay disconnects cancel their artwork tasks. A shared + /// download must keep running while anyone still waits, and stop once nobody does. + @Test func lastCancelledWaiterCancelsTheSharedDownload() async throws { + let url = BlockingURLProtocol.url() + let loader = ArtworkLoader(session: BlockingURLProtocol.session()) + let first = Task { await loader.trackArtwork(at: url) } + let second = Task { await loader.trackArtwork(at: url) } + try await BlockingURLProtocol.wait { $0.started(url) == 1 } + first.cancel() + try await Task.sleep(for: .milliseconds(200)) + #expect(BlockingURLProtocol.stopped(url) == 0) + BlockingURLProtocol.respond(url) + #expect(await second.value != nil) // The remaining waiter still gets the image. + _ = await first.value + + let abandoned = BlockingURLProtocol.url() + let third = Task { await loader.trackArtwork(at: abandoned) } + let fourth = Task { await loader.trackArtwork(at: abandoned) } + try await BlockingURLProtocol.wait { $0.started(abandoned) == 1 } + third.cancel() + fourth.cancel() + try await BlockingURLProtocol.wait { $0.stopped(abandoned) == 1 } + #expect(await third.value == nil) + #expect(await fourth.value == nil) + let retry = Task { await loader.trackArtwork(at: abandoned) } // A later request starts over. + try await BlockingURLProtocol.wait { $0.started(abandoned) == 2 } + retry.cancel() + _ = await retry.value + } +} + +/// Holds every request until the test responds, and records starts and stops per URL. +private final class BlockingURLProtocol: URLProtocol, @unchecked Sendable { + struct Log { + var startCounts: [URL: Int] = [:] + var stopCounts: [URL: Int] = [:] + var open: [URL: [BlockingURLProtocol]] = [:] + func started(_ url: URL) -> Int { startCounts[url, default: 0] } + func stopped(_ url: URL) -> Int { stopCounts[url, default: 0] } + } + private static let log = OSAllocatedUnfairLock(initialState: Log()) + + static func url() -> URL { URL(string: "https://example.com/blocking/\(UUID().uuidString)")! } + static func started(_ url: URL) -> Int { log.withLock { $0.started(url) } } + static func stopped(_ url: URL) -> Int { log.withLock { $0.stopped(url) } } + + static func session() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [Self.self] + configuration.urlCache = nil + return URLSession(configuration: configuration) + } + + /// Polls for up to two seconds; cancellation reaches the protocol on URLSession's own queue. + static func wait(until condition: @Sendable (Log) -> Bool, + sourceLocation: SourceLocation = #_sourceLocation) async throws { + for _ in 0..<200 { + if log.withLock({ condition($0) }) { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("Timed out waiting for the URL protocol", sourceLocation: sourceLocation) + } + + static func respond(_ url: URL) { + let requests = log.withLock { $0.open.removeValue(forKey: url) ?? [] } + let data = try! Data(contentsOf: Bundle.module.url(forResource: "station", withExtension: "png", + subdirectory: "Fixtures")!) + for request in requests { + request.client?.urlProtocol(request, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, + headerFields: nil)!, + cacheStoragePolicy: .notAllowed) + request.client?.urlProtocol(request, didLoad: data) + request.client?.urlProtocolDidFinishLoading(request) + } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { return } + Self.log.withLock { + $0.startCounts[url, default: 0] += 1 + $0.open[url, default: []].append(self) + } + } + + override func stopLoading() { + guard let url = request.url else { return } + Self.log.withLock { + $0.stopCounts[url, default: 0] += 1 + $0.open[url]?.removeAll { $0 === self } + } + } +} + +/// Answers after a short delay so concurrent callers overlap, and counts requests per URL. +/// Each test uses its own URL, so parallel tests do not share counts. +private final class CountingURLProtocol: URLProtocol, @unchecked Sendable { + private static let counts = OSAllocatedUnfairLock(initialState: [URL: Int]()) + + static func url(failing: Bool = false) -> URL { + URL(string: "https://example.com/\(failing ? "missing" : "image")/\(UUID().uuidString)")! + } + + static func requestCount(for url: URL) -> Int { counts.withLock { $0[url, default: 0] } } + + static func session() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [Self.self] + configuration.urlCache = nil + return URLSession(configuration: configuration) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { return } + Self.counts.withLock { $0[url, default: 0] += 1 } + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { [self] in + let status = url.pathComponents.contains("missing") ? 404 : 200 + let data = try! Data(contentsOf: Bundle.module.url(forResource: "station", withExtension: "png", + subdirectory: "Fixtures")!) + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, + headerFields: nil)!, + cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + } + + override func stopLoading() {} +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderTests.swift new file mode 100644 index 000000000..6d0a1811c --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/ArtworkLoaderTests.swift @@ -0,0 +1,44 @@ +// +// ArtworkLoaderTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Testing +import UIKit +@testable import SwiftRadioCore + +/// Verifies station fallback, request-keyed decoded caching, and deterministic network failures. +struct ArtworkLoaderTests { + @Test func bundleImagesAndEmptyArtwork() async throws { + let loader = ArtworkLoader(session: StubURLProtocol.session(), placeholderName: "Fixtures/placeholder", bundle: .module) + var station = try RadioStationTests.fixtures()[0] + station.imageURL = "Fixtures/station" + let image = await loader.image(for: station) + let expected = try #require(UIImage(named: "Fixtures/station", in: .module, compatibleWith: nil)) + #expect(image.pngData() == expected.pngData()) + station.imageURL = "" + let fallback = await loader.image(for: station) + let placeholder = try #require(UIImage(named: "Fixtures/placeholder", in: .module, compatibleWith: nil)) + #expect(fallback.pngData() == placeholder.pngData()) + station.imageURL = "https://example.com/offline" + #expect(await loader.image(for: station).pngData() == placeholder.pngData()) + #expect(await loader.trackArtwork(at: URL(string: "https://example.com/invalid")!) == nil) + #expect(await loader.trackArtwork(at: URL(string: "https://example.com/missing")!) == nil) + } + + @Test func decodedImagesAreCachedByURL() async throws { + let loader = ArtworkLoader(session: StubURLProtocol.session()) + let url = URL(string: "https://example.com/image")! + let first = try #require(await loader.trackArtwork(at: url)) + let second = try #require(await loader.trackArtwork(at: url)) + #expect(first === second) + let different = try #require(await loader.trackArtwork(at: URL(string: "https://example.com/another")!)) + #expect(first !== different) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/AudioSessionConfiguratorTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/AudioSessionConfiguratorTests.swift new file mode 100644 index 000000000..496f4fdd9 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/AudioSessionConfiguratorTests.swift @@ -0,0 +1,27 @@ +// +// AudioSessionConfiguratorTests.swift +// SwiftRadioCoreTests +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import AVFAudio +import Testing +@testable import SwiftRadioCore + +/// Guards the category options that decide whether the app can own Now Playing. +struct AudioSessionConfiguratorTests { + @Test func playbackCategoryIsNonMixableByDefault() { + let options = AudioSessionConfigurator.categoryOptions() + #expect(!options.contains(.mixWithOthers)) + // Playback supports these routes implicitly; allowAirPlay is invalid for this category. + #expect(options.isEmpty) + } + + @Test func mixingIsAnExplicitOptIn() { + let options = AudioSessionConfigurator.categoryOptions(mixesWithOthers: true) + #expect(options.contains(.mixWithOthers)) + #expect(options == [.mixWithOthers]) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/CatalogListContentTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/CatalogListContentTests.swift new file mode 100644 index 000000000..05f1c9080 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/CatalogListContentTests.swift @@ -0,0 +1,72 @@ +// +// CatalogListContentTests.swift +// SwiftRadioCoreTests +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Testing +@testable import SwiftRadioCore + +/// Covers what a head unit list shows for each catalog state and list-size limit. +struct CatalogListContentTests { + private static func stations(_ count: Int) -> [RadioStation] { + (0..] = [] + var pendingCount: Int { waiting.count } + + func activate() async throws { + requests += 1 + try await withCheckedThrowingContinuation { waiting.append($0) } + } + + func release() { waiting.removeFirst().resume() } + func fail() { waiting.removeFirst().resume(throwing: Failure()) } + + /// Yields until `count` requests are waiting; activation starts on a later main-actor turn. + func waitForPending(_ count: Int, sourceLocation: SourceLocation = #_sourceLocation) async { + var turns = 0 + while waiting.count < count, turns < 10_000 { await Task.yield(); turns += 1 } + #expect(waiting.count == count, sourceLocation: sourceLocation) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ControlledStationsLoader.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ControlledStationsLoader.swift new file mode 100644 index 000000000..815f733e8 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ControlledStationsLoader.swift @@ -0,0 +1,30 @@ +// +// ControlledStationsLoader.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +@testable import SwiftRadioCore + +/// Allows overlapping loads to complete in an explicitly chosen order. +actor ControlledStationsLoader: StationsLoader { + private var requests: [CheckedContinuation<[RadioStation], any Error>] = [] + private var waiters: [(Int, CheckedContinuation)] = [] + func load() async throws -> [RadioStation] { + try await withCheckedThrowingContinuation { continuation in + requests.append(continuation) + let ready = waiters.filter { $0.0 <= requests.count } + waiters.removeAll { $0.0 <= requests.count } + for (_, waiter) in ready { waiter.resume() } + } + } + func waitForRequests(_ count: Int) async { + if requests.count >= count { return } + await withCheckedContinuation { waiters.append((count, $0)) } + } + func finish(_ index: Int, with stations: [RadioStation]) { requests[index].resume(returning: stations) } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRadioPlayer.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRadioPlayer.swift new file mode 100644 index 000000000..d058049e1 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRadioPlayer.swift @@ -0,0 +1,139 @@ +// +// FakeRadioPlayer.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +@testable import SwiftRadioCore + +/// Records transport commands and exposes all six application-owned event callbacks. +/// +/// `.recording` only records calls. `.vendor` also reproduces what FRadioPlayer 0.4.0 does +/// synchronously inside those calls, so ordering bugs show up against the real engine's shape: +/// - `play`/`pause`/`stop` emit `playbackStateDidChange` re-entrantly, only when the value changes, +/// and do nothing without a stream (the vendor has no `AVPlayer` until `radioURL` is set); +/// - setting `radioURL` stops the old item, resets the duration and autoplays the new one; +/// - `stop()` on a live stream clears the metadata; +/// - `seek` preserves transport and always completes on the main actor, including with no duration; +/// - interruption resumption requires a prior playing intent and no intervening user command. +/// Automatic vendor seek completions are asynchronous; pending completions are test-controlled. +@MainActor final class FakeRadioPlayer: RadioPlaying { + enum Fidelity: CaseIterable, Sendable { case recording, vendor } + + let fidelity: Fidelity + var radioURL: URL? { + didSet { + loadedURLs.append(radioURL) + guard fidelity == .vendor else { return } + if hasStream { stop() } + hasStream = radioURL != nil + if (duration ?? 0) != 0 { announceDuration(0) } + if radioURL == nil { + observer?.playerStateDidChange(.urlNotSet) + } else { + observer?.playerStateDidChange(.loading) + play() // The production adapter turns on isAutoPlay. + } + } + } + var isPlaying = false + var duration: TimeInterval? + var currentMetadata: (artist: String?, track: String?)? + weak var observer: (any RadioPlayerEvents)? + private(set) var calls: [String] = [] + /// Every `radioURL` assignment, including a test's own sentinel writes. + private(set) var loadedURLs: [URL?] = [] + private(set) var soughtTime: TimeInterval? + var completesSeeksImmediately = true + var pendingSeeks: [@MainActor @Sendable () -> Void] = [] + private var playbackState: PlayerService.State = .stopped + /// The vendor's `AVPlayer` exists from a non-nil `radioURL` until the next reset. + private var hasStream = false + private var resumeAfterInterruption: Bool? + + init(fidelity: Fidelity = .recording) { self.fidelity = fidelity } + + func play() { + resumeAfterInterruption = nil + calls.append("play") + guard fidelity == .vendor else { isPlaying = true; return } + guard hasStream else { return } + setPlaybackState(.playing) + } + func pause() { + resumeAfterInterruption = nil + calls.append("pause") + guard fidelity == .vendor else { isPlaying = false; return } + guard hasStream else { return } + setPlaybackState(.paused) + } + func stop() { + resumeAfterInterruption = nil + calls.append("stop") + guard fidelity == .vendor else { isPlaying = false; return } + guard hasStream else { return } + if (duration ?? 0) == 0 { observer?.metadataDidChange(artist: nil, track: nil) } + setPlaybackState(.stopped) + } + func togglePlaying() { calls.append("toggle"); isPlaying.toggle() } + func seek(to seconds: TimeInterval, completion: @escaping @MainActor @Sendable () -> Void) { + if fidelity == .recording || (duration ?? 0) > 0 { soughtTime = seconds } + if completesSeeksImmediately { + if fidelity == .vendor { Task { @MainActor in completion() } } + else { completion() } + } else { + pendingSeeks.append(completion) + } + } + func addObserver(_ observer: any RadioPlayerEvents) { self.observer = observer } + + /// The vendor's duration setter: stores the value and notifies only when it changed. + func announceDuration(_ value: TimeInterval) { + guard fidelity == .recording || value != (duration ?? 0) else { return } + duration = value + observer?.durationDidChange(value) + } + + // FRadioPlayer 0.4 handles session notifications and preserves transport intent. + // The app observes the resulting playback-state events. + func interruptionBegan() { + let resume = resumeAfterInterruption ?? isPlaying + pause() + resumeAfterInterruption = resume + } + func interruptionEnded(shouldResume: Bool) { + guard let resume = resumeAfterInterruption else { return } + resumeAfterInterruption = nil + if shouldResume, resume { play() } else { pause() } + } + /// `.oldDeviceUnavailable` (headphones unplugged, car disconnected) pauses. + func previousOutputBecameUnavailable() { pause() } + + private func setPlaybackState(_ state: PlayerService.State) { + isPlaying = state == .playing + guard state != playbackState else { return } + playbackState = state + observer?.playbackStateDidChange(state) + } + + /// Injects an adversarial off-main event through the adapter's defensive delivery helper. + /// This does not model the supported engine's guaranteed main-actor delivery. It + /// resumes once the main-actor observer has been updated. Returns whether it really ran + /// off-main, so the test cannot silently pass on the synchronous path. + nonisolated func emitPlaybackStateFromBackground(_ state: PlayerService.State) async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { [weak self] in + let wasOffMain = !Thread.isMainThread + deliverOnMainActor { + self?.observer?.playbackStateDidChange(state) + continuation.resume(returning: wasOffMain) + } + } + } + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRemoteCommandHandler.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRemoteCommandHandler.swift new file mode 100644 index 000000000..6d662c174 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeRemoteCommandHandler.swift @@ -0,0 +1,30 @@ +// +// FakeRemoteCommandHandler.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer +@testable import SwiftRadioCore + +/// Exercises registration and enablement without the process-wide command center. +@MainActor final class FakeRemoteCommandHandler: RemoteCommandHandling { + var enabled: [RemoteCommand: Bool] = [:] + var handlers: [RemoteCommand: @Sendable () -> MPRemoteCommandHandlerStatus] = [:] + var removedCount = 0 + var beginCount = 0 + func setEnabled(_ enabled: Bool, for command: RemoteCommand) { self.enabled[command] = enabled } + func addTarget(for command: RemoteCommand, handler: @escaping @Sendable () -> MPRemoteCommandHandlerStatus) -> Any { + handlers[command] = handler + return command + } + func removeTarget(_ token: Any, for command: RemoteCommand) { + handlers[command] = nil + removedCount += 1 + } + func beginReceivingEvents() { beginCount += 1 } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeStationsLoader.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeStationsLoader.swift new file mode 100644 index 000000000..f776a2c08 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/FakeStationsLoader.swift @@ -0,0 +1,24 @@ +// +// FakeStationsLoader.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +@testable import SwiftRadioCore + +/// Supplies deterministic station lists and counts refreshes without networking. +actor FakeStationsLoader: StationsLoader { + var stations: [RadioStation] + private(set) var loadCount = 0 + init(stations: [RadioStation]) { self.stations = stations } + func replace(with stations: [RadioStation]) { self.stations = stations } + func load() async throws -> [RadioStation] { + loadCount += 1 + return stations + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ObservationCounter.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ObservationCounter.swift new file mode 100644 index 000000000..74756cc02 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/ObservationCounter.swift @@ -0,0 +1,19 @@ +// +// ObservationCounter.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Bridges Observation's sendable invalidation callback to deterministic assertions. +final class ObservationCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { lock.withLock { count } } + func increment() { lock.withLock { count += 1 } } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/StubURLProtocol.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/StubURLProtocol.swift new file mode 100644 index 000000000..7a7353b5d --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fakes/StubURLProtocol.swift @@ -0,0 +1,47 @@ +// +// StubURLProtocol.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Returns deterministic image/data responses based on URL paths, with no shared mutable state. +final class StubURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let url = request.url else { return } + if url.path == "/offline" { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + return + } + let status = url.path == "/missing" ? 404 : 200 + let data: Data + if url.path == "/stations" || url.path == "/policy" { + if url.path == "/policy" && request.cachePolicy != .reloadIgnoringLocalCacheData { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + data = try! Data(contentsOf: Bundle.module.url(forResource: "stations", withExtension: "json", subdirectory: "Fixtures")!) + } else if url.path == "/invalid" { + data = Data("not an image or JSON".utf8) + } else { + data = try! Data(contentsOf: Bundle.module.url(forResource: "station", withExtension: "png", subdirectory: "Fixtures")!) + } + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, + headerFields: ["Cache-Control": "max-age=3600"])!, cacheStoragePolicy: .allowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + static func session() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [Self.self] + return URLSession(configuration: configuration) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/placeholder.png b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/placeholder.png new file mode 100755 index 0000000000000000000000000000000000000000..5dc5dc557a0b1a11a73a9e5b8145fde965c3d5fd GIT binary patch literal 54271 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 literal 0 HcmV?d00001 diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/station.png b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/Fixtures/station.png new file mode 100644 index 0000000000000000000000000000000000000000..ad8cd4e242388200b840eb3673a9a3b180d5c75e GIT binary patch literal 20806 zcmb??(|0Az6KycDlZox*#I|kQwrzXjoY=N)^ThVVb~52i?)Q7S_aC?qyLNSVt$yn6 zRkc@jjIyE>5C>yXJ{|osKc>e(m46+a$4Dvq)|KD68#Q$BLEQI`j{{I4V^aOxlV8UQB z;v(wa;5T{i9Sm|RXEk%qcL6ALQ6)&&C+R$a+~YBTg>nNTub=%HO@uGR?^&TyS`_YP1GJmF9Nd zmn63IV?WPa@ZmY5FR@`*-dyonXjs`x;EN*QchRMvn$OS+<*Z2}lJesV^=Q}ic#-DI z?Xq28*GEhcjbhqt9OYmySyeh=2ND+|T-0Rt2CE_A#ZbitJdmsthb& z1n_R%4^#_QTJg<%;w&aSu8nPv?(^880P9s_%~t}D=2iHEU%BSR_+mHld=OGyJ#6F* zy!w_bUUSI2EVuXL=^SXPx3_PRK6!Q~wrtn)oAy~+^?udwPsyL?26$#zocU|s%`dMn zM7Aa12zRIv2%0%SyU4HGrx4)({q4@>wz9=yJGM>SfOfM7$(7WBeoHF)+9T7p8?JzH zcCEx)rnl2-`R;&J0NG%GI8|nm`C^Uz zvMa+&Dm{4^?N_=pU#QG&$|B35q!y7_6LC+^JKdiNsns599Lu~@U!go1T2EaTTXlsY z8aq=KqIk19rLeT9HG8jX1n&-?>Rlxc66dD9d~hG3*d{ktCr3FF1)DRf;{~gco*BiS zC49aqKYv=j-AISFEhPK$njJodyAU0yS~3hh4QPjIq+Oa?oa(p*a`%Sh<3RQ(DEfZh zQl}4;B%TBaGbF) zL%C|cfa7F3yW(Zcpy&*16{wtsLe+o4GF5fx5vIklrlpEBau^^^E-PaM^D?AB4E~<6 zWLs9%RUlX=4A4)m3CFKbr}o+aRACM z4sS_%DzNT(n1vo*7jF&Ky6C<9W6JCWBqcqNeuO!G(vSwHR34CyyfN0RRD%Po0Yd+! z`j;teg!1$hsP*Y$N(Wlf=qLFM96T${?4Y!d!X}Pp9L4J9UXvQV&Sy{iPajnrze zfl8)NYrg3uW`MS42-mg%X&bqEUnd2{2#(K*!`>$r#W6ou zto~}&F|4*JvX}$93sgGbPflUWW`pf6PDM4&uW2>*x*BLsZdQr0wAN{O1Qr7B&nzTo zY-2?EQk2M0ncXT%_c8T-X(&3ZvTnWY&2!4MF|)NvP2%(jj%f+$KLMC0KwXrE;*pZ7 z;l<$VO^B|`O5V=-y>+j(2qXbUybm8p68*>EXg}0wZ)JvpmF7 z?t=X3Unj5`@B_U-B`%+u0NS}9(|S)aNj%KP;A!N`a+VgSLsl+QEN1$QcW}!rr>1Zv zEp23odkQr#?;BIOn6&{4Dz<9;P=&{v3!zn~R7h@OY&P*|TcM51HD3N>&g$Uiqv-){ zh&-IheeD?>r?u?%tn0FN**Ek22DV1prRsh=`Ff_L%v{N=dV1`_RZUz_8Y*RLm6g8o zdYqQJB*Tpsl#f`op09(-KUs+I6GDd$tJpaKi#Bbh3J|9yIDrw$KntWK@GYZaC!n_Y z$cTPg6(*XqOX~}?n>Hn`?kwIW?jU-b@ywKpGFpuASxd{)PklHweQx^v3D}M{#-Y$r zq}@Pk7%&`H7|LiJ8*)Hba2}_L9A8}n%dtfyW{D=9uT&KW$yEk#dE;v(gmiI>ADm;a4mEeG$~|t=6Nh0I03n>_tTbD3Hb!vDKU^Bb`H{EklMvl(S ztfvV+XgezI_@cp5xoSu$1wP3Y4Xfke% zDaHk38JyF2a@AOlIwm0{Hoc#2lFOEcpc=M?Bvk3G(DYVdr9YzG5Zp?0TWw4o2kZ5t z%Q3Z8;n!$6+_%I4tySC>OJ;D$xiGHW>wtM0b+@}>_16!O{nJlFu zL(9xqBYN3-wCa>Aq=e;otRlUOxGqqNWwVH(=mu7AMqy&a_iODab-(xiL9b|iv;u=Gt%5p-dx_pyy+PlIZ|N*2RV3> z!6C){A850tG;UP=m%ILI0ko}6`AeGwL_Bo&0$(1nP@oDeDAU%EbLqp8a#N1w2Cb|s zv~5mHN0_YEzQ)()H*YVqIKyClPb~lp%aAWOYiDPLXkYTsm#zG`Ue%-SCbMTtUEc}^ z1XVswwz4>O|2~nt%CnxK{<203sxS9aYC_ixhY_xcd$legD$$AnPU={jp5e2+p7$*-@jm zH50LjC;;vPmSMzy(-j>o#D}mg4i-_<#$w_)%nFIQ<#KW~!|bw2-eAqjBs6s#xiVAJ z3@JStTXOF~G-bcl#BQK=c?=1*L&1cbJgjr{#(z-YrG|N`idbycj{haiT(pg{OkL1R zb#Ob@F@hFV!28<-b+gpnFLR1T?NrwzE`4d^hYFoHrd+(#14LqC5+2I@ab zvBuDViA*+Pl3pUH$kZMpl#oS`01!k2F__gz}&O*QU-Sk-dXl ztSRbzNVf(vRkNMdxBo$Yuovy^gg?8Q!;aAEI*5c12u*$(nwy1qYLkGB0C$si9?Bap zuV>N*>tSB8ksjW3tn|W#D97(Bpi})^`@%D-gAe4Or^H9Fprf9=)`nJ`BGY(YGFrAI znQPsu6^4bHH?wK3G@1uI(XHZ1sm$QF>#Fv}ggz<>iJuNcW=yJoUZ!3!R6NB!xsp?a z#^ygq%hzbw^;9B7cfWeNY zO#;vcA{rdj8_J^RSw~!(W2q8zh8z_B9MdMWy?Um>(l2||9g)kUNOvGrZY@<-m_kWq zAXUb!)6OP$~!q~P`EPsWAB-B=0#x39P$`tZ#^MBp^ z%9mQMX)8Lgh-Nbt*A~f}OHm#8aQ{fHIg#Vq55Qt4Jw~%3ILNo)QCod$aw0KY3(%Ar)u|Ek>^6 zzTuoDW!Vtq^y}7!^H8@?kBIxWc43CpPeGb^A&|DKz&cO&ujy;Ne z22zTKmf2Zq{(Z6v@MbBVb09_UCQ(|_ za1cgprim~CU0pYfQLprK%wOuR7o-g z+lmJ^R&SuDlVf=r{JXER>P#g#nwkw)?6%p9s~s}|dt$o>@H4pmx;W*`%hmsrj)riE2j)oHgi zJ1J@~FxclyZKuhw-`0!U9oMO)9Q}dMQPmt9)m??9fj6;zm0&zeq2pQocx!fSMhYor zDW`&cM|N(C*=M@3DTkT^0}kx2!hGsp>Q+Uxpi82#MY82rwo6j4qjJ;+?Z)@zs5bLx z<&giOX+tZj9LDM=GX!6hK5Wfr;)>e%pk!fZNb9u9DsugBcJ;4DMk zZfO?uk;yPrke%gs*0hqyRdKH0q-j-#@|1gyKgd}1%wCXPsT^kJ4C)ZlImL;yf<(!M z1YJ4)-n@)jv{PutMoT<&DWUY!s0-n zHcL=Z0NVPc$K-@-x3KTZj(O6y^dtavhU#lL)G86x^d*tH35)N`Tr=PPG40a;y~E}F z1?Dc}+`!Yt=lZ8ELA>%6{psX{fxqtW_dp8ey-G5|qK^)XLpw!%9PYMGvKHn}a0m8lIys^`uQWx3&Q^9RevPvud=zf15PBv1l~RYN1xi2GD$ak- zQV&iM1}Q58v48KJ5&~BM3CdYSck$J~nJlN}Qd0um?usH5Q{!t%RR@vR zt`j%I4teuivGL~qnoCC_iL&<_s&A4Rag@7LxVTXW zvfBzkTMcPkOx;1vSS6KE!p0Y?sRk3_qP(V*!6jTtH?9&KrBMIGQ*tAgYZ@CQSrzxC z@06`wfjByI*=+F%7KdKyRtIhLI*?_HzIN=%P&nB8RhD)T6pybt6+eB8Eim-A&%eCq zLH^ugp-@a{IJ6g>X=Bd7JN$^A{e4aT$MjdpxvCqWLL{Q1q}z13!W2-XC?shJgvR3S zOjG;v>tn8zh@H}~wQ*U|2w^=}F`b0=R+&({gwiigsNq<1w+JN$1Ke1f z#Y@oV{)zl~rBr?=ltFFSsfT8{)dft_!0Q<8Po8J3sh2Ib-zN6BSDWFu;L1}J`e%Je zM&9tVF8$xhkqVo$TbWB_eyIT^ZJ|*~8@E#Cygng?u9^6w6h`_)PQq)e zu8X`gyIX|#!~g=+VPuHvVen9oQ~3PF3s*T2D5i;wUpC6PVx;O~ZWP{3cvN?gM&!7E zm#95vTvw@RO`E5tpBT4n)XLt->Afu5@aWW`iwg8nKS-qeZzU+SC=Bv(2VBIMxDML6 z=GytLIv5fg9vmA=hzN?8B2w)jFhW<~dXGB~1nx;kol8vgE<@BUX^#rmz%*L}(c)hc zbD=1wTFtAaofU8vFXZElnZ~dx&*j}eM)tQGkvn?m9#0_8(#$-S0~kWw zoqwru?xxIwQ?1e}J0`)mF$08C%kH-^MP6z*3ys}HV2bt$+${GT`pUX2CJEAy^kJH+ zB}>U8Uk?Y)y>M@<Lu2=`Pe1#>lUmTn@Oiyvu9`A+s5fwh3tgm44V5aFSp7JqN`fD04>RZJLq zUG*L%e!kQsr{)NDWwf^us53Il98!Hp#dBYEl!`VwqYIjQS~9ujj58E>m%2U45p!2ilZ- zbW?)|Ys!=AFQ*Z?g3B)YnhhGC+q_lQ7sHKFNiFGxy5M#SA2r2Q-mZajIgxtX4L^!v z3f^|)d$y+xd|tiyA3j!Io@=m|sq0U=Ug~5bu0-kyz9&*6{8Zj0SN3pfS@kCPaGj&ZtbkzyI~sY!2a-VA#eja+BdD0`J6S;+oT0Obb1it3;v1Pv`l~|dWx48 zG4*Dd*khORsA=dJ!Kbe0EG2#LRNGeM97s_4%cHlGRwg~wb$u!=tJg@8hchZJQrDM_ z^c9$14;_9=$wO;Vh`TSW)#Q3&OX zL_)u2w+(yu>@>45;5d83_g6xfR2^2hf9%H4qqQEM5BIK->qEMf6t9g`{UC`kn>s1{ zQdHfvG_qZ9T}ooB1imJufrDs5t$A*>m!Msco7Vxj8gK3EvabzWt+GuP5qrB(3^iF; zz6E8B+6=Nx^}OZ9BXTt1yC+Cy1e?5Trs!>woss~KrtziVQLSAHY0h{c_@N-5p#-_P zEKN@+dY?mlP5JNWFXTZS(aE+x`+t5nE+=6Ggc5LOy|E@ew}hLKhWMm>?A}+Li{fYJ zK;LdjCT;(8J(OJ-5nUsmo}Ex`N(@5^PxkC&TgupWk(b3*&uRe8iSY!S@-U}p;!>8a zus3}6Qd7Gn9hIIFv+$|EUM1#V26^MIyw%uGNeS{+!X?7TAPa<-T{75gOzd*>K%SD! zg25V5IYf)OI(8cs0ZFUr-)Pj+#(NY~E0UgMS3Q4qc#bOp)@`e$DY8%<0$8L!J1#T) zUG3wL*E~ok*J{>V@$PjcO2V`u?b2R#jaeSLG2U4x?L6Ws_u2&>&n&pjnm5kSQ>@w& z_wjPlP@g5$%0|{}B3XS%NSV9IV56R^=OiNPI%zaGILKW`fc`0^06UFtiB9vHJ2D+! zne=_-oF#4c|RBj+E8D z_RZjf91xOT5;QQWn~@9OkLKKyC>t(QF9{8v4(hYI_++Fu?Dv)cK509)RkHZ+>Vin_ zlv#HD0^TewKVHD#B`_M^!lrdN_g~4G1%j=XhgxcqMtSr04%c%JC;9x{Kcp*a2KpI0 zh`@+e9z|E4mPjomaRn$9p7mfj14!GrHhZx9l_M${&52SD>VYCoyaGoeD5d`W)Iq4- zt##7*s;8Wx0OWAytZ1R_>Lm`Cft>FWSlP(~->3$>L*xa|RYNyR+JEg){J9vrd*bWL zPk))I0DV85o?lfq@T=yKLJxzW3vR^_sT+4-GN!9=6q^m22hojB1{9o*h){*cN{AXDr6|LbTl9sSE`@0vq3JAfW}KY89p~T!~>u^ zvelF(k;*J-831FIb1WB36ZkX7bZ7Q58$7Ho7rA2V`N5tJwlQWGqX-}ZE2$*7WYjiJ zX4V}WA)f-aW!{D>B%UQFJ?)xWbi{}DQP_lN3LRNn-YvY$!-3umCe0yX@4H|ByPn+& z=Fiq+Y*^x3XI|e?I|mi0ySJBel>km)CL7k{UU~5TN!Q}Hi77;(Y%lvxL+h|gUqLrT zo7nn3C#;3~i?A?mds5zEQ>b)%T+QNK$N&8+OT^4`Ye;`6gm00Dj9U~LjbZ}Vt2Reyu z3&HtHa{K{o(xQ7vvZ!?V^p89_~vv}rO%P>{giM>X88 zUPDsSx4;>pHzn&sqb zEB*TN*XGqb3KRBcUi9FPDP+W8+w|7Z=hX0X}+sEjp+i(=3S0o9P(C`Ss4DDF#DMr(`kE^!1IJ zb2MyXd#xpkjS?@qyuIL1G&}~P8;V_Bz2VB(7?PB5iSoDrL1jgb|YyjM#Q>%0+{l2b~>I~GyWVehyx z2Ijory&L&=Gl)NK!i{ONx$Gwjbl`hflyfm8W=B_x;qZ%{(kT}R!K-V}8-QZzvUd7r zat-dRd9ns6R56kpYgax#?}kJFXdoT9cyXop;e&t?i2Zi7{(=9=T!^A?nL?@+OvC~ydw&B$(I#VD9sD@im@$V)s&`%jpF%2!;DpmUCj{XgQZt(3MQw zGmKqfY|Y2ig)`(8iUcs+^X2SQ_>$!ucrf=R3{;{Zx^hV%bB*3Pv~Jq@h|0M@VJaW! z%s2F4{d@oZJoi+t2McW(sC{*@d{HtIn+kXYCISN=(IN@TT+UNCT{FM`zC7|_4-eYy zfK9fpCDvBUcd62`$4jUsSz`TYk6rP?kWk}PT}Cm3{Xx#};R=wAO1y(8jA;-n-n>E1}_QetAm9MZL#M$@h@6$qs@lvv1 zJDk*&W&b%U$NIu*faWu224cXr!Oj`0b)30v^FRBP54_HAu$W*H41t>Z^;RU8btdSw ztmH8yjGdSpKixS@f{;GW5X^U{s697kNT1HIlNgSi)>kPn5IHM3Ff(jgYpqNNb!Bu4 z_84Y(^b)63k!UczWMyY2>N`_K&@bf6xQrR*Kut2@HH1+)Ha(U7<@Yr82x;{=c7np6 z*F{BAq=naVnn|1Lv>dd)`TTDJVH1QqtJo=-pO+BMdUVO;;l?QWTV6`3PWZ4FrHmOz56Zmv7OI?rkAylo3kUK`-MsYR~-;{9rgG6ODc;*+S z6f4#gR5491;Zb&|?xZ`+Bsz-0$V@JUmR~L86!!xHf>0fTo@K^BUkXN6+cFKJOyGY? z>@!;;CE9xnn7>zkZn^CXeG*bKk5Ot`!{W=rdI9}_3(Ma zxP}}|wSKLgfHGJY&fL_T(J?P3d*Ak?W|V}o_gtt*i}YXYX3dEeS{lLGI37(^dHhEZ z`rlx42i70S&s=?0be4sPd$k2!X@d}GcmX^O_3T&GZF2t%L&(ZD#|5YgV{)6@n!4&3 zW|XIu+h(OiMREk?A69LOWq(xTa!%nVCRSytwDvn2u$aINR}Q?QdF4}K_4*wBy6>}E z4hmvjoXP#%k_AT>A5AtBz3n^qz+xljs-HVO$;h8x3fyTVlUD4x3*!HKO|DZnA_OtZ zWL#}s^I<+q^p)|eK&A|8HU(iN7o!XI1D_GS4}m)(V0-g;_zYO7Q;X%xnC!k`J=HegiI1Bx|e&*ts#LY zlWw%W>7WL~c{Z;0V^Qi6L3ZC=vjv`l;_c&a#SD|60XI?oi+eh*|8x_UGKMISbV`@y zZl)Nc?CNIsboP;7vr5*y*aeaj#|y?&l!M^C(3qk#YIOhnV?*P;hT9#{cTLhsAa8$` z=||qwO@ZSe4q`dXT6MtyK;swP6~K~yjpmnWM=}|`-_$Q9!>6M?whm^RVhR@>ITtds zh|#LiB}RMNX4?b(5&O~oa`Mk7xC9cJggX;~S7{t>QqMr}B;jwCslNz}Y~TMR9jTH9 zN;&Y|05_%_kqon>%*c%+TspG(NGD(0&N22H~Hf}UN@YGKTt_W9S3G0eI@Ohg0 zN@$4?`IuufzewJ3ca2b~_=B-NW94Mo{}~J2;cdd*#r8Uqh;_@89Au^$LJItT^gaAH zMr;(j&r}hWTZ`FX8Q@7p?Img&59MZS-P(!9Od)-1{dH{CMLykxR^LSVBIMEH|NG)G zIW8hKxbfrOE496`)uodpKu3Ry>Ml|{mbNu(v$>VtHJ4fP4 zKl0H}gHf;~jAvTeghj9XY22iq?Pu=n4dar@cmk~7UqE3;;;){3nD|%@h;;>di69RF zw~8o)?t-9RW{+gb16Y80N{*4%QN^W@PBG{Ife?dJk)A`=l%Uc-SWp|!y02-GrDsFe3E8&NRp z<|S{TRKG_%pn%r9feV!VdiG z1>Lz%I-pY5D+YSL(=)<)aBY;mvA%(CYPfN*4AmTY=`a^!A0h_biBMm*;G25C#e7DQ znkyD7^C>E4PsRO5>ig8uNzOB_=N%RJ(-%uBt`|nXGwlGxFwZOBiDR`nP8J%zy|#;L zDzSTGm+h?6Z#3)ju_Ry(t4Oo)chghW6mSN+JsK6AHUG}q|Ndea#>gMKTBl96rHq}` zP6U-tTT`|OamY^NK-Wj;VCDQw7-rQ=s#?-HzWDHu49p+B{Q+0dg_J!=CeJFCo`hSN z2U}ay<=?cU{HfCG*V@=<2DWAkAj-j8({I3&)Xm&WkJ#&n7<5p^9^;|R53Jog-V7xo z+-pZjkm|24qyhcg_|2UI6s9HZQ)#Sg{Z41`F-c^XUrmu_He)aeoZmVQe$?u|E@r4@ zwi~Tl#~2*DvfsXo`*lk#()%Yl3;#8s)mE^ug~AoxWBz&8Jdp9RLsR-QjcCL+?=L-U zVhgT=3C@yB0Uk%KDWs3}&_n{c??3^wZc6;$VOS)MVrnILF6hk{^cCp4lALon7JrlY z{SfZNqx05M&uu#H%SVjW7E)A^(E1rSbCFnYxuy~Ib~9|qa7YMvm)3I{axV3_v9NiW z`C2TBi--d=O+sC7&(+X@&WX+bS!OT4hy+wu*VvV-RaK5*IRhy1vS=34(EFumRVi$5 zgexv%rdGvV0v!*>Otcc6{yBeLfKqb(&T~&I<2OuxOl4iQaJi1n$%NqF+T#kJx>%h% zHJkp zh&PHc(TMP>be`Q?+J#^XbUY0>cYUuzsAp(QNYcuZ$IT>bJ(@`hhX@Y)Yk=8?sw~po z&A8|G({LNg#i&o+;2?U`qW?g(K+vQ~G%|WANGNaARA<>h1=37rZe_Uih4=3eyQA+( zCd(Oo-27W-Jp~lXKJ)QdcMj-{>Fg1qj>ic{MNx8x+H4h+&wmBBfxa!|jBgNhGQRQ0 zR|&EOrY1@W1HH}?{3AK;LMxxKz}TjZ{$=ud$`wUtw^D{vH4BW6th#u$BYfcskbS4; zhw&>Gx<=;r!lzFZF_S#c&i3($58tTDQWymW+ytdSSHU`qx=Yn007wZ%&DS%#As`4} zF-O2Quvw7pWeuW#m(fR{+7^~z{vm1Vg5GL!3ziF$MScUlWVjwxq5cP*_3Ot}%)b(? z8DgVpeLs0kCv@3)|{hX@X?j zwokd>!L%WId0(MKL4<0|**?FTCUm%KCob%sqoVAjYdhf>6HAfPxeuQwFv;Kq)MM?? zKj>yax(qC1C9D3N<|b4QxD$(J??Hcc~C%|DLrOjLgQhc$2O zj!C|HaC*_R>gpdk@6H8p$_aj~-p=btlw_j(J9hn`>p)TWd z)bR|{bbVC5lRB{Gi+Qq^frC-ay?CtuGN&ODz3{<(WI$6m%WaS9$YxK zornh7r)%-L1^~gk11_Ew{e%}cHkUbiQF((#2gQdKs*JpCK4uZ#*)!q^}rVaXi)iMh>zOwpZJQ$I_-0e#{Nmw8|IfQcir5`{fbA7V_{CKq(@6AHy*_jy}B_ zJnFjf)o4O`4Yy5O(mD+igj@Tgryirj-(moT{YtmABpGh^>JmmBQ}yMz*D6<~&jnL1 z!myX`^ry4)umno zL=28ecT#e8?0!XZbySuy)3bdBY20xa1YS1zUw3v-nL{YHKUzZV5);*I;3e*C1AdV~ z)sTr7tvARrRX6)YPWC~+%f_=l-x3~6Zr^F&LbHjw<5qA=#wA=iD&nymG}RvLp=a!L zS%JH8#eEj|K!Ud7dIb3c1J!MHsEuFNFy58PUSby1%-76q5~W;cu$=RZ7|Gu%&*J|W zH0gVBWeFbB93mrlopv@Fr_3FR7&jE05eKmc?PvMp#vmy`xZgB_-vDdC(~oJ>B3Q;o?{L(6z*Ig3-`+E6KI8>D(SIL4ufb-)6;qpRGWszzpZ7Hkrds zLQthrOaK>bH90&U`tt=&X&4DX|H?-v%Q#AFPFp1RhlyX4j2C^-w8JXW`+)(AP}0k`c)@DMqhp!1Lw zQyJeIcUI4~|9x&jEyVZ<4zWd&9wLs`x<5fAkJ~DBLpjsEK-{1D9~u;g9R-hlky*sN z6aJ-7&>mb6RRYNQ6B`2TXsnd9e=KWc>4%>HMakAKhDbfo{8 zcpKp#yXx=KHwfh!y;%FI_{~qkwJ#_zr(pekmjv^KjSR`?+N0_IyCjxV>}iL-L+m{t{qGtQSR6zC!bjGG ze;DhR<#)bBSxU9;Hl;p%t#LDx#)bKhh$Zp5^zvnF!($b;v?3Hy*J3~m>~I`mAy!_Q z^8yrz_>McLEAXAS%a{fqUBjPsgiQt{HaePVAkTJ&r+RkvQZ*N=N$$IcvG^zGBS3KrxtD zr;vN^>cLD9@wuMc>2h-YX0xFn#fKqI_yYvRxl;dF3AbocPVwqDk`U^@7j&w*4Fmlb z=C`-Y5UDw#E&Rrls=qu$4`G`D;;a$I9tKT#>?xAFqGl;eqp_A8mI^~`aAo0_V|eYh zwu|v73iNB*@+zEQ{;|`&V{C08V*c6PykTj=r3mc#&n?zJk^`KRFsrOM?>H;k7Zy5j zG60aPMp+9vgJrJwL2|r#K+o6vZUhRLH$fViM$;nT-d}h$41K_hXB!ehK1uw|qvBCR zFyG}|7nx`vJbXV#=gSo_Den9U;jTB6kmn+mMm-t62P5uGMJRAA%OIbJN-=+rD&Q${ z0{cHHeOdl&;|w^wZC{qFS}cWXSsI;lJO12O!PGlQJhC_7Llu(d>D|& zLCB`5NFNEukAY}5a+xzTn5)+sTz~AFuYoMpSbO+hT|rdEe;S;Dm&%EtH~uSkS&HJA zHLE@<1l!nqVL(h!O*imQ6G(XHV_F9arb=EnNn;>z=tJ_q#!m>`5hmzK$h$nPd7J9r z)HLYPZP+}cN?ObXF2)PIme?sNa4Ghu4_cZ|t=S_s6Tc*+0#p-)vHS@Foeo%&=7*QR zY?1s&V)&OIvg~|#vrbFTDe{iTf_5w6b?jmV_gXa<;L3rTShi=f3D)*48qy_>@cA0? ztS+7(+dtvLK9P6K&%~d)sdsPzYI4pG+>j_eEC+6;rtt5>v!K3Zr__l!?D`G|B35Sg zW2}-Fu}7TC;S!)eoiqE05~uSFJU0CX4@$}qqJM_QGd`VAIw)xH^|pcX=*E5~UncH^ z@WW~CMKEjYg$M^dLxPlxX|Dny$WkXE3Zeg|#705g=|GRvD&>U##~+(-mU8P;SK$&=&XM}^Ja6yPv0NYhs2rb=as8e(uS4QF}%$%+VvWJBk#8jzRDq=u*Aq7E+jEG zr(UX<&yREn%Cm+bg1=TP9+ye@lO}!RV#0f)!YRgMapRuJD{Rg0@A#_+y+`Bh1$sX2CcYgcWwri0ym%b;e_CPm0fIr(12>kY9&mBc|1?}Z+JQD`eCGdSRMj| z_x|0>Zp1M2`F=~&fuou$(Cz77M+z1hVw32HaoW9BCr4>M2LrRa)$($nqvXfYV3Cf^bBF&nUJz88&BGVO7j$E z-QWf?{02M2ocDuIpyK41` z+N}FM&Joyv`(7LwXmOPktlg9@vV6q?6s3pvW5#AikZj3EFD?#e+Piz=9zn&f2~}tkFm;A;Mv*mw2ly?l7WP z`tP>VC-ZgZ>weHv3Np654?#aNN$Zmi)S-o`?96HP^(ik zdI7Lzp1(PLc1kFVFv5*5OFZZ{*)Gu9AkK&W`~40om3WO23s_Y9F%mL&vO3XllHL1= z*9DP@vXQxwb$?(ogD8)#?{6~GI~G8~NyT$K&~sW|NszbxCr&MU)18v(YirMY(0Fnx zjs0-+13%-5$>?6K-CV|nqLh!z`?q=iC+G!aHfy9l@GBPyUuxwXo1clZoS^p!pJ*re zWW#vP=gw?u=D#n1t-m}dxSHOI$*3&tL{!GqXJN{p=k?~Fo!ZJ6hdExiJI{te;`KuL zR;SdrfTL)hh>FjXSMqJWDjo^Y*k%7<>%51S3aiD7 z?crz#B0vxDa2-{h>8$`|PR?A$uCB2KQ#p85bEF@nZ6F8vs0~)!`z#hFOWGV1dPrA9 znCa6D9PXZjjK-`|BQ~Zt3l|2Sx+l6#G0xw45o}CLaWV)Yn;Crcndik#Z!1_cNX%Pr z9a?c7X^J?S7G8 z?(+h5X9-4SgDYb!&^oIiR)#?lK$o^*5<7; z^%WB1vfO3c@hG-(IV*sr;n)zeiEkdyO7PWG^P2rE_L?+~Lt7(d(r(=nCe>sWvGJSL z^f>H*1!$H{s9j<|s&P_gc=_NqM2tOy^L$q=NYbuA8-!H1Zmj)DL5p%ctt?D6K=)F` zWn75r;lwD1=iHj$H)+wmXit+D>1Rt4qf0e%k@$E_nT-NNllR}S_g>VF5VlBtNbQc+ zYO|pR+m%*eO{}vnP4s`$a3UazXol9lNuy#B??T%#Dp?6#zvoZ4*G%(I`S~OpBU_WW zhim7AwKd{aT++!QhJ!~s`$!d6TyvAVSC*MIYxZ2JVIj<|t0Bn{ilUMPoE~a~^zNq( z3(YCa)#MN7Q)4xUJ!u!IvrjUGs9IYa^FyT^I;>sX<0VBuqZ6%F@XbH4P9(nKg+30y z2Kzq&yAVY001%`}L_t&y_OY>wzYTLsjZHvUg^*IsY%Q29)3RFh6$L31Ee@e=$VOIe zpj+E#S!&wzEFC>(?m9?EIvx#+CS}QP$__6@C-wJ+z?6Bp&PvoL`1m%O*OdMvw6YrWt|KNWUI zeZYe6PQpFY-6@vmZT#%AYLj_&#;Vt3G1WA4TdH5}BJsBAQR|vfE~mosunkrG$gL3c zr$*7r))>3es%+l?a>f6r@~*ztChrBTcFc;^IP&R?CV0=MkPAsNOd{|wNZvx32~`I zAzONB$i_&S3Hz{Zgbgd`hgE=0ll3vbbjnPKPVLE*- zRyhS(l}_Dhq8I|el3>bH^pSIUhyf(Mdr0q|STbVrm#KtX=%pw!f)71hHm)i9%A}cI zi$_YGKyc1&T&LmpKw2t1ic zU@bmKq{_Agptb;{N=cP2CYy3fnwUHEc@QM+2G)FIaH9F^=8yzSq|-d+WO68#r=WiyFO;OQYN< zteEdb#=t8wNN2$JX6JY=-auS5t;1wt3RMOu_Pi2EA2z}jiIL6Ow;k31qb~8+*&$XyyIz-1Vlekled!%a{Bd zuwL{uElb(3@kNtzNR-AVJdsylsu$+yPebaRM_?7H4VGexBZ*aAO^j9u^}ltQ+89qJ z3o4+M!Hy)#o><0__BAPzRjNNk#BxVY?5@rHAWIO|a;*#I5VFZLl}j)iKBU$Mqr@h| zRJRzCs)`0C!N$li*f$htZ*KHKl)_Otldq~&RAw+aYb<7UDt4txDMS`p{zrpqeUZe4 zidK%q!a_7+m_CfIMx|a3loYzMrK3m68=FK`!U_cRy(;RGP@_QU-Wz2CKL^oVN93!K zm{Rs0a1CtKlrgE0&Z@E*L>X${R7u=LJk_e|q`m=osVKE5a)nuzSw&NmsY>WUUH*za zJY&Xyty$z2@Y_FQvRv`t3cJ2pzq=V!l`F%JklVbh%!g2{MR1;C1ODl95mGmL#< zDK3ji^9G_F@2*yg254I@YK3apDsAjaFSjIEg&{&6i4BrH>LIq>cyk>AY=J9amA_S1 zuz!EM(ke3ASdpraVi_#$C{v~+kc<$*)+Ev-NEBl!VFqSY>H{W1$}BBLrI_d%%QS|K z0n?zupc=I>X)OV?qprjfTm?pBTRwuL>uT3YY98hKlvx}vMt*-JGBZCVto zkeXbSt_cjCS!MN}6(I|z78(<)vcNkko3S(*EK6i26pWUXrR>}=e272e+}^L9A`6-- zn435Uo*|zGL{aPwq#Ew_SaF-ufxfg)g+%b-Gz6#L!PZo@hE0|7o2KX$EV5fQ_^9<5 znh3QD(aaDI@DS#LO@^Tl!D>q}A+uF=sDNr~6+Wy% z5<)i2R$H>*ErvK1mQ0cg&ng8~ij|nEkuZ*%O-rHIyX}=Q=|il7W#o#}3OQWDsZ1f> zgT>Ig;Bc0G$i8XtM=FP}5O%WlRDiqS)m&hSu(K+~RlvYzRGFd5E!dD2e#MlsD=e{^ z1UVwHnpPQe*{8G!y_{Ytq;^Ktx>Bn>(uzG+GKLkN)kuzIPxu`!N);Xtb~>_&rZsOc zTGeP2Uo~TXw1k;Itg=y2dJKU{ec&QDNb4W-Q(~{73DvO3Xws>zR>zt>02m~sEjTjX zyr8j(B%D?usH&<+#me;~t=HWC{V2{Zp3ZBfqD8#6x2-z#yvB}d@T<%#Ls+PaZL-R2 zAvXjTxtj#0Hpr_)HlJ0eyqHYv@vDyW*<7-t)tYC#m|G8O- z(u|Y>v7}!*-{r=b>He$&=P`*1Y`JJG!@fp8RNYD8O$(Cybp%^OctA_L-e}A%$|2^W zu5A#b6~hAPgHUvVXSt^h{vie*B?qoUG+c*jwE9gEcqM0*_d)nc1E_wVm3Zc2QI z%|ON8QYtmZVxqPWIyu}?l~8vOQ#^ZF4iBav6M5?2O+S@C849{R$SW+E627l(ZH3IN zc9;b1fUGsAr)U17eg#$Qk|fT;lM8)_3BO-7lC}BCY$L0J3U}ixYF`Gy`=N8)cS~uB zH%P5AMk)rX4Cwd;o>W=xssz=pYLZ=Xx^6xM*%(?O#oUody*!U4|8&u02JEHmI+i{B zJiD&3)ix#kqyhquzOM5J;9FowgQb6XYK$F%O8eJ|F zIR;B}s{%sbI~mF?vNCi)PKEqhy=9d&J_E}oTKW!}01Oe|Z7s(NhBona{F}ve&;+LG z{m>>MN0-#CXc5BSc0mhzPNf=f+g|TQ?{LYVij7gA>q};p%7iH`$b&Z43N6~zj&;g4 zE03%1rX1uJ#%xa6!rEMT6jIXv;W-4cXnh=%SZ(Kq+XIEh`++tG!mKS8Ue~Sz8Ma`n zP1at;V!K7_HS5~sx(DoNOIy_&CeD7KwQ3jMY$LHeoie4%8ia6pH`%RG6y~72uvqn4 zl+Iw(yPUf7Xa1hMxDD@qT(59SXRP+pmdB&Og(SDDt|PyfZdj*P!EzsTxq#9~j?P1#CxFM9xXe+t!=L`nRoVp`~tsXr78|q0m~{ zjWCqs*1*={ZvdnHUd?IxT5g*|8!cAG;xz(_?lXU(w_;b@#RND5%ruq(efX-7UfPuz$;mEQPkBsv`#gxp6{l63 zf@?-@^q!GXi!$`15HJ%0u%+JubokwDtKh?x#IQSz_I%cMu34_zySBaTc5N469iXma zt=Pt}hPbtk8EZSYHd$8#$`jryV3iK54!mQk%vD*=%%%@bM;`k75n^<83Rw+FlrbNL zSHhA!*&SW57qg6B^%e9cfH7F7r?lZ8YitW;McIDH>+1J)!g~gFSGnyDmVp>SKiMr+#H1 zXwCt_)3g4Ne`){TzOK#ddQ9AvW6ytLv0_!)3aBUA!gX|aF6PDiCfVV+)h;74Vc)=9 z_n$%D$}JmcY7tT&g(&s?bUx(P?rf=C1-Bc1KcjI_V!5k`&=vCjJfh70UYRmufB1Yf z_M>S={rZ9Go(wMous#T(1sL*k>c68B>!6tW!%J2ly`f=DpYbEIw>+w>oyjg}v#+C= z4jY)f%z~b0wTG6kdX+ej8Tit1n_R0E&slkkOTt|du8v%%j3PqFM%op3c{;2v57!;Y zGtFU+4^Nb1vkL#1+#;d_1HUKOea=|x^LAc2+(Xb?J(}l+EzCW*-=@gZQw<<%H*70- z12C#z_QsAO^_u! [Float] + } + + private final class RateLog { + var rates: [Float] = [] + } + + /// Station 0 is live, station 5 is a file; `isFile` picks which one is playing. + private static func playing(isFile: Bool = false) async throws -> Harness { + let engine = FakeRadioPlayer(fidelity: .vendor) + let log = RateLog() + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { + if let rate = $0?[MPNowPlayingInfoPropertyPlaybackRate] as? Float { log.rates.append(rate) } + }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let catalog = try RadioStationTests.fixtures() + let stations = StationsStore(loader: FakeStationsLoader(stations: catalog), player: service) + await stations.load() + #expect(stations.activate(catalog[isFile ? 5 : 0], on: .carPlay) == .selected) + if isFile { engine.announceDuration(180) } + engine.observer?.playerStateDidChange(.loadingFinished) // The item is ready and playing. + #expect(service.state == .playing) + return Harness(engine: engine, service: service, stations: stations, catalog: catalog, rates: { log.rates }) + } + + @Test(arguments: [false, true]) func interruptionPausesThenResumesWhenAsked(isFile: Bool) async throws { + let h = try await Self.playing(isFile: isFile) + h.engine.interruptionBegan() + #expect(h.service.state == .paused) + #expect(h.rates().last == 0) + h.engine.interruptionEnded(shouldResume: true) + #expect(h.service.state == .playing) + #expect(h.engine.isPlaying) + #expect(h.rates().last == 1) + } + + @Test func interruptionEndedWithoutShouldResumeStaysPaused() async throws { + let h = try await Self.playing() + h.engine.interruptionBegan() + h.engine.interruptionEnded(shouldResume: false) + #expect(h.service.state == .paused) + #expect(!h.engine.isPlaying) + } + + /// The supported engine respects a pause or stop across an interruption. The app also rejects + /// a separately injected unexpected resume, without publishing Playing to lock screen/CarPlay. + @Test(arguments: [false, true]) func resumeAfterAnExplicitPauseOrStopIsReverted(stop: Bool) async throws { + let h = try await Self.playing(isFile: !stop) + if stop { h.service.stop() } else { h.service.pause() } + let published = h.rates().count + h.engine.interruptionBegan() + // The vendor answers the interruption with pause(); a stopped live stream stays stopped. + #expect(h.service.state == (stop ? .stopped : .paused)) + h.engine.interruptionEnded(shouldResume: true) + #expect(h.service.state == (stop ? .stopped : .paused)) + #expect(!h.engine.isPlaying) + #expect(!h.rates().dropFirst(published).contains(1)) + h.engine.play() // Adversarial resume: test app policy independently of the library fix. + #expect(h.service.state == (stop ? .stopped : .paused)) + #expect(!h.engine.isPlaying) + #expect(!h.rates().dropFirst(published).contains(1)) + } + + /// Unplugging headphones (or losing the car's route) pauses in the vendor. That is not a user + /// command, so the service keeps the playing intent: a later vendor resume is accepted, as in + /// the UIKit app, and the user can resume from any surface. + @Test func routeLossPausesWithoutRecordingAUserPause() async throws { + let h = try await Self.playing() + h.engine.previousOutputBecameUnavailable() + #expect(h.service.state == .paused) + #expect(!h.engine.isPlaying) + #expect(h.rates().last == 0) + h.engine.interruptionEnded(shouldResume: true) // No matching interruption began. + #expect(h.service.state == .paused) + #expect(!h.engine.isPlaying) + #expect(h.stations.activate(h.catalog[0], on: .carPlay) == .resumed) + #expect(h.service.state == .playing) + #expect(h.engine.isPlaying) + } + + // MARK: - Contracts the CarPlay list relies on + + /// Re-tapping the playing station must not touch the engine, with the vendor's autoplay and + /// re-entrant state events in play. + @Test func reselectingTheCurrentStationIsANoOp() async throws { + let h = try await Self.playing() + let calls = h.engine.calls + let url = h.engine.radioURL + #expect(h.stations.activate(h.catalog[0], on: .carPlay) == .alreadyActive) + #expect(h.engine.calls == calls) + #expect(h.engine.radioURL == url) + } + + /// A row whose stream URL cannot be opened must not start, or keep, the previous station + /// under the new row's name: the bridge ignores `.unavailable` and stays on the list. + @Test func malformedStreamURLLeavesTheCurrentStationPlaying() async throws { + let h = try await Self.playing() + let broken = RadioStation(name: "Broken", streamURL: "not a url", imageURL: "", desc: "") + let calls = h.engine.calls + #expect(h.stations.activate(broken, on: .carPlay) == .unavailable) + #expect(h.stations.currentStation == h.catalog[0]) + #expect(h.engine.calls == calls) + #expect(h.service.state == .playing) + } + + /// The bridge badges a row when `player.state == .playing` and the row is the current station. + /// Every way playback ends must leave that state, and a station change moves it. + @Test func playingBadgeInputsFollowTransport() async throws { + let h = try await Self.playing() + let badged = { (station: RadioStation) in + h.service.state == .playing && h.stations.currentStation?.id == station.id + } + #expect(badged(h.catalog[0])) + h.service.stop() + #expect(!badged(h.catalog[0])) + #expect(h.stations.activate(h.catalog[0], on: .carPlay) == .resumed) + #expect(badged(h.catalog[0])) + h.engine.interruptionBegan() + #expect(!badged(h.catalog[0])) + h.engine.interruptionEnded(shouldResume: true) + #expect(h.stations.activate(h.catalog[1], on: .carPlay) == .selected) + #expect(!badged(h.catalog[0])) + #expect(badged(h.catalog[1])) + h.engine.observer?.playerStateDidChange(.failed("offline")) + #expect(!badged(h.catalog[1])) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerArtworkTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerArtworkTests.swift new file mode 100644 index 000000000..12e76d3b4 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerArtworkTests.swift @@ -0,0 +1,109 @@ +// +// PlayerArtworkTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer +import Testing +import UIKit +@testable import SwiftRadioCore + +/// Prevents late artwork and removed selections from leaving stale lock-screen content. +@MainActor struct PlayerArtworkTests { + @Test func identityGuardsAndImmediateStationFallback() throws { + var info: [String: Any]? = [:] + let engine = FakeRadioPlayer() + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { info = $0 }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let stations = try RadioStationTests.fixtures() + let stationArt = try #require(UIImage(named: "Fixtures/station", in: .module, compatibleWith: nil)) + let trackArt = try #require(UIImage(named: "Fixtures/placeholder", in: .module, compatibleWith: nil)) + let firstURL = URL(string: "https://example.com/first")! + let secondURL = URL(string: "https://example.com/second")! + service.updateStation(stations[1]) + service.load(url: URL(string: stations[1].streamURL)!) + service.updateArtwork(stationArt, for: stations[0].id, artworkURL: nil) + #expect(info?[MPMediaItemPropertyArtwork] == nil) + service.updateArtwork(stationArt, for: stations[1].id, artworkURL: nil) + engine.observer?.metadataDidChange(artist: "Artist", track: "Track") // A lookup needs a track. + engine.observer?.artworkDidChange(secondURL) + service.updateArtwork(trackArt, for: stations[1].id, artworkURL: firstURL) + var artwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(artwork.bounds.size == stationArt.size) + service.updateArtwork(trackArt, for: stations[1].id, artworkURL: secondURL) + artwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(artwork.bounds.size == trackArt.size) + engine.observer?.artworkDidChange(nil) + artwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(artwork.bounds.size == stationArt.size) + engine.observer?.metadataDidChange(artist: "Old Artist", track: "Old Track") + service.stop() + service.updateStation(nil) + // nil removes the system media session; an empty dictionary would leave a blank card. + #expect(info == nil) + engine.observer?.metadataDidChange(artist: "Late Artist", track: "Late Track") + #expect(info == nil, "A late engine callback must not recreate a removed lock-screen session") + } + + /// Inject a stale event despite the supported engine's lookup-generation guard. The app's + /// station/metadata identity checks must also prevent station A's cover from landing on B. + @Test func lateVendorArtworkAfterAStationSwitchIsDropped() throws { + var info: [String: Any]? + let engine = FakeRadioPlayer(fidelity: .vendor) + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { info = $0 }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let stations = try RadioStationTests.fixtures() + let stationArt = try #require(UIImage(named: "Fixtures/station", in: .module, compatibleWith: nil)) + let trackArt = try #require(UIImage(named: "Fixtures/placeholder", in: .module, compatibleWith: nil)) + let lateURL = URL(string: "https://example.com/a-cover")! + let currentURL = URL(string: "https://example.com/b-cover")! + service.updateStation(stations[0]) + service.load(url: URL(string: stations[0].streamURL)!) + engine.observer?.metadataDidChange(artist: "A Artist", track: "A Track") // Starts A's lookup. + + service.updateStation(stations[1]) + service.load(url: URL(string: stations[1].streamURL)!) + service.updateArtwork(stationArt, for: stations[1].id, artworkURL: nil) + engine.observer?.artworkDidChange(lateURL) // A's lookup answers after the switch. + #expect(service.artworkURL == nil) + service.updateArtwork(trackArt, for: stations[1].id, artworkURL: lateURL) + var artwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(artwork.bounds.size == stationArt.size) + + engine.observer?.metadataDidChange(artist: "B Artist", track: "B Track") + engine.observer?.artworkDidChange(currentURL) + #expect(service.artworkURL == currentURL) + service.updateArtwork(trackArt, for: stations[1].id, artworkURL: currentURL) + artwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(artwork.bounds.size == trackArt.size) + } + + /// Accepted artwork belongs to the station that was current when it arrived, even if a new + /// selection has not replaced the track metadata yet. + @Test func acceptedArtworkIsKeyedToItsStation() throws { + var info: [String: Any]? + let engine = FakeRadioPlayer() + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { info = $0 }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let stations = try RadioStationTests.fixtures() + let stationArt = try #require(UIImage(named: "Fixtures/station", in: .module, compatibleWith: nil)) + let trackArt = try #require(UIImage(named: "Fixtures/placeholder", in: .module, compatibleWith: nil)) + let url = URL(string: "https://example.com/a-cover")! + service.updateStation(stations[0]) + engine.observer?.metadataDidChange(artist: "A Artist", track: "A Track") + engine.observer?.artworkDidChange(url) + service.updateStation(stations[1]) // Selection moved; B's load has not cleared A's track yet. + service.updateArtwork(stationArt, for: stations[1].id, artworkURL: nil) + service.updateArtwork(trackArt, for: stations[1].id, artworkURL: url) + let artwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(artwork.bounds.size == stationArt.size) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift new file mode 100644 index 000000000..d30f554f1 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift @@ -0,0 +1,533 @@ +// +// PlayerServiceTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import MediaPlayer +import Testing +@testable import SwiftRadioCore + +/// Checks event mapping and lock-screen state using only injected infrastructure. +@MainActor struct PlayerServiceTests { + static func service(_ engine: FakeRadioPlayer = FakeRadioPlayer()) -> PlayerService { + PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { _ in }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + } + + static func gated(_ engine: FakeRadioPlayer, gate: ActivationGate) -> PlayerService { + PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { _ in }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: { try await gate.activate() }) + } + + /// The simulator's audio server (and, rarely, a device's) can stall session arbitration; Play + /// must not stay dead behind it. After the deadline the engine starts without ownership. + @Test func stalledActivationStartsTheEngineAfterTheDeadline() async throws { + let engine = FakeRadioPlayer() + let gate = ActivationGate() + let service = Self.gated(engine, gate: gate) + service.activationDeadline = .milliseconds(50) + let station = try RadioStationTests.fixtures()[5] + service.updateStation(station) + let url = try #require(URL(string: station.streamURL)) + service.load(url: url) + await gate.waitForPending(1) + #expect(engine.radioURL == nil) + await service.activation?.value // The gate is never released. + #expect(engine.radioURL == url) + if case .failed = service.state { Issue.record("a timeout is not a failure") } + gate.release() + } + + /// A non-mixable session activated early would silence other apps just by opening this one, + /// and the session only applies its category when it activates, so the engine must not get a + /// stream, Play or a seek until activation has finished. + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func engineWaitsForTheActiveSession(_ fidelity: FakeRadioPlayer.Fidelity) async throws { + let engine = FakeRadioPlayer(fidelity: fidelity) + let gate = ActivationGate() + let service = Self.gated(engine, gate: gate) + service.play() // No selection: nothing will play, so nothing may activate. + let station = try RadioStationTests.fixtures()[5] + service.updateStation(station) + await gate.waitForPending(0) + #expect(gate.requests == 0) + + let url = try #require(URL(string: station.streamURL)) + service.load(url: url) + await gate.waitForPending(1) + #expect(engine.loadedURLs.isEmpty) + #expect(engine.calls.isEmpty) + #expect(service.isBuffering) + #expect(service.state != .playing) + gate.release() + await service.activation?.value + #expect(engine.radioURL == url) + + engine.announceDuration(180) + service.stop() + service.play() + await gate.waitForPending(1) + #expect(engine.calls.last == "stop") + #expect(service.state == .stopped) + gate.release() + await service.activation?.value + #expect(engine.calls.last == "play") + #expect(service.state == .playing) + + service.seek(to: 60) + await gate.waitForPending(1) + #expect(engine.soughtTime == nil) + gate.release() + await service.activation?.value + #expect(engine.soughtTime == 60) + #expect(gate.requests == 3) + } + + /// Activation can fail (another app holds a non-mixable session, for one). Nothing may then + /// report Playing, and the next command retries. + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func failedActivationIsAPlayerFailureAndRetries(_ fidelity: FakeRadioPlayer.Fidelity) async throws { + let engine = FakeRadioPlayer(fidelity: fidelity) + let gate = ActivationGate() + let service = Self.gated(engine, gate: gate) + let station = try RadioStationTests.fixtures()[0] + let url = try #require(URL(string: station.streamURL)) + service.updateStation(station) + service.load(url: url) + await gate.waitForPending(1) + gate.fail() + await service.activation?.value + guard case .failed = service.state else { Issue.record("state \(service.state)"); return } + #expect(!service.isBuffering) + #expect(engine.loadedURLs.isEmpty) + #expect(!engine.calls.contains("play")) + + service.play() // A remote Play or a tap retries the stream that never reached the engine. + await gate.waitForPending(1) + gate.release() + await service.activation?.value + #expect(engine.radioURL == url) + } + + /// Stop, Pause or a newer selection while the session is activating wins: the superseded + /// request never reaches the engine, and the activations run one after another. + @Test(arguments: ["stop", "pause", "select"]) + func laterCommandSupersedesPendingActivation(_ command: String) async throws { + let engine = FakeRadioPlayer(fidelity: .vendor) + let gate = ActivationGate() + let service = Self.gated(engine, gate: gate) + let stations = try RadioStationTests.fixtures() + let first = try #require(URL(string: stations[0].streamURL)) + let second = try #require(URL(string: stations[1].streamURL)) + service.updateStation(stations[0]) + service.load(url: first) + await gate.waitForPending(1) + switch command { + case "stop": service.stop() + case "pause": service.pause() + default: service.updateStation(stations[1]); service.load(url: second) + } + await gate.waitForPending(1) // The next activation waits for the one in progress. + #expect(gate.requests == 1) + gate.release() + if command == "select" { + await gate.waitForPending(1) // Only now, after the first one finished. + #expect(engine.loadedURLs.isEmpty) + gate.release() + await service.activation?.value + #expect(gate.requests == 2) + #expect(engine.loadedURLs == [second]) + #expect(service.state == .playing) + } else { + await service.activation?.value + #expect(engine.loadedURLs.isEmpty) + #expect(service.state == (command == "stop" ? .stopped : .paused)) + #expect(!service.isBuffering) + } + } + + /// The previous stream must not keep playing, or report its events under the new station, + /// while the session activates for the next one. + @Test func selectionStopsThePreviousStreamWhileActivating() async throws { + let engine = FakeRadioPlayer(fidelity: .vendor) + let gate = ActivationGate() + let service = Self.gated(engine, gate: gate) + let stations = try RadioStationTests.fixtures() + service.updateStation(stations[0]) + service.load(url: try #require(URL(string: stations[0].streamURL))) + await gate.waitForPending(1) + gate.release() + await service.activation?.value + #expect(engine.isPlaying) + service.updateStation(stations[1]) + service.load(url: try #require(URL(string: stations[1].streamURL))) + #expect(!engine.isPlaying) + #expect(service.state == .idle) + #expect(service.isBuffering) + } + + /// The system advances lock-screen elapsed time from the published rate, so clock ticks must + /// not rebuild the Now Playing dictionary; only new information (duration, seek result) does. + @Test func playbackClockTicksDoNotRepublishNowPlaying() throws { + let engine = FakeRadioPlayer() + engine.duration = 180 + engine.completesSeeksImmediately = false + var published: [[String: Any]?] = [] + let remote = FakeRemoteCommandHandler() + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { published.append($0) }), + remoteCommands: RemoteCommandsController(handler: remote), + activateAudioSession: nil) + service.updateStation(try RadioStationTests.fixtures()[0]) + service.durationDidChange(180) + let before = published.count + remote.enabled.removeAll() + for second in 1...10 { service.playTimeDidChange(TimeInterval(second), duration: 180) } + #expect(published.count == before) + #expect(remote.enabled.isEmpty) + #expect(service.elapsed == 10) + + engine.duration = 240 + service.playTimeDidChange(11, duration: 240) + #expect(published.count == before + 1) + + service.seek(to: 100) + let afterSeek = published.count + engine.pendingSeeks.removeFirst()() + #expect(published.count == afterSeek + 1) + // The engine lands near, not on, the target: the first tick after completion corrects it once. + service.playTimeDidChange(99, duration: 240) + service.playTimeDidChange(100, duration: 240) + #expect(published.count == afterSeek + 2) + #expect(published.last??[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double == 99) + } + + @Test func toggleUsesLiveRule() throws { + let engine = FakeRadioPlayer() + let service = Self.service(engine) + service.updateStation(try RadioStationTests.fixtures()[0]) // play() requires a selection. + engine.isPlaying = true + engine.duration = 0 + service.togglePlayPause() + #expect(engine.calls.last == "stop") + engine.isPlaying = true + engine.duration = 180 + service.togglePlayPause() + #expect(engine.calls.last == "pause") + service.togglePlayPause() + #expect(engine.calls.last == "play") + } + + /// An injected engine event can arrive off-main, so the adapter's defensive delivery + /// helper must hop instead of asserting isolation. + @Test func backgroundCallbackReachesTheObserverOnTheMainActor() async { + let engine = FakeRadioPlayer() + let service = Self.service(engine) + #expect(service.state == .idle) + let ranOffMain = await engine.emitPlaybackStateFromBackground(.stopped) + #expect(ranOffMain) + #expect(service.state == .stopped) + } + + /// Transport is gated on a selection: `unload()` clears both, so a late remote Play is inert. + @Test func unloadClearsTheEngineAndRejectsPlay() throws { + let engine = FakeRadioPlayer() + let service = Self.service(engine) + let station = try RadioStationTests.fixtures()[0] + service.updateStation(station) + service.load(url: try #require(URL(string: station.streamURL))) + service.play() + #expect(engine.calls.last == "play") + service.unload() + #expect(engine.radioURL == nil) + #expect(engine.calls.last == "stop") + #expect(service.state == .stopped) + #expect(service.readiness == .idle) + let callsAfterUnload = engine.calls.count + service.play() + service.togglePlayPause() + #expect(engine.calls.count == callsAfterUnload) + #expect(service.state == .stopped) + } + + @Test func readinessIsSeparateAndRepeatedReadyEventsPublish() { + let engine = FakeRadioPlayer() + var publications = 0 + let remote = FakeRemoteCommandHandler() + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { _ in publications += 1 }), + remoteCommands: RemoteCommandsController(handler: remote), + activateAudioSession: nil) + engine.observer?.playbackStateDidChange(.playing) + engine.observer?.playerStateDidChange(.loading) + #expect(service.state == .playing) + #expect(service.readiness == .loading) + #expect(service.isBuffering) + engine.duration = 180 + engine.observer?.playerStateDidChange(.readyToPlay) + #expect(!service.isBuffering) + #expect(!service.isLive) + #expect(remote.enabled[.pause] == true) + let before = publications + engine.observer?.playerStateDidChange(.loadingFinished) + #expect(publications == before + 1) + service.stop() + engine.observer?.playerStateDidChange(.loading) + #expect(!service.isBuffering) + #expect(service.state == .stopped) + } + + /// A seekable file with a pending-seek engine. The vendor engine only seeks with a stream and + /// a nonzero duration of its own, so both are set up the way the engine reports them. + static func seekableFile(_ fidelity: FakeRadioPlayer.Fidelity) throws -> (FakeRadioPlayer, PlayerService) { + let engine = FakeRadioPlayer(fidelity: fidelity) + engine.completesSeeksImmediately = false + let service = service(engine) + let station = try RadioStationTests.fixtures()[0] + service.updateStation(station) + service.load(url: try #require(URL(string: station.streamURL))) + engine.announceDuration(180) + return (engine, service) + } + + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func seekHoldsPositionUntilCompletion(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.seek(to: 90) + #expect(service.isSeeking) + service.playTimeDidChange(12, duration: 180) + #expect(service.elapsed == 90) + engine.pendingSeeks.removeFirst()() + #expect(!service.isSeeking) + #expect(service.state == .playing) + service.playTimeDidChange(91, duration: 180) + #expect(service.elapsed == 91) + } + + /// The app owns resume-on-scrub, even when the engine only changes position (0.4). + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func completedSeekResumesPausedFile(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.pause() + service.seek(to: 90) + #expect(!engine.isPlaying) + engine.pendingSeeks.removeFirst()() + #expect(service.state == .playing) + #expect(engine.isPlaying) + #expect(!service.isSeeking) + } + + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func staleIndependentSeekCannotResumeBeforeNewSeekCompletes(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.pause() + service.seek(to: 30) + service.seek(to: 90) + let plays = engine.calls.filter { $0 == "play" }.count + engine.pendingSeeks.removeFirst()() + #expect(engine.calls.filter { $0 == "play" }.count == plays) + #expect(!engine.isPlaying) + #expect(service.isSeeking) + engine.pendingSeeks.removeFirst()() + #expect(engine.isPlaying) + #expect(!service.isSeeking) + } + + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func staleIndependentSeekDoesNotPlayAnotherStation(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.pause() + service.seek(to: 30) + let station = try RadioStationTests.fixtures()[1] + service.updateStation(station) + service.load(url: try #require(URL(string: station.streamURL))) + let plays = engine.calls.filter { $0 == "play" }.count + engine.pendingSeeks.removeFirst()() + #expect(engine.calls.filter { $0 == "play" }.count == plays) + // Loading the new station autoplays only in vendor mode. The old completion + // must leave that station's state unchanged and must not issue another Play. + #expect(service.state == (fidelity == .vendor ? .playing : .idle)) + #expect(engine.isPlaying == (fidelity == .vendor)) + #expect(!service.isSeeking) + } + + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func staleSeekCompletionCannotReleaseNewSeek(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.seek(to: 30) + service.seek(to: 90) + engine.pendingSeeks.removeFirst()() + #expect(service.isSeeking) + #expect(service.elapsed == 90) + engine.pendingSeeks.removeFirst()() + #expect(!service.isSeeking) + } + + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func stationChangeAndStopInvalidateSeek(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.seek(to: 90) + service.load(url: try #require(URL(string: "https://example.com/new.mp3"))) + #expect(!service.isSeeking) + #expect(service.elapsed == 0) + // The old item's completion still arrives after the new item has been loaded. + engine.pendingSeeks.removeFirst()() + #expect(service.elapsed == 0) + #expect(!service.isSeeking) + engine.announceDuration(180) + service.seek(to: 20) + service.stop() + #expect(!service.isSeeking) + engine.pendingSeeks.removeFirst()() + #expect(service.state == .stopped) + #expect(!engine.isPlaying) + } + + /// Both engine shapes preserve the user's pause/stop across seek completion. Recording mode + /// additionally injects an unexpected Playing event to exercise the app's defensive guard. + @Test(arguments: FakeRadioPlayer.Fidelity.allCases, [false, true]) + func lateSeekCannotOverrideTransportCommand(_ fidelity: FakeRadioPlayer.Fidelity, stop: Bool) throws { + let (engine, service) = try Self.seekableFile(fidelity) + service.seek(to: 90) + if stop { service.stop() } else { service.pause() } + if fidelity == .recording { + engine.play() + engine.observer?.playbackStateDidChange(.playing) + #expect(service.state == (stop ? .stopped : .paused)) + } + engine.pendingSeeks.removeFirst()() + #expect(service.state == (stop ? .stopped : .paused)) + #expect(!engine.isPlaying) + #expect(!service.isSeeking) + } + + /// A live or reloading engine should not enter the app's seek/resume flow. The service's + /// cached duration can lag behind a reload; using it would wrongly request a seek and resume + /// a stream whose current duration is unknown. + @Test(arguments: FakeRadioPlayer.Fidelity.allCases) + func unknownEngineDurationDoesNotEnterSeekFlow(_ fidelity: FakeRadioPlayer.Fidelity) throws { + let (engine, service) = try Self.seekableFile(fidelity) + engine.duration = 0 // Changed underneath the service, with no durationDidChange yet. + #expect(service.duration == 180) + service.seek(to: 60) + #expect(!service.isSeeking) + #expect(engine.pendingSeeks.isEmpty) + #expect(service.isLive) + service.playTimeDidChange(5, duration: 0) + #expect(service.elapsed == 5) + } + + /// What the lock screen shows for each transport change: the published rate and elapsed time + /// are all the system has to run its own clock between updates. + @Test func nowPlayingReflectsTransport() throws { + let engine = FakeRadioPlayer(fidelity: .vendor) + engine.completesSeeksImmediately = false + var info: [String: Any]? + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { info = $0 }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let stations = try RadioStationTests.fixtures() + service.updateStation(stations[5]) + service.load(url: try #require(URL(string: stations[5].streamURL))) + engine.announceDuration(180) + #expect(info?[MPNowPlayingInfoPropertyPlaybackRate] as? Float == 0) // Still buffering. + engine.observer?.playerStateDidChange(.loadingFinished) + #expect(info?[MPNowPlayingInfoPropertyPlaybackRate] as? Float == 1) + service.playTimeDidChange(30, duration: 180) + + service.pause() + #expect(info?[MPNowPlayingInfoPropertyPlaybackRate] as? Float == 0) + #expect(info?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double == 30) + #expect(info?[MPMediaItemPropertyPlaybackDuration] as? Double == 180) + + service.seek(to: 100) + #expect(info?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double == 100) + engine.pendingSeeks.removeFirst()() // The app explicitly resumes after the independent seek completes. + #expect(info?[MPNowPlayingInfoPropertyPlaybackRate] as? Float == 1) + #expect(info?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double == 100) + + service.updateStation(stations[0]) + service.load(url: try #require(URL(string: stations[0].streamURL))) + #expect(info?[MPNowPlayingInfoPropertyIsLiveStream] as? Bool == true) + #expect(info?[MPNowPlayingInfoPropertyPlaybackRate] as? Float == 1) + service.stop() + #expect(info?[MPNowPlayingInfoPropertyPlaybackRate] as? Float == 0) + #expect(info?[MPNowPlayingInfoPropertyElapsedPlaybackTime] == nil) + #expect(info?[MPMediaItemPropertyPlaybackDuration] == nil) + #expect(info?[MPMediaItemPropertyTitle] as? String == stations[0].name) + } + + /// A buffering file keeps its transport intent but its clock stands still; a nonzero rate + /// would let the lock screen run ahead and jump back when audio resumes. + @Test func bufferingFilePublishesAStoppedClock() throws { + let engine = FakeRadioPlayer(fidelity: .vendor) + engine.completesSeeksImmediately = false + var published: [[String: Any]] = [] + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { published.append($0 ?? [:]) }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let station = try RadioStationTests.fixtures()[5] + service.updateStation(station) + service.load(url: try #require(URL(string: station.streamURL))) + engine.announceDuration(180) + engine.observer?.playerStateDidChange(.loadingFinished) + service.playTimeDidChange(30, duration: 180) + func last(_ key: String) -> Any? { published.last?[key] } + #expect(last(MPNowPlayingInfoPropertyPlaybackRate) as? Float == 1) + + engine.observer?.playerStateDidChange(.loading) // Stalled mid-file. + #expect(service.state == .playing) + #expect(last(MPNowPlayingInfoPropertyPlaybackRate) as? Float == 0) + #expect(last(MPNowPlayingInfoPropertyElapsedPlaybackTime) as? Double == 30) + engine.observer?.playerStateDidChange(.loadingFinished) + #expect(last(MPNowPlayingInfoPropertyPlaybackRate) as? Float == 1) + #expect(last(MPNowPlayingInfoPropertyElapsedPlaybackTime) as? Double == 30) + + service.pause() + #expect(last(MPNowPlayingInfoPropertyPlaybackRate) as? Float == 0) + service.seek(to: 100) + #expect(last(MPNowPlayingInfoPropertyElapsedPlaybackTime) as? Double == 100) + engine.pendingSeeks.removeFirst()() // The app explicitly resumes after the independent seek completes. + #expect(last(MPNowPlayingInfoPropertyPlaybackRate) as? Float == 1) + service.pause() + service.play() + #expect(last(MPNowPlayingInfoPropertyPlaybackRate) as? Float == 1) + #expect(last(MPNowPlayingInfoPropertyElapsedPlaybackTime) as? Double == 100) + } + + @Test func metadataArtworkTimeAndFallbacks() throws { + let engine = FakeRadioPlayer() + let service = Self.service(engine) + let station = try RadioStationTests.fixtures()[0] + #expect(service.displayTitle(for: station) == station.name) + #expect(service.displayArtist(for: station) == station.desc) + engine.observer?.metadataDidChange(artist: "Artist", track: "Track") + #expect(service.track == "Track") + #expect(service.artist == "Artist") + #expect(service.displayTitle(for: station) == "Track") + let artwork = URL(string: "https://example.com/art.jpg")! + engine.observer?.artworkDidChange(artwork) + #expect(service.artworkURL == artwork) + engine.duration = 180 + engine.observer?.durationDidChange(180) + engine.observer?.playTimeDidChange(30, duration: 180) + #expect(service.elapsed == 30) + #expect(service.duration == 180) + service.seek(to: 45) + #expect(engine.soughtTime == 45) + #expect(service.elapsed == 45) + engine.observer?.artworkDidChange(nil) + #expect(service.artworkURL == nil) + service.load(url: URL(string: station.streamURL)!) + #expect(service.track == nil) + #expect(service.artist == nil) + #expect(service.elapsed == 0) + #expect(service.isLive) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RadioStationTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RadioStationTests.swift new file mode 100644 index 000000000..cb04cf5c1 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RadioStationTests.swift @@ -0,0 +1,74 @@ +// +// RadioStationTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Testing +@testable import SwiftRadioCore + +/// Verifies decoding, stable identity, legacy equality, and URL presentation rules. +struct RadioStationTests { + static func fixtures() throws -> [RadioStation] { + let url = try #require(Bundle.module.url(forResource: "stations", withExtension: "json", subdirectory: "Fixtures")) + return try JSONDecoder().decode(StationsResponse.self, from: Data(contentsOf: url)).station + } + + @Test func decodingAndArtwork() throws { + let stations = try Self.fixtures() + #expect(stations.count == 6) + #expect(stations.last?.artworkSource == .placeholder) + #expect(stations.first?.artworkSource == .asset("station-absolutecountry")) + var station = stations[0] + station.imageURL = "https://example.com/art.png" + #expect(station.artworkSource == .remote(URL(string: station.imageURL)!)) + } + + @Test(arguments: [nil, "", "radio.com", "ftp://radio.com", "https://", "httpx://radio.com"] as [String?]) + func rejectsInvalidWebsites(website: String?) throws { + var station = try Self.fixtures()[0] + station.website = website + #expect(!station.hasValidWebsite) + } + + @Test func identityAndEquality() throws { + let station = try Self.fixtures()[0] + var other = station + other.website = "https://different.example" + #expect(station == other) + #expect(Set([station, other]).count == 1) + other.streamURL += "/other" + #expect(station.id != other.id) // Duplicate station names remain distinct. + #expect(station != other) + other = station + other.name += " renamed" + #expect(station.id != other.id) + #expect(station.hasValidWebsite) + } + + /// A fork's catalog may omit `longDesc` and `website`; neither may fail the whole catalog. + @Test func decodesWithoutOptionalKeys() throws { + let json = Data(#"{"name":"Test","streamURL":"https://radio.com/live","imageURL":"","desc":"Radio"}"#.utf8) + let station = try JSONDecoder().decode(RadioStation.self, from: json) + #expect(station.longDesc.isEmpty) + #expect(station.website == nil) + #expect(station.name == "Test") + let roundTripped = try JSONDecoder().decode(RadioStation.self, from: JSONEncoder().encode(station)) + #expect(roundTripped == station) + } + + @Test func musicSearchEscapesQueryAndInitializerKeepsWebsite() throws { + let station = RadioStation(name: "Test", website: "https://radio.com", streamURL: "https://radio.com/live", + imageURL: "", desc: "Radio") + #expect(station.hasValidWebsite) + let url = try #require(station.musicSearchURL(track: "Rock & Roll + ?", artist: "Björk / AC/DC")) + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items == [URLQueryItem(name: "term", value: "Rock & Roll + ? Björk / AC/DC")]) + #expect(station.musicSearchURL(track: nil, artist: "Artist") == nil) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RealEngineSeekTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RealEngineSeekTests.swift new file mode 100644 index 000000000..dbd48b498 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RealEngineSeekTests.swift @@ -0,0 +1,110 @@ +// +// RealEngineSeekTests.swift +// SwiftRadioCoreTests +// +// Created on 2026-10-03. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import XCTest +import FRadioPlayer +@testable import SwiftRadioCore + +/// Exercises the production adapter with AVPlayer, including FRadioPlayer 0.4's independent seek. +@MainActor final class RealEngineSeekTests: XCTestCase { + private var files: [URL] = [] + + override func tearDown() async throws { + FRadioPlayer.shared.radioURL = nil + FRadioPlayer.shared.isAutoPlay = true + for url in files { try? FileManager.default.removeItem(at: url) } + files = [] + } + + private func audio() throws -> URL { + let size = 20 * 8000 * 2 + var data = Data("RIFF".utf8) + func append32(_ value: Int) { + withUnsafeBytes(of: UInt32(value).littleEndian) { data.append(contentsOf: $0) } + } + func append16(_ value: Int) { + withUnsafeBytes(of: UInt16(value).littleEndian) { data.append(contentsOf: $0) } + } + append32(36 + size) + data.append(Data("WAVEfmt ".utf8)); append32(16) + append16(1); append16(1); append32(8000); append32(16000); append16(2); append16(16) + data.append(Data("data".utf8)); append32(size); data.append(Data(count: size)) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("seek-\(UUID()).wav") + try data.write(to: url) + files.append(url) + return url + } + + private func station(_ url: URL) -> RadioStation { + RadioStation(name: url.lastPathComponent, streamURL: url.absoluteString, imageURL: "", desc: "Seek fixture") + } + + private func waitUntil(_ predicate: () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(10) + while !predicate(), Date() < deadline { try? await Task.sleep(for: .milliseconds(50)) } + return predicate() + } + + private func loadedService() async throws -> PlayerService { + let url = try audio() + let service = PlayerService(player: PlayerService.makeRadioPlayer(), + nowPlaying: NowPlayingInfoPublisher(publish: { _ in }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + service.updateStation(station(url)) + service.load(url: url) + let ready = await waitUntil { service.duration > 0 && FRadioPlayer.shared.currentTime > 0 } + XCTAssertTrue(ready, "The real engine must load and advance before testing seek") + if !ready { throw NSError(domain: "RealEngineSeekTests", code: 1) } + service.pause() + return service + } + + func testCurrentSeekResumesPausedRealEngine() async throws { + let service = try await loadedService() + defer { service.unload() } + service.seek(to: 5) + let resumed = await waitUntil { + !service.isSeeking && service.state == .playing && (FRadioPlayer.shared.rate ?? 0) > 0 + } + XCTAssertTrue(resumed) + XCTAssertGreaterThanOrEqual(FRadioPlayer.shared.currentTime, 4.5) + } + + func testPauseAndStopAfterRealSeekWin() async throws { + for stop in [false, true] { + let service = try await loadedService() + service.seek(to: 5) + if stop { service.stop() } else { service.pause() } + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(service.state, stop ? .stopped : .paused) + XCTAssertEqual(FRadioPlayer.shared.playbackState, stop ? .stopped : .paused) + XCTAssertEqual(FRadioPlayer.shared.rate ?? 0, 0) + XCTAssertFalse(service.isSeeking) + service.unload() + } + } + + func testStationSwitchAndStopInvalidateRealSeek() async throws { + let service = try await loadedService() + defer { service.unload() } + service.seek(to: 5) + let next = try audio() + service.updateStation(station(next)) + service.load(url: next) + service.stop() + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(FRadioPlayer.shared.radioURL, next) + XCTAssertEqual(service.state, .stopped) + XCTAssertEqual(FRadioPlayer.shared.playbackState, .stopped) + XCTAssertEqual(FRadioPlayer.shared.rate ?? 0, 0) + XCTAssertFalse(service.isSeeking) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RemoteCommandsControllerTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RemoteCommandsControllerTests.swift new file mode 100644 index 000000000..67ac5ec9f --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/RemoteCommandsControllerTests.swift @@ -0,0 +1,245 @@ +// +// RemoteCommandsControllerTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MediaPlayer +import Testing +@testable import SwiftRadioCore + +/// Ensures command callbacks hop to the main actor and registrations are removed. +@MainActor struct RemoteCommandsControllerTests { + @Test func enablementRebindingAndUnbind() { + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + #expect(handler.enabled[.pause] == true) + #expect(handler.enabled[.stop] == false) + controller.bind(play: { .success }, pause: { .success }, stop: { .success }, + toggle: { .success }, next: { .success }, previous: { .success }) + #expect(handler.handlers.count == 6) + #expect(handler.beginCount == 1) + controller.updateLiveState(isLive: true) + #expect(handler.enabled[.pause] == false) + #expect(handler.enabled[.stop] == true) + controller.bind(play: { .success }, pause: { .success }, stop: { .success }, + toggle: { .success }, next: { .success }, previous: { .success }) + #expect(handler.enabled[.pause] == false) + #expect(handler.enabled[.stop] == true) + controller.updateLiveState(isLive: false) + #expect(handler.enabled[.pause] == true) + #expect(handler.removedCount == 6) + controller.unbind() + #expect(handler.handlers.isEmpty) + #expect(handler.removedCount == 12) + } + + @Test func backgroundCallbackReportsTheActionStatus() async throws { + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + let runs = ObservationCounter() + controller.bind(play: { runs.increment(); return .commandFailed }, pause: { .success }, + stop: { .success }, toggle: { .success }, next: { .success }, previous: { .success }) + let callback = try #require(handler.handlers[.play]) + // Read the status back here: a detached task does not carry this test's context, so an + // expectation inside it could be attributed to no test at all. + let (status, ranOffMain) = await Task.detached { Self.callReportingThread(callback) }.value + #expect(ranOffMain) + #expect(status == .commandFailed) + #expect(runs.value == 1) // Ran before the callback returned, not on a later turn. + } + + nonisolated private static func callReportingThread(_ callback: @Sendable () -> MPRemoteCommandHandlerStatus) + -> (MPRemoteCommandHandlerStatus, Bool) { + (callback(), !Thread.isMainThread) + } + + /// MediaPlayer can call a target off the main thread; the system must still learn whether the + /// command did anything. Without a selection every command is rejected; with a single station + /// Next and Previous have nowhere to go and must not claim success. + @Test(arguments: [0, 1]) func backgroundCommandsReportWhatHappened(catalogSize: Int) async throws { + let stations = Array(try RadioStationTests.fixtures().prefix(catalogSize)) + let engine = FakeRadioPlayer() + let player = PlayerServiceTests.service(engine) + let store = StationsStore(loader: FakeStationsLoader(stations: stations), player: player) + await store.load() + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + controller.bind(player: player, stations: store) + if let only = stations.first { store.select(only) } + let selected = store.currentStation + let expected: [RemoteCommand: MPRemoteCommandHandlerStatus] = catalogSize == 0 + ? Dictionary(uniqueKeysWithValues: RemoteCommand.allCases.map { ($0, .noActionableNowPlayingItem) }) + : [.play: .success, .pause: .success, .stop: .success, .toggle: .success, + .next: .noSuchContent, .previous: .noSuchContent] + for command in [RemoteCommand.next, .previous, .pause, .play, .stop, .toggle] { + let callback = try #require(handler.handlers[command]) + let status = await Task.detached { callback() }.value + #expect(status == expected[command], "\(command)") + #expect(store.currentStation == selected, "\(command)") + } + if catalogSize == 0 { #expect(engine.calls.isEmpty) } else { #expect(engine.calls.last == "play") } + } + + /// `bind` pairs commands with actions positionally; reordering either list would silently send + /// the lock screen's Pause to Stop, so every command must reach its own action. + @Test func bindMapsEveryCommandToItsOwnAction() throws { + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + var ran: [String] = [] + controller.bind(play: { ran.append("play"); return .success }, + pause: { ran.append("pause"); return .success }, + stop: { ran.append("stop"); return .success }, + toggle: { ran.append("toggle"); return .success }, + next: { ran.append("next"); return .success }, + previous: { ran.append("previous"); return .success }) + let expected: [(RemoteCommand, String)] = [(.play, "play"), (.pause, "pause"), (.stop, "stop"), + (.toggle, "toggle"), (.next, "next"), (.previous, "previous")] + #expect(Set(handler.handlers.keys) == Set(RemoteCommand.allCases)) + for (command, name) in expected { + ran.removeAll() + let callback = try #require(handler.handlers[command]) + _ = callback() + #expect(ran == [name], "\(command)") + } + } + + /// Live streams can only stop (a paused live stream would resume behind real time), files can + /// only pause; everything else stays enabled in both. + @Test(arguments: [true, false]) func enablementTableForLiveAndFile(isLive: Bool) { + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + controller.bind(play: { .success }, pause: { .success }, stop: { .success }, + toggle: { .success }, next: { .success }, previous: { .success }) + controller.updateLiveState(isLive: isLive) + let expected: [RemoteCommand: Bool] = [.play: true, .pause: !isLive, .stop: isLive, + .toggle: true, .next: true, .previous: true] + #expect(handler.enabled == expected) + } + + /// The service drives the table from the engine's duration, including the flip back when a + /// file is replaced by a live stream. + @Test func serviceDurationDrivesCommandEnablement() throws { + let engine = FakeRadioPlayer(fidelity: .vendor) + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + let service = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { _ in }), + remoteCommands: controller, activateAudioSession: nil) + controller.bind(play: { .success }, pause: { .success }, stop: { .success }, + toggle: { .success }, next: { .success }, previous: { .success }) + let stations = try RadioStationTests.fixtures() + service.updateStation(stations[0]) + service.load(url: try #require(URL(string: stations[0].streamURL))) + #expect(handler.enabled[.stop] == true) + #expect(handler.enabled[.pause] == false) + engine.announceDuration(180) + #expect(handler.enabled[.stop] == false) + #expect(handler.enabled[.pause] == true) + service.updateStation(stations[1]) + service.load(url: try #require(URL(string: stations[1].streamURL))) + #expect(handler.enabled[.stop] == true) + #expect(handler.enabled[.pause] == false) + } + + /// Once the catalog drops the playing station there is nothing to act on: the engine was + /// unloaded, and a remote command must neither resume it nor pick a new station. + @Test func commandsAfterTheCatalogDropsTheStationAreRejected() async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer(fidelity: .vendor) + let player = PlayerServiceTests.service(engine) + let loader = FakeStationsLoader(stations: stations) + let store = StationsStore(loader: loader, player: player) + await store.load() + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + controller.bind(player: player, stations: store) + store.select(stations[2]) + await loader.replace(with: stations.filter { $0 != stations[2] }) + await store.refresh() + #expect(store.currentStation == nil) + #expect(engine.radioURL == nil) + let calls = engine.calls + for command in RemoteCommand.allCases { + let callback = try #require(handler.handlers[command]) + #expect(callback() == .noActionableNowPlayingItem, "\(command)") + } + #expect(engine.calls == calls) + #expect(store.currentStation == nil) + #expect(!engine.isPlaying) + } + + /// The controller outlives neither the player nor the catalog it was bound to. + @Test func commandsAfterTheBoundObjectsAreGoneAreRejected() async throws { + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + do { + let player = PlayerServiceTests.service(FakeRadioPlayer()) + let store = StationsStore(loader: FakeStationsLoader(stations: try RadioStationTests.fixtures()), player: player) + await store.load() + store.select(store.stations[0]) + controller.bind(player: player, stations: store) + let pause = try #require(handler.handlers[.pause]) + #expect(pause() == .success) + } + for command in RemoteCommand.allCases { + let callback = try #require(handler.handlers[command]) + #expect(callback() == .noActionableNowPlayingItem, "\(command)") + } + } + + @Test func mainThreadCallbackReportsTheActionStatus() { + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + let runs = ObservationCounter() + controller.bind(play: { runs.increment(); return .noActionableNowPlayingItem }, pause: { .success }, + stop: { .success }, toggle: { .success }, next: { .success }, previous: { .success }) + #expect(handler.handlers[.play]!() == .noActionableNowPlayingItem) + #expect(runs.value == 1) + } + + /// Headset, lock screen and steering-wheel commands must not start the radio from nothing: + /// UIKit's setNext/setPrevious ignored a missing selection, and the system is told why. + @Test func commandsWithoutASelectionAreRejectedAndDoNothing() async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer() + let player = PlayerServiceTests.service(engine) + let store = StationsStore(loader: FakeStationsLoader(stations: stations), player: player) + await store.load() + let handler = FakeRemoteCommandHandler() + let controller = RemoteCommandsController(handler: handler) + controller.bind(player: player, stations: store) + for command in RemoteCommand.allCases { + #expect(handler.handlers[command]!() == .noActionableNowPlayingItem) + } + #expect(store.currentStation == nil) + #expect(engine.radioURL == nil) + #expect(engine.calls.isEmpty) + + store.select(stations[0]) + #expect(handler.handlers[.next]!() == .success) + #expect(store.currentStation == stations[1]) + #expect(handler.handlers[.previous]!() == .success) + #expect(store.currentStation == stations[0]) + #expect(handler.handlers[.pause]!() == .success) + #expect(engine.calls.last == "pause") + #expect(handler.handlers[.play]!() == .success) + #expect(engine.calls.last == "play") + #expect(handler.handlers[.stop]!() == .success) + #expect(engine.calls.last == "stop") + #expect(handler.handlers[.toggle]!() == .success) + #expect(engine.calls.last == "play") + } + + @Test func deinitRemovesHandlers() { + let handler = FakeRemoteCommandHandler() + var controller: RemoteCommandsController? = RemoteCommandsController(handler: handler) + controller?.bind(play: { .success }, pause: { .success }, stop: { .success }, + toggle: { .success }, next: { .success }, previous: { .success }) + controller = nil + #expect(handler.handlers.isEmpty) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift new file mode 100644 index 000000000..e77f929e6 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift @@ -0,0 +1,182 @@ +// +// StationActivationTests.swift +// SwiftRadioCoreTests +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Testing +@testable import SwiftRadioCore + +/// Covers the row-tap contract on the phone and in the car (re-tapping the current station in every +/// transport state, unusable stream URLs) and stepping past unusable entries with Next/Previous. +@MainActor struct StationActivationTests { + private static func store(_ stations: [RadioStation], engine: FakeRadioPlayer) async -> (StationsStore, PlayerService) { + let player = PlayerServiceTests.service(engine) + let store = StationsStore(loader: FakeStationsLoader(stations: stations), player: player) + await store.load() + return (store, player) + } + + private static func unusable(_ name: String) -> RadioStation { + // No scheme: URL(string:) succeeds but the engine cannot open it. + RadioStation(name: name, streamURL: "stream.example.com/live", imageURL: "", desc: "") + } + + @Test(arguments: StationsStore.ActivationSurface.allCases) func activatingADifferentStationSelectsIt(surface: StationsStore.ActivationSurface) async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer() + let (store, player) = await Self.store(stations, engine: engine) + store.activate(stations[0], on: surface) + player.playbackStateDidChange(.playing) + #expect(store.activate(stations[1], on: surface) == .selected) + #expect(store.currentStation == stations[1]) + #expect(engine.radioURL == URL(string: stations[1].streamURL)) + } + + @Test(arguments: StationsStore.ActivationSurface.allCases) func activatingThePlayingStationLeavesTheStreamAlone(surface: StationsStore.ActivationSurface) async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer() + let (store, player) = await Self.store(stations, engine: engine) + #expect(store.activate(stations[1], on: surface) == .selected) + player.playbackStateDidChange(.playing) + let calls = engine.calls + #expect(store.activate(stations[1], on: surface) == .alreadyActive) + #expect(engine.calls == calls) + #expect(engine.radioURL == URL(string: stations[1].streamURL)) + } + + /// FRadioPlayer's autoplay flips it to playing inside the `radioURL` setter, so a stream that is + /// still buffering already reads as playing: a second tap opens the player on the phone (as + /// UIKit's MainCoordinator does) and is ignored in the car. Nothing is restarted or cancelled. + @Test(arguments: StationsStore.ActivationSurface.allCases) func activatingTheBufferingStation(surface: StationsStore.ActivationSurface) async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer(fidelity: .vendor) + let (store, player) = await Self.store(stations, engine: engine) + #expect(store.activate(stations[2], on: surface) == .selected) + #expect(player.isBuffering) + #expect(player.state == .playing) + let calls = engine.calls + #expect(store.activate(stations[2], on: surface) == .alreadyActive) + #expect(engine.calls == calls) + #expect(engine.radioURL == URL(string: stations[2].streamURL)) + } + + /// Before the engine gets the stream (the audio session is still activating) the station is + /// idle and loading. The car leaves it alone; the phone toggles like UIKit, so a second tap + /// cancels the pending start. A CarPlay-only selection goes through the same activation. + @Test(arguments: StationsStore.ActivationSurface.allCases) func activatingWhileTheSessionActivates(surface: StationsStore.ActivationSurface) async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer(fidelity: .vendor) + let gate = ActivationGate() + let player = PlayerServiceTests.gated(engine, gate: gate) + let store = StationsStore(loader: FakeStationsLoader(stations: stations), player: player) + await store.load() + #expect(store.activate(stations[2], on: .carPlay) == .selected) + await gate.waitForPending(1) + #expect(engine.loadedURLs.isEmpty) + #expect(player.state == .idle) + #expect(player.isBuffering) + switch surface { + case .carPlay: + #expect(store.activate(stations[2], on: surface) == .alreadyActive) + case .phone: + #expect(store.activate(stations[2], on: surface) == .paused) + #expect(player.state == .stopped) + } + gate.release() + await player.activation?.value + #expect(engine.loadedURLs == (surface == .carPlay ? [URL(string: stations[2].streamURL)] : [])) + #expect(player.state == (surface == .carPlay ? .playing : .stopped)) + } + + @Test(arguments: StationsStore.ActivationSurface.allCases, [false, true]) + func activatingThePausedOrStoppedStationResumesWithoutReloading(surface: StationsStore.ActivationSurface, stopped: Bool) async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer() + let (store, player) = await Self.store(stations, engine: engine) + store.activate(stations[0], on: surface) + player.playbackStateDidChange(.playing) + if stopped { player.stop() } else { player.pause() } + engine.radioURL = URL(string: "sentinel://unchanged") + let calls = engine.calls + #expect(store.activate(stations[0], on: surface) == .resumed) + #expect(engine.calls == calls + ["play"]) + #expect(player.state == .playing) + #expect(engine.radioURL == URL(string: "sentinel://unchanged")) + } + + @Test(arguments: StationsStore.ActivationSurface.allCases) func activatingAFailedStationReloadsIt(surface: StationsStore.ActivationSurface) async throws { + let stations = try RadioStationTests.fixtures() + let engine = FakeRadioPlayer() + let (store, player) = await Self.store(stations, engine: engine) + store.activate(stations[0], on: surface) + player.playerStateDidChange(.failed("offline")) + engine.radioURL = nil + #expect(store.activate(stations[0], on: surface) == .selected) + #expect(engine.radioURL == URL(string: stations[0].streamURL)) + } + + @Test(arguments: StationsStore.ActivationSurface.allCases) func unusableStationIsNotSelectedAndDoesNotPlayThePreviousOne(surface: StationsStore.ActivationSurface) async throws { + var stations = try RadioStationTests.fixtures() + stations.insert(Self.unusable("Broken"), at: 1) + let engine = FakeRadioPlayer() + let (store, player) = await Self.store(stations, engine: engine) + store.activate(stations[0], on: surface) + player.pause() + let calls = engine.calls + #expect(store.select(stations[1]) == false) + #expect(store.activate(stations[1], on: surface) == .unavailable) + #expect(store.currentStation == stations[0]) + #expect(engine.calls == calls) + #expect(engine.radioURL == URL(string: stations[0].streamURL)) + } + + @Test func nextAndPreviousStepOverUnusableStations() async throws { + var stations = try RadioStationTests.fixtures() + stations.insert(Self.unusable("Broken A"), at: 1) + stations.insert(Self.unusable("Broken B"), at: 2) + let engine = FakeRadioPlayer() + let (store, _) = await Self.store(stations, engine: engine) + store.select(stations[0]) + store.selectNext() + #expect(store.currentStation == stations[3]) + store.selectPrevious() + #expect(store.currentStation == stations[0]) + store.selectPrevious() // Wraps around to the last usable entry. + #expect(store.currentStation == stations.last) + } + + @Test func nextFromNoSelectionPicksTheFirstUsableStation() async throws { + var stations = try RadioStationTests.fixtures() + stations.insert(Self.unusable("Broken"), at: 0) + let (store, _) = await Self.store(stations, engine: FakeRadioPlayer()) + #expect(store.selectNext()) + #expect(store.currentStation == stations[1]) + } + + @Test func nextWithOnlyUnusableNeighboursKeepsTheCurrentStream() async throws { + let good = try RadioStationTests.fixtures()[0] + let stations = [good, Self.unusable("Broken A"), Self.unusable("Broken B")] + let engine = FakeRadioPlayer() + let (store, _) = await Self.store(stations, engine: engine) + store.select(good) + let calls = engine.calls + engine.radioURL = URL(string: "sentinel://unchanged") + #expect(store.selectNext() == false) + #expect(store.selectPrevious() == false) + #expect(store.currentStation == good) + #expect(engine.calls == calls) + #expect(engine.radioURL == URL(string: "sentinel://unchanged")) + } + + @Test func allUnusableCatalogSelectsNothing() async { + let stations = [Self.unusable("A"), Self.unusable("B")] + let (store, _) = await Self.store(stations, engine: FakeRadioPlayer()) + #expect(store.selectNext() == false) + #expect(store.selectPrevious() == false) + #expect(store.currentStation == nil) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsLoaderTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsLoaderTests.swift new file mode 100644 index 000000000..ab2938d9a --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsLoaderTests.swift @@ -0,0 +1,33 @@ +// +// StationsLoaderTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Testing +@testable import SwiftRadioCore + +/// Checks remote cache policy, decoding failures, HTTP errors, and store error propagation. +@MainActor struct StationsLoaderTests { + @Test func remoteBypassesCache() async throws { + let loader = RemoteStationsLoader(url: URL(string: "https://example.com/policy")!, session: StubURLProtocol.session()) + #expect(try await loader.load().count == 6) + } + + @Test(arguments: ["offline", "invalid", "missing"]) + func failuresReachStore(path: String) async { + let loader = RemoteStationsLoader(url: URL(string: "https://example.com/\(path)")!, session: StubURLProtocol.session()) + let store = StationsStore(loader: loader, player: PlayerServiceTests.service()) + await store.load() + guard case .failed = store.loadState else { + Issue.record("Expected failed load state") + return + } + #expect(store.stations.isEmpty) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift new file mode 100644 index 000000000..dc2650b1b --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift @@ -0,0 +1,43 @@ +// +// StationsRefreshTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Testing +@testable import SwiftRadioCore + +/// Verifies late or cancelled requests cannot overwrite the latest catalog state. +@MainActor struct StationsRefreshTests { + @Test func latestRequestWins() async throws { + let loader = ControlledStationsLoader() + let store = StationsStore(loader: loader, player: PlayerServiceTests.service()) + let stations = try RadioStationTests.fixtures() + let first = Task { await store.load() } + await loader.waitForRequests(1) + let second = Task { await store.refresh() } + await loader.waitForRequests(2) + await loader.finish(1, with: Array(stations.prefix(1))) + await second.value + await loader.finish(0, with: stations) + await first.value + #expect(store.stations == Array(stations.prefix(1))) + #expect(store.loadState == .loaded) + } + + @Test func cancellationRestoresIdle() async throws { + let loader = ControlledStationsLoader() + let store = StationsStore(loader: loader, player: PlayerServiceTests.service()) + let request = Task { await store.load() } + await loader.waitForRequests(1) + request.cancel() + await loader.finish(0, with: try RadioStationTests.fixtures()) + await request.value + #expect(store.loadState == .idle) + #expect(store.stations.isEmpty) + } +} diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsStoreTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsStoreTests.swift new file mode 100644 index 000000000..26ae167f1 --- /dev/null +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsStoreTests.swift @@ -0,0 +1,76 @@ +// +// StationsStoreTests.swift +// SwiftRadioCoreTests +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import Observation +import Testing +@testable import SwiftRadioCore + +/// Covers loading, unchanged-list suppression, selection, search, and refresh removal. +@MainActor struct StationsStoreTests { + @Test func loadAndSearch() async throws { + let stations = try RadioStationTests.fixtures() + let loader = FakeStationsLoader(stations: stations) + let store = StationsStore(loader: loader, player: PlayerServiceTests.service()) + #expect(store.loadState == .idle) + await store.load() + #expect(store.loadState == .loaded) + #expect(store.stations == stations) + store.searchText = "rOcK" + #expect(store.filteredStations.count == 3) + store.searchText = " \n " + #expect(store.filteredStations.count == 6) + let before = store.stations + let changes = ObservationCounter() + withObservationTracking { _ = store.stations } onChange: { changes.increment() } + await store.load() + #expect(store.stations == before) + #expect(changes.value == 0) + #expect(await loader.loadCount == 2) + } + + @Test func circularSelectionAndRemovalStopsAudio() async throws { + let stations = try RadioStationTests.fixtures() + let loader = FakeStationsLoader(stations: stations) + let engine = FakeRadioPlayer() + let player = PlayerServiceTests.service(engine) + let store = StationsStore(loader: loader, player: player) + await store.load() + store.select(stations[5]) + store.selectNext() + #expect(store.currentStation == stations[0]) + #expect(engine.radioURL == URL(string: stations[0].streamURL)) + store.selectPrevious() + #expect(store.currentStation == stations[5]) + await loader.replace(with: Array(stations.dropLast())) + await store.refresh() + #expect(store.currentStation == nil) + #expect(engine.calls.last == "stop") + // Stopping alone leaves the removed stream loaded; a late remote Play would resume it. + #expect(engine.radioURL == nil) + let callsAfterRemoval = engine.calls.count + player.play() + #expect(engine.calls.count == callsAfterRemoval) + #expect(player.state == .stopped) + } + + @Test func bundleLoaderUsesFixtureBundle() async throws { + let loader = BundleStationsLoader(fileName: "Fixtures/stations", bundle: .module) + #expect(try await loader.load().count == 6) + } + + @Test func emptyListSelectionIsSafe() async { + let store = StationsStore(loader: FakeStationsLoader(stations: []), player: PlayerServiceTests.service()) + await store.load() + store.selectNext() + store.selectPrevious() + #expect(store.currentStation == nil) + } +} diff --git a/README.md b/README.md index a2c99aa84..52a0b02be 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![iOS build](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/ios.yml/badge.svg)](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/ios.yml) [![CarPlay build](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/carplay.yml/badge.svg)](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/carplay.yml) -Open-source radio station app built entirely in Swift. Used by **80+ apps** on the App Store. +Open-source radio station app built entirely in Swift and SwiftUI. Used by **80+ apps** on the App Store.

Swift Radio @@ -25,6 +25,14 @@ Open-source radio station app built entirely in Swift. Used by **80+ apps** on t Built on [FRadioPlayer](https://github.com/fethica/FRadioPlayer) for streaming, metadata parsing, and iTunes album art fetching. +## Requirements + +- **iOS 17 or later**: the deployment target of the app target, the CarPlay target, and the `SwiftRadioCore` package. +- **Xcode 26.6 or later**: CI builds the app and CarPlay targets with Xcode 26.6 and 27.0. The test suites are run with Xcode 27. +- **Swift 6 language mode**: every target compiles with `SWIFT_VERSION = 6.0` and `SWIFT_STRICT_CONCURRENCY = complete`. + +Dependencies resolve through Swift Package Manager, so there's nothing to install before the first build. + ## Getting Started 1. Open `SwiftRadio.xcodeproj` in Xcode @@ -32,9 +40,13 @@ Built on [FRadioPlayer](https://github.com/fethica/FRadioPlayer) for streaming, 3. Update your stations in `SwiftRadio/Data/stations.json` or host the file remotely 4. Build and run -### Station Format +### Adding a station -Each station in `stations.json` supports the following fields: +1. Add an entry to `SwiftRadio/Data/stations.json`, or to the JSON you host at `Config.stationsURL` +2. Add a square image to `SwiftRadio/Images.xcassets/Stations/` and name the image set exactly like the station's `imageURL` +3. Build and run: no code change is needed + +Each station supports these fields: ```json { @@ -47,26 +59,94 @@ Each station in `stations.json` supports the following fields: } ``` -Images can be local (asset name without `http`) or remote (full URL). +- **`imageURL`**: an asset name for a bundled image, a full `http(s)` URL for a remote one, or an empty string to fall back to the `stationImage` placeholder. +- **`website`**: optional. The "Visit Website" row and the popup's website option appear only when it starts with `http` or `https`. +- **`longDesc`**: optional. Omit the key or leave it empty and the station info screen shows a default description. + +### Forking for one station + +The template works at any catalog size, so a single-station app is mostly configuration: + +- **Trim the catalog**: leave one entry in `stations.json` and keep `Config.useLocalStations = true`. +- **Drop the controls it doesn't need**: set `Config.hideNextPreviousButtons = true` and leave `Config.searchable = false`. +- **Open on the player**: call `StationsStore.select(_:)` once the catalog loads and set `RootView`'s `popupOpen` to `true`. The one-row list stays behind the player as the back destination. +- **Rebrand**: replace `logo` and the station image in `Images.xcassets`, then edit the strings in `Localizable.xcstrings`. + +### Customizing text and translation + +All user-facing strings are managed through `Localizable.xcstrings` (the String Catalog). Open it in Xcode to change the default English text or add new languages. Most views read their strings through `Content.swift`; a few accessibility labels in the player and toolbar use their catalog keys directly (`player.next`, `player.seek`, `common.close` and similar). Either way the text lives in the catalog, so a translation needs no code change. + +## Architecture + +Screens and navigation use SwiftUI. UIKit remains at focused boundaries: CarPlay, the popup library, system presentations (Safari, mail, AirPlay and sharing), and small wrappers that keep the reference appearance and native accessibility: the seek slider, the gradient, the marquee title (`MarqueeText`), the equalizer and buffering indicators (`PlaybackActivityIndicator`), and the artwork containers (`CrossfadeImage`, `PlaybackArtworkImage`). Playback state stays outside those wrappers. + +``` +SwiftRadio/ + App/ SwiftRadioApp (@main), AppDelegate, AppEnvironment, RootView, + ConfiguredStationsLoader, ArtworkLoaderKey, UITestRadioPlayer (Debug only) + CarPlay/ CarPlaySceneDelegate and its observation helper + Config/ Config, Content (String Catalog keys), InfoSection + Data/ stations.json + Features/ One folder per screen: Loading, Stations, NowPlaying, StationInfo, About + Helpers/ Small Foundation extensions + UI/ Views shared across screens: artwork, gradient, marquee, equalizer, + AirPlay, Safari, mail, share sheet, share card + Images.xcassets, Localizable.xcstrings, Info.plist, Info-CarPlay.plist + +Packages/SwiftRadioCore/ + Models/ RadioStation, StationsResponse + Stations/ StationsStore, the loader protocol, bundle and remote loaders, + CatalogListContent (what a size-limited list shows) + Playback/ PlayerService, the FRadioPlayer boundary, audio session, now playing info, + remote commands + Images/ ArtworkLoader + Handoff/ HandoffActivity +``` + +- **`AppEnvironment`**: builds one `PlayerService`, one `StationsStore`, and one `ArtworkLoader` at launch. The phone scene and the CarPlay scene share those instances, so a station picked in the car updates the phone, the lock screen, and Control Center. +- **`RootView`**: shows `LoadingScreen` until the first catalog load settles, then a `NavigationStack` holding the station list, a `navigationDestination` for station info, and the popup player. +- **`SwiftRadioCore`**: a local Swift package that holds the models, the stores, and the only import of FRadioPlayer. It has no SwiftUI in it and carries the unit tests. +- **CarPlay**: the `SwiftRadio-CarPlay` target compiles the same sources with `-D CarPlay` and swaps in `Info-CarPlay.plist`. `CarPlaySceneDelegate` renders a `CPListTemplate` from the shared store: + - **Catalog states**: a loading message while the first load runs, and a Retry row when loading failed or the catalog is empty, so the driver can recover without the phone. + - **Item limit**: the list shows at most `CPListTemplate.maximumItemCount` stations, which some cars lower while driving. + - **Selection**: tapping a station pushes the Now Playing template. Tapping the station that is already playing or buffering doesn't restart its stream, and a station whose stream URL can't be opened is ignored. + +### The popup player + +[LNPopupUI](https://github.com/LeoNatan/LNPopupUI) draws the bar above the station list and expands it into the full player. + +- **Presentation**: `RootView` attaches `.popup(isBarPresented:isPopupOpen:)` to the `NavigationStack`. The bar shows whenever `StationsStore.currentStation` is set, including when CarPlay made the selection. +- **Bar content**: `NowPlayingView` feeds the title, artwork, progress, and play/pause button to the bar through `.popupTitle`, `.popupImage`, `.popupProgress`, and `.popupBarButtons`. +- **Full player**: the same view's body is the expanded content: blurred artwork backdrop, marquee title, transport controls, and the options sheet. +- **Sequencing**: options that leave the player (station info, station website) wait for the options sheet's `onDismiss`, close the popup, then run from `onClose`, so a push or a sheet never fights either dismissal animation. + +### Audio session and system controls + +- **Session**: `PlayerService` activates a non-mixable `.playback` session right before it loads, plays or seeks, never at launch. Opening the app doesn't stop other apps' audio; starting a station does. +- **Mixing**: set `Config.mixesWithOtherAudio = true` to keep other audio playing under the radio. A mixable session can't own the lock screen, Control Center or CarPlay Now Playing, so those controls stop working. +- **Remote commands**: live streams enable Stop and disable Pause, files do the opposite. Play, play/pause, next and previous stay enabled. With no station selected every command reports `.noActionableNowPlayingItem` and does nothing. +- **Now Playing info**: each update replaces the whole dictionary, so a live stream never keeps a file's scrubber or an old track's artwork. Clock ticks don't republish it: the system advances elapsed time from the published rate. + +### Testing -### Customizing Text and Translation +Run the core package tests from `Packages/SwiftRadioCore` (see its README for the command). They need no network and cover playback, remote commands, Now Playing info, interruptions and the catalog. -All user-facing strings are managed through `Localizable.xcstrings` (the String Catalog). Open it in Xcode to change the default English text or add new languages. No code changes required. +For visual changes, use the [visual parity protocol](Documentation/SwiftUIParity.md): matching simulator screenshots, accessibility checks and the UI test commands. The [core package guide](Packages/SwiftRadioCore/README.md) documents dependency ownership. ## Dependencies | Library | Purpose | |---------|---------| | [FRadioPlayer](https://github.com/fethica/FRadioPlayer) | Streaming, metadata parsing, iTunes album art | -| [LNPopupController](https://github.com/LeoNatan/LNPopupController) | Now playing popup bar | -| [NVActivityIndicatorView](https://github.com/ninjaprox/NVActivityIndicatorView) | Loading indicators | -| [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) | Scrolling text labels | +| [LNPopupUI](https://github.com/LeoNatan/LNPopupUI) | Now playing popup bar and player | +| [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) | Continuous now-playing title, matching the UIKit app | +| [NVActivityIndicatorView](https://github.com/ninjaprox/NVActivityIndicatorView) | Equalizer and buffering animations, matching the UIKit app | -All managed via Swift Package Manager. +All are managed by Swift Package Manager. LNPopupUI brings LNPopupController and LNSwiftUIUtils with it. `SwiftRadioCore` lives in this repo as a local package and needs no setup. The two animation libraries are isolated behind small SwiftUI adapters; application state and navigation remain in SwiftUI. ## Contributing -Contributions are welcome. Please branch off [`dev`](https://github.com/analogcode/Swift-Radio-Pro/tree/dev) and open a pull request --do not commit directly to `master`. +Contributions are welcome. Please branch off [`dev`](https://github.com/analogcode/Swift-Radio-Pro/tree/dev) and open a pull request. Do not commit directly to `master`. ## Single Station Version @@ -78,12 +158,12 @@ All proceeds go directly toward maintaining and improving Swift Radio. For custom work or more advanced needs, reach out to [Fethi](mailto:contact@fethica.com). -**Built something with Swift Radio?** We'd love to see it --drop us a line at [contact@fethica.com](mailto:contact@fethica.com). +**Built something with Swift Radio?** We'd love to see it. Drop us a line at [contact@fethica.com](mailto:contact@fethica.com). ## Credits -- [Fethi](https://fethica.com) --co-organizer and lead developer -- [Matthew Fecher](http://matthewfecher.com) --creator, [AudioKit Pro](https://audiokitpro.com) +- [Fethi](https://fethica.com), co-organizer and lead developer +- [Matthew Fecher](http://matthewfecher.com), creator, [AudioKit Pro](https://audiokitpro.com) - [All contributors](https://github.com/analogcode/Swift-Radio-Pro/graphs/contributors) ## License diff --git a/SwiftRadio.xcodeproj/project.pbxproj b/SwiftRadio.xcodeproj/project.pbxproj index efb238096..ea7078dd3 100755 --- a/SwiftRadio.xcodeproj/project.pbxproj +++ b/SwiftRadio.xcodeproj/project.pbxproj @@ -3,108 +3,23 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ 2C5545BD1C1124DE00728469 /* SwiftRadioUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5545BC1C1124DE00728469 /* SwiftRadioUITests.swift */; }; - 53113F39230C720900462C0E /* ShareActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53113F38230C720900462C0E /* ShareActivity.swift */; }; - 6258DCD822D93A3500166C65 /* LogoShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6258DCD722D93A3500166C65 /* LogoShareView.swift */; }; - 6258DCDA22D93A5400166C65 /* LogoShareView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6258DCD922D93A5400166C65 /* LogoShareView.xib */; }; - 9409E11C1ABF6FEA00312E2B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409E11B1ABF6FEA00312E2B /* AppDelegate.swift */; }; - 9409E1261ABF6FEA00312E2B /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9409E1251ABF6FEA00312E2B /* Images.xcassets */; }; - 94452E551AD7086800BFE7A5 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94452E541AD7086800BFE7A5 /* AboutViewController.swift */; }; - 945DB3C21AD58E3A00495EBB /* stations.json in Resources */ = {isa = PBXBuildFile; fileRef = 945DB3C11AD58E3A00495EBB /* stations.json */; }; - 945DB3C51AD5A6E200495EBB /* NothingFoundCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 945DB3C41AD5A6E200495EBB /* NothingFoundCell.xib */; }; - 94AC70AE1AD05C6200652982 /* RadioStation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AC70AD1AD05C6200652982 /* RadioStation.swift */; }; - 94D1D0A51AD6D6230022CA11 /* InfoDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D1D0A41AD6D6230022CA11 /* InfoDetailViewController.swift */; }; - 94D260911B45D20000DE671C /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D260901B45D20000DE671C /* Config.swift */; }; - 94E9761C1B1A8F3200F52B1E /* UIImage+DropShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9761B1B1A8F3200F52B1E /* UIImage+DropShadow.swift */; }; - AA0000011ACC9DEE005B7C26 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000001ACC9DEE005B7C26 /* NetworkService.swift */; }; - AA000002291F376D0058C82A /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000001ACC9DEE005B7C26 /* NetworkService.swift */; }; - AA0000101ACC9DEE005B7C26 /* LNPopupController in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000111ACC9DEE005B7C26 /* LNPopupController */; }; - AA0000121ACC9DEE005B7C26 /* LNPopupController in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000131ACC9DEE005B7C26 /* LNPopupController */; }; - CA142F672D3D8E2F0071A388 /* NSLayoutConstraint+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA142F662D3D8E280071A388 /* NSLayoutConstraint+with.swift */; }; - CA142F682D3D8E2F0071A388 /* NSLayoutConstraint+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA142F662D3D8E280071A388 /* NSLayoutConstraint+with.swift */; }; - CA142F6A2D3D9A0D0071A388 /* BottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA142F692D3D9A0D0071A388 /* BottomSheetViewController.swift */; }; - CA142F6B2D3D9A0D0071A388 /* BottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA142F692D3D9A0D0071A388 /* BottomSheetViewController.swift */; }; - CA142F6D2D3D9DFB0071A388 /* BottomSheetHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA142F6C2D3D9DFB0071A388 /* BottomSheetHandler.swift */; }; - CA142F6E2D3D9DFB0071A388 /* BottomSheetHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA142F6C2D3D9DFB0071A388 /* BottomSheetHandler.swift */; }; - CA2511B32D4E100000111003 /* AboutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511B22D4E100000111003 /* AboutCoordinator.swift */; }; - CA2511B32D4E100000111004 /* AboutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511B22D4E100000111003 /* AboutCoordinator.swift */; }; - CA2511B52D458033006D1A99 /* AudioSetupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511B42D458033006D1A99 /* AudioSetupService.swift */; }; - CA2511B62D458033006D1A99 /* AudioSetupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511B42D458033006D1A99 /* AudioSetupService.swift */; }; - CA2511B82D45B1B6006D1A99 /* FeaturesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511B72D45B1B6006D1A99 /* FeaturesViewController.swift */; }; - CA2511B92D45B1B6006D1A99 /* FeaturesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511B72D45B1B6006D1A99 /* FeaturesViewController.swift */; }; - CA2511BB2D45C2D1006D1A99 /* LibrariesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511BA2D45C2CA006D1A99 /* LibrariesViewController.swift */; }; - CA2511BC2D45C2D1006D1A99 /* LibrariesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511BA2D45C2CA006D1A99 /* LibrariesViewController.swift */; }; - CA2511BE2D45CD13006D1A99 /* ContributorsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511BD2D45CD11006D1A99 /* ContributorsViewController.swift */; }; - CA2511BF2D45CD13006D1A99 /* ContributorsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2511BD2D45CD11006D1A99 /* ContributorsViewController.swift */; }; - CA2B6C6F2F31B769000CB4D4 /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = CA2B6C6E2F31B769000CB4D4 /* MarqueeLabel */; }; - CA2B6C702F327016000CB4D4 /* LNPopupController in Embed Frameworks */ = {isa = PBXBuildFile; productRef = AA0000131ACC9DEE005B7C26 /* LNPopupController */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - CA2B6C722F327107000CB4D4 /* LNPopupController in Embed Frameworks */ = {isa = PBXBuildFile; productRef = AA0000111ACC9DEE005B7C26 /* LNPopupController */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - CA3A11012D4E000000111001 /* InfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3A11002D4E000000111001 /* InfoSection.swift */; }; - CA3A11022D4E000000111001 /* InfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3A11002D4E000000111001 /* InfoSection.swift */; }; - CA3A11042D4E000000111002 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3A11032D4E000000111002 /* Content.swift */; }; - CA3A11052D4E000000111002 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3A11032D4E000000111002 /* Content.swift */; }; - CA58087D2D40BAC000F8082B /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA58087B2D40BAC000F8082B /* CarPlaySceneDelegate.swift */; }; - CA5F00022F40A00000000002 /* GradientBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5F00012F40A00000000001 /* GradientBackgroundView.swift */; }; - CA5F00032F40A00000000003 /* GradientBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5F00012F40A00000000001 /* GradientBackgroundView.swift */; }; - CA5F00042F41000000000001 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = CA5F00062F41000000000003 /* Localizable.xcstrings */; }; - CA5F00052F41000000000002 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = CA5F00062F41000000000003 /* Localizable.xcstrings */; }; - CA900001200000000000000A /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = CA900002200000000000000A /* MarqueeLabel */; }; - CAB4E8292D3D7DC7001282E9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4E8282D3D7DC7001282E9 /* SceneDelegate.swift */; }; - CAB4E82A2D3D7DC7001282E9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4E8282D3D7DC7001282E9 /* SceneDelegate.swift */; }; - CE321FF329371140001572BD /* Bundle+appName.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE321FF229371140001572BD /* Bundle+appName.swift */; }; - CE321FF429371140001572BD /* Bundle+appName.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE321FF229371140001572BD /* Bundle+appName.swift */; }; + AB1000020000000000000001 /* SwiftRadioCore in Frameworks */ = {isa = PBXBuildFile; productRef = AB1000030000000000000001 /* SwiftRadioCore */; }; + AB1000020000000000000002 /* SwiftRadioCore in Frameworks */ = {isa = PBXBuildFile; productRef = AB1000030000000000000002 /* SwiftRadioCore */; }; + AB1A00032F5A0001000AB003 /* LNPopupUI in Frameworks */ = {isa = PBXBuildFile; productRef = AB1A00022F5A0001000AB002 /* LNPopupUI */; }; + AB2000010000000000000001 /* LNPopupUI in Frameworks */ = {isa = PBXBuildFile; productRef = AB2000010000000000000002 /* LNPopupUI */; }; + AB3000000000000000000001 /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = AB3000000000000000000011 /* MarqueeLabel */; }; + AB3000000000000000000002 /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = AB3000000000000000000012 /* MarqueeLabel */; }; + AB3000000000000000000003 /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = AB3000000000000000000013 /* NVActivityIndicatorView */; }; + AB3000000000000000000004 /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = AB3000000000000000000014 /* NVActivityIndicatorView */; }; + AB4000010000000000000001 /* LNPopupUI in Embed Frameworks */ = {isa = PBXBuildFile; productRef = AB1A00022F5A0001000AB002 /* LNPopupUI */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + AB4000010000000000000002 /* LNPopupUI in Embed Frameworks */ = {isa = PBXBuildFile; productRef = AB2000010000000000000002 /* LNPopupUI */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE37D7B7290F4A9700B0933B /* FRadioPlayer in Frameworks */ = {isa = PBXBuildFile; productRef = CE37D7B6290F4A9700B0933B /* FRadioPlayer */; }; - CE6036132A47A87A00E15E15 /* StationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6036122A47A87A00E15E15 /* StationsViewController.swift */; }; - CE6036142A47A87A00E15E15 /* StationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6036122A47A87A00E15E15 /* StationsViewController.swift */; }; - CE6036162A47B88600E15E15 /* StationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6036152A47B88600E15E15 /* StationTableViewCell.swift */; }; - CE6036172A47B88600E15E15 /* StationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6036152A47B88600E15E15 /* StationTableViewCell.swift */; }; - CE6036192A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6036182A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift */; }; - CE60361A2A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6036182A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift */; }; - CE6036212A48C51A00E15E15 /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = CE6036202A48C51A00E15E15 /* NVActivityIndicatorView */; }; - CE6036232A48C51A00E15E15 /* NVActivityIndicatorViewExtended in Frameworks */ = {isa = PBXBuildFile; productRef = CE6036222A48C51A00E15E15 /* NVActivityIndicatorViewExtended */; }; - CE6036282A48F70A00E15E15 /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = CE6036272A48F70A00E15E15 /* NVActivityIndicatorView */; }; - CE60362A2A48F70A00E15E15 /* NVActivityIndicatorViewExtended in Frameworks */ = {isa = PBXBuildFile; productRef = CE6036292A48F70A00E15E15 /* NVActivityIndicatorViewExtended */; }; - CE6A3E31291F376D0058C82A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D260901B45D20000DE671C /* Config.swift */; }; - CE6A3E33291F376D0058C82A /* ShareActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53113F38230C720900462C0E /* ShareActivity.swift */; }; - CE6A3E35291F376D0058C82A /* StationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE963ECB29135A6F004F299E /* StationsManager.swift */; }; - CE6A3E36291F376D0058C82A /* InfoDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D1D0A41AD6D6230022CA11 /* InfoDetailViewController.swift */; }; - CE6A3E37291F376D0058C82A /* LogoShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6258DCD722D93A3500166C65 /* LogoShareView.swift */; }; - CE6A3E39291F376D0058C82A /* UIImage+DropShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9761B1B1A8F3200F52B1E /* UIImage+DropShadow.swift */; }; - CE6A3E3A291F376D0058C82A /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94452E541AD7086800BFE7A5 /* AboutViewController.swift */; }; - CE6A3E3C291F376D0058C82A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409E11B1ABF6FEA00312E2B /* AppDelegate.swift */; }; - CE6A3E3E291F376D0058C82A /* UIImage+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDABBEC29121BBA00C0367F /* UIImage+Cache.swift */; }; - CE6A3E40291F376D0058C82A /* RadioStation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AC70AD1AD05C6200652982 /* RadioStation.swift */; }; - CE6A3E41291F376D0058C82A /* UIImageView+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDABBEA291217AF00C0367F /* UIImageView+Cache.swift */; }; CE6A3E43291F376D0058C82A /* FRadioPlayer in Frameworks */ = {isa = PBXBuildFile; productRef = CE6A3E2E291F376D0058C82A /* FRadioPlayer */; }; - CE6A3E46291F376D0058C82A /* stations.json in Resources */ = {isa = PBXBuildFile; fileRef = 945DB3C11AD58E3A00495EBB /* stations.json */; }; - CE6A3E47291F376D0058C82A /* NothingFoundCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 945DB3C41AD5A6E200495EBB /* NothingFoundCell.xib */; }; - CE6A3E48291F376D0058C82A /* LogoShareView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6258DCD922D93A5400166C65 /* LogoShareView.xib */; }; - CE6A3E4B291F376D0058C82A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9409E1251ABF6FEA00312E2B /* Images.xcassets */; }; - CE6ECCFA292F13DF008B3C16 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6ECCF9292F13DF008B3C16 /* Coordinator.swift */; }; - CE6ECCFB292F13E6008B3C16 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6ECCF9292F13DF008B3C16 /* Coordinator.swift */; }; - CE6ECCFD292F1445008B3C16 /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6ECCFC292F1445008B3C16 /* MainCoordinator.swift */; }; - CE6ECCFE292F1448008B3C16 /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6ECCFC292F1445008B3C16 /* MainCoordinator.swift */; }; - CE6ECD03292F358C008B3C16 /* UIViewController+Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6ECD02292F358C008B3C16 /* UIViewController+Email.swift */; }; - CE6ECD04292F358C008B3C16 /* UIViewController+Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6ECD02292F358C008B3C16 /* UIViewController+Email.swift */; }; - CE963ECC29135A6F004F299E /* StationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE963ECB29135A6F004F299E /* StationsManager.swift */; }; - CE9EE8DE293BB41300F62041 /* BaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9EE8DD293BB41300F62041 /* BaseController.swift */; }; - CE9EE8DF293BB41300F62041 /* BaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9EE8DD293BB41300F62041 /* BaseController.swift */; }; - CE9EE8E1293C048A00F62041 /* LoaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9EE8E0293C048A00F62041 /* LoaderController.swift */; }; - CE9EE8E2293C048A00F62041 /* LoaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9EE8E0293C048A00F62041 /* LoaderController.swift */; }; - CED6353B293081ED002B216F /* Handoffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED6353A293081ED002B216F /* Handoffable.swift */; }; - CED6353C293081ED002B216F /* Handoffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED6353A293081ED002B216F /* Handoffable.swift */; }; - CEDABBEB291217AF00C0367F /* UIImageView+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDABBEA291217AF00C0367F /* UIImageView+Cache.swift */; }; - CEDABBED29121BBA00C0367F /* UIImage+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDABBEC29121BBA00C0367F /* UIImage+Cache.swift */; }; - CEE9A2782B5345C30018FE68 /* NowPlayingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A2772B5345C30018FE68 /* NowPlayingViewController.swift */; }; - CEE9A2792B5345C30018FE68 /* NowPlayingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A2772B5345C30018FE68 /* NowPlayingViewController.swift */; }; - CEE9A27B2B535C780018FE68 /* AlbumArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A27A2B535C780018FE68 /* AlbumArtworkView.swift */; }; - CEE9A27C2B535C780018FE68 /* AlbumArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A27A2B535C780018FE68 /* AlbumArtworkView.swift */; }; - CEE9A27E2B54A5520018FE68 /* ControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A27D2B54A5520018FE68 /* ControlsView.swift */; }; - CEE9A27F2B54A5520018FE68 /* ControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A27D2B54A5520018FE68 /* ControlsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -124,7 +39,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - CA2B6C702F327016000CB4D4 /* LNPopupController in Embed Frameworks */, + AB4000010000000000000002 /* LNPopupUI in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -135,7 +50,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - CA2B6C722F327107000CB4D4 /* LNPopupController in Embed Frameworks */, + AB4000010000000000000001 /* LNPopupUI in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -146,56 +61,41 @@ 2C5545BA1C1124DE00728469 /* SwiftRadioUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftRadioUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2C5545BC1C1124DE00728469 /* SwiftRadioUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftRadioUITests.swift; sourceTree = ""; }; 2C5545BE1C1124DE00728469 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 53113F38230C720900462C0E /* ShareActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActivity.swift; sourceTree = ""; }; - 6258DCD722D93A3500166C65 /* LogoShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoShareView.swift; sourceTree = ""; }; - 6258DCD922D93A5400166C65 /* LogoShareView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LogoShareView.xib; sourceTree = ""; }; 9409E1161ABF6FEA00312E2B /* SwiftRadio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftRadio.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 9409E11A1ABF6FEA00312E2B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9409E11B1ABF6FEA00312E2B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 9409E1251ABF6FEA00312E2B /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; - 94452E541AD7086800BFE7A5 /* AboutViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; - 945DB3C11AD58E3A00495EBB /* stations.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = stations.json; sourceTree = ""; }; - 945DB3C41AD5A6E200495EBB /* NothingFoundCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NothingFoundCell.xib; sourceTree = ""; }; - 94AC70AD1AD05C6200652982 /* RadioStation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadioStation.swift; sourceTree = ""; }; - 94D1D0A41AD6D6230022CA11 /* InfoDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoDetailViewController.swift; sourceTree = ""; }; - 94D260901B45D20000DE671C /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; - 94E9761B1B1A8F3200F52B1E /* UIImage+DropShadow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+DropShadow.swift"; sourceTree = ""; }; - AA0000001ACC9DEE005B7C26 /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; - CA142F662D3D8E280071A388 /* NSLayoutConstraint+with.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+with.swift"; sourceTree = ""; }; - CA142F692D3D9A0D0071A388 /* BottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetViewController.swift; sourceTree = ""; }; - CA142F6C2D3D9DFB0071A388 /* BottomSheetHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetHandler.swift; sourceTree = ""; }; - CA2511B22D4E100000111003 /* AboutCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutCoordinator.swift; sourceTree = ""; }; - CA2511B42D458033006D1A99 /* AudioSetupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSetupService.swift; sourceTree = ""; }; - CA2511B72D45B1B6006D1A99 /* FeaturesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturesViewController.swift; sourceTree = ""; }; - CA2511BA2D45C2CA006D1A99 /* LibrariesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrariesViewController.swift; sourceTree = ""; }; - CA2511BD2D45CD11006D1A99 /* ContributorsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsViewController.swift; sourceTree = ""; }; - CA3A11002D4E000000111001 /* InfoSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoSection.swift; sourceTree = ""; }; - CA3A11032D4E000000111002 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; - CA58087B2D40BAC000F8082B /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; - CA5F00012F40A00000000001 /* GradientBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientBackgroundView.swift; sourceTree = ""; }; - CA5F00062F41000000000003 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; - CAB4E8282D3D7DC7001282E9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - CE0A4994291F3A220071C0CC /* SwiftRadio.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SwiftRadio.entitlements; sourceTree = ""; }; - CE321FF229371140001572BD /* Bundle+appName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+appName.swift"; sourceTree = ""; }; - CE6036122A47A87A00E15E15 /* StationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationsViewController.swift; sourceTree = ""; }; - CE6036152A47B88600E15E15 /* StationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationTableViewCell.swift; sourceTree = ""; }; - CE6036182A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+reuseIdentifier.swift"; sourceTree = ""; }; CE6A3E4F291F376D0058C82A /* SwiftRadio-CarPlay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SwiftRadio-CarPlay.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - CE6ECCF9292F13DF008B3C16 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; - CE6ECCFC292F1445008B3C16 /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; - CE6ECD02292F358C008B3C16 /* UIViewController+Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Email.swift"; sourceTree = ""; }; - CE963ECB29135A6F004F299E /* StationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationsManager.swift; sourceTree = ""; }; - CE9EE8DD293BB41300F62041 /* BaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseController.swift; sourceTree = ""; }; - CE9EE8E0293C048A00F62041 /* LoaderController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoaderController.swift; sourceTree = ""; }; - CEA82F492921F260009E9FA0 /* Info-CarPlay.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-CarPlay.plist"; sourceTree = ""; }; - CED6353A293081ED002B216F /* Handoffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Handoffable.swift; sourceTree = ""; }; - CEDABBEA291217AF00C0367F /* UIImageView+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+Cache.swift"; sourceTree = ""; }; - CEDABBEC29121BBA00C0367F /* UIImage+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Cache.swift"; sourceTree = ""; }; - CEE9A2772B5345C30018FE68 /* NowPlayingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingViewController.swift; sourceTree = ""; }; - CEE9A27A2B535C780018FE68 /* AlbumArtworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumArtworkView.swift; sourceTree = ""; }; - CEE9A27D2B54A5520018FE68 /* ControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + AB1000010000000000000001 /* Exceptions for "SwiftRadio" folder in "SwiftRadio" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Info-CarPlay.plist", + Info.plist, + ); + target = 9409E1151ABF6FEA00312E2B /* SwiftRadio */; + }; + AB1000010000000000000002 /* Exceptions for "SwiftRadio" folder in "SwiftRadio-CarPlay" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Info-CarPlay.plist", + Info.plist, + ); + target = CE6A3E2B291F376D0058C82A /* SwiftRadio-CarPlay */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 9409E1181ABF6FEA00312E2B /* SwiftRadio */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AB1000010000000000000001 /* Exceptions for "SwiftRadio" folder in "SwiftRadio" target */, + AB1000010000000000000002 /* Exceptions for "SwiftRadio" folder in "SwiftRadio-CarPlay" target */, + ); + path = SwiftRadio; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 2C5545B71C1124DE00728469 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -208,11 +108,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AB3000000000000000000001 /* MarqueeLabel in Frameworks */, + AB3000000000000000000003 /* NVActivityIndicatorView in Frameworks */, + AB1000020000000000000001 /* SwiftRadioCore in Frameworks */, CE37D7B7290F4A9700B0933B /* FRadioPlayer in Frameworks */, - CE6036232A48C51A00E15E15 /* NVActivityIndicatorViewExtended in Frameworks */, - CE6036212A48C51A00E15E15 /* NVActivityIndicatorView in Frameworks */, - AA0000101ACC9DEE005B7C26 /* LNPopupController in Frameworks */, - CA900001200000000000000A /* MarqueeLabel in Frameworks */, + AB1A00032F5A0001000AB003 /* LNPopupUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -220,11 +120,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AB3000000000000000000002 /* MarqueeLabel in Frameworks */, + AB3000000000000000000004 /* NVActivityIndicatorView in Frameworks */, + AB1000020000000000000002 /* SwiftRadioCore in Frameworks */, + AB2000010000000000000001 /* LNPopupUI in Frameworks */, CE6A3E43291F376D0058C82A /* FRadioPlayer in Frameworks */, - CE60362A2A48F70A00E15E15 /* NVActivityIndicatorViewExtended in Frameworks */, - CA2B6C6F2F31B769000CB4D4 /* MarqueeLabel in Frameworks */, - CE6036282A48F70A00E15E15 /* NVActivityIndicatorView in Frameworks */, - AA0000121ACC9DEE005B7C26 /* LNPopupController in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -260,77 +160,6 @@ name = Products; sourceTree = ""; }; - 9409E1181ABF6FEA00312E2B /* SwiftRadio */ = { - isa = PBXGroup; - children = ( - CE0A4994291F3A220071C0CC /* SwiftRadio.entitlements */, - CE0A4993291F39660071C0CC /* CarPlay */, - CE8062F7291EF5D9008BD097 /* Helpers */, - CE6ECCF8292F139D008B3C16 /* Coordinators */, - CE60361B2A48C43D00E15E15 /* Views */, - CE8062F6291EF5B3008BD097 /* ViewControllers */, - CE8062F5291EF598008BD097 /* Model */, - CE8062F4291EF57D008BD097 /* Data */, - CE8062F3291EF566008BD097 /* Networking */, - BB0000032D4E000000000003 /* Services */, - CE8062F2291EF545008BD097 /* Cells */, - BB0000012D4E000000000001 /* App */, - BB0000022D4E000000000002 /* Config */, - 9409E1251ABF6FEA00312E2B /* Images.xcassets */, - CA5F00062F41000000000003 /* Localizable.xcstrings */, - 9409E11A1ABF6FEA00312E2B /* Info.plist */, - CEA82F492921F260009E9FA0 /* Info-CarPlay.plist */, - ); - path = SwiftRadio; - sourceTree = ""; - }; - BB0000012D4E000000000001 /* App */ = { - isa = PBXGroup; - children = ( - 9409E11B1ABF6FEA00312E2B /* AppDelegate.swift */, - CAB4E8282D3D7DC7001282E9 /* SceneDelegate.swift */, - ); - path = App; - sourceTree = ""; - }; - BB0000022D4E000000000002 /* Config */ = { - isa = PBXGroup; - children = ( - 94D260901B45D20000DE671C /* Config.swift */, - CA3A11032D4E000000111002 /* Content.swift */, - CA3A11002D4E000000111001 /* InfoSection.swift */, - ); - path = Config; - sourceTree = ""; - }; - BB0000032D4E000000000003 /* Services */ = { - isa = PBXGroup; - children = ( - CA2511B42D458033006D1A99 /* AudioSetupService.swift */, - ); - path = Services; - sourceTree = ""; - }; - CE0A4993291F39660071C0CC /* CarPlay */ = { - isa = PBXGroup; - children = ( - CA58087B2D40BAC000F8082B /* CarPlaySceneDelegate.swift */, - ); - path = CarPlay; - sourceTree = ""; - }; - CE60361B2A48C43D00E15E15 /* Views */ = { - isa = PBXGroup; - children = ( - 6258DCD722D93A3500166C65 /* LogoShareView.swift */, - 6258DCD922D93A5400166C65 /* LogoShareView.xib */, - CEE9A27A2B535C780018FE68 /* AlbumArtworkView.swift */, - CEE9A27D2B54A5520018FE68 /* ControlsView.swift */, - CA5F00012F40A00000000001 /* GradientBackgroundView.swift */, - ); - path = Views; - sourceTree = ""; - }; CE6036262A48F70A00E15E15 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -338,84 +167,6 @@ name = Frameworks; sourceTree = ""; }; - CE6ECCF8292F139D008B3C16 /* Coordinators */ = { - isa = PBXGroup; - children = ( - CE6ECCF9292F13DF008B3C16 /* Coordinator.swift */, - CE6ECCFC292F1445008B3C16 /* MainCoordinator.swift */, - CA2511B22D4E100000111003 /* AboutCoordinator.swift */, - ); - path = Coordinators; - sourceTree = ""; - }; - CE8062F2291EF545008BD097 /* Cells */ = { - isa = PBXGroup; - children = ( - 945DB3C41AD5A6E200495EBB /* NothingFoundCell.xib */, - CE6036152A47B88600E15E15 /* StationTableViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; - CE8062F3291EF566008BD097 /* Networking */ = { - isa = PBXGroup; - children = ( - AA0000001ACC9DEE005B7C26 /* NetworkService.swift */, - ); - path = Networking; - sourceTree = ""; - }; - CE8062F4291EF57D008BD097 /* Data */ = { - isa = PBXGroup; - children = ( - 945DB3C11AD58E3A00495EBB /* stations.json */, - ); - path = Data; - sourceTree = ""; - }; - CE8062F5291EF598008BD097 /* Model */ = { - isa = PBXGroup; - children = ( - 94AC70AD1AD05C6200652982 /* RadioStation.swift */, - CE963ECB29135A6F004F299E /* StationsManager.swift */, - ); - path = Model; - sourceTree = ""; - }; - CE8062F6291EF5B3008BD097 /* ViewControllers */ = { - isa = PBXGroup; - children = ( - CA2511BD2D45CD11006D1A99 /* ContributorsViewController.swift */, - CA2511BA2D45C2CA006D1A99 /* LibrariesViewController.swift */, - CA2511B72D45B1B6006D1A99 /* FeaturesViewController.swift */, - CE9EE8DD293BB41300F62041 /* BaseController.swift */, - CE9EE8E0293C048A00F62041 /* LoaderController.swift */, - CA142F692D3D9A0D0071A388 /* BottomSheetViewController.swift */, - 94452E541AD7086800BFE7A5 /* AboutViewController.swift */, - 94D1D0A41AD6D6230022CA11 /* InfoDetailViewController.swift */, - CE6036122A47A87A00E15E15 /* StationsViewController.swift */, - CEE9A2772B5345C30018FE68 /* NowPlayingViewController.swift */, - ); - path = ViewControllers; - sourceTree = ""; - }; - CE8062F7291EF5D9008BD097 /* Helpers */ = { - isa = PBXGroup; - children = ( - CA142F662D3D8E280071A388 /* NSLayoutConstraint+with.swift */, - CEDABBEA291217AF00C0367F /* UIImageView+Cache.swift */, - CEDABBEC29121BBA00C0367F /* UIImage+Cache.swift */, - CE6ECD02292F358C008B3C16 /* UIViewController+Email.swift */, - 94E9761B1B1A8F3200F52B1E /* UIImage+DropShadow.swift */, - 53113F38230C720900462C0E /* ShareActivity.swift */, - CED6353A293081ED002B216F /* Handoffable.swift */, - CE321FF229371140001572BD /* Bundle+appName.swift */, - CE6036182A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift */, - CA142F6C2D3D9DFB0071A388 /* BottomSheetHandler.swift */, - ); - path = Helpers; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -450,13 +201,16 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 9409E1181ABF6FEA00312E2B /* SwiftRadio */, + ); name = SwiftRadio; packageProductDependencies = ( + AB3000000000000000000011 /* MarqueeLabel */, + AB3000000000000000000013 /* NVActivityIndicatorView */, + AB1000030000000000000001 /* SwiftRadioCore */, CE37D7B6290F4A9700B0933B /* FRadioPlayer */, - CE6036202A48C51A00E15E15 /* NVActivityIndicatorView */, - CE6036222A48C51A00E15E15 /* NVActivityIndicatorViewExtended */, - AA0000111ACC9DEE005B7C26 /* LNPopupController */, - CA900002200000000000000A /* MarqueeLabel */, + AB1A00022F5A0001000AB002 /* LNPopupUI */, ); productName = RadioPro; productReference = 9409E1161ABF6FEA00312E2B /* SwiftRadio.app */; @@ -475,13 +229,16 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 9409E1181ABF6FEA00312E2B /* SwiftRadio */, + ); name = "SwiftRadio-CarPlay"; packageProductDependencies = ( + AB3000000000000000000012 /* MarqueeLabel */, + AB3000000000000000000014 /* NVActivityIndicatorView */, + AB1000030000000000000002 /* SwiftRadioCore */, + AB2000010000000000000002 /* LNPopupUI */, CE6A3E2E291F376D0058C82A /* FRadioPlayer */, - CE6036272A48F70A00E15E15 /* NVActivityIndicatorView */, - CE6036292A48F70A00E15E15 /* NVActivityIndicatorViewExtended */, - AA0000131ACC9DEE005B7C26 /* LNPopupController */, - CA2B6C6E2F31B769000CB4D4 /* MarqueeLabel */, ); productName = RadioPro; productReference = CE6A3E4F291F376D0058C82A /* SwiftRadio-CarPlay.app */; @@ -516,7 +273,6 @@ }; }; buildConfigurationList = 9409E1111ABF6FE900312E2B /* Build configuration list for PBXProject "SwiftRadio" */; - compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -525,11 +281,13 @@ ); mainGroup = 9409E10D1ABF6FE900312E2B; packageReferences = ( + AB3000000000000000000021 /* XCRemoteSwiftPackageReference "MarqueeLabel" */, + AB3000000000000000000022 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */, + AB1000040000000000000001 /* XCLocalSwiftPackageReference "Packages/SwiftRadioCore" */, CE37D7B5290F4A9700B0933B /* XCRemoteSwiftPackageReference "FRadioPlayer" */, - CE60361F2A48C51A00E15E15 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */, - AA0000141ACC9DEE005B7C26 /* XCRemoteSwiftPackageReference "LNPopupController" */, - CA900003200000000000000A /* XCRemoteSwiftPackageReference "MarqueeLabel" */, + AB1A00012F5A0001000AB001 /* XCRemoteSwiftPackageReference "LNPopupUI" */, ); + preferredProjectObjectVersion = 77; productRefGroup = 9409E1171ABF6FEA00312E2B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -553,11 +311,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 945DB3C21AD58E3A00495EBB /* stations.json in Resources */, - 945DB3C51AD5A6E200495EBB /* NothingFoundCell.xib in Resources */, - 6258DCDA22D93A5400166C65 /* LogoShareView.xib in Resources */, - 9409E1261ABF6FEA00312E2B /* Images.xcassets in Resources */, - CA5F00042F41000000000001 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -565,11 +318,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - CE6A3E46291F376D0058C82A /* stations.json in Resources */, - CE6A3E47291F376D0058C82A /* NothingFoundCell.xib in Resources */, - CE6A3E48291F376D0058C82A /* LogoShareView.xib in Resources */, - CE6A3E4B291F376D0058C82A /* Images.xcassets in Resources */, - CA5F00052F41000000000002 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -588,43 +336,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CA2511BF2D45CD13006D1A99 /* ContributorsViewController.swift in Sources */, - CA2511BC2D45C2D1006D1A99 /* LibrariesViewController.swift in Sources */, - CA2511B92D45B1B6006D1A99 /* FeaturesViewController.swift in Sources */, - 94D260911B45D20000DE671C /* Config.swift in Sources */, - CA3A11012D4E000000111001 /* InfoSection.swift in Sources */, - CA3A11042D4E000000111002 /* Content.swift in Sources */, - CE6036192A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift in Sources */, - CE6036162A47B88600E15E15 /* StationTableViewCell.swift in Sources */, - CE9EE8DE293BB41300F62041 /* BaseController.swift in Sources */, - CA142F6D2D3D9DFB0071A388 /* BottomSheetHandler.swift in Sources */, - CEE9A2782B5345C30018FE68 /* NowPlayingViewController.swift in Sources */, - 53113F39230C720900462C0E /* ShareActivity.swift in Sources */, - CA142F682D3D8E2F0071A388 /* NSLayoutConstraint+with.swift in Sources */, - CE963ECC29135A6F004F299E /* StationsManager.swift in Sources */, - CE321FF329371140001572BD /* Bundle+appName.swift in Sources */, - CE6ECCFA292F13DF008B3C16 /* Coordinator.swift in Sources */, - 94D1D0A51AD6D6230022CA11 /* InfoDetailViewController.swift in Sources */, - 6258DCD822D93A3500166C65 /* LogoShareView.swift in Sources */, - AA0000011ACC9DEE005B7C26 /* NetworkService.swift in Sources */, - 94E9761C1B1A8F3200F52B1E /* UIImage+DropShadow.swift in Sources */, - 94452E551AD7086800BFE7A5 /* AboutViewController.swift in Sources */, - CED6353B293081ED002B216F /* Handoffable.swift in Sources */, - CEE9A27B2B535C780018FE68 /* AlbumArtworkView.swift in Sources */, - CE6ECCFD292F1445008B3C16 /* MainCoordinator.swift in Sources */, - CA2511B32D4E100000111003 /* AboutCoordinator.swift in Sources */, - 9409E11C1ABF6FEA00312E2B /* AppDelegate.swift in Sources */, - CA2511B52D458033006D1A99 /* AudioSetupService.swift in Sources */, - CAB4E82A2D3D7DC7001282E9 /* SceneDelegate.swift in Sources */, - CE6ECD03292F358C008B3C16 /* UIViewController+Email.swift in Sources */, - CEDABBED29121BBA00C0367F /* UIImage+Cache.swift in Sources */, - 94AC70AE1AD05C6200652982 /* RadioStation.swift in Sources */, - CA142F6A2D3D9A0D0071A388 /* BottomSheetViewController.swift in Sources */, - CE9EE8E1293C048A00F62041 /* LoaderController.swift in Sources */, - CEE9A27E2B54A5520018FE68 /* ControlsView.swift in Sources */, - CA5F00022F40A00000000002 /* GradientBackgroundView.swift in Sources */, - CEDABBEB291217AF00C0367F /* UIImageView+Cache.swift in Sources */, - CE6036132A47A87A00E15E15 /* StationsViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -632,44 +343,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CA2511BB2D45C2D1006D1A99 /* LibrariesViewController.swift in Sources */, - CE6A3E31291F376D0058C82A /* Config.swift in Sources */, - CA3A11022D4E000000111001 /* InfoSection.swift in Sources */, - CA3A11052D4E000000111002 /* Content.swift in Sources */, - CA2511BE2D45CD13006D1A99 /* ContributorsViewController.swift in Sources */, - CE60361A2A48BA2500E15E15 /* UITableViewCell+reuseIdentifier.swift in Sources */, - CE6036172A47B88600E15E15 /* StationTableViewCell.swift in Sources */, - CE9EE8DF293BB41300F62041 /* BaseController.swift in Sources */, - CA142F6E2D3D9DFB0071A388 /* BottomSheetHandler.swift in Sources */, - CA2511B62D458033006D1A99 /* AudioSetupService.swift in Sources */, - CEE9A2792B5345C30018FE68 /* NowPlayingViewController.swift in Sources */, - CE6A3E33291F376D0058C82A /* ShareActivity.swift in Sources */, - CA142F672D3D8E2F0071A388 /* NSLayoutConstraint+with.swift in Sources */, - CE6A3E35291F376D0058C82A /* StationsManager.swift in Sources */, - CE321FF429371140001572BD /* Bundle+appName.swift in Sources */, - CE6ECCFB292F13E6008B3C16 /* Coordinator.swift in Sources */, - CE6A3E36291F376D0058C82A /* InfoDetailViewController.swift in Sources */, - CE6A3E37291F376D0058C82A /* LogoShareView.swift in Sources */, - AA000002291F376D0058C82A /* NetworkService.swift in Sources */, - CE6A3E39291F376D0058C82A /* UIImage+DropShadow.swift in Sources */, - CE6A3E3A291F376D0058C82A /* AboutViewController.swift in Sources */, - CED6353C293081ED002B216F /* Handoffable.swift in Sources */, - CEE9A27C2B535C780018FE68 /* AlbumArtworkView.swift in Sources */, - CA2511B82D45B1B6006D1A99 /* FeaturesViewController.swift in Sources */, - CE6ECCFE292F1448008B3C16 /* MainCoordinator.swift in Sources */, - CA2511B32D4E100000111004 /* AboutCoordinator.swift in Sources */, - CA58087D2D40BAC000F8082B /* CarPlaySceneDelegate.swift in Sources */, - CE6A3E3C291F376D0058C82A /* AppDelegate.swift in Sources */, - CAB4E8292D3D7DC7001282E9 /* SceneDelegate.swift in Sources */, - CE6ECD04292F358C008B3C16 /* UIViewController+Email.swift in Sources */, - CE6A3E3E291F376D0058C82A /* UIImage+Cache.swift in Sources */, - CE6A3E40291F376D0058C82A /* RadioStation.swift in Sources */, - CA142F6B2D3D9A0D0071A388 /* BottomSheetViewController.swift in Sources */, - CE9EE8E2293C048A00F62041 /* LoaderController.swift in Sources */, - CEE9A27F2B54A5520018FE68 /* ControlsView.swift in Sources */, - CA5F00032F40A00000000003 /* GradientBackgroundView.swift in Sources */, - CE6A3E41291F376D0058C82A /* UIImageView+Cache.swift in Sources */, - CE6036142A47A87A00E15E15 /* StationsViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -690,7 +363,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = SwiftRadioUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -698,7 +371,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = jonah.SwiftRadioUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = SwiftRadio; USES_XCTRUNNER = YES; }; @@ -710,7 +383,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = SwiftRadioUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -718,7 +391,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = jonah.SwiftRadioUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = SwiftRadio; USES_XCTRUNNER = YES; }; @@ -774,11 +447,12 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -826,7 +500,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -851,7 +525,7 @@ "$(PROJECT_DIR)/SwiftRadio", ); INFOPLIST_FILE = SwiftRadio/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -863,7 +537,8 @@ PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -883,7 +558,7 @@ "$(PROJECT_DIR)/SwiftRadio", ); INFOPLIST_FILE = SwiftRadio/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -895,7 +570,8 @@ PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -918,7 +594,7 @@ "$(PROJECT_DIR)/SwiftRadio", ); INFOPLIST_FILE = "SwiftRadio/Info-CarPlay.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -932,7 +608,8 @@ PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = swiftradiocarplay3; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -955,7 +632,7 @@ "$(PROJECT_DIR)/SwiftRadio", ); INFOPLIST_FILE = "SwiftRadio/Info-CarPlay.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -969,7 +646,8 @@ PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = swiftradiocarplay3; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1015,98 +693,97 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - AA0000141ACC9DEE005B7C26 /* XCRemoteSwiftPackageReference "LNPopupController" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LeoNatan/LNPopupController.git"; - requirement = { - kind = exactVersion; - version = 4.5.12; - }; +/* Begin XCLocalSwiftPackageReference section */ + AB1000040000000000000001 /* XCLocalSwiftPackageReference "Packages/SwiftRadioCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/SwiftRadioCore; }; - CA900003200000000000000A /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = { +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AB1A00012F5A0001000AB001 /* XCRemoteSwiftPackageReference "LNPopupUI" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/cbpowell/MarqueeLabel.git"; + repositoryURL = "https://github.com/LeoNatan/LNPopupUI.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 4.0.1; }; }; - CE37D7B5290F4A9700B0933B /* XCRemoteSwiftPackageReference "FRadioPlayer" */ = { + AB3000000000000000000021 /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/fethica/FRadioPlayer.git"; + repositoryURL = "https://github.com/cbpowell/MarqueeLabel.git"; requirement = { kind = exactVersion; - version = 0.2.2; + version = 4.5.3; }; }; - CE60361F2A48C51A00E15E15 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */ = { + AB3000000000000000000022 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ninjaprox/NVActivityIndicatorView.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.0.0; + kind = exactVersion; + version = 5.1.1; }; }; - CE6A3E2F291F376D0058C82A /* XCRemoteSwiftPackageReference "FRadioPlayer" */ = { + CE37D7B5290F4A9700B0933B /* XCRemoteSwiftPackageReference "FRadioPlayer" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/fethica/FRadioPlayer.git"; requirement = { - branch = v0.2.2; - kind = branch; + kind = upToNextMinorVersion; + minimumVersion = 0.4.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - AA0000111ACC9DEE005B7C26 /* LNPopupController */ = { + AB1000030000000000000001 /* SwiftRadioCore */ = { isa = XCSwiftPackageProductDependency; - package = AA0000141ACC9DEE005B7C26 /* XCRemoteSwiftPackageReference "LNPopupController" */; - productName = LNPopupController; + package = AB1000040000000000000001 /* XCLocalSwiftPackageReference "Packages/SwiftRadioCore" */; + productName = SwiftRadioCore; }; - AA0000131ACC9DEE005B7C26 /* LNPopupController */ = { + AB1000030000000000000002 /* SwiftRadioCore */ = { isa = XCSwiftPackageProductDependency; - package = AA0000141ACC9DEE005B7C26 /* XCRemoteSwiftPackageReference "LNPopupController" */; - productName = LNPopupController; + package = AB1000040000000000000001 /* XCLocalSwiftPackageReference "Packages/SwiftRadioCore" */; + productName = SwiftRadioCore; }; - CA2B6C6E2F31B769000CB4D4 /* MarqueeLabel */ = { + AB1A00022F5A0001000AB002 /* LNPopupUI */ = { isa = XCSwiftPackageProductDependency; - package = CA900003200000000000000A /* XCRemoteSwiftPackageReference "MarqueeLabel" */; - productName = MarqueeLabel; + package = AB1A00012F5A0001000AB001 /* XCRemoteSwiftPackageReference "LNPopupUI" */; + productName = LNPopupUI; }; - CA900002200000000000000A /* MarqueeLabel */ = { + AB2000010000000000000002 /* LNPopupUI */ = { isa = XCSwiftPackageProductDependency; - package = CA900003200000000000000A /* XCRemoteSwiftPackageReference "MarqueeLabel" */; - productName = MarqueeLabel; + package = AB1A00012F5A0001000AB001 /* XCRemoteSwiftPackageReference "LNPopupUI" */; + productName = LNPopupUI; }; - CE37D7B6290F4A9700B0933B /* FRadioPlayer */ = { + AB3000000000000000000011 /* MarqueeLabel */ = { isa = XCSwiftPackageProductDependency; - package = CE37D7B5290F4A9700B0933B /* XCRemoteSwiftPackageReference "FRadioPlayer" */; - productName = FRadioPlayer; + package = AB3000000000000000000021 /* XCRemoteSwiftPackageReference "MarqueeLabel" */; + productName = MarqueeLabel; }; - CE6036202A48C51A00E15E15 /* NVActivityIndicatorView */ = { + AB3000000000000000000012 /* MarqueeLabel */ = { isa = XCSwiftPackageProductDependency; - package = CE60361F2A48C51A00E15E15 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; - productName = NVActivityIndicatorView; + package = AB3000000000000000000021 /* XCRemoteSwiftPackageReference "MarqueeLabel" */; + productName = MarqueeLabel; }; - CE6036222A48C51A00E15E15 /* NVActivityIndicatorViewExtended */ = { + AB3000000000000000000013 /* NVActivityIndicatorView */ = { isa = XCSwiftPackageProductDependency; - package = CE60361F2A48C51A00E15E15 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; - productName = NVActivityIndicatorViewExtended; + package = AB3000000000000000000022 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; + productName = NVActivityIndicatorView; }; - CE6036272A48F70A00E15E15 /* NVActivityIndicatorView */ = { + AB3000000000000000000014 /* NVActivityIndicatorView */ = { isa = XCSwiftPackageProductDependency; - package = CE60361F2A48C51A00E15E15 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; + package = AB3000000000000000000022 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; productName = NVActivityIndicatorView; }; - CE6036292A48F70A00E15E15 /* NVActivityIndicatorViewExtended */ = { + CE37D7B6290F4A9700B0933B /* FRadioPlayer */ = { isa = XCSwiftPackageProductDependency; - package = CE60361F2A48C51A00E15E15 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; - productName = NVActivityIndicatorViewExtended; + package = CE37D7B5290F4A9700B0933B /* XCRemoteSwiftPackageReference "FRadioPlayer" */; + productName = FRadioPlayer; }; CE6A3E2E291F376D0058C82A /* FRadioPlayer */ = { isa = XCSwiftPackageProductDependency; - package = CE6A3E2F291F376D0058C82A /* XCRemoteSwiftPackageReference "FRadioPlayer" */; + package = CE37D7B5290F4A9700B0933B /* XCRemoteSwiftPackageReference "FRadioPlayer" */; productName = FRadioPlayer; }; /* End XCSwiftPackageProductDependency section */ diff --git a/SwiftRadio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftRadio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 10b6139a2..41fa3f0c3 100755 --- a/SwiftRadio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftRadio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "0416c4350e26dd8381e8013055fd6682f096f689f7ff7b39e31ebddd835b1404", + "originHash" : "c1d25d2d752f0ff881ba5a81d8e14e2bc4d3d1f3986d4dfe86b7f7f63d342ff4", "pins" : [ { "identity" : "fradioplayer", "kind" : "remoteSourceControl", "location" : "https://github.com/fethica/FRadioPlayer.git", "state" : { - "revision" : "243cd9246b79a186c39b3c53dbe725c8c01bab59", - "version" : "0.2.2" + "revision" : "8fff8f5f220acb1640e6cca9b18f9ca61f45075a", + "version" : "0.4.0" } }, { @@ -19,6 +19,24 @@ "version" : "4.5.12" } }, + { + "identity" : "lnpopupui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNPopupUI.git", + "state" : { + "revision" : "9eb7413a6d8428ef83e598293963dc21dedc39f1", + "version" : "4.0.1" + } + }, + { + "identity" : "lnswiftuiutils", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNSwiftUIUtils.git", + "state" : { + "revision" : "181a95b992f84d99a14c562c86e8ebef3cbcdca9", + "version" : "1.1.5" + } + }, { "identity" : "lnsystemmarqueelabel", "kind" : "remoteSourceControl", diff --git a/SwiftRadio/App/AppDelegate.swift b/SwiftRadio/App/AppDelegate.swift index b8a335cdd..e7c38cb79 100755 --- a/SwiftRadio/App/AppDelegate.swift +++ b/SwiftRadio/App/AppDelegate.swift @@ -7,39 +7,33 @@ // import UIKit +#if CarPlay +import CarPlay +#endif + +/// Initializes the shared composition and routes CarPlay scenes alongside SwiftUI's phone scene. +@MainActor final class AppDelegate: UIResponder, UIApplicationDelegate { + let environment = AppEnvironment.shared -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - private let audioService = AudioSetupService.shared - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - // Setup all audio-related configurations at app launch - audioService.setupFRadioPlayer() - audioService.setupAudioSession() - audioService.setupRemoteCommandCenter() - + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + UINavigationBar.appearance().barStyle = .black + UINavigationBar.appearance().tintColor = Config.tintColor + UINavigationBar.appearance().prefersLargeTitles = true return true } - - // MARK: UISceneSession Lifecycle - + +#if CarPlay func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { -#if CarPlay if connectingSceneSession.role == .carTemplateApplication { let config = UISceneConfiguration(name: "CarPlay Configuration", sessionRole: connectingSceneSession.role) config.delegateClass = CarPlaySceneDelegate.self return config } -#endif - - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, - didDiscardSceneSessions sceneSessions: Set) { + // Leave delegateClass unset so SwiftUI supplies its own phone scene delegate. + return UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) } +#endif } diff --git a/SwiftRadio/App/AppEnvironment.swift b/SwiftRadio/App/AppEnvironment.swift new file mode 100644 index 000000000..d175e2cc8 --- /dev/null +++ b/SwiftRadio/App/AppEnvironment.swift @@ -0,0 +1,93 @@ +// +// AppEnvironment.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Observation +import OSLog +import SwiftRadioCore + +/// Composes the single playback owner, shared stores, and scene-independent artwork updates. +@MainActor final class AppEnvironment { + /// The single intentional application global: UIKit CarPlay and SwiftUI share this instance. + /// AppDelegate initializes it at launch. The audio session is activated by PlayerService when + /// playback starts, not here: activating on launch would stop other apps' audio. + static let shared = AppEnvironment() + + let stations: StationsStore + let player: PlayerService + let artwork: ArtworkLoader + private var stationArtworkTask: Task? + private var trackArtworkTask: Task? + + private init() { + let engine: any RadioPlaying + #if DEBUG + // Opt-in only: UI regressions exercise the real app without relying on remote streams. + engine = ProcessInfo.processInfo.arguments.contains("--ui-test-playback") + ? UITestRadioPlayer() : PlayerService.makeRadioPlayer() + #else + engine = PlayerService.makeRadioPlayer() + #endif + let commands = RemoteCommandsController() + #if DEBUG + if engine is UITestRadioPlayer { + // A fake engine produces no audio: owning the session would only gate UI tests on the + // simulator's audio server. Session behaviour is covered by the core package tests. + player = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(), + remoteCommands: commands, activateAudioSession: nil) + } else { + player = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(), remoteCommands: commands, + mixesWithOtherAudio: Config.mixesWithOtherAudio) + } + #else + player = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(), remoteCommands: commands, + mixesWithOtherAudio: Config.mixesWithOtherAudio) + #endif + stations = StationsStore(loader: ConfiguredStationsLoader(), player: player) + artwork = ArtworkLoader() + commands.bind(player: player, stations: stations) + observeArtwork() + if Config.debugLog { observePlayback() } + } + + private func observeArtwork() { + let (station, url) = withObservationTracking { + (stations.currentStation, player.artworkURL) + } onChange: { [weak self] in + // Observation fires before mutation; re-read and re-register on the next actor turn. + Task { @MainActor [weak self] in self?.observeArtwork() } + } + stationArtworkTask?.cancel() + trackArtworkTask?.cancel() + guard let station else { return } + stationArtworkTask = Task { [weak self, artwork] in + let image = await artwork.image(for: station) + guard !Task.isCancelled, let self, stations.currentStation?.id == station.id else { return } + player.updateArtwork(image, for: station.id, artworkURL: nil) + } + if let url { + trackArtworkTask = Task { [weak self, artwork] in + let image = await artwork.trackArtwork(at: url) + guard !Task.isCancelled, let self, stations.currentStation?.id == station.id, + player.artworkURL == url else { return } + player.updateArtwork(image, for: station.id, artworkURL: url) + } + } + } + + private func observePlayback() { + let (state, readiness) = withObservationTracking { + (player.state, player.readiness) + } onChange: { [weak self] in + Task { @MainActor [weak self] in self?.observePlayback() } + } + Logger(subsystem: "SwiftRadio", category: "Playback") + .info("Player state: \(String(describing: state), privacy: .public), readiness: \(String(describing: readiness), privacy: .public)") + } +} diff --git a/SwiftRadio/App/ArtworkLoaderKey.swift b/SwiftRadio/App/ArtworkLoaderKey.swift new file mode 100644 index 000000000..7a6cd6e4e --- /dev/null +++ b/SwiftRadio/App/ArtworkLoaderKey.swift @@ -0,0 +1,24 @@ +// +// ArtworkLoaderKey.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI +import SwiftRadioCore + +/// Allows an explicitly injected loader without creating a second default artwork service. +struct ArtworkLoaderKey: EnvironmentKey { + static let defaultValue: ArtworkLoader? = nil +} + +extension EnvironmentValues { + var artworkLoader: ArtworkLoader? { + get { self[ArtworkLoaderKey.self] } + set { self[ArtworkLoaderKey.self] = newValue } + } +} diff --git a/SwiftRadio/App/ConfiguredStationsLoader.swift b/SwiftRadio/App/ConfiguredStationsLoader.swift new file mode 100644 index 000000000..0a3b76344 --- /dev/null +++ b/SwiftRadio/App/ConfiguredStationsLoader.swift @@ -0,0 +1,24 @@ +// +// ConfiguredStationsLoader.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation +import SwiftRadioCore + +/// Resolves template configuration at load time so an invalid remote URL reaches the retry screen. +struct ConfiguredStationsLoader: StationsLoader { + func load() async throws -> [SwiftRadioCore.RadioStation] { + if Config.useLocalStations { return try await BundleStationsLoader().load() } + guard let url = URL(string: Config.stationsURL), + ["http", "https"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else { + throw URLError(.badURL) + } + return try await RemoteStationsLoader(url: url).load() + } +} diff --git a/SwiftRadio/App/RootView.swift b/SwiftRadio/App/RootView.swift new file mode 100644 index 000000000..ff3f92a8c --- /dev/null +++ b/SwiftRadio/App/RootView.swift @@ -0,0 +1,123 @@ +// +// RootView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import LNPopupUI +import SwiftRadioCore +import SwiftUI + +/// Keeps navigation alive after bootstrap and coordinates selection with popup presentation. +/// Also owns the sequenced popup-close → push-Info/present-Safari handoff from the options sheet. +@MainActor struct RootView: View { + @Environment(StationsStore.self) private var stations + @State private var bootstrapped = false + @State private var path = NavigationPath() + @State private var barPresented = false + @State private var popupOpen = false + @State private var safariURL: URL? + /// Requests queued by the options sheet; run from `popupDidClose` so the push/presentation + /// only starts after the popup has fully closed. + @State private var pendingInfo: SwiftRadioCore.RadioStation? + @State private var pendingSafari: URL? + + var body: some View { + Group { + if bootstrapped { + NavigationStack(path: $path) { + StationsScreen(isPopupOpen: $popupOpen) + .navigationDestination(for: SwiftRadioCore.RadioStation.self) { station in + StationInfoScreen(station: station) + } + } + .popup(isBarPresented: $barPresented, isPopupOpen: $popupOpen, onClose: popupDidClose) { + NowPlayingView( + requestInfo: { station in + pendingInfo = station + popupOpen = false + }, + requestWebsite: { url in + pendingSafari = url + popupOpen = false + } + ) + } + .popupBarStyle(Self.popupBarStyle) + .popupInteractionStyle(.drag) + .popupBarProgressViewStyle(.bottom) + .popupCloseButtonStyle(.chevron) + // Names the bar for UI tests and accessibility tooling. The library keeps + // supplying the bar's own VoiceOver label from the popup item's title, + // which a custom popup title view would drop. + .popupBarCustomizer { bar in + bar.accessibilityIdentifier = "popupBar" + bar.tintColor = Config.tintColor + } + .tint(Color(uiColor: Config.tintColor)) + } else { + LoadingScreen(error: loadingError) { await stations.load() } + } + } + .sheet(isPresented: safariPresented) { + if let url = safariURL { + SafariView(url: url) + } + } + .task { + if stations.loadState == .idle { await stations.load() } + } + .onChange(of: stations.loadState, initial: true) { _, state in + if state == .loaded { bootstrapped = true } + } + .onChange(of: stations.currentStation, initial: true) { _, station in + if station != nil { + barPresented = true + } else if popupOpen { + // Finish closing content before removing its bar. Future pushes/presentations + // should also run from onClose, after any options sheet has dismissed. + popupOpen = false + } else { + barPresented = false + } + } + } + + /// Matches the UIKit reference's `.prominent` bar. `.default` is not equivalent: it resolves to + /// floating on iOS 17/18 and to floating compact on iOS 26, where prominent maps to floating. + private static var popupBarStyle: LNPopupBar.Style { + if #available(iOS 26, *) { + return .floating + } else { + return .prominent + } + } + + private var loadingError: String? { + if case .failed(let message) = stations.loadState { return message } + return nil + } + + private var safariPresented: Binding { + Binding( + get: { safariURL != nil }, + set: { if !$0 { safariURL = nil } } + ) + } + + private func popupDidClose() { + if let station = pendingInfo { + pendingInfo = nil + path.append(station) + } + if let url = pendingSafari { + pendingSafari = nil + safariURL = url + } + if stations.currentStation == nil { barPresented = false } + } +} diff --git a/SwiftRadio/App/SceneDelegate.swift b/SwiftRadio/App/SceneDelegate.swift deleted file mode 100644 index 88296a95c..000000000 --- a/SwiftRadio/App/SceneDelegate.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// SceneDelegate.swift -// Swift Radio -// -// Created by Fethi El Hassasna on 1/25/25. -// Copyright (c) 2015 MatthewFecher.com. All rights reserved. -// - -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - private var coordinator: MainCoordinator? - - private let audioService = AudioSetupService.shared - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let windowScene = (scene as? UIWindowScene) else { return } - - // UI Setup - setupUIAppearance() - - // Start the coordinator - setupCoordinator(windowScene: windowScene) - } - - private func setupUIAppearance() { - UINavigationBar.appearance().barStyle = .black - UINavigationBar.appearance().tintColor = Config.tintColor - UINavigationBar.appearance().prefersLargeTitles = true - } - - private func setupCoordinator(windowScene: UIWindowScene) { - coordinator = MainCoordinator(navigationController: UINavigationController()) - - window = UIWindow(windowScene: windowScene) - window?.rootViewController = coordinator?.navigationController - window?.makeKeyAndVisible() - - coordinator?.start() - } - - func sceneDidDisconnect(_ scene: UIScene) { - } - - func sceneDidBecomeActive(_ scene: UIScene) { - audioService.activateAudioSession() - } - - func sceneWillResignActive(_ scene: UIScene) { - } - - func sceneWillEnterForeground(_ scene: UIScene) { - audioService.activateAudioSession() - } - - func sceneDidEnterBackground(_ scene: UIScene) { - audioService.activateAudioSession() - } -} diff --git a/SwiftRadio/App/SwiftRadioApp.swift b/SwiftRadio/App/SwiftRadioApp.swift new file mode 100644 index 000000000..e421182cc --- /dev/null +++ b/SwiftRadio/App/SwiftRadioApp.swift @@ -0,0 +1,26 @@ +// +// SwiftRadioApp.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Boots the phone UI with the same composition used by the CarPlay scene. +@main @MainActor struct SwiftRadioApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup { + RootView() + .environment(appDelegate.environment.stations) + .environment(appDelegate.environment.player) + .environment(\.artworkLoader, appDelegate.environment.artwork) + .tint(Color(uiColor: Config.tintColor)) + } + } +} diff --git a/SwiftRadio/App/UITestRadioPlayer.swift b/SwiftRadio/App/UITestRadioPlayer.swift new file mode 100644 index 000000000..232b1c164 --- /dev/null +++ b/SwiftRadio/App/UITestRadioPlayer.swift @@ -0,0 +1,63 @@ +// +// UITestRadioPlayer.swift +// Swift Radio +// +// Created on 2026-09-23. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +#if DEBUG +import Foundation +import SwiftRadioCore + +/// Deterministic transport at the existing engine boundary, excluded from Release builds. +/// Catalog, stores, navigation, controls and vendor animation views remain production code. +@MainActor final class UITestRadioPlayer: RadioPlaying { + private weak var observer: (any RadioPlayerEvents)? + private var loadTask: Task? + private(set) var isPlaying = false + private(set) var duration: TimeInterval? + var currentMetadata: (artist: String?, track: String?)? { + ("Swift Radio UI Test Artist", "A deliberately long now playing title to verify continuous marquee scrolling") + } + var radioURL: URL? { + didSet { + loadTask?.cancel() + isPlaying = false + duration = radioURL?.pathExtension == "mp3" ? 180 : 0 + guard radioURL != nil else { return } + observer?.playerStateDidChange(.loading) + loadTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(700)) + guard !Task.isCancelled, let self else { return } + observer?.durationDidChange(duration ?? 0) + observer?.metadataDidChange(artist: currentMetadata?.artist, track: currentMetadata?.track) + observer?.playerStateDidChange(.readyToPlay) + play() + } + } + } + func addObserver(_ observer: any RadioPlayerEvents) { self.observer = observer } + func play() { isPlaying = true; observer?.playbackStateDidChange(.playing) } + func pause() { isPlaying = false; observer?.playbackStateDidChange(.paused) } + func stop() { + loadTask?.cancel() + isPlaying = false + observer?.playbackStateDidChange(.stopped) + } + func togglePlaying() { isPlaying ? pause() : play() } + func seek(to seconds: TimeInterval, completion: @escaping @MainActor @Sendable () -> Void) { + // A stale time event before completion reproduces the AVPlayer seek race. + observer?.playTimeDidChange(0, duration: duration ?? 0) + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard let self else { return } + // FRadioPlayer 0.4 preserves transport; PlayerService owns resume-on-scrub. + completion() + observer?.playTimeDidChange(seconds, duration: duration ?? 0) + } + } +} +#endif diff --git a/SwiftRadio/CarPlay/CarPlayObservation.swift b/SwiftRadio/CarPlay/CarPlayObservation.swift new file mode 100644 index 000000000..2aa7cad21 --- /dev/null +++ b/SwiftRadio/CarPlay/CarPlayObservation.swift @@ -0,0 +1,50 @@ +// +// CarPlayObservation.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Observation +import SwiftRadioCore + +/// Re-renders CarPlay for catalog, load state, selection, and transport changes, never playback-clock ticks. +@MainActor final class CarPlayObservation { + private let stations: StationsStore + private let player: PlayerService + private var generation = 0 + private var render: (() -> Void)? + + init(stations: StationsStore, player: PlayerService) { + self.stations = stations + self.player = player + } + + func start(render: @escaping () -> Void) { + generation += 1 + self.render = render + observe(generation: generation) + } + + func stop() { + generation += 1 + render = nil + } + + private func observe(generation: Int) { + guard generation == self.generation, render != nil else { return } + withObservationTracking { + _ = stations.stations + _ = stations.loadState // Drives the loading, failure and retry rows. + _ = stations.currentStation + _ = player.state + } onChange: { [weak self] in + // Tracking fires before the write. Re-read after it, and re-register on MainActor. + Task { @MainActor [weak self] in self?.observe(generation: generation) } + } + render?() // Initial render and subsequent renders use the latest main-actor values. + } +} diff --git a/SwiftRadio/CarPlay/CarPlaySceneDelegate.swift b/SwiftRadio/CarPlay/CarPlaySceneDelegate.swift index 6b91c8606..2275475f1 100644 --- a/SwiftRadio/CarPlay/CarPlaySceneDelegate.swift +++ b/SwiftRadio/CarPlay/CarPlaySceneDelegate.swift @@ -8,101 +8,197 @@ // import CarPlay -import FRadioPlayer +import OSLog +import SwiftRadioCore + +/// Bridges CarPlay templates to the same catalog, transport, and artwork used by the phone. +@MainActor final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { + private let environment = AppEnvironment.shared + private weak var interfaceController: CPInterfaceController? + private var listTemplate: CPListTemplate? + private var observation: CarPlayObservation? + private var artworkTasks: [Task] = [] + private var displayedContent: CatalogListContent? + private var items: [String: CPListItem] = [:] + private var isPushingNowPlaying = false + private var generation = 0 + /// Pushed catalog pages by page number, so a new paging can update them in place. + private var pageTemplates: [Int: WeakListTemplate] = [:] + /// CarPlay audio apps may stack five templates: four list pages leave room for Now Playing. + private static let maximumPageCount = 4 + + private struct WeakListTemplate { + weak var template: CPListTemplate? + } -class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { - - private var interfaceController: CPInterfaceController? - private let audioService = AudioSetupService.shared - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let templateApplicationScene = scene as? CPTemplateApplicationScene else { return } - // Set up the CarPlay window - templateApplicationScene.delegate = self + guard let scene = scene as? CPTemplateApplicationScene else { return } + scene.delegate = self } - + func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { - print("CarPlay connected") + disconnect() self.interfaceController = interfaceController - - // Create a simple list template - let listTemplate = CPListTemplate(title: "Radio Stations", sections: []) - - // Set as root template immediately - interfaceController - .setRootTemplate(listTemplate, animated: false, completion: nil) - - // Then fetch and update stations - if StationsManager.shared.stations.isEmpty { - StationsManager.shared.fetch { [weak self] _ in - self?.updateStationsList(listTemplate) - } - } else { - updateStationsList(listTemplate) + let template = CPListTemplate(title: Content.CarPlay.title, sections: []) + listTemplate = template + interfaceController.setRootTemplate(template, animated: false, completion: nil) + + let generation = generation + let observation = CarPlayObservation(stations: environment.stations, player: environment.player) + self.observation = observation + observation.start { [weak self] in + guard let self, self.generation == generation else { return } + render() + } + if environment.stations.stations.isEmpty, environment.stations.loadState != .loading { + loadCatalog() } - - // Subscribe to updates - StationsManager.shared.addObserver(self) } - - private func updateStationsList(_ template: CPListTemplate) { - let stations = StationsManager.shared.stations - print("Setting up stations list with \(stations.count) stations") - - let items = stations.map { station -> CPListItem in - // Create list item with image - let item = CPListItem(text: station.name, detailText: station.desc) - - // Add station image if available - station.getImage { image in - item.setImage(image) + + private func render() { + guard let template = listTemplate else { return } + // Some cars cap lists (often at 12 items while driving); the limit can change at runtime. + let content = CatalogListContent(stations: environment.stations.stations, + loadState: environment.stations.loadState, + maximumItemCount: CPListTemplate.maximumItemCount, + maximumPageCount: Self.maximumPageCount) + if content != displayedContent { + artworkTasks.forEach { $0.cancel() } + artworkTasks.removeAll() + items.removeAll() + displayedContent = content + template.updateSections(sections(for: content)) + // Pushed pages follow the new paging; one that no longer exists is left empty. + pageTemplates = pageTemplates.filter { $0.value.template != nil } + for (page, box) in pageTemplates { + box.template?.updateSections(stationSections(for: content, page: page)) } - - // Handle selection - item.handler = { _, completion in - print("Selected station: \(station.name)") - StationsManager.shared.set(station: station) - FRadioPlayer.shared.play() - completion() + } + let currentID = environment.stations.currentStation?.id + let playing = environment.player.state == .playing + for (stationID, item) in items { + item.isPlaying = playing && stationID == currentID + } + } + + /// One section at most, which always fits `CPListTemplate.maximumSectionCount`. + private func sections(for content: CatalogListContent) -> [CPListSection] { + guard let template = listTemplate else { return [] } + switch content { + case .loading: + // The empty view only shows while the list has no items. + template.emptyViewTitleVariants = [Content.Stations.loadingMessage] + template.emptyViewSubtitleVariants = [] + return [] + case .failed(let message): + // A row rather than the empty view: the driver must be able to recover without the phone. + return [CPListSection(items: [retryItem(detail: message)], header: Content.Loader.errorTitle, + sectionIndexTitle: nil)] + case .empty: + return [CPListSection(items: [retryItem(detail: nil)])] + case .stations(let pages, let hiddenCount): + if hiddenCount > 0 { + Logger(subsystem: "SwiftRadio", category: "CarPlay") + .debug("CarPlay list pages hold \(pages.joined().count) stations; \(hiddenCount) not shown") } - - return item + return stationSections(for: content, page: 0) } - - let section = CPListSection(items: items) - template.updateSections([section]) } - - func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, - didDisconnectInterfaceController interfaceController: CPInterfaceController) { - print("CarPlay disconnected") - self.interfaceController = nil - StationsManager.shared.removeObserver(self) + + /// A page's stations, then a More row when a later page exists. + private func stationSections(for content: CatalogListContent, page: Int) -> [CPListSection] { + guard case .stations(let pages, _) = content, pages.indices.contains(page) else { return [] } + var rows = pages[page].map(stationItem) + if page + 1 < pages.count { rows.append(moreItem(page: page + 1)) } + return [CPListSection(items: rows)] } -} -// MARK: - StationsManagerObserver + private func moreItem(page: Int) -> CPListItem { + let generation = generation + let item = CPListItem(text: Content.CarPlay.more, detailText: nil) + item.accessoryType = .disclosureIndicator + item.handler = { [weak self] _, completion in + defer { completion() } + guard let self, self.generation == generation, let interfaceController = self.interfaceController, + let content = self.displayedContent else { return } + let template = CPListTemplate(title: Content.CarPlay.title, + sections: self.stationSections(for: content, page: page)) + self.pageTemplates[page] = WeakListTemplate(template: template) + interfaceController.pushTemplate(template, animated: true, completion: nil) + } + return item + } -extension CarPlaySceneDelegate: StationsManagerObserver { - - func stationsManager(_ manager: StationsManager, stationsDidUpdate stations: [RadioStation]) { - print("Stations updated: \(stations.count) stations") - if let listTemplate = interfaceController?.rootTemplate as? CPListTemplate { - DispatchQueue.main.async { - self.updateStationsList(listTemplate) - } + private func stationItem(_ station: SwiftRadioCore.RadioStation) -> CPListItem { + let generation = generation + let item = CPListItem(text: station.name, detailText: station.desc) + items[station.id] = item + item.handler = { [weak self] _, completion in + // CarPlay delivers selection on the main actor; always release its spinner. + defer { completion() } + guard let self, self.generation == generation, self.listTemplate != nil, + self.environment.stations.stations.contains(station) else { return } + // Re-tapping the playing or loading station must never restart or cancel it in the car. + guard self.environment.stations.activate(station, on: .carPlay) != .unavailable else { return } + self.showNowPlaying() + } + artworkTasks.append(Task { [weak self, weak item, artwork = environment.artwork] in + let image = await artwork.image(for: station) + guard !Task.isCancelled, let self, let item, self.generation == generation, + self.items[station.id] === item else { return } + item.setImage(image) + }) + return item + } + + private func retryItem(detail: String?) -> CPListItem { + let generation = generation + let item = CPListItem(text: Content.Loader.retryButton, detailText: detail) + item.handler = { [weak self] _, completion in + defer { completion() } + guard let self, self.generation == generation, + self.environment.stations.loadState != .loading else { return } + self.loadCatalog() } + return item + } + + private func loadCatalog() { + // A catalog request belongs to all scenes and must survive CarPlay disconnect. + Task { [stations = environment.stations] in await stations.load() } } - - func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) { - guard let listTemplate = interfaceController?.rootTemplate as? CPListTemplate else { return } - let items = listTemplate.sections.flatMap { $0.items }.compactMap { $0 as? CPListItem } - let stations = manager.stations - - for (index, item) in items.enumerated() { - guard index < stations.count else { break } - item.isPlaying = (stations[index] == station) + + /// Apple's audio-app flow: a selection lands on Now Playing, and Back returns to the list. + private func showNowPlaying() { + guard let interfaceController, !isPushingNowPlaying, + !(interfaceController.topTemplate is CPNowPlayingTemplate) else { return } + isPushingNowPlaying = true + let generation = generation + interfaceController.pushTemplate(CPNowPlayingTemplate.shared, animated: true) { [weak self] _, _ in + Task { @MainActor [weak self] in + guard let self, self.generation == generation else { return } + self.isPushingNowPlaying = false + } } } + + func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, + didDisconnectInterfaceController interfaceController: CPInterfaceController) { + disconnect() + } + + private func disconnect() { + generation += 1 + observation?.stop() + observation = nil + artworkTasks.forEach { $0.cancel() } + artworkTasks.removeAll() + items.removeAll() + pageTemplates.removeAll() + displayedContent = nil + isPushingNowPlaying = false + listTemplate = nil + interfaceController = nil + } } diff --git a/SwiftRadio/Cells/NothingFoundCell.xib b/SwiftRadio/Cells/NothingFoundCell.xib deleted file mode 100755 index ea215c4e9..000000000 --- a/SwiftRadio/Cells/NothingFoundCell.xib +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftRadio/Cells/StationTableViewCell.swift b/SwiftRadio/Cells/StationTableViewCell.swift deleted file mode 100755 index 5dc018261..000000000 --- a/SwiftRadio/Cells/StationTableViewCell.swift +++ /dev/null @@ -1,337 +0,0 @@ -// -// StationTableViewCell.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2023-06-24. -// Copyright © 2023 matthewfecher.com. All rights reserved. -// - -import UIKit -import NVActivityIndicatorView - -class StationTableViewCell: UITableViewCell { - - // MARK: - UI - private var representedStation: RadioStation? - - private let cardBlurView: UIVisualEffectView = { - let blur = UIBlurEffect(style: .systemUltraThinMaterialDark) - let view = UIVisualEffectView(effect: blur) - view.layer.cornerRadius = 14 - view.clipsToBounds = true - view.layer.borderWidth = 0.5 - view.layer.borderColor = UIColor.white.withAlphaComponent(0.12).cgColor - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let cardView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.layer.cornerRadius = 14 - view.layer.shadowColor = UIColor.black.cgColor - view.layer.shadowOpacity = 0.2 - view.layer.shadowOffset = CGSize(width: 0, height: 2) - view.layer.shadowRadius = 8 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let pulseRingView: UIView = { - let view = UIView() - view.layer.cornerRadius = 12 - view.layer.borderWidth = 2.5 - view.layer.borderColor = UIColor.white.cgColor - view.layer.shadowColor = UIColor.white.cgColor - view.layer.shadowOpacity = 0.5 - view.layer.shadowOffset = .zero - view.layer.shadowRadius = 6 - view.alpha = 0 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let stationImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - imageView.layer.cornerRadius = 12 - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - }() - - private let artworkShadowView: UIView = { - let view = UIView() - view.layer.shadowColor = UIColor.black.cgColor - view.layer.shadowOpacity = 0.35 - view.layer.shadowOffset = CGSize(width: 0, height: 3) - view.layer.shadowRadius = 8 - view.layer.cornerRadius = 12 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - let titleLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .headline) - label.numberOfLines = 1 - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private let subtitleStack: UIStackView = { - let stack = UIStackView() - stack.axis = .horizontal - stack.spacing = 6 - stack.alignment = .center - stack.translatesAutoresizingMaskIntoConstraints = false - return stack - }() - - let subtitleLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .subheadline) - label.textColor = .white.withAlphaComponent(0.55) - label.numberOfLines = 1 - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private let equalizerView: NVActivityIndicatorView = { - let view = NVActivityIndicatorView(frame: .zero, type: .audioEqualizer, color: .white, padding: nil) - view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - view.widthAnchor.constraint(equalToConstant: 16), - view.heightAnchor.constraint(equalToConstant: 12) - ]) - view.alpha = 0 - return view - }() - - private let bufferingOverlay: UIView = { - let view = UIView() - view.backgroundColor = UIColor.black.withAlphaComponent(0.5) - view.layer.cornerRadius = 12 - view.clipsToBounds = true - view.alpha = 0 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let bufferingIndicator: NVActivityIndicatorView = { - let view = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: .white, padding: nil) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private var isAnimatingPulse = false - - // MARK: - Init - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupViews() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - representedStation = nil - titleLabel.text = nil - subtitleLabel.text = nil - stationImageView.image = nil - stopPulseAnimation() - pulseRingView.alpha = 0 - equalizerView.stopAnimating() - equalizerView.alpha = 0 - bufferingIndicator.stopAnimating() - bufferingOverlay.alpha = 0 - } - - // MARK: - Highlight / Tap Feedback - - override func setHighlighted(_ highlighted: Bool, animated: Bool) { - super.setHighlighted(highlighted, animated: animated) - let scale: CGFloat = highlighted ? 0.97 : 1.0 - UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) { - self.cardView.transform = CGAffineTransform(scaleX: scale, y: scale) - self.cardBlurView.transform = CGAffineTransform(scaleX: scale, y: scale) - } - } - - // MARK: - Setup - - private func setupViews() { - selectionStyle = .none - backgroundColor = .clear - contentView.backgroundColor = .clear - - // Card shadow container + blur - contentView.addSubview(cardView) - cardView.addSubview(cardBlurView) - - // Artwork container: shadow view + pulse ring + image - let artworkContainer = UIView() - artworkContainer.translatesAutoresizingMaskIntoConstraints = false - - artworkContainer.addSubview(artworkShadowView) - artworkContainer.addSubview(pulseRingView) - artworkContainer.addSubview(stationImageView) - artworkContainer.addSubview(bufferingOverlay) - bufferingOverlay.addSubview(bufferingIndicator) - - // Subtitle stack: label + equalizer - subtitleStack.addArrangedSubview(equalizerView) - subtitleStack.addArrangedSubview(subtitleLabel) - - // Labels stack - let vStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleStack]) - vStackView.spacing = 4 - vStackView.axis = .vertical - vStackView.translatesAutoresizingMaskIntoConstraints = false - - // Main horizontal stack - let hStackView = UIStackView(arrangedSubviews: [artworkContainer, vStackView]) - hStackView.spacing = 14 - hStackView.axis = .horizontal - hStackView.alignment = .center - hStackView.translatesAutoresizingMaskIntoConstraints = false - - cardBlurView.contentView.addSubview(hStackView) - - let artworkSize: CGFloat = 70 - let pulseInset: CGFloat = -4 - - NSLayoutConstraint.activate([ - // Card inset from contentView - cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), - cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5), - cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - - // Blur fills card - cardBlurView.topAnchor.constraint(equalTo: cardView.topAnchor), - cardBlurView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor), - cardBlurView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor), - cardBlurView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor), - - // HStack inside blur content - hStackView.topAnchor.constraint(equalTo: cardBlurView.contentView.topAnchor, constant: 12), - hStackView.bottomAnchor.constraint(equalTo: cardBlurView.contentView.bottomAnchor, constant: -12), - hStackView.leadingAnchor.constraint(equalTo: cardBlurView.contentView.leadingAnchor, constant: 14), - hStackView.trailingAnchor.constraint(equalTo: cardBlurView.contentView.trailingAnchor, constant: -14), - - // Artwork container size - artworkContainer.widthAnchor.constraint(equalToConstant: artworkSize), - artworkContainer.heightAnchor.constraint(equalToConstant: artworkSize), - - // Image fills container - stationImageView.topAnchor.constraint(equalTo: artworkContainer.topAnchor), - stationImageView.bottomAnchor.constraint(equalTo: artworkContainer.bottomAnchor), - stationImageView.leadingAnchor.constraint(equalTo: artworkContainer.leadingAnchor), - stationImageView.trailingAnchor.constraint(equalTo: artworkContainer.trailingAnchor), - - // Shadow matches image - artworkShadowView.topAnchor.constraint(equalTo: stationImageView.topAnchor), - artworkShadowView.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor), - artworkShadowView.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor), - artworkShadowView.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor), - - // Pulse ring slightly larger than artwork - pulseRingView.topAnchor.constraint(equalTo: stationImageView.topAnchor, constant: pulseInset), - pulseRingView.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor, constant: -pulseInset), - pulseRingView.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor, constant: pulseInset), - pulseRingView.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor, constant: -pulseInset), - - // Buffering overlay on top of image - bufferingOverlay.topAnchor.constraint(equalTo: stationImageView.topAnchor), - bufferingOverlay.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor), - bufferingOverlay.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor), - bufferingOverlay.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor), - bufferingIndicator.centerXAnchor.constraint(equalTo: bufferingOverlay.centerXAnchor), - bufferingIndicator.centerYAnchor.constraint(equalTo: bufferingOverlay.centerYAnchor), - bufferingIndicator.widthAnchor.constraint(equalToConstant: 30), - bufferingIndicator.heightAnchor.constraint(equalToConstant: 20), - ]) - } - - // MARK: - Now Playing - - func setNowPlaying(isPlaying: Bool, isBuffering: Bool, isCurrentStation: Bool) { - guard isCurrentStation else { - stopPulseAnimation() - pulseRingView.alpha = 0 - equalizerView.stopAnimating() - equalizerView.alpha = 0 - bufferingIndicator.stopAnimating() - bufferingOverlay.alpha = 0 - return - } - - if isBuffering { - // Dark overlay + buffering indicator on artwork - stopPulseAnimation() - pulseRingView.alpha = 0 - equalizerView.stopAnimating() - equalizerView.alpha = 0 - bufferingIndicator.startAnimating() - bufferingOverlay.alpha = 1 - } else if isPlaying { - // Pulse ring + equalizer - bufferingIndicator.stopAnimating() - bufferingOverlay.alpha = 0 - startPulseAnimation() - equalizerView.startAnimating() - equalizerView.alpha = 0.7 - } else { - // Stopped — subtle indicators - bufferingIndicator.stopAnimating() - bufferingOverlay.alpha = 0 - stopPulseAnimation() - pulseRingView.alpha = 0.25 - pulseRingView.transform = .identity - equalizerView.stopAnimating() - equalizerView.alpha = 0.4 - } - } - - // MARK: - Pulse Animation - - private func startPulseAnimation() { - guard !isAnimatingPulse else { return } - isAnimatingPulse = true - - pulseRingView.alpha = 0 - pulseRingView.transform = .identity - - UIView.animate(withDuration: 1.5, delay: 0, options: [.repeat, .autoreverse, .curveEaseInOut]) { - self.pulseRingView.alpha = 0.4 - self.pulseRingView.transform = CGAffineTransform(scaleX: 1.06, y: 1.06) - } - } - - private func stopPulseAnimation() { - guard isAnimatingPulse else { return } - isAnimatingPulse = false - pulseRingView.layer.removeAllAnimations() - pulseRingView.transform = .identity - } -} - -// MARK: - Configuration - -extension StationTableViewCell { - func configureStationCell(station: RadioStation) { - representedStation = station - titleLabel.text = station.name - subtitleLabel.text = station.desc - - station.getImage { [weak self] image in - guard let self = self, self.representedStation == station else { return } - self.stationImageView.image = image - } - } -} diff --git a/SwiftRadio/Config/Config.swift b/SwiftRadio/Config/Config.swift index 83a240996..4e3845224 100755 --- a/SwiftRadio/Config/Config.swift +++ b/SwiftRadio/Config/Config.swift @@ -12,6 +12,10 @@ struct Config { static let debugLog = true + // Set to "true" to keep other apps' audio playing under the radio. This disables the + // lock-screen, Control Center and CarPlay Now Playing controls. + static let mixesWithOtherAudio = false + // Tint color used across the app (navigation bar, controls, etc.) static let tintColor: UIColor = .white @@ -37,12 +41,13 @@ struct Config { static let licenseURL = "https://raw.githubusercontent.com/analogcode/Swift-Radio-Pro/refs/heads/master/LICENSE" struct Libraries { + // The app's actual package dependencies, plus the template itself. static let items: [LibraryItem] = [ LibraryItem(owner: "analogcode", repo: "Swift-Radio-Pro"), LibraryItem(owner: "fethica", repo: "FRadioPlayer"), - LibraryItem(owner: "ninjaprox", repo: "NVActivityIndicatorView"), - LibraryItem(owner: "LeoNatan", repo: "LNPopupController"), + LibraryItem(owner: "LeoNatan", repo: "LNPopupUI"), LibraryItem(owner: "cbpowell", repo: "MarqueeLabel"), + LibraryItem(owner: "ninjaprox", repo: "NVActivityIndicatorView"), ] } @@ -84,4 +89,3 @@ struct Config { ] } } - diff --git a/SwiftRadio/Config/Content.swift b/SwiftRadio/Config/Content.swift index 4db5dfdac..b3aa22b14 100644 --- a/SwiftRadio/Config/Content.swift +++ b/SwiftRadio/Config/Content.swift @@ -76,7 +76,17 @@ struct Content { static let playInMusicApp = String(localized: "bottomSheet.playInMusicApp") } + /// Titles used by the CarPlay catalog. + struct CarPlay { + static let title = String(localized: "carPlay.title") + /// Opens the next page when the car limits how many rows a list may show. + static let more = String(localized: "carPlay.more") + } + struct Player { + static let play = String(localized: "player.play") + static let pause = String(localized: "player.pause") + static let stop = String(localized: "player.stop") static let liveBadge = String(localized: "player.liveBadge") } @@ -85,7 +95,10 @@ struct Content { static let couldNotSendEmail = String(localized: "common.couldNotSendEmail") static let emailErrorMessage = String(localized: "common.emailErrorMessage") static let noDescription = String(localized: "common.noDescription") - static let commitsFormat = String(localized: "common.commitsFormat") + /// The count travels with the lookup so the catalog's plural variations ("1 commit") apply. + static func commits(_ count: Int) -> String { + String(localized: "common.commitsFormat", defaultValue: "\(count) commits") + } } struct Features { diff --git a/SwiftRadio/Config/InfoSection.swift b/SwiftRadio/Config/InfoSection.swift index eb85afc8c..05755a364 100644 --- a/SwiftRadio/Config/InfoSection.swift +++ b/SwiftRadio/Config/InfoSection.swift @@ -8,24 +8,24 @@ import Foundation -struct FeatureItem { +struct FeatureItem: Sendable { let title: String let subtitle: String let icon: String // SF Symbol name } -struct LibraryItem { +struct LibraryItem: Sendable { let owner: String let repo: String } -struct InfoSection { +struct InfoSection: Sendable { let title: String let items: [InfoItem] var isEnabled: Bool = true } -enum InfoItem { +enum InfoItem: Sendable { // Navigation (pushes to a screen) case features(title: String = "Features", icon: String? = nil) case libraries(title: String = "Open Source Libraries", subtitle: String? = nil, icon: String? = nil) diff --git a/SwiftRadio/Coordinators/AboutCoordinator.swift b/SwiftRadio/Coordinators/AboutCoordinator.swift deleted file mode 100644 index 6bf75bf67..000000000 --- a/SwiftRadio/Coordinators/AboutCoordinator.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// AboutCoordinator.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2025-01-31. -// Copyright © 2025 matthewfecher.com. All rights reserved. -// - -import UIKit -import SafariServices -import MessageUI - -class AboutCoordinator: NSObject, NavigationCoordinator { - var childCoordinators: [Coordinator] = [] - let navigationController: UINavigationController - weak var parentCoordinator: MainCoordinator? - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { - let aboutVC = AboutViewController() - aboutVC.delegate = self - navigationController.setViewControllers([aboutVC], animated: false) - } -} - -// MARK: - AboutViewControllerDelegate - -extension AboutCoordinator: AboutViewControllerDelegate { - func aboutViewController(_ controller: AboutViewController, didSelectItem item: InfoItem) { - switch item { - case .features: - let featuresVC = FeaturesViewController() - navigationController.pushViewController(featuresVC, animated: true) - - case .libraries: - let librariesVC = LibrariesViewController() - navigationController.pushViewController(librariesVC, animated: true) - - case .credits(_, _, let owner, let repo, _): - let contributorsVC = ContributorsViewController(owner: owner, repo: repo) - navigationController.pushViewController(contributorsVC, animated: true) - - case .link(_, _, let urlString, _): - if let url = URL(string: urlString) { - let safariVC = SFSafariViewController(url: url) - navigationController.present(safariVC, animated: true) - } - - case .email(_, _, let address, _): - parentCoordinator?.openEmail(to: address, from: self) - - case .share(_, let text, _): - guard let aboutVC = navigationController.viewControllers.first as? AboutViewController else { return } - parentCoordinator?.share(text, from: aboutVC) - - case .rateApp(_, let appID, _): - if let url = URL(string: "itms-apps://itunes.apple.com/app/id\(appID)") { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - - case .version: - break - } - } -} - -// MARK: - MFMailComposeViewControllerDelegate - -extension AboutCoordinator: MFMailComposeViewControllerDelegate { - func mailComposeController(_ controller: MFMailComposeViewController, - didFinishWith result: MFMailComposeResult, - error: Error?) { - controller.dismiss(animated: true) - } -} diff --git a/SwiftRadio/Coordinators/Coordinator.swift b/SwiftRadio/Coordinators/Coordinator.swift deleted file mode 100755 index 066b4bca5..000000000 --- a/SwiftRadio/Coordinators/Coordinator.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Coordinator.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-23. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit - -protocol Coordinator: AnyObject { - var childCoordinators: [Coordinator] { get set } - func start() -} - -protocol NavigationCoordinator: Coordinator { - var navigationController: UINavigationController { get } -} - -protocol TabCoordinator: Coordinator { - var tabBarController: UITabBarController { get } -} diff --git a/SwiftRadio/Coordinators/MainCoordinator.swift b/SwiftRadio/Coordinators/MainCoordinator.swift deleted file mode 100755 index 94c8f9c54..000000000 --- a/SwiftRadio/Coordinators/MainCoordinator.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// MainCoordinator.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-23. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit -import MessageUI -import SafariServices -import LNPopupController -import FRadioPlayer - -class MainCoordinator: NavigationCoordinator { - var childCoordinators: [Coordinator] = [] - let navigationController: UINavigationController - - private lazy var nowPlayingViewController: NowPlayingViewController = { - let vc = NowPlayingViewController() - vc.delegate = self - return vc - }() - - private let player = FRadioPlayer.shared - private var isPopupBarPresented = false - - func start() { - let loaderVC = LoaderController() - loaderVC.delegate = self - navigationController.setViewControllers([loaderVC], animated: false) - } - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - // MARK: - Popup Bar - - func presentPopupBarIfNeeded() { - guard !isPopupBarPresented else { return } - navigationController.popupBar.barStyle = .prominent - navigationController.popupBar.tintColor = Config.tintColor - navigationController.popupBar.progressViewStyle = .bottom - navigationController.popupContentView.popupCloseButtonStyle = .chevron - navigationController.presentPopupBar(with: nowPlayingViewController, animated: true) - isPopupBarPresented = true - } - - // MARK: - Shared - - func openEmail(to email: String, from coordinator: AboutCoordinator) { - guard let aboutVC = coordinator.navigationController.viewControllers.first as? AboutViewController else { return } - guard MFMailComposeViewController.canSendMail() else { - aboutVC.showSendMailErrorAlert() - return - } - - let mailComposer = MFMailComposeViewController() - mailComposer.mailComposeDelegate = coordinator - mailComposer.setToRecipients([email]) - mailComposer.setSubject(Config.emailSubject) - mailComposer.setMessageBody("", isHTML: false) - aboutVC.present(mailComposer, animated: true) - } - - func openAbout() { - let modalNav = UINavigationController() - let aboutCoordinator = AboutCoordinator(navigationController: modalNav) - aboutCoordinator.parentCoordinator = self - aboutCoordinator.start() - childCoordinators.append(aboutCoordinator) - navigationController.present(modalNav, animated: true) - } - - func share(_ text: String, from viewController: UIViewController) { - let activityViewController = UIActivityViewController(activityItems: [text], applicationActivities: nil) - if let popoverController = activityViewController.popoverPresentationController { - popoverController.sourceView = viewController.view - popoverController.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) - popoverController.permittedArrowDirections = [] - } - viewController.present(activityViewController, animated: true) - } -} - -// MARK: - LoaderControllerDelegate - -extension MainCoordinator: LoaderControllerDelegate { - func didFinishLoading(_ controller: LoaderController, stations: [RadioStation]) { - let stationsVC = StationsViewController() - stationsVC.delegate = self - navigationController.setViewControllers([stationsVC], animated: false) - } -} - -// MARK: - StationsViewControllerDelegate - -extension MainCoordinator: StationsViewControllerDelegate { - - func didSelectStation(_ station: RadioStation, from stationsViewController: StationsViewController) { - let isNewStation = station != StationsManager.shared.currentStation - if isNewStation { - StationsManager.shared.set(station: station) - presentPopupBarIfNeeded() - } else if player.isPlaying { - navigationController.openPopup(animated: true) - } else { - player.togglePlaying() - } - } - - func didTapNowPlaying(_ stationsViewController: StationsViewController) { - navigationController.openPopup(animated: true) - } - - func presentAbout(_ stationsViewController: StationsViewController) { - openAbout() - } -} - -// MARK: - NowPlayingViewControllerDelegate - -extension MainCoordinator: NowPlayingViewControllerDelegate { - - func didSelectBottomSheetOption(_ option: BottomSheetViewController.Option, from controller: NowPlayingViewController) { - guard let station = StationsManager.shared.currentStation else { return } - - switch option { - case .info: - let infoController = InfoDetailViewController(station: station) - navigationController.pushViewController(infoController, animated: true) - navigationController.closePopup(animated: true) - case .website: - if let website = station.website, let url = URL(string: website) { - let safariVC = SFSafariViewController(url: url) - navigationController.closePopup(animated: true, completion: { [weak self] in - self?.navigationController.present(safariVC, animated: true) - }) - } - default: - BottomSheetHandler.handle(option, station: station, from: controller) - } - } - - func didTapCompanyButton(_ nowPlayingViewController: NowPlayingViewController) { - openAbout() - } -} diff --git a/SwiftRadio/Data/stations.json b/SwiftRadio/Data/stations.json index a2d976f4d..ac99db28d 100755 --- a/SwiftRadio/Data/stations.json +++ b/SwiftRadio/Data/stations.json @@ -26,11 +26,12 @@ "longDesc": "NZ's number one Rock music station." }, { - "name": "Classic Rock", - "streamURL": "https://rfcm.streamguys1.com/classicrock-mp3", - "imageURL": "station-classicrock", - "desc": "Classic Rock Hits", - "longDesc": "Classic rock is a radio format which developed from the album-oriented rock (AOR) format in the early 1980s. In the United States, the classic rock format features music ranging generally from the late 1960s to the late 1980s, primarily focusing on commercially successful hard rock popularized in the 1970s. The radio format became increasingly popular with the baby boomer demographic by the end of the 1990s." + "name": "Choice Classic Rock", + "website": "https://www.choiceclassicrock.com", + "streamURL": "https://streamer.radio.co/s66f2626b4/listen", + "imageURL": "https://www.choiceclassicrock.com/wp-content/uploads/2022/08/Choice-Classic-Rock-flaming-guitar-200-x-200.jpg", + "desc": "Classic rock hits and deep tracks", + "longDesc": "Classic rock hits and deep tracks." }, { "name": "Radio 1190", diff --git a/SwiftRadio/Features/About/AboutScreen.swift b/SwiftRadio/Features/About/AboutScreen.swift new file mode 100644 index 000000000..3f37060e9 --- /dev/null +++ b/SwiftRadio/Features/About/AboutScreen.swift @@ -0,0 +1,273 @@ +// +// AboutScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Root of the About flow. Owns its own `NavigationStack` inside the sheet, +/// renders `Config.About.sections` (filtered by `isEnabled`) as an +/// inset-grouped list with a markdown header and a logo/author footer, +/// and routes every row action (push, Safari sheet, mail sheet/alert, +/// share, rate, version display). +struct AboutScreen: View { + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + + @State private var safariURL: URL? + @State private var mailAddress: String? + @State private var showMailError = false + + /// Built once: the configuration is static, and ids must not be recomputed per body. + private static let sections: [AboutSection] = { + let enabled = Config.About.sections.filter(\.isEnabled) + return zip(ConfiguredRowID.make(enabled.map(\.title)), enabled).map { id, section in + AboutSection(id: id, section: section) + } + }() + + var body: some View { + NavigationStack { + List { + AboutIntroSection(text: Self.aboutAttributedText) + ForEach(Self.sections) { section in + Section { + ForEach(section.rows) { row in + self.row(for: row.item) + } + } header: { + Text(section.title) + .textCase(nil) + } + } + Section { + VStack(spacing: 16) { + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .accessibilityIdentifier("aboutFooterLogo") + Text(verbatim: "\(Content.About.footerAuthors)\n\(Self.copyrightNotice)") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + // The grouped section already separates the footer from the final row. + .padding(.top, 8) + .padding(.bottom, 32) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .contentMargins(.horizontal, 20, for: .scrollContent) + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(Text(Content.About.title)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel(Text("common.close")) + } + } + .tint(Color(uiColor: Config.tintColor)) + .sheet(isPresented: safariSheetPresented) { + if let url = safariURL { + SafariView(url: url) + } + } + .sheet(isPresented: mailSheetPresented) { + if let address = mailAddress { + MailComposeView(subject: Config.emailSubject, recipients: [address]) + } + } + .alert(Text(Content.Common.couldNotSendEmail), isPresented: $showMailError) { + Button(Content.Common.ok) {} + } message: { + Text(Content.Common.emailErrorMessage) + } + } + } + + // MARK: - Rows + + @ViewBuilder + private func row(for item: InfoItem) -> some View { + switch item { + case .features: + NavigationLink { + FeaturesScreen() + } label: { + rowLabel(for: item) + } + case .libraries: + NavigationLink { + LibrariesScreen() + } label: { + rowLabel(for: item) + } + case .credits(_, _, let owner, let repo, _): + NavigationLink { + ContributorsScreen(owner: owner, repo: repo) + } label: { + rowLabel(for: item) + } + case .link(_, _, let urlString, _): + Button { + safariURL = URL(string: urlString) + } label: { + rowLabel(for: item) + } + case .email(_, _, let address, _): + Button { + if MailComposeView.canSendMail { + mailAddress = address + } else { + showMailError = true + } + } label: { + rowLabel(for: item) + } + case .rateApp(_, let appID, _): + Button { + if let url = URL(string: "itms-apps://itunes.apple.com/app/id\(appID)") { + openURL(url) + } + } label: { + rowLabel(for: item) + } + case .share(_, let text, _): + ShareLink(item: text) { + rowLabel(for: item) + } + case .version: + rowLabel(for: item) + } + } + + /// Icon + title (+ optional subtitle) label shared by all tappable rows. + private func rowLabel(for item: InfoItem) -> some View { + HStack(spacing: 16) { + if let icon = item.icon { + Image(systemName: icon) + .font(.system(size: 22)) + .frame(width: 24) + } + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + if let subtitle = item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + if item.showsExplicitDisclosure { + Spacer(minLength: 8) + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .foregroundStyle(.primary) + } + + // MARK: - Helpers + + /// "Copyright © ", matching the UIKit footer. + private static var copyrightNotice: String { + let year = Calendar.current.component(.year, from: Date()) + return "\(Content.About.copyright) © \(year) \(Content.About.footerCopyright)" + } + + /// The About header text with `**bold**` markdown markers rendered bold. + private static var aboutAttributedText: AttributedString { + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + return (try? AttributedString(markdown: Content.About.headerText, options: options)) + ?? AttributedString(Content.About.headerText) + } + + /// Bridges the optional `safariURL` state to a `sheet(isPresented:)` binding. + private var safariSheetPresented: Binding { + Binding( + get: { safariURL != nil }, + set: { if !$0 { safariURL = nil } } + ) + } + + /// Bridges the optional `mailAddress` state to a `sheet(isPresented:)` binding. + private var mailSheetPresented: Binding { + Binding( + get: { mailAddress != nil }, + set: { if !$0 { mailAddress = nil } } + ) + } +} + +private struct AboutIntroSection: View { + let text: AttributedString + + var body: some View { + if #available(iOS 26, *) { + section.listSectionMargins(.horizontal, 16) + } else { + section + } + } + + private var section: some View { + Section { + Text(text) + .accessibilityIdentifier("aboutIntroduction") + .font(.body) + .foregroundStyle(.secondary) + .lineSpacing(6) + .padding(.top, 24) + // List supplies additional spacing before the first labeled section. + .padding(.bottom, 8) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } +} + +/// A configured section with ids that stay unique when a fork repeats a title. +private struct AboutSection: Identifiable { + struct Row: Identifiable { + let id: String + let item: InfoItem + } + + let id: String + let title: String + let rows: [Row] + + init(id: String, section: InfoSection) { + self.id = id + title = section.title + rows = zip(ConfiguredRowID.make(section.items.map(\.title)), section.items).map(Row.init) + } +} + +private extension InfoItem { + /// NavigationLink supplies its own chevron; modal and external actions need one. + var showsExplicitDisclosure: Bool { + switch self { + case .link, .email, .rateApp, .share: true + case .features, .libraries, .credits, .version: false + } + } +} diff --git a/SwiftRadio/Features/About/ConfiguredRowID.swift b/SwiftRadio/Features/About/ConfiguredRowID.swift new file mode 100644 index 000000000..112c50fc1 --- /dev/null +++ b/SwiftRadio/Features/About/ConfiguredRowID.swift @@ -0,0 +1,27 @@ +// +// ConfiguredRowID.swift +// Swift Radio +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +/// Row identity for the configurable About, Features and Libraries lists. +/// +/// A fork can list the same title or repository twice. ForEach needs ids that are unique as +/// well as stable, so repeats get an occurrence suffix. The lists come from static +/// configuration and never reorder at runtime, so an occurrence count is stable. +enum ConfiguredRowID { + static func make(_ keys: [String]) -> [String] { + var used = Set() + return keys.map { key in + var id = key + var occurrence = 1 + while !used.insert(id).inserted { + occurrence += 1 + id = "\(key)#\(occurrence)" + } + return id + } + } +} diff --git a/SwiftRadio/Features/About/ContributorsScreen.swift b/SwiftRadio/Features/About/ContributorsScreen.swift new file mode 100644 index 000000000..034463329 --- /dev/null +++ b/SwiftRadio/Features/About/ContributorsScreen.swift @@ -0,0 +1,92 @@ +// +// ContributorsScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Shows GitHub contributors (sorted by contributions, descending) in a +/// 3-column grid: circular avatar, "#rank login", and commit count. +/// Tapping a cell opens the contributor's profile in a Safari sheet. +struct ContributorsScreen: View { + + let owner: String + let repo: String + + @State private var contributors: [GitHubClient.Contributor] = [] + @State private var isLoading = true + @State private var safariURL: URL? + + private let client = GitHubClient() + private let columns = Array(repeating: GridItem(.flexible(), spacing: 16), count: 3) + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(Array(contributors.enumerated()), id: \.element.id) { index, contributor in + Button { + safariURL = contributor.htmlURL + } label: { + cell(rank: index + 1, contributor: contributor) + } + .tint(Color(uiColor: Config.tintColor)) + } + } + .padding() + } + .scrollContentBackground(.hidden) + .navigationTitle(Text(Content.Contributors.title)) + .overlay { + if isLoading { + ProgressView() + } + } + .task { + await load() + } + .sheet(isPresented: safariSheetPresented) { + if let url = safariURL { + SafariView(url: url) + } + } + } + + /// Bridges the optional `safariURL` state to a `sheet(isPresented:)` binding. + private var safariSheetPresented: Binding { + Binding( + get: { safariURL != nil }, + set: { if !$0 { safariURL = nil } } + ) + } + + @ViewBuilder + private func cell(rank: Int, contributor: GitHubClient.Contributor) -> some View { + VStack(spacing: 6) { + AsyncImage(url: contributor.avatarURL) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.secondary.opacity(0.3) + } + .aspectRatio(1, contentMode: .fit) + .clipShape(Circle()) + Text("#\(rank) \(contributor.login)") + .font(.caption) + .lineLimit(1) + Text(Content.Common.commits(contributor.contributions)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + /// Loads contributors; any failure leaves the grid empty rather than crashing. + private func load() async { + let fetched = (try? await client.contributors(owner: owner, name: repo)) ?? [] + contributors = fetched.sorted { $0.contributions > $1.contributions } + isLoading = false + } +} diff --git a/SwiftRadio/Features/About/FeaturesScreen.swift b/SwiftRadio/Features/About/FeaturesScreen.swift new file mode 100644 index 000000000..80c226bf0 --- /dev/null +++ b/SwiftRadio/Features/About/FeaturesScreen.swift @@ -0,0 +1,45 @@ +// +// FeaturesScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Displays `Config.Features.items` as an inset-grouped list of +/// icon + title + subtitle rows. Rows are display-only. +struct FeaturesScreen: View { + + /// A fork can repeat a title, or two translations can coincide; ids stay unique regardless. + private static let rows: [(id: String, item: FeatureItem)] = + Array(zip(ConfiguredRowID.make(Config.Features.items.map(\.title)), Config.Features.items)) + + var body: some View { + List(Self.rows, id: \.id) { row in + let item = row.item + HStack(spacing: 16) { + Image(systemName: item.icon) + .font(.system(size: 22)) + .foregroundStyle(Color(uiColor: Config.tintColor)) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.headline) + Text(item.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .contentMargins(.horizontal, 20, for: .scrollContent) + .contentMargins(.top, 20, for: .scrollContent) + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(Text(Content.Features.title)) + } +} diff --git a/SwiftRadio/Features/About/GitHubClient.swift b/SwiftRadio/Features/About/GitHubClient.swift new file mode 100644 index 000000000..3766f95d2 --- /dev/null +++ b/SwiftRadio/Features/About/GitHubClient.swift @@ -0,0 +1,94 @@ +// +// GitHubClient.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import Foundation + +/// Async, `Sendable` client for the subset of the GitHub REST API the About +/// flow needs: repository metadata and the contributor list. +struct GitHubClient: Sendable { + + private let baseURL = "https://api.github.com" + + // MARK: - DTOs + + // GitHub's keys are snake_case. `.convertFromSnakeCase` would produce `htmlUrl`/`avatarUrl`, + // never the acronym-cased properties below, so every response would throw `keyNotFound`. + // Explicit CodingKeys with the default strategy is the documented way around that limitation. + + /// Repository metadata from `GET /repos/{owner}/{name}`. + struct Repository: Decodable, Sendable { + let name: String + let description: String? + let htmlURL: URL + let owner: Owner + + private enum CodingKeys: String, CodingKey { + case name, description, owner + case htmlURL = "html_url" + } + } + + /// Repository owner (user or organization). + struct Owner: Decodable, Sendable { + let login: String + let htmlURL: URL + + private enum CodingKeys: String, CodingKey { + case login + case htmlURL = "html_url" + } + } + + /// One entry from `GET /repos/{owner}/{name}/contributors`. + struct Contributor: Decodable, Identifiable, Sendable { + let id: Int + let login: String + let avatarURL: URL + let htmlURL: URL + let contributions: Int + + private enum CodingKeys: String, CodingKey { + case id, login, contributions + case avatarURL = "avatar_url" + case htmlURL = "html_url" + } + } + + /// Errors surfaced by the client; callers degrade gracefully on `throw`. + enum ClientError: Error { + case badURL + case badResponse(Int) + } + + // MARK: - Endpoints + + /// Fetches metadata for a repository. + func repository(owner: String, name: String) async throws -> Repository { + try await fetch("\(baseURL)/repos/\(owner)/\(name)") + } + + /// Fetches contributors for a repository (first page, GitHub's default ordering). + func contributors(owner: String, name: String) async throws -> [Contributor] { + try await fetch("\(baseURL)/repos/\(owner)/\(name)/contributors") + } + + // MARK: - Private + + private func fetch(_ urlString: String) async throws -> T { + guard let url = URL(string: urlString) else { + throw ClientError.badURL + } + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ClientError.badResponse(http.statusCode) + } + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/SwiftRadio/Features/About/LibrariesScreen.swift b/SwiftRadio/Features/About/LibrariesScreen.swift new file mode 100644 index 000000000..21f0ac414 --- /dev/null +++ b/SwiftRadio/Features/About/LibrariesScreen.swift @@ -0,0 +1,130 @@ +// +// LibrariesScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + + +import SwiftUI + +/// Lists `Config.Libraries.items`, one section per library with two rows +/// (repository name + description, then owner login). Metadata is fetched +/// from the GitHub API in parallel via a task group; failures degrade to the +/// configured names. Tapping a row opens the repo/owner page in a Safari sheet. +struct LibrariesScreen: View { + + @State private var repositories: [String: GitHubClient.Repository] = [:] + @State private var isLoading = true + @State private var safariURL: URL? + + private let client = GitHubClient() + + /// A fork can list the same repository twice; ids stay unique while fetches share a key. + private static let rows: [(id: String, item: LibraryItem)] = { + let items = Config.Libraries.items + return Array(zip(ConfiguredRowID.make(items.map(key(for:))), items)) + }() + + var body: some View { + List { + ForEach(Self.rows, id: \.id) { row in + let item = row.item + let fetched = repositories[Self.key(for: item)] + Section { + Button { + safariURL = fetched?.htmlURL ?? Self.fallbackRepoURL(for: item) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + // Same fallbacks as the reference list when the fetch fails. + Text(fetched?.name ?? Self.key(for: item)).font(.headline) + Text(fetched?.description ?? Content.Common.noDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .foregroundStyle(.primary) + Spacer() + disclosureIndicator + } + } + Button { + safariURL = fetched?.owner.htmlURL ?? Self.fallbackOwnerURL(for: item) + } label: { + HStack { + Label("@" + (fetched?.owner.login ?? item.owner), systemImage: "person.circle") + .foregroundStyle(.primary) + Spacer() + disclosureIndicator + } + } + } + } + .tint(Color(uiColor: Config.tintColor)) + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .navigationTitle(Text(Content.Libraries.title)) + .overlay { + if isLoading { + ProgressView() + } + } + .task { + await load() + } + .sheet(isPresented: safariSheetPresented) { + if let url = safariURL { + SafariView(url: url) + } + } + } + + private var disclosureIndicator: some View { + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + + /// Bridges the optional `safariURL` state to a `sheet(isPresented:)` binding. + private var safariSheetPresented: Binding { + Binding( + get: { safariURL != nil }, + set: { if !$0 { safariURL = nil } } + ) + } + + /// Fetches every configured repository concurrently. Individual failures + /// yield `nil` so the row falls back to the configured names. + private func load() async { + var fetched: [String: GitHubClient.Repository] = [:] + await withTaskGroup(of: (String, GitHubClient.Repository?).self) { group in + for item in Config.Libraries.items { + group.addTask { + (Self.key(for: item), try? await client.repository(owner: item.owner, name: item.repo)) + } + } + for await (key, repository) in group { + fetched[key] = repository + } + } + repositories = fetched + isLoading = false + } + + nonisolated private static func key(for item: LibraryItem) -> String { + "\(item.owner)/\(item.repo)" + } + + private static func fallbackRepoURL(for item: LibraryItem) -> URL? { + URL(string: "https://github.com/\(item.owner)/\(item.repo)") + } + + private static func fallbackOwnerURL(for item: LibraryItem) -> URL? { + URL(string: "https://github.com/\(item.owner)") + } +} diff --git a/SwiftRadio/Features/Loading/LoadingScreen.swift b/SwiftRadio/Features/Loading/LoadingScreen.swift new file mode 100644 index 000000000..9077da50e --- /dev/null +++ b/SwiftRadio/Features/Loading/LoadingScreen.swift @@ -0,0 +1,39 @@ +// +// LoadingScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Presents bootstrap progress or a recoverable catalog-loading failure. +@MainActor struct LoadingScreen: View { + let error: String? + let retry: () async -> Void + + var body: some View { + ZStack { + GradientBackground() + VStack(spacing: 24) { + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 220, height: 220) + .accessibilityHidden(true) + if let error { + Text(Content.Loader.errorTitle).font(.headline) + Text(error).multilineTextAlignment(.center) + Button(Content.Loader.retryButton) { Task { await retry() } } + .buttonStyle(.bordered) + } else { + ProgressView(Content.Stations.loadingMessage) + } + } + .padding() + } + } +} diff --git a/SwiftRadio/Features/NowPlaying/NowPlayingOptionsSheet.swift b/SwiftRadio/Features/NowPlaying/NowPlayingOptionsSheet.swift new file mode 100644 index 000000000..a94be8672 --- /dev/null +++ b/SwiftRadio/Features/NowPlaying/NowPlayingOptionsSheet.swift @@ -0,0 +1,127 @@ +// +// NowPlayingOptionsSheet.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// Content-sized options sheet for the popup: Info, Website, Open in Music, Share. +/// SwiftUI successor to BottomSheetViewController; Info/Website hand off to RootView so the +/// popup can close before the push/Safari presentation runs. +@MainActor struct NowPlayingOptionsSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.displayScale) private var displayScale + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .body) private var rowHeight = 50.0 + /// The list's measured content height; row and section metrics differ between OS releases. + @State private var measuredHeight: CGFloat? + + let station: SwiftRadioCore.RadioStation + /// Artwork currently shown in the popup (track art when available), reused for sharing. + let artwork: UIImage? + let track: String? + let artist: String? + let hasTrackArtwork: Bool + let requestInfo: () -> Void + let requestWebsite: (URL) -> Void + let requestMusic: (URL) -> Void + let requestShare: ([Any]) -> Void + + var body: some View { + List { + Section { + option(Content.BottomSheet.aboutStation, systemImage: "info.circle") { + choose { requestInfo() } + } + if let websiteURL { + option(Content.BottomSheet.stationWebsite, systemImage: "safari") { + choose { requestWebsite(websiteURL) } + } + } + } + Section { + option(Content.BottomSheet.playInMusicApp, systemImage: "music.note") { + if let musicURL { choose { requestMusic(musicURL) } } + } + .disabled(musicURL == nil || !hasTrackArtwork) + } + Section { + option(Content.BottomSheet.shareNowPlaying, systemImage: "square.and.arrow.up") { + share() + } + } + } + .contentMargins(.horizontal, 20, for: .scrollContent) + .readingContentHeight(into: $measuredHeight) + // Fit the rows like the UIKit table did. Accessibility sizes get the full sheet rather + // than compressing their labels. + .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.height(measuredHeight ?? estimatedHeight)]) + .presentationDragIndicator(.visible) + .accessibilityIdentifier("playerOptionsSheet") + } + + /// Used until the list reports its size, and on iOS 17, which has no scroll geometry to read. + private var estimatedHeight: CGFloat { + rowHeight * (websiteURL == nil ? 3 : 4) + 188 + } + + private var websiteURL: URL? { + guard station.hasValidWebsite, let website = station.website else { return nil } + return URL(string: website) + } + + private var musicURL: URL? { + station.musicSearchURL(track: track, artist: artist) + } + + private func option(_ title: String, systemImage: String, + action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + } + .foregroundStyle(.primary) + } + + /// Queue the request on the presenter, which executes it from the sheet's onDismiss. + private func choose(_ action: @escaping @MainActor () -> Void) { + action() + dismiss() + } + + /// Shares the shoutout plus the composed share card; text-only when rendering fails. + private func share() { + var items: [Any] = [station.shoutout] + // Without stream metadata the card still names the station: the display fallbacks are + // the station's name and description, matching the popup and Now Playing info. + let card = LogoShareView(artwork: artwork, shoutout: station.shoutout, + track: track ?? station.name, + artist: artist ?? station.desc) + if let image = card.rendered(scale: displayScale) { + items.append(image) + } + choose { requestShare(items) } + } +} + +/// Reports a scroll view's content height plus its top inset. A height detent excludes the +/// bottom safe area, which the scroll view adds to its own bottom inset, so that part is left out. +private extension View { + @ViewBuilder func readingContentHeight(into height: Binding) -> some View { + if #available(iOS 18, *) { + onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentSize.height > 0 ? geometry.contentSize.height + geometry.contentInsets.top : 0 + } action: { _, newHeight in + // The first pass runs before the rows exist; keep the estimate until they do. + if newHeight > 0 { height.wrappedValue = newHeight.rounded(.up) } + } + } else { + self + } + } +} diff --git a/SwiftRadio/Features/NowPlaying/NowPlayingView.swift b/SwiftRadio/Features/NowPlaying/NowPlayingView.swift new file mode 100644 index 000000000..b8829ab96 --- /dev/null +++ b/SwiftRadio/Features/NowPlaying/NowPlayingView.swift @@ -0,0 +1,275 @@ +// +// NowPlayingView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import LNPopupUI +import SwiftRadioCore +import SwiftUI + +/// Full-screen popup player: blurred artwork backdrop, marquee metadata, transport controls, +/// and the LNPopup bar configuration (title, image, progress, transport button). +/// SwiftUI successor to NowPlayingViewController. +@MainActor struct NowPlayingView: View { + @Environment(StationsStore.self) private var stations + @Environment(PlayerService.self) private var player + @Environment(\.artworkLoader) private var artworkLoader + @Environment(\.openURL) private var openURL + + /// RootView-owned requests; RootView runs them after the popup has closed. + let requestInfo: (SwiftRadioCore.RadioStation) -> Void + let requestWebsite: (URL) -> Void + + @State private var presentedSheet: PlayerSheet? + /// Effective artwork (track art when the stream supplies it, else station art) used for + /// the blurred backdrop and the popup bar image. + @State private var displayedArtwork: UIImage? + /// Station artwork source `displayedArtwork` belongs to; a track-only change keeps it. + @State private var displayedArtworkStationKey: String? + @State private var pendingOption: NowPlayingOption? + + var body: some View { + GeometryReader { geometry in + if let station = stations.currentStation { + NowPlayingContent(station: station, artworkURL: player.artworkURL, + title: title, subtitle: subtitle, + isPlaying: player.state == .playing, + isBuffering: player.isBuffering, + availableSize: geometry.size) { + presentedSheet = .options(OptionsContext(station: station, artwork: displayedArtwork, + track: player.track, artist: player.artist, + hasTrackArtwork: player.artworkURL != nil)) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("nowPlayingContent") + } + } + // A background must not contribute the source image's intrinsic size to layout. + .background { NowPlayingBackground(image: displayedArtwork) } + .sheet(item: $presentedSheet, onDismiss: performPendingOption) { sheet in + switch sheet { + case .options(let context): + NowPlayingOptionsSheet( + station: context.station, + artwork: context.artwork, + track: context.track, + artist: context.artist, + hasTrackArtwork: context.hasTrackArtwork, + requestInfo: { pendingOption = .info(context.station) }, + requestWebsite: { pendingOption = .website($0) }, + requestMusic: { pendingOption = .music($0) }, + requestShare: { pendingOption = .share($0) } + ) + case .share(let payload): + ActivityView(items: payload.items) + } + } + .task(id: artworkIdentity) { await loadArtwork() } + .popupTitle(verbatim: title, subtitle: subtitle) + .popupImage(displayedArtwork.map { Image(uiImage: $0) }) + .modifier(PopupProgress()) + .popupBarButtons { playbackToggle } + } + + private func performPendingOption() { + let option = pendingOption + pendingOption = nil + switch option { + case .info(let station): requestInfo(station) + case .website(let url): requestWebsite(url) + case .music(let url): openURL(url) + case .share(let items): presentedSheet = .share(SharePayload(items: items)) + case nil: break + } + } + + private enum NowPlayingOption { + case info(SwiftRadioCore.RadioStation) + case website(URL) + case music(URL) + case share([Any]) + } + + private struct SharePayload: Identifiable { + let id = UUID() + let items: [Any] + } + + /// Snapshot a coherent station/metadata pair for the duration of an options presentation. + private struct OptionsContext: Identifiable { + let id = UUID() + let station: SwiftRadioCore.RadioStation + let artwork: UIImage? + let track: String? + let artist: String? + let hasTrackArtwork: Bool + } + + private enum PlayerSheet: Identifiable { + case options(OptionsContext) + case share(SharePayload) + var id: UUID { + switch self { + case .options(let context): context.id + case .share(let payload): payload.id + } + } + } + + /// Play/pause in the popup bar; shows Stop while a live stream plays (UI-test contract). + private var playbackToggle: some View { + Button { player.togglePlayPause() } label: { + Image(systemName: player.state == .playing ? (player.isLive ? "stop.fill" : "pause.fill") : "play.fill") + } + .accessibilityLabel(player.state == .playing ? (player.isLive ? Content.Player.stop : Content.Player.pause) : Content.Player.play) + .accessibilityIdentifier("playbackToggle") + } + + private var title: String { + guard let station = stations.currentStation else { return "" } + guard let track = player.track else { return station.name } + return [track, player.artist].compactMap { $0 }.joined(separator: " — ") + } + + private var subtitle: String { + guard let station = stations.currentStation else { return "" } + return player.track == nil ? station.desc : station.name + } + + /// Reload when the station changes or new track artwork arrives. + private var artworkIdentity: String { + (stations.currentStation?.id ?? "") + "|" + (stations.currentStation?.imageURL ?? "") + + "|" + (player.artworkURL?.absoluteString ?? "") + } + + private func loadArtwork() async { + guard let station = stations.currentStation, let artworkLoader else { + displayedArtwork = nil + displayedArtworkStationKey = nil + return + } + // Like the reference stationDidChange: drop the previous station's backdrop and bar image + // at once instead of keeping it under the new station's title until the load finishes. + let stationKey = station.id + "|" + station.imageURL + if displayedArtworkStationKey != stationKey { + displayedArtwork = nil + displayedArtworkStationKey = stationKey + } + let image: UIImage + if let url = player.artworkURL, let track = await artworkLoader.trackArtwork(at: url) { + image = track + } else { + image = await artworkLoader.image(for: station) + } + guard !Task.isCancelled else { return } + displayedArtwork = image // CrossfadeImage owns the explicit, Reduce Motion-aware transition. + } +} + +/// Only this modifier reads playback time, so a time tick re-runs it instead of the whole +/// player (content, controls, artwork and sheets). +@MainActor private struct PopupProgress: ViewModifier { + @Environment(PlayerService.self) private var player + + func body(content: Self.Content) -> some View { + content.popupProgress(progress) + } + + private var progress: Float { + guard !player.isLive, player.duration > 0 else { return 0 } + return Float(min(max(player.elapsed / player.duration, 0), 1)) + } +} + +@MainActor private struct NowPlayingContent: View { + let station: SwiftRadioCore.RadioStation + let artworkURL: URL? + let title: String + let subtitle: String + let isPlaying: Bool + let isBuffering: Bool + let availableSize: CGSize + let onMore: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + ScrollView { + VStack(spacing: 24) { + ArtworkView(station: station, cornerRadius: 20, trackURL: artworkURL, + playback: PlaybackArtworkState(isPlaying: isPlaying, isBuffering: isBuffering)) + .frame(width: artworkSide, height: artworkSide) + .accessibilityHidden(true) + VStack(spacing: 20) { + NowPlayingMetadata(title: title, subtitle: subtitle) + PlaybackControls(onMore: onMore) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 24) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(width: availableSize.width) + .frame(minHeight: availableSize.height) + } + .scrollBounceBehavior(.basedOnSize) + } + + private var artworkSide: CGFloat { + let width = max(0, availableSize.width - 48) + // Leave room for controls in landscape, iPad windows and larger text sizes. + return min(width, max(120, availableSize.height * (dynamicTypeSize.isAccessibilitySize ? 0.3 : 0.5))) + } +} + +private struct NowPlayingMetadata: View { + let title: String + let subtitle: String + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 4) { + if dynamicTypeSize.isAccessibilitySize || reduceMotion { + Text(title) + .font(.title2.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + MarqueeText(text: title) + } + Text(subtitle) + .font(.title3) + .foregroundStyle(.white.opacity(0.7)) + .multilineTextAlignment(.center) + } + } +} + +private struct NowPlayingBackground: View { + let image: UIImage? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + GeometryReader { geometry in + if let image { + CrossfadeImage(image: image, duration: 0.5) + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + .overlay(.thickMaterial) + .overlay(.black.opacity(0.3)) + } else { + GradientBackground() + } + } + // Dissolves to and from the gradient when a station switch clears the art, matching the + // reference backdrop transition; image-to-image changes are CrossfadeImage's. + .animation(reduceMotion ? nil : .easeInOut(duration: 0.5), value: image == nil) + .ignoresSafeArea() + } +} diff --git a/SwiftRadio/Features/NowPlaying/PlaybackControls.swift b/SwiftRadio/Features/NowPlaying/PlaybackControls.swift new file mode 100644 index 000000000..b037a5b9c --- /dev/null +++ b/SwiftRadio/Features/NowPlaying/PlaybackControls.swift @@ -0,0 +1,214 @@ +// +// PlaybackControls.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// Transport cluster for the popup: seek slider with time labels for files, a LIVE badge for +/// streams, previous/play/next, the AirPlay route picker, and the "more" options button. +@MainActor struct PlaybackControls: View { + @Environment(StationsStore.self) private var stations + @Environment(PlayerService.self) private var player + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Opens the options sheet owned by NowPlayingView. + let onMore: () -> Void + + /// Holds the in-flight drag position so playback time does not fight the user's finger. + @State private var scrubPosition: Double? + @ScaledMetric(relativeTo: .caption2) private var timeFontSize = 10.0 + + var body: some View { + VStack(spacing: 8) { + timeline + .frame(minHeight: 44, maxHeight: .infinity, alignment: .top) + transport + .frame(minHeight: 64, maxHeight: .infinity) + HStack(spacing: 40) { + AirPlayButton() + .frame(width: 44, height: 44) + Button(action: onMore) { + Image(systemName: "list.dash") + .font(.system(size: 17)) + .foregroundStyle(Color(uiColor: Config.tintColor).opacity(0.7)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(Text("player.options")) + .accessibilityIdentifier("playerOptions") + } + .frame(minHeight: 44, maxHeight: .infinity, alignment: .bottom) + } + .onChange(of: stations.currentStation?.id) { scrubPosition = nil } + } + + @ViewBuilder private var timeline: some View { + if player.isLive { + ZStack { + Rectangle() + .fill(Color(uiColor: Config.tintColor).opacity(0.3)) + .frame(height: 2) + .accessibilityHidden(true) + Text(Content.Player.liveBadge) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6)) + } + .frame(height: 31) + } else { + VStack(spacing: 6) { + PlaybackSeekSlider(value: sliderValue, duration: max(player.duration, 1)) { value in + player.seek(to: value) + scrubPosition = nil + } + .id(stations.currentStation?.id) + .frame(height: 31) + HStack { + Text(formatted(scrubPosition ?? player.elapsed)) + .accessibilityIdentifier("playbackElapsed") + Spacer() + Text("-" + formatted(remaining)) + } + .font(.system(size: timeFontSize, weight: .medium)) + .monospacedDigit() + .opacity(0.8) + } + } + } + + private var transport: some View { + HStack(spacing: 40) { + if !Config.hideNextPreviousButtons { + Button { stations.selectPrevious() } label: { + Image(systemName: "backward.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Color(uiColor: Config.tintColor).opacity(0.7)) + .frame(width: 44, height: 44) + } + .accessibilityLabel(Text("player.previous")) + } + Button { player.togglePlayPause() } label: { + Image(systemName: playIcon) + .font(.system(size: 60)) + .foregroundStyle(Color(uiColor: Config.tintColor)) + .contentTransition(reduceMotion ? .identity : .symbolEffect(.replace)) + } + .accessibilityLabel(playLabel) + .accessibilityIdentifier("playerTransport") + if !Config.hideNextPreviousButtons { + Button { stations.selectNext() } label: { + Image(systemName: "forward.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Color(uiColor: Config.tintColor).opacity(0.7)) + .frame(width: 44, height: 44) + } + .accessibilityLabel(Text("player.next")) + } + } + } + + private var sliderValue: Binding { + Binding( + get: { scrubPosition ?? player.elapsed }, + set: { scrubPosition = $0 } + ) + } + + private var remaining: TimeInterval { + max(0, player.duration - (scrubPosition ?? player.elapsed)) + } + + private var playIcon: String { + guard player.state == .playing else { return "play.circle.fill" } + return player.isLive ? "stop.circle.fill" : "pause.circle.fill" + } + + private var playLabel: String { + guard player.state == .playing else { return Content.Player.play } + return player.isLive ? Content.Player.stop : Content.Player.pause + } + + /// Same "05:07" shape as the reference, with the locale's digits and separator. + private func formatted(_ seconds: TimeInterval) -> String { + let total = max(0, Int(seconds.rounded(.down))) + return Duration.seconds(total).formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) + } +} + +/// Preserves the template's thin native seek control and adjustable accessibility. +/// Scrubbing state and the playback action stay owned by SwiftUI. +private struct PlaybackSeekSlider: UIViewRepresentable { + @Binding var value: Double + let duration: Double + let onEndEditing: (Double) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> SeekSlider { + let slider = SeekSlider() + slider.minimumValue = 0 + slider.minimumTrackTintColor = Config.tintColor + slider.maximumTrackTintColor = Config.tintColor.withAlphaComponent(0.3) + slider.accessibilityLabel = String(localized: "player.seek") + slider.accessibilityIdentifier = "playbackSeek" + slider.addTarget(context.coordinator, action: #selector(Coordinator.began(_:)), for: .touchDown) + slider.addTarget(context.coordinator, action: #selector(Coordinator.changed(_:)), for: .valueChanged) + slider.addTarget(context.coordinator, action: #selector(Coordinator.ended(_:)), + for: [.touchUpInside, .touchUpOutside, .touchCancel]) + return slider + } + + func updateUIView(_ slider: SeekSlider, context: Context) { + context.coordinator.parent = self + slider.maximumValue = Float(duration) + if !slider.isTracking { slider.value = Float(value) } + let elapsed = spokenTime(value) + let total = spokenTime(duration) + slider.accessibilityValue = String(localized: "player.seekValue", + defaultValue: "\(elapsed) of \(total)", + comment: "Elapsed playback time followed by total duration") + } + + private func spokenTime(_ seconds: Double) -> String { + Duration.seconds(max(0, seconds).rounded(.down)) + .formatted(.units(allowed: [.hours, .minutes, .seconds], width: .wide)) + } + + @MainActor final class Coordinator: NSObject { + var parent: PlaybackSeekSlider + init(_ parent: PlaybackSeekSlider) { self.parent = parent } + @objc func began(_ slider: UISlider) { parent.value = Double(slider.value) } + @objc func changed(_ slider: UISlider) { + parent.value = Double(slider.value) + // VoiceOver adjustments don't send a touch-up event. + if !slider.isTracking { parent.onEndEditing(Double(slider.value)) } + } + @objc func ended(_ slider: UISlider) { parent.onEndEditing(Double(slider.value)) } + } + + final class SeekSlider: UISlider { + override init(frame: CGRect) { + super.init(frame: frame) + for (state, size) in [(UIControl.State.normal, CGFloat(10)), (.highlighted, CGFloat(16))] { + let image = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { context in + Config.tintColor.setFill() + context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) + } + setThumbImage(image, for: state) + } + } + required init?(coder: NSCoder) { super.init(coder: coder) } + override func trackRect(forBounds bounds: CGRect) -> CGRect { + CGRect(x: bounds.minX, y: bounds.midY - 1, width: bounds.width, height: 2) + } + } +} diff --git a/SwiftRadio/Features/StationInfo/StationInfoScreen.swift b/SwiftRadio/Features/StationInfo/StationInfoScreen.swift new file mode 100644 index 000000000..58e0d3e94 --- /dev/null +++ b/SwiftRadio/Features/StationInfo/StationInfoScreen.swift @@ -0,0 +1,93 @@ +// +// StationInfoScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// Pushed station detail: artwork, name, descriptions, and an in-app "Visit Website" link. +/// SwiftUI successor to InfoDetailViewController. +@MainActor struct StationInfoScreen: View { + let station: SwiftRadioCore.RadioStation + @State private var showSafari = false + @Environment(\.displayScale) private var displayScale + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ArtworkView(station: station, cornerRadius: 20) + .frame(width: 160, height: 160) + .padding(.top, 24) + .padding(.bottom, 16) + VStack(spacing: 4) { + Text(station.name) + .font(.title2.bold()) + .multilineTextAlignment(.center) + Text(station.desc) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + .multilineTextAlignment(.center) + } + .padding(.bottom, 24) + separator + Text(longDescription) + .font(.body) + .foregroundStyle(.white.opacity(0.85)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 20) + if websiteURL != nil { + separator + Button { showSafari = true } label: { + HStack { + HStack(spacing: 10) { + Image(systemName: "safari") + .font(.system(size: 22)) + Text(Content.StationDetail.visitWebsite) + .font(.body) + } + Spacer() + Image(systemName: "chevron.forward") + .foregroundStyle(Color(uiColor: Config.tintColor).opacity(0.3)) + .accessibilityHidden(true) + } + .foregroundStyle(.white) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .accessibilityIdentifier("stationWebsite") + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + .background(GradientBackground()) + .navigationTitle(Content.StationDetail.title) + .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $showSafari) { + if let url = websiteURL { + SafariView(url: url) + } + } + } + + private var longDescription: String { + station.longDesc.isEmpty ? Content.StationDetail.defaultDescription : station.longDesc + } + + private var websiteURL: URL? { + guard station.hasValidWebsite, let website = station.website else { return nil } + return URL(string: website) + } + + private var separator: some View { + Rectangle() + .fill(.white.opacity(0.15)) + .frame(height: 1 / displayScale) + } +} diff --git a/SwiftRadio/Features/Stations/EmptyStationsView.swift b/SwiftRadio/Features/Stations/EmptyStationsView.swift new file mode 100644 index 000000000..af82d7caf --- /dev/null +++ b/SwiftRadio/Features/Stations/EmptyStationsView.swift @@ -0,0 +1,21 @@ +// +// EmptyStationsView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Shown in place of the station list while the catalog is empty. +@MainActor struct EmptyStationsView: View { + var body: some View { + // The reference's empty-catalog cell shows this same string. + Text(Content.Stations.loadingMessage) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/SwiftRadio/Features/Stations/NowPlayingIndicatorButton.swift b/SwiftRadio/Features/Stations/NowPlayingIndicatorButton.swift new file mode 100644 index 000000000..83a6a18f1 --- /dev/null +++ b/SwiftRadio/Features/Stations/NowPlayingIndicatorButton.swift @@ -0,0 +1,35 @@ +// +// NowPlayingIndicatorButton.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// Toolbar control that mirrors transport state (equalizer when playing, ball pulse while buffering) +/// and opens the Now Playing popup on tap. +@MainActor struct NowPlayingIndicatorButton: View { + @Environment(PlayerService.self) private var player + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if player.isBuffering { + BufferingIndicator() + } else { + EqualizerBars(isAnimating: player.state == .playing) + } + } + .frame(width: 30, height: 20) + } + .tint(Color(uiColor: Config.tintColor)) + .accessibilityLabel(Text("player.nowPlaying")) + .accessibilityIdentifier("showNowPlaying") + } +} diff --git a/SwiftRadio/Features/Stations/StationRow.swift b/SwiftRadio/Features/Stations/StationRow.swift new file mode 100644 index 000000000..9130fbb4b --- /dev/null +++ b/SwiftRadio/Features/Stations/StationRow.swift @@ -0,0 +1,138 @@ +// +// StationRow.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// Matches the UIKit catalog's 104-point card rhythm at the default text size. +/// Text can grow at accessibility sizes rather than inheriting UIKit's fixed row height. +@MainActor struct StationRow: View, Equatable { + let station: SwiftRadioCore.RadioStation + let isCurrent: Bool + let isPlaying: Bool + let isBuffering: Bool + let action: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var activationFeedback = false + + /// `action` is left out: a fresh closure is built on every parent pass but always routes + /// the tap through the same stores, so comparing it would only defeat the skip. + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.station == rhs.station && lhs.isCurrent == rhs.isCurrent + && lhs.isPlaying == rhs.isPlaying && lhs.isBuffering == rhs.isBuffering + } + + var body: some View { + Button { + activationFeedback.toggle() + action() + } label: { + HStack(spacing: 14) { + StationRowArtwork(station: station, isCurrent: isCurrent, + isPlaying: isPlaying, isBuffering: isBuffering) + VStack(alignment: .leading, spacing: 4) { + Text(station.name) + .font(.headline) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 1) + HStack(spacing: 6) { + // UIKit hides the arranged equalizer when stopped, collapsing its space. + if isCurrent && isPlaying && !isBuffering { + EqualizerBars(isAnimating: isPlaying, color: .white) + .frame(width: 16, height: 12) + .opacity(isPlaying ? 0.7 : 0.4) + .accessibilityHidden(true) + } + Text(station.desc) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.2), radius: 8, y: 2) + .contentShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(StationCardButtonStyle(activationTrigger: activationFeedback)) + .padding(.horizontal, 16) + .padding(.vertical, 5) + } +} + +@MainActor private struct StationRowArtwork: View { + let station: SwiftRadioCore.RadioStation + let isCurrent: Bool + let isPlaying: Bool + let isBuffering: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ArtworkView(station: station, cornerRadius: 12) + .frame(width: 70, height: 70) + .overlay { + if isCurrent && isBuffering { + RoundedRectangle(cornerRadius: 12) + .fill(.black.opacity(0.5)) + .overlay { BufferingIndicator(color: .white).frame(width: 30, height: 20) } + .transition(.opacity) + } + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.3), value: isBuffering) + .background { + if isCurrent && !isBuffering { + if isPlaying && !reduceMotion { + selectionRing.phaseAnimator([false, true]) { content, phase in + content + .opacity(phase ? 0.4 : 0) + .scaleEffect(phase ? 1.06 : 1) + } animation: { _ in + .easeInOut(duration: 1.5) + } + } else { + selectionRing.opacity(isPlaying ? 0.4 : 0.25) + } + } + } + .accessibilityHidden(true) + } + + private var selectionRing: some View { + RoundedRectangle(cornerRadius: 12) + .stroke(.white, lineWidth: 2.5) + .padding(-4) + } +} + +private struct StationCardButtonStyle: ButtonStyle { + let activationTrigger: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + // A short tap may finish before List exposes a visible pressed frame. + // Complete one feedback pulse on activation too, without replacing Button's + // cancellation/scrolling/accessibility behavior with a competing gesture. + .phaseAnimator([false, true], trigger: activationTrigger) { content, active in + content + .scaleEffect(!reduceMotion && (configuration.isPressed || active) ? 0.97 : 1) + .opacity(reduceMotion && (configuration.isPressed || active) ? 0.8 : 1) + } animation: { active in + active ? .easeOut(duration: 0.08) : .spring(duration: 0.3, bounce: 0.2) + } + .animation(reduceMotion ? nil : .spring(duration: 0.3), value: configuration.isPressed) + } +} diff --git a/SwiftRadio/Features/Stations/StationsScreen.swift b/SwiftRadio/Features/Stations/StationsScreen.swift new file mode 100644 index 000000000..956ef4065 --- /dev/null +++ b/SwiftRadio/Features/Stations/StationsScreen.swift @@ -0,0 +1,115 @@ +// +// StationsScreen.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// The station catalog: material cards with search, pull-to-refresh, About menu, and +/// a Now Playing toolbar indicator that opens the popup owned by RootView. +@MainActor struct StationsScreen: View { + @Environment(StationsStore.self) private var stations + @Environment(PlayerService.self) private var player + @Binding var isPopupOpen: Bool + @State private var showAbout = false + + var body: some View { + catalog + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(GradientBackground()) + // The reference shows its loading cell only for an empty catalog; a search that + // matches nothing leaves the list empty. + .overlay { if stations.stations.isEmpty && stations.searchText.isEmpty { EmptyStationsView() } } + .navigationTitle(Content.Stations.title) + .navigationBarTitleDisplayMode(.large) + .refreshable { await stations.refresh() } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { showAbout = true } label: { + Image(systemName: "line.3.horizontal") + } + .accessibilityLabel(Content.About.title) + } + ToolbarItem(placement: .topBarTrailing) { + NowPlayingToolbarSlot { isPopupOpen = true } + } + } + .sheet(isPresented: $showAbout) { + AboutScreen() + } + .modifier(TrackHandoff()) + } + + /// Search filtering is only compiled in when `Config.searchable` is enabled. + @ViewBuilder private var catalog: some View { + if Config.searchable { + stationList.searchable(text: Bindable(stations).searchText, + placement: .navigationBarDrawer(displayMode: .automatic)) + } else { + stationList + } + } + + private var stationList: some View { + List(stations.filteredStations) { station in + let isCurrent = station == stations.currentStation + // Only the current row carries playback state, so a play/pause or buffering change + // leaves every other row's inputs equal and SwiftUI skips their bodies. + StationRow( + station: station, + isCurrent: isCurrent, + isPlaying: isCurrent && player.state == .playing, + isBuffering: isCurrent && player.isBuffering + ) { + tap(station) + } + .equatable() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + } + + /// Row tap follows the UIKit reference: a new station selects, the playing one opens the + /// player, and any other state of the current one toggles playback. + private func tap(_ station: SwiftRadioCore.RadioStation) { + if stations.activate(station, on: .phone) == .alreadyActive { + isPopupOpen = true + } + } +} + +/// Shows the Now Playing indicator only while something plays or loads. Reading transport +/// state here keeps those changes from re-running the whole catalog body. +@MainActor private struct NowPlayingToolbarSlot: View { + @Environment(StationsStore.self) private var stations + @Environment(PlayerService.self) private var player + let action: () -> Void + + var body: some View { + if stations.currentStation != nil && (player.state == .playing || player.isBuffering) { + NowPlayingIndicatorButton(action: action) + } + } +} + +/// Advertises a web search for the playing track. Only this modifier reads track metadata, so +/// a metadata change does not re-run the catalog body. +@MainActor private struct TrackHandoff: ViewModifier { + @Environment(PlayerService.self) private var player + + func body(content: Self.Content) -> some View { + let url = HandoffActivity.searchURL(track: player.track, artist: player.artist) + content.userActivity(NSUserActivityTypeBrowsingWeb, isActive: url != nil) { activity in + activity.webpageURL = url + activity.isEligibleForHandoff = true + } + } +} diff --git a/SwiftRadio/Helpers/BottomSheetHandler.swift b/SwiftRadio/Helpers/BottomSheetHandler.swift deleted file mode 100644 index 02fde96d9..000000000 --- a/SwiftRadio/Helpers/BottomSheetHandler.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// BottomSheetHandler.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2024-01-14. -// Copyright © 2024 matthewfecher.com. All rights reserved. -// - -import UIKit -import SafariServices -import FRadioPlayer - -class BottomSheetHandler { - static func handle(_ option: BottomSheetViewController.Option, - station: RadioStation, - from viewController: UIViewController) { - switch option { - case .info: - let infoController = InfoDetailViewController(station: station) - viewController.navigationController?.pushViewController(infoController, animated: true) - - case .share(let image): - ShareActivity.activityController(image: image, - station: station, - sourceView: viewController.view) { controller in - viewController.present(controller, animated: true) - } - - case .website: - if let website = station.website, let websiteURL = URL(string: website) { - let safariVC = SFSafariViewController(url: websiteURL) - viewController.present(safariVC, animated: true) - } - - case .openInMusic(let url): - if let url { - UIApplication.shared.open(url) - } - } - } -} - diff --git a/SwiftRadio/Helpers/Handoffable.swift b/SwiftRadio/Helpers/Handoffable.swift deleted file mode 100755 index b1f142518..000000000 --- a/SwiftRadio/Helpers/Handoffable.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Handoffable.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-24. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit -import FRadioPlayer - -protocol Handoffable: UIResponder {} - -extension Handoffable { - - func setupHandoffUserActivity() { - userActivity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) - userActivity?.becomeCurrent() - } - - func updateHandoffUserActivity(_ activity: NSUserActivity?, station: RadioStation?) { - guard let activity = activity else { return } - - defer { updateUserActivityState(activity) } - - guard let metadata = FRadioPlayer.shared.currentMetadata, let artistName = metadata.artistName, let trackName = metadata.trackName else { - activity.webpageURL = nil - return - } - - activity.webpageURL = getHandoffURL(artistName: artistName, trackName: trackName) - } - - private func getHandoffURL(artistName: String, trackName: String) -> URL? { - var components = URLComponents() - components.scheme = "https" - components.host = "google.com" - components.path = "/search" - components.queryItems = [URLQueryItem]() - components.queryItems?.append(URLQueryItem(name: "q", value: "\(artistName) \(trackName)")) - return components.url - } -} diff --git a/SwiftRadio/Helpers/NSLayoutConstraint+with.swift b/SwiftRadio/Helpers/NSLayoutConstraint+with.swift deleted file mode 100644 index 372a74984..000000000 --- a/SwiftRadio/Helpers/NSLayoutConstraint+with.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// NSLayoutConstraint+with.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 1/19/25. -// Copyright © 2025 matthewfecher.com. All rights reserved. -// - -import UIKit - -extension NSLayoutConstraint { - func with(_ config: (NSLayoutConstraint) -> Void) -> NSLayoutConstraint { - config(self) - return self - } -} diff --git a/SwiftRadio/Helpers/ShareActivity.swift b/SwiftRadio/Helpers/ShareActivity.swift deleted file mode 100755 index 8a57ec136..000000000 --- a/SwiftRadio/Helpers/ShareActivity.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ShareActivity.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2019-08-20. -// Copyright © 2019 matthewfecher.com. All rights reserved. -// - -import UIKit - -struct ShareActivity { - - static func activityController(image: UIImage?, station: RadioStation, sourceView: UIView, _ completion: @escaping (UIActivityViewController) -> Void) { - let shareImage = generateImage(from: image, station: station) - - let activityViewController = UIActivityViewController(activityItems: [station.shoutout, shareImage], applicationActivities: nil) - activityViewController.popoverPresentationController?.sourceRect = CGRect(x: sourceView.center.x, y: sourceView.center.y, width: 0, height: 0) - activityViewController.popoverPresentationController?.sourceView = sourceView - activityViewController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection(rawValue: 0) - - activityViewController.completionWithItemsHandler = {(activityType: UIActivity.ActivityType?, completed: Bool, returnedItems:[Any]?, error: Error?) in - if completed { - // do something on completion if you want - } - } - - DispatchQueue.main.async { - completion(activityViewController) - } - } - - private static func generateImage(from image: UIImage?, station: RadioStation) -> UIImage { - let logoShareView = LogoShareView.instanceFromNib() - - logoShareView.shareSetup(albumArt: image ?? UIImage(named: "stationImage")!, radioShoutout: station.shoutout, trackTitle: station.trackName, trackArtist: station.artistName) - - let renderer = UIGraphicsImageRenderer(size: logoShareView.bounds.size) - let shareImage = renderer.image { ctx in - logoShareView.drawHierarchy(in: logoShareView.bounds, afterScreenUpdates: true) - } - - return shareImage - } -} diff --git a/SwiftRadio/Helpers/UIImage+Cache.swift b/SwiftRadio/Helpers/UIImage+Cache.swift deleted file mode 100755 index 7f53cd7eb..000000000 --- a/SwiftRadio/Helpers/UIImage+Cache.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// UIImage+Cache.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-01. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit - -extension UIImage { - - static func image(from url: URL?, completion: @escaping (_ image: UIImage?) -> Void) { - guard let url else { - completion(nil) - return - } - Task { - let image = await NetworkService.fetchImage(from: url) - await MainActor.run { completion(image) } - } - } -} diff --git a/SwiftRadio/Helpers/UIImage+DropShadow.swift b/SwiftRadio/Helpers/UIImage+DropShadow.swift deleted file mode 100755 index 5413e519f..000000000 --- a/SwiftRadio/Helpers/UIImage+DropShadow.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// UIImage+DropShadow.swift -// Swift Radio -// -// Created by Matthew Fecher on 5/30/15. -// Copyright (c) 2015 MatthewFecher.com. All rights reserved. -// - -import UIKit - -extension UIImageView { - - // APPLY DROP SHADOW - func applyShadow() { - layer.shadowColor = UIColor.black.cgColor - layer.shadowOffset = CGSize(width: 0, height: 1) - layer.shadowOpacity = 0.4 - layer.shadowRadius = 2 - } - -} diff --git a/SwiftRadio/Helpers/UIImageView+Cache.swift b/SwiftRadio/Helpers/UIImageView+Cache.swift deleted file mode 100755 index f9f821b47..000000000 --- a/SwiftRadio/Helpers/UIImageView+Cache.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// UIImageView+Cache.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-01. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit - -extension UIImageView { - - func load(url: URL, placeholder: UIImage? = nil) { - self.image = placeholder - Task { - guard let image = await NetworkService.fetchImage(from: url) else { return } - self.image = image - } - } -} diff --git a/SwiftRadio/Helpers/UITableViewCell+reuseIdentifier.swift b/SwiftRadio/Helpers/UITableViewCell+reuseIdentifier.swift deleted file mode 100755 index 538c40370..000000000 --- a/SwiftRadio/Helpers/UITableViewCell+reuseIdentifier.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// UITableViewCell+reuseIdentifier.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2023-06-25. -// Copyright © 2023 matthewfecher.com. All rights reserved. -// - -import UIKit - -extension UITableViewCell { - static var reuseIdentifier: String { - return String(describing: self) - } -} - -extension UITableView { - func register(_: T.Type) { - register(T.self, forCellReuseIdentifier: T.reuseIdentifier) - } - - func dequeueReusableCell(for indexPath: IndexPath) -> T { - guard let cell = dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as? T else { - fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)") - } - return cell - } -} diff --git a/SwiftRadio/Helpers/UIViewController+Email.swift b/SwiftRadio/Helpers/UIViewController+Email.swift deleted file mode 100755 index dd9310bc2..000000000 --- a/SwiftRadio/Helpers/UIViewController+Email.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// UIViewController+Email.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-24. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import MessageUI - -extension MFMailComposeViewControllerDelegate where Self: UIViewController { - - var canSendMail: Bool { - MFMailComposeViewController.canSendMail() - } - - func configureMailComposeViewController(recepients: [String], subject: String, messageBody: String) -> MFMailComposeViewController { - - let mailComposerVC = MFMailComposeViewController() - mailComposerVC.mailComposeDelegate = self - - mailComposerVC.setToRecipients(recepients) - mailComposerVC.setSubject(subject) - mailComposerVC.setMessageBody(messageBody, isHTML: false) - - return mailComposerVC - } -} - -extension UIViewController { - func showSendMailErrorAlert() { - let sendMailErrorAlert = UIAlertController(title: Content.Common.couldNotSendEmail, message: Content.Common.emailErrorMessage, preferredStyle: .alert) - let cancelAction = UIAlertAction(title: Content.Common.ok, style: .cancel, handler: nil) - - sendMailErrorAlert.addAction(cancelAction) - present(sendMailErrorAlert, animated: true, completion: nil) - } -} diff --git a/SwiftRadio/Info-CarPlay.plist b/SwiftRadio/Info-CarPlay.plist index c107b080a..e381e798d 100755 --- a/SwiftRadio/Info-CarPlay.plist +++ b/SwiftRadio/Info-CarPlay.plist @@ -54,15 +54,6 @@ $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - UIBackgroundModes @@ -70,8 +61,6 @@ audio external-accessory - UIBrowsableContentSupportsSectionedBrowsing - UILaunchScreen UIColorName diff --git a/SwiftRadio/Info.plist b/SwiftRadio/Info.plist index 197a8802e..df6a89c08 100755 --- a/SwiftRadio/Info.plist +++ b/SwiftRadio/Info.plist @@ -71,15 +71,6 @@ UISceneConfigurations - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - diff --git a/SwiftRadio/Localizable.xcstrings b/SwiftRadio/Localizable.xcstrings index bce80ea68..a7a827a06 100644 --- a/SwiftRadio/Localizable.xcstrings +++ b/SwiftRadio/Localizable.xcstrings @@ -1,35 +1,35 @@ { "sourceLanguage" : "en", "strings" : { - "about.feedback.message" : { + "about.copyright" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "We value your input! Please take a moment to provide feedback" + "value" : "Copyright" } } } }, - "about.feedback.title" : { + "about.feedback.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Feedback" + "value" : "We value your input! Please take a moment to provide feedback" } } } }, - "about.copyright" : { + "about.feedback.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Copyright" + "value" : "Feedback" } } } @@ -56,6 +56,17 @@ } } }, + "about.headerText" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "**Swift Radio** is a fully featured, open-source radio station app written entirely in **Swift**. It provides robust, professional functionality out of the box, complete with **Apple CarPlay** support, making it the perfect foundation for **building** or **customizing** your own streaming radio experience." + } + } + } + }, "about.items.appVersion" : { "extractionState" : "manual", "localizations" : { @@ -144,17 +155,6 @@ } } }, - "about.headerText" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "**Swift Radio** is a fully featured, open-source radio station app written entirely in **Swift**. It provides robust, professional functionality out of the box, complete with **Apple CarPlay** support, making it the perfect foundation for **building** or **customizing** your own streaming radio experience." - } - } - } - }, "about.license.detail" : { "extractionState" : "manual", "localizations" : { @@ -309,24 +309,70 @@ } } }, - "common.couldNotSendEmail" : { + "carPlay.more" : { + "comment" : "CarPlay list row that opens the next page of stations when the car limits list length.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Could Not Send Email" + "value" : "More" + } + } + } + }, + "carPlay.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio Stations" + } + } + } + }, + "common.close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" } } } }, "common.commitsFormat" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld commit" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld commits" + } + } + } + } + } + } + }, + "common.couldNotSendEmail" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d commits" + "value" : "Could Not Send Email" } } } @@ -584,6 +630,106 @@ } } }, + "player.next" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next station" + } + } + } + }, + "player.nowPlaying" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now Playing" + } + } + } + }, + "player.options" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player options" + } + } + } + }, + "player.pause" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + } + } + }, + "player.play" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" + } + } + } + }, + "player.previous" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous station" + } + } + } + }, + "player.seek" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback position" + } + } + } + }, + "player.seekValue" : { + "comment" : "Elapsed playback time followed by total duration", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ of %2$@" + } + } + } + }, + "player.stop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" + } + } + } + }, "stationDetail.defaultDescription" : { "extractionState" : "manual", "localizations" : { diff --git a/SwiftRadio/Model/RadioStation.swift b/SwiftRadio/Model/RadioStation.swift deleted file mode 100755 index 7a193c120..000000000 --- a/SwiftRadio/Model/RadioStation.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// RadioStation.swift -// Swift Radio -// -// Created by Matthew Fecher on 7/4/15. -// Copyright (c) 2015 MatthewFecher.com. All rights reserved. -// - -import UIKit -import FRadioPlayer - -// Radio Station - -struct RadioStation: Codable { - - var name: String - var website: String? - var streamURL: String - var imageURL: String - var desc: String - var longDesc: String - - init(name: String, website: String? = nil, streamURL: String, imageURL: String, desc: String, longDesc: String = "") { - self.name = name - self.streamURL = streamURL - self.imageURL = imageURL - self.desc = desc - self.longDesc = longDesc - } -} - -extension RadioStation { - var hasValidWebsite: Bool { - guard let websiteString = website, - !websiteString.isEmpty, - let url = URL(string: websiteString), - url.scheme?.hasPrefix("http") == true else { - return false - } - return true - } - - var shoutout: String { - "I'm listening to \(name) via \(Bundle.main.appName) app" - } -} - -extension RadioStation: Equatable { - - static func == (lhs: RadioStation, rhs: RadioStation) -> Bool { - return (lhs.name == rhs.name) && (lhs.streamURL == rhs.streamURL) && (lhs.imageURL == rhs.imageURL) && (lhs.desc == rhs.desc) && (lhs.longDesc == rhs.longDesc) - } -} - -extension RadioStation { - func getImage(completion: @escaping (_ image: UIImage) -> Void) { - if imageURL.contains("http"), let url = URL(string: imageURL) { - Task { - let image = await NetworkService.fetchImage(from: url) - await MainActor.run { completion(image ?? UIImage(named: "stationImage")!) } - } - } else { - completion(UIImage(named: imageURL) ?? UIImage(named: "stationImage")!) - } - } -} - -extension RadioStation { - - var trackName: String { - FRadioPlayer.shared.currentMetadata?.trackName ?? name - } - - var artistName: String { - FRadioPlayer.shared.currentMetadata?.artistName ?? desc - } - - var musicSearchURL: URL? { - guard let encodedSongName = FRadioPlayer.shared.currentMetadata?.trackName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let encodedArtistName = FRadioPlayer.shared.currentMetadata?.artistName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil } - let musicSearchURLString = "https://music.apple.com/search?term=\(encodedSongName)+\(encodedArtistName)".replacingOccurrences(of: "%2B", with: "%20") - return URL(string: musicSearchURLString) - } -} diff --git a/SwiftRadio/Model/StationsManager.swift b/SwiftRadio/Model/StationsManager.swift deleted file mode 100755 index 6ad6e917d..000000000 --- a/SwiftRadio/Model/StationsManager.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// StationsManager.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-11-02. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit -import FRadioPlayer -import MediaPlayer - -protocol StationsManagerObserver: AnyObject { - func stationsManager(_ manager: StationsManager, stationsDidUpdate stations: [RadioStation]) - func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) -} - -extension StationsManagerObserver { - func stationsManager(_ manager: StationsManager, stationsDidUpdate stations: [RadioStation]) {} -} - -class StationsManager { - - static let shared = StationsManager() - - private(set) var stations: [RadioStation] = [] { - didSet { - notifiyObservers { observer in - observer.stationsManager(self, stationsDidUpdate: stations) - } - } - } - - private(set) var currentStation: RadioStation? { - didSet { - notifiyObservers { observer in - observer.stationsManager(self, stationDidChange: currentStation) - } - - resetArtwork(with: currentStation) - } - } - - var searchedStations: [RadioStation] = [] - - private var observations = [ObjectIdentifier : Observation]() - private let player = FRadioPlayer.shared - - private init() { - self.player.addObserver(self) - } - - @MainActor - func fetch() async throws { - let stations = try await NetworkService.fetchStations() - guard self.stations != stations else { return } - self.stations = stations - if let currentStation, !stations.contains(currentStation) { reset() } - } - - func fetch(_ completion: ((Result<[RadioStation], Error>) -> Void)? = nil) { - Task { @MainActor in - do { - try await fetch() - completion?(.success(stations)) - } catch { - completion?(.failure(error)) - } - } - } - - func set(station: RadioStation?) { - guard let station = station else { - reset() - return - } - - currentStation = station - player.radioURL = URL(string: station.streamURL) - } - - func setNext() { - guard let index = getIndex(of: currentStation) else { return } - let station = (index + 1 == stations.count) ? stations[0] : stations[index + 1] - currentStation = station - player.radioURL = URL(string: station.streamURL) - } - - func setPrevious() { - guard let index = getIndex(of: currentStation), let station = (index == 0) ? stations.last : stations[index - 1] else { return } - currentStation = station - player.radioURL = URL(string: station.streamURL) - } - - func updateSearch(with filter: String) { - searchedStations.removeAll(keepingCapacity: false) - searchedStations = stations.filter { $0.name.range(of: filter, options: [.caseInsensitive]) != nil } - } - - private func reset() { - currentStation = nil - player.radioURL = nil - } - - private func getIndex(of station: RadioStation?) -> Int? { - guard let station = station, let index = stations.firstIndex(of: station) else { return nil } - return index - } -} - -// MARK: - StationsManager Observation - -extension StationsManager { - - private struct Observation { - weak var observer: StationsManagerObserver? - } - - func addObserver(_ observer: StationsManagerObserver) { - let id = ObjectIdentifier(observer) - observations[id] = Observation(observer: observer) - } - - func removeObserver(_ observer: StationsManagerObserver) { - let id = ObjectIdentifier(observer) - observations.removeValue(forKey: id) - } - - private func notifiyObservers(with action: (_ observer: StationsManagerObserver) -> Void) { - for (id, observation) in observations { - guard let observer = observation.observer else { - observations.removeValue(forKey: id) - continue - } - - action(observer) - } - } -} - -// MARK: - MPNowPlayingInfoCenter (Lock screen) - -extension StationsManager { - - private func resetArtwork(with station: RadioStation?) { - - guard let station = station else { - updateLockScreen(with: nil) - return - } - - station.getImage { [weak self] image in - self?.updateLockScreen(with: image) - } - } - - private func updateLockScreen(with artworkImage: UIImage?) { - - // Define Now Playing Info - var nowPlayingInfo = [String : Any]() - - if let image = artworkImage { - nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { size -> UIImage in - return image - }) - } - - if let artistName = currentStation?.artistName { - nowPlayingInfo[MPMediaItemPropertyArtist] = artistName - } - - if let trackName = currentStation?.trackName { - nowPlayingInfo[MPMediaItemPropertyTitle] = trackName - } - - let isLive = player.duration == 0 - - if isLive { - nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = true - } else { - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.duration - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate - } - - // Set the metadata - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - AudioSetupService.shared.updateLiveCommands(isLive: isLive) - } -} - -// MARK: - FRadioPlayerObserver - -extension StationsManager: FRadioPlayerObserver { - - func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) { - // Update lock screen when player finishes loading so duration is accurate - // (fixes live indicator sticking when switching from live to MP3) - if state == .readyToPlay || state == .loadingFinished { - resetArtwork(with: currentStation) - } - } - - func radioPlayer(_ player: FRadioPlayer, metadataDidChange metadata: FRadioPlayer.Metadata?) { - resetArtwork(with: currentStation) - } - - func radioPlayer(_ player: FRadioPlayer, artworkDidChange artworkURL: URL?) { - guard let artworkURL else { - resetArtwork(with: currentStation) - return - } - - Task { [weak self] in - guard let self else { return } - guard let image = await NetworkService.fetchImage(from: artworkURL) else { - await MainActor.run { self.resetArtwork(with: self.currentStation) } - return - } - await MainActor.run { self.updateLockScreen(with: image) } - } - } -} diff --git a/SwiftRadio/Networking/NetworkService.swift b/SwiftRadio/Networking/NetworkService.swift deleted file mode 100644 index 678de9296..000000000 --- a/SwiftRadio/Networking/NetworkService.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// NetworkService.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2025-01-31. -// Copyright © 2025 matthewfecher.com. All rights reserved. -// - -import UIKit - -// MARK: - Error - -enum NetworkError: Error { - case urlNotValid, dataNotValid, dataNotFound, fileNotFound, httpResponseNotValid -} - -// MARK: - GitHub Models - -struct Contributor: Decodable { - let login: String - let avatarURL: URL - let htmlURL: URL - let contributions: Int - - enum CodingKeys: String, CodingKey { - case login - case avatarURL = "avatar_url" - case htmlURL = "html_url" - case contributions - } -} - -struct GitHubRepo: Decodable { - let name: String - let description: String? -} - -// MARK: - NetworkService - -struct NetworkService { - - // MARK: - Stations - - static func fetchStations() async throws -> [RadioStation] { - let data: Data - - if Config.useLocalStations { - guard let fileURL = Bundle.main.url(forResource: "stations", withExtension: "json") else { - if Config.debugLog { print("The local JSON file could not be found") } - throw NetworkError.fileNotFound - } - data = try Data(contentsOf: fileURL, options: .uncached) - } else { - guard let url = URL(string: Config.stationsURL) else { - if Config.debugLog { print("stationsURL not a valid URL") } - throw NetworkError.urlNotValid - } - - let config = URLSessionConfiguration.default - config.requestCachePolicy = .reloadIgnoringLocalCacheData - let session = URLSession(configuration: config) - - let (responseData, response) = try await session.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else { - if Config.debugLog { print("API: HTTP status code has unexpected value") } - throw NetworkError.httpResponseNotValid - } - - data = responseData - } - - if Config.debugLog { print("Stations JSON Found") } - - let jsonDictionary = try JSONDecoder().decode([String: [RadioStation]].self, from: data) - - guard let stations = jsonDictionary["station"] else { - throw NetworkError.dataNotValid - } - - return stations - } - - // MARK: - Images - - static func fetchImage(from url: URL) async -> UIImage? { - let cache = URLCache.shared - let request = URLRequest(url: url) - - if let data = cache.cachedResponse(for: request)?.data, let image = UIImage(data: data) { - return image - } - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - 200...299 ~= httpResponse.statusCode, - let image = UIImage(data: data) else { - return nil - } - - let cachedData = CachedURLResponse(response: httpResponse, data: data) - cache.storeCachedResponse(cachedData, for: request) - return image - } catch { - return nil - } - } - - // MARK: - GitHub API - - static func fetchContributors(owner: String, repo: String) async throws -> [Contributor] { - guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/contributors") else { - throw NetworkError.urlNotValid - } - - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - 200...299 ~= httpResponse.statusCode else { - throw NetworkError.httpResponseNotValid - } - - return try JSONDecoder().decode([Contributor].self, from: data) - } - - static func fetchRepository(owner: String, repo: String) async throws -> GitHubRepo { - guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)") else { - throw NetworkError.urlNotValid - } - - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - 200...299 ~= httpResponse.statusCode else { - throw NetworkError.httpResponseNotValid - } - - return try JSONDecoder().decode(GitHubRepo.self, from: data) - } -} diff --git a/SwiftRadio/Services/AudioSetupService.swift b/SwiftRadio/Services/AudioSetupService.swift deleted file mode 100644 index a30b67288..000000000 --- a/SwiftRadio/Services/AudioSetupService.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// AudioSetupService.swift -// Swift Radio -// -// Created by Fethi El Hassasna on 1/25/25. -// Copyright (c) 2015 MatthewFecher.com. All rights reserved. -// - -import AVFAudio -import MediaPlayer -import FRadioPlayer - -class AudioSetupService { - static let shared = AudioSetupService() - - private let player = FRadioPlayer.shared - private let manager = StationsManager.shared - - private init() {} - - func setupAudioSession() { - do { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playback, mode: .default, options: [.mixWithOthers, .allowBluetoothHFP]) - try session.setActive(true) - } catch { - if Config.debugLog { - print("Failed to configure audio session: \(error.localizedDescription)") - } - } - } - - func setupFRadioPlayer() { - player.isAutoPlay = true - player.enableArtwork = true - player.artworkAPI = iTunesAPI(artworkSize: 600) - } - - func setupRemoteCommandCenter() { - let commandCenter = MPRemoteCommandCenter.shared() - - commandCenter.playCommand.addTarget { [weak self] _ in - self?.player.play() - return .success - } - - commandCenter.pauseCommand.addTarget { [weak self] _ in - self?.player.pause() - return .success - } - - commandCenter.stopCommand.addTarget { [weak self] _ in - self?.player.stop() - return .success - } - commandCenter.stopCommand.isEnabled = false - - commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in - guard let self else { return .commandFailed } - if self.player.isPlaying, self.player.duration == 0 { - self.player.stop() - } else { - self.player.togglePlaying() - } - return .success - } - - commandCenter.nextTrackCommand.addTarget { [weak self] _ in - self?.manager.setNext() - return .success - } - - commandCenter.previousTrackCommand.addTarget { [weak self] _ in - self?.manager.setPrevious() - return .success - } - - UIApplication.shared.beginReceivingRemoteControlEvents() - } - - func updateLiveCommands(isLive: Bool) { - let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.pauseCommand.isEnabled = !isLive - commandCenter.stopCommand.isEnabled = isLive - } - - func activateAudioSession() { - do { - try AVAudioSession.sharedInstance().setActive(true) - } catch let error { - if Config.debugLog { - print("audioSession could not be activated: \(error.localizedDescription)") - } - } - } -} diff --git a/SwiftRadio/SwiftRadio.entitlements b/SwiftRadio/SwiftRadio.entitlements index b161a3b0d..71a0e11ce 100755 --- a/SwiftRadio/SwiftRadio.entitlements +++ b/SwiftRadio/SwiftRadio.entitlements @@ -2,8 +2,6 @@ - com.apple.developer.playable-content - com.apple.developer.carplay-audio diff --git a/SwiftRadio/UI/ActivityView.swift b/SwiftRadio/UI/ActivityView.swift new file mode 100644 index 000000000..cc4395076 --- /dev/null +++ b/SwiftRadio/UI/ActivityView.swift @@ -0,0 +1,23 @@ +// +// ActivityView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Thin UIKit share sheet. ShareLink cannot transfer a mixed text + image payload, so the +/// shoutout string and the composed LogoShareView image go through UIActivityViewController. +@MainActor struct ActivityView: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} diff --git a/SwiftRadio/UI/AirPlayButton.swift b/SwiftRadio/UI/AirPlayButton.swift new file mode 100644 index 000000000..820b11ec2 --- /dev/null +++ b/SwiftRadio/UI/AirPlayButton.swift @@ -0,0 +1,24 @@ +// +// AirPlayButton.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import AVKit +import SwiftUI + +/// The system AirPlay route picker, tinted like the old AVRoutePickerView in ControlsView. +@MainActor struct AirPlayButton: UIViewRepresentable { + func makeUIView(context: Context) -> AVRoutePickerView { + let picker = AVRoutePickerView() + picker.activeTintColor = Config.tintColor + picker.tintColor = Config.tintColor.withAlphaComponent(0.7) + return picker + } + + func updateUIView(_ picker: AVRoutePickerView, context: Context) {} +} diff --git a/SwiftRadio/UI/ArtworkView.swift b/SwiftRadio/UI/ArtworkView.swift new file mode 100644 index 000000000..35b273702 --- /dev/null +++ b/SwiftRadio/UI/ArtworkView.swift @@ -0,0 +1,59 @@ +// +// ArtworkView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftRadioCore +import SwiftUI + +/// Station artwork resolved asynchronously through the shared `ArtworkLoader`, with a dimmed placeholder while loading. +/// When `trackURL` is set (popup player), stream metadata artwork wins and station art is the fallback. +@MainActor struct ArtworkView: View { + let station: SwiftRadioCore.RadioStation + var cornerRadius: CGFloat = 8 + var trackURL: URL? = nil + var playback: PlaybackArtworkState? = nil + @Environment(\.artworkLoader) private var artworkLoader + @State private var image: UIImage? + /// Station artwork source the current image belongs to; a track-only change keeps it. + @State private var imageStationKey: String? + + var body: some View { + imageContent + // Row identity stays the station ID; the image request additionally keys on the station's + // artwork source so a refreshed imageURL restarts the load instead of showing stale art. + .task(id: station.id + "|" + station.imageURL + "|" + (trackURL?.absoluteString ?? "")) { + guard let artworkLoader else { return } + // A different station must not show the previous station's art while its own loads + // (or fails after the request timeout); a new track on the same station still crossfades. + let stationKey = station.id + "|" + station.imageURL + if imageStationKey != stationKey { + image = nil + imageStationKey = stationKey + } + let loaded: UIImage + if let trackURL, let track = await artworkLoader.trackArtwork(at: trackURL) { + loaded = track + } else { + loaded = await artworkLoader.image(for: station) + } + guard !Task.isCancelled else { return } + image = loaded + } + } + + @ViewBuilder private var imageContent: some View { + if let playback { + PlaybackArtworkImage(image: image, state: playback, cornerRadius: cornerRadius) + } else { + CrossfadeImage(image: image, duration: 0.3) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + } +} diff --git a/SwiftRadio/UI/CrossfadeImage.swift b/SwiftRadio/UI/CrossfadeImage.swift new file mode 100644 index 000000000..65a3ab5eb --- /dev/null +++ b/SwiftRadio/UI/CrossfadeImage.swift @@ -0,0 +1,58 @@ +// +// CrossfadeImage.swift +// Swift Radio +// +// Created on 2026-09-23. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// UIKit's image cross-dissolve, without allowing image dimensions to influence SwiftUI layout. +@MainActor struct CrossfadeImage: UIViewRepresentable { + let image: UIImage? + let duration: TimeInterval + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeUIView(context: Context) -> Container { Container() } + + func updateUIView(_ view: Container, context: Context) { + if reduceMotion { view.imageView.layer.removeAllAnimations() } + guard view.imageView.image !== image else { return } + // Clearing on a station switch is instant; only image-to-image changes dissolve. + if view.imageView.image == nil || image == nil || reduceMotion { + view.imageView.image = image + } else { + UIView.transition(with: view.imageView, duration: duration, + options: [.transitionCrossDissolve, .beginFromCurrentState, .allowAnimatedContent]) { + view.imageView.image = image + } + } + } + + static func dismantleUIView(_ view: Container, coordinator: ()) { + view.imageView.layer.removeAllAnimations() + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: Container, context: Context) -> CGSize? { + CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0) + } + + final class Container: UIView { + let imageView = UIImageView() + init() { + super.init(frame: .zero) + clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(imageView) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + imageView.frame = bounds + } + } +} diff --git a/SwiftRadio/UI/EqualizerBars.swift b/SwiftRadio/UI/EqualizerBars.swift new file mode 100644 index 000000000..8049fc7da --- /dev/null +++ b/SwiftRadio/UI/EqualizerBars.swift @@ -0,0 +1,26 @@ +// +// EqualizerBars.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Uses the reference indicator's explicit start/stop lifecycle, not a retained repeatForever. +@MainActor struct EqualizerBars: View { + var isAnimating = true + var color: UIColor = Config.tintColor + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { + if reduceMotion && isAnimating { + Image(systemName: "waveform").resizable().scaledToFit() + .foregroundStyle(Color(uiColor: color)) + } else { + PlaybackActivityIndicator(kind: .equalizer, isAnimating: isAnimating, color: color) + } + } +} diff --git a/SwiftRadio/UI/GradientBackground.swift b/SwiftRadio/UI/GradientBackground.swift new file mode 100644 index 000000000..e270288f5 --- /dev/null +++ b/SwiftRadio/UI/GradientBackground.swift @@ -0,0 +1,42 @@ +// +// GradientBackground.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Uses the template's native gradient primitive. SwiftUI's LinearGradient interpolates +/// the diagonal differently in a tall rectangle, even with identical normalized endpoints. +@MainActor struct GradientBackground: View { + var body: some View { + NativeGradient().ignoresSafeArea().accessibilityHidden(true) + } +} + +private struct NativeGradient: UIViewRepresentable { + func makeUIView(context: Context) -> GradientView { + let view = GradientView() + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ view: GradientView, context: Context) { + let gradient = view.layer as! CAGradientLayer + gradient.backgroundColor = UIColor.black.cgColor + gradient.startPoint = CGPoint(x: 0, y: 1) + gradient.endPoint = CGPoint(x: 1, y: 0) + gradient.colors = [0.3, 0.15, 0.05].map { + Config.gradientColor.withAlphaComponent($0).cgColor + } + [UIColor.clear.cgColor] + gradient.locations = [0, 0.3, 0.6, 1] + } + + final class GradientView: UIView { + override class var layerClass: AnyClass { CAGradientLayer.self } + } +} diff --git a/SwiftRadio/UI/LogoShareView.swift b/SwiftRadio/UI/LogoShareView.swift new file mode 100644 index 000000000..ed4992d1d --- /dev/null +++ b/SwiftRadio/UI/LogoShareView.swift @@ -0,0 +1,56 @@ +// +// LogoShareView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SwiftUI + +/// Off-screen share card (logo, album art, shoutout, track) rendered to a UIImage for the +/// share sheet. SwiftUI replacement for LogoShareView.xib + UIGraphicsImageRenderer. +@MainActor struct LogoShareView: View { + let artwork: UIImage? + let shoutout: String + let track: String? + let artist: String? + + var body: some View { + VStack(spacing: 16) { + Image("logo") + .resizable() + .scaledToFit() + .frame(height: 56) + Group { + if let artwork { + Image(uiImage: artwork).resizable().scaledToFill() + } else { + Image("stationImage").resizable().scaledToFill() + } + } + .frame(width: 280, height: 280) + .clipShape(RoundedRectangle(cornerRadius: 12)) + VStack(spacing: 4) { + Text(shoutout).font(.headline) + if let track { Text(track).font(.subheadline) } + if let artist { Text(artist).font(.subheadline).foregroundStyle(.secondary) } + } + .multilineTextAlignment(.center) + } + .padding(32) + .frame(width: 375) + .background(Color.black) + .foregroundStyle(.white) + .environment(\.colorScheme, .dark) + } + + /// Renders the card off-screen; on failure the share carries the shoutout text only. + func rendered(scale: CGFloat) -> UIImage? { + let renderer = ImageRenderer(content: self) + renderer.scale = scale + return renderer.uiImage + } +} diff --git a/SwiftRadio/UI/MailComposeView.swift b/SwiftRadio/UI/MailComposeView.swift new file mode 100644 index 000000000..1a4949dda --- /dev/null +++ b/SwiftRadio/UI/MailComposeView.swift @@ -0,0 +1,59 @@ +// +// MailComposeView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MessageUI +import SwiftUI + +/// A `UIViewControllerRepresentable` wrapper over `MFMailComposeViewController`. +/// The coordinator acts as the compose delegate and dismisses the sheet on finish. +struct MailComposeView: UIViewControllerRepresentable { + + /// Whether the device is configured to send email. Check before presenting. + static var canSendMail: Bool { + MFMailComposeViewController.canSendMail() + } + + let subject: String + let recipients: [String] + + @Environment(\.dismiss) private var dismiss + + func makeCoordinator() -> Coordinator { + Coordinator(dismiss: dismiss) + } + + func makeUIViewController(context: Context) -> MFMailComposeViewController { + let controller = MFMailComposeViewController() + controller.mailComposeDelegate = context.coordinator + controller.setSubject(subject) + controller.setToRecipients(recipients) + return controller + } + + func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} + + /// Compose delegate that dismisses the sheet for any result (sent, saved, cancelled, failed). + /// The conformance is isolated to the main actor (SE-0470): MessageUI calls the delegate on the main thread. + @MainActor final class Coordinator: NSObject, @MainActor MFMailComposeViewControllerDelegate { + + private let dismiss: DismissAction + + init(dismiss: DismissAction) { + self.dismiss = dismiss + } + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + dismiss() + } + } +} diff --git a/SwiftRadio/UI/MarqueeText.swift b/SwiftRadio/UI/MarqueeText.swift new file mode 100644 index 000000000..272551b13 --- /dev/null +++ b/SwiftRadio/UI/MarqueeText.swift @@ -0,0 +1,46 @@ +// +// MarqueeText.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import MarqueeLabel +import SwiftUI + +/// Reference title behavior: continuous scrolling, 30pt/s, 10pt fades and a 30pt buffer. +/// The caller uses wrapping Text instead at accessibility sizes or with Reduce Motion. +/// The label keeps the default text color like the reference title, not the configured tint. +@MainActor struct MarqueeText: UIViewRepresentable { + let text: String + func makeUIView(context: Context) -> MarqueeLabel { + let label = MarqueeLabel(frame: .zero, rate: 30, fadeLength: 10) + label.type = .continuous + label.trailingBuffer = 30 + label.textAlignment = .center + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.accessibilityIdentifier = "nowPlayingTitle" + return label + } + func updateUIView(_ label: MarqueeLabel, context: Context) { + let base = UIFont.preferredFont(forTextStyle: .title2, compatibleWith: label.traitCollection) + let descriptor = base.fontDescriptor.withSymbolicTraits(.traitBold) ?? base.fontDescriptor + let font = UIFont(descriptor: descriptor, size: 0) + if label.font != font { label.font = font } + // Reassigning identical text every playback tick would restart the marquee. + if label.text != text { + label.text = text + label.accessibilityLabel = text + } + } + func sizeThatFits(_ proposal: ProposedViewSize, uiView: MarqueeLabel, context: Context) -> CGSize? { + guard let width = proposal.width else { return nil } + return CGSize(width: width, height: ceil(uiView.font.lineHeight)) + } + static func dismantleUIView(_ label: MarqueeLabel, coordinator: ()) { + label.shutdownLabel() + } +} diff --git a/SwiftRadio/UI/PlaybackActivityIndicator.swift b/SwiftRadio/UI/PlaybackActivityIndicator.swift new file mode 100644 index 000000000..9a2cf8003 --- /dev/null +++ b/SwiftRadio/UI/PlaybackActivityIndicator.swift @@ -0,0 +1,81 @@ +// +// PlaybackActivityIndicator.swift +// Swift Radio +// +// Created on 2026-09-23. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import NVActivityIndicatorView +import SwiftUI + +/// The same equalizer/ball-pulse implementation as UIKit, with deterministic teardown. +@MainActor struct PlaybackActivityIndicator: UIViewRepresentable { + enum Kind { case equalizer, buffering } + let kind: Kind + var isAnimating = true + /// The reference draws row indicators white and only the toolbar one in the tint. + var color: UIColor = Config.tintColor + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeUIView(context: Context) -> Container { + Container(type: kind == .equalizer ? .audioEqualizer : .ballPulse, color: color) + } + + func updateUIView(_ view: Container, context: Context) { + view.indicator.color = color + view.shouldAnimate = isAnimating && !reduceMotion + view.setNeedsLayout() + if !view.shouldAnimate { view.indicator.stopAnimating() } + } + + static func dismantleUIView(_ view: Container, coordinator: ()) { + view.shouldAnimate = false + view.indicator.stopAnimating() + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: Container, context: Context) -> CGSize? { + CGSize(width: proposal.width ?? 30, height: proposal.height ?? 20) + } + + /// NV builds its animation from frame, not the SwiftUI proposal. Start only after layout; + /// SwiftUI may otherwise call updateUIView while the vendor view still has a zero frame. + final class Container: UIView { + let indicator: NVActivityIndicatorView + var shouldAnimate = false + private var animationSize = CGSize.zero + init(type: NVActivityIndicatorType, color: UIColor) { + indicator = NVActivityIndicatorView(frame: .zero, type: type, color: color, padding: 0) + super.init(frame: .zero) + isAccessibilityElement = false + indicator.isAccessibilityElement = false + addSubview(indicator) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + indicator.frame = bounds + if animationSize != bounds.size { + indicator.stopAnimating() + animationSize = bounds.size + } + if shouldAnimate && bounds.width > 0 && bounds.height > 0 { indicator.startAnimating() } + else { indicator.stopAnimating() } + } + } +} + +/// Reduced motion keeps a visible, stationary loading cue. +struct BufferingIndicator: View { + var color: UIColor = Config.tintColor + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { + if reduceMotion { + Image(systemName: "ellipsis").foregroundStyle(Color(uiColor: color)) + } else { + PlaybackActivityIndicator(kind: .buffering, color: color) + } + } +} diff --git a/SwiftRadio/UI/PlaybackArtworkImage.swift b/SwiftRadio/UI/PlaybackArtworkImage.swift new file mode 100644 index 000000000..cdb3cdbe0 --- /dev/null +++ b/SwiftRadio/UI/PlaybackArtworkImage.swift @@ -0,0 +1,136 @@ +// +// PlaybackArtworkImage.swift +// Swift Radio +// +// Created on 2026-09-23. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import NVActivityIndicatorView +import SwiftUI + +struct PlaybackArtworkState { + let isPlaying: Bool + let isBuffering: Bool +} + +/// Exact reference artwork transitions. Loading/state ownership stays in SwiftUI. +@MainActor struct PlaybackArtworkImage: UIViewRepresentable { + let image: UIImage? + let state: PlaybackArtworkState + let cornerRadius: CGFloat + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeUIView(context: Context) -> ArtworkContainer { ArtworkContainer(cornerRadius: cornerRadius) } + + func updateUIView(_ view: ArtworkContainer, context: Context) { + view.update(image: image, state: state, reduceMotion: reduceMotion) + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: ArtworkContainer, context: Context) -> CGSize? { + CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0) + } + + static func dismantleUIView(_ view: ArtworkContainer, coordinator: ()) { view.stopAnimations() } + + final class ArtworkContainer: UIView { + private let container = UIView() + private let imageView = UIImageView() + private let bufferingOverlay = UIView() + private let indicator = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: .white) + private let reducedMotionIndicator = UIImageView(image: UIImage(systemName: "ellipsis")) + private var previousState: PlaybackArtworkState? + private var previousReduceMotion: Bool? + + init(cornerRadius: CGFloat) { + super.init(frame: .zero) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.4 + layer.shadowOffset = CGSize(width: 0, height: 10) + layer.shadowRadius = 20 + container.clipsToBounds = true + container.layer.cornerRadius = cornerRadius + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .white.withAlphaComponent(0.12) + bufferingOverlay.backgroundColor = .black.withAlphaComponent(0.4) + bufferingOverlay.alpha = 0 + reducedMotionIndicator.contentMode = .scaleAspectFit + reducedMotionIndicator.tintColor = .white + reducedMotionIndicator.isHidden = true + addSubview(container) + container.addSubview(imageView) + container.addSubview(bufferingOverlay) + bufferingOverlay.addSubview(indicator) + bufferingOverlay.addSubview(reducedMotionIndicator) + isAccessibilityElement = false + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + // Bounds/center are safe while the content has a scale transform. + container.bounds = bounds + container.center = CGPoint(x: bounds.midX, y: bounds.midY) + imageView.frame = container.bounds + bufferingOverlay.frame = container.bounds + indicator.frame = CGRect(x: bounds.midX - 20, y: bounds.midY - 15, width: 40, height: 30) + reducedMotionIndicator.frame = indicator.frame + updateIndicator() + } + + func update(image: UIImage?, state: PlaybackArtworkState, reduceMotion: Bool) { + if reduceMotion { stopAnimations() } + if imageView.image !== image { + // Clearing on a station switch is instant, like the reference setImage(nil). + if imageView.image == nil || image == nil || reduceMotion { + imageView.image = image + } else { + UIView.transition(with: imageView, duration: 0.3, + options: [.transitionCrossDissolve, .beginFromCurrentState]) { + self.imageView.image = image + } + } + } + if previousState?.isPlaying != state.isPlaying || previousReduceMotion != reduceMotion { + let transform = CGAffineTransform(scaleX: state.isPlaying ? 1 : 0.85, + y: state.isPlaying ? 1 : 0.85) + if reduceMotion || previousState == nil { + container.transform = transform + } else { + UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, + initialSpringVelocity: 0, options: [.allowUserInteraction, .beginFromCurrentState]) { + self.container.transform = transform + } + } + } + reducedMotionIndicator.isHidden = !state.isBuffering || !reduceMotion + if previousState?.isBuffering != state.isBuffering || previousReduceMotion != reduceMotion { + let alpha: CGFloat = state.isBuffering ? 1 : 0 + if reduceMotion { bufferingOverlay.alpha = alpha } + else { + UIView.animate(withDuration: 0.3, delay: 0, options: .beginFromCurrentState) { + self.bufferingOverlay.alpha = alpha + } + } + } + previousState = state + previousReduceMotion = reduceMotion + updateIndicator() + } + + private func updateIndicator() { + if previousState?.isBuffering == true && previousReduceMotion == false && indicator.bounds.width > 0 { + indicator.startAnimating() + } else { indicator.stopAnimating() } + } + + func stopAnimations() { + indicator.stopAnimating() + container.layer.removeAllAnimations() + imageView.layer.removeAllAnimations() + bufferingOverlay.layer.removeAllAnimations() + } + } +} diff --git a/SwiftRadio/UI/SafariView.swift b/SwiftRadio/UI/SafariView.swift new file mode 100644 index 000000000..90bbc634d --- /dev/null +++ b/SwiftRadio/UI/SafariView.swift @@ -0,0 +1,23 @@ +// +// SafariView.swift +// Swift Radio +// +// Created by Fethi El Hassasna on 2026-09-07. +// +// SPDX-License-Identifier: MIT +// See LICENSE in the repository root. +// + +import SafariServices +import SwiftUI + +/// Presents a web page in the in-app browser. Replaces direct SFSafariViewController presents. +@MainActor struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + + func updateUIViewController(_ controller: SFSafariViewController, context: Context) {} +} diff --git a/SwiftRadio/ViewControllers/AboutViewController.swift b/SwiftRadio/ViewControllers/AboutViewController.swift deleted file mode 100755 index 68cfdd1fe..000000000 --- a/SwiftRadio/ViewControllers/AboutViewController.swift +++ /dev/null @@ -1,290 +0,0 @@ -// -// AboutViewController.swift -// Swift Radio -// -// Created by Matthew Fecher on 7/9/15. -// Copyright (c) 2015 MatthewFecher.com. All rights reserved. -// - -import UIKit - -protocol AboutViewControllerDelegate: AnyObject { - func aboutViewController(_ controller: AboutViewController, didSelectItem item: InfoItem) -} - -class AboutViewController: UITableViewController { - - // MARK: - Properties - - weak var delegate: AboutViewControllerDelegate? - private var sections: [InfoSection] = [] - - // MARK: - Init - - init() { - super.init(style: .insetGrouped) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - // Navigation - navigationItem.largeTitleDisplayMode = .always - navigationController?.navigationBar.prefersLargeTitles = true - navigationItem.backButtonDisplayMode = .minimal - title = Content.About.title - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissController)) - - // Table setup - tableView.estimatedRowHeight = 60 - tableView.rowHeight = UITableView.automaticDimension - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - tableView.backgroundColor = .systemGroupedBackground - - // Header & Footer - setupTableHeaderView() - setupFooter() - - // Build data - setupSections() - } - - @objc private func dismissController() { - dismiss(animated: true) - } - - // MARK: - Setup: Header - - /// Creates a custom header with a bold title and descriptive text about Swift Radio. - private func setupTableHeaderView() { - let headerView = UIView() - - let aboutLabel = UILabel() - aboutLabel.font = .systemFont(ofSize: 16) - aboutLabel.textColor = .secondaryLabel - aboutLabel.numberOfLines = 0 - aboutLabel.translatesAutoresizingMaskIntoConstraints = false - - aboutLabel.attributedText = makeAboutText() - - headerView.addSubview(aboutLabel) - - NSLayoutConstraint.activate([ - aboutLabel.topAnchor.constraint(equalTo: headerView.topAnchor, constant: 24), - aboutLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16), - aboutLabel.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16), - aboutLabel.bottomAnchor.constraint(equalTo: headerView.bottomAnchor, constant: -24) - ]) - - // Force a layout pass so Auto Layout can calculate correct sizes - headerView.setNeedsLayout() - headerView.layoutIfNeeded() - - // Measure and update the header's frame - let targetSize = CGSize(width: tableView.bounds.width, height: 0) - let size = headerView.systemLayoutSizeFitting(targetSize, - withHorizontalFittingPriority: .required, - verticalFittingPriority: .fittingSizeLevel) - headerView.frame.size.height = size.height - tableView.tableHeaderView = headerView - } - - private func makeAboutText() -> NSAttributedString { - let (plainText, boldRanges) = Self.parseBoldMarkers(Content.About.headerText) - - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - - let attributed = NSMutableAttributedString(string: plainText, attributes: [ - .font: UIFont.systemFont(ofSize: 17), - .foregroundColor: UIColor.secondaryLabel, - .paragraphStyle: paragraphStyle - ]) - - for range in boldRanges { - attributed.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 17), range: range) - } - - return attributed - } - - /// Parses `**bold**` markers from a string, returning the plain text and the NSRanges of bold segments. - private static func parseBoldMarkers(_ input: String) -> (String, [NSRange]) { - var plain = "" - var boldRanges: [NSRange] = [] - var remaining = input[...] - - while let start = remaining.range(of: "**") { - plain += remaining[.. Int { - sections.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - sections[section].items.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let item = sections[indexPath.section].items[indexPath.row] - - var content = cell.defaultContentConfiguration() - content.text = item.title - content.secondaryText = item.subtitle - content.textProperties.numberOfLines = 0 - content.secondaryTextProperties.numberOfLines = 0 - - // Use SF Symbol - if let icon = item.icon { - content.image = UIImage(systemName: icon) - content.imageProperties.tintColor = .label - } - - // Decide how to display - switch item { - case .version: - cell.selectionStyle = .none - cell.accessoryType = .none - default: - cell.accessoryType = .disclosureIndicator - } - - // Assign the content - cell.contentConfiguration = content - - // Make the cells "card-like" in an insetGrouped table: - var background = UIBackgroundConfiguration.listGroupedCell() - background.backgroundColor = .secondarySystemBackground - background.cornerRadius = 8 - background.strokeColor = .clear - cell.backgroundConfiguration = background - - return cell - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - // Hide the title if it's empty - let title = sections[section].title - return title.isEmpty ? nil : title - } - - // MARK: - TableView Delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - let item = sections[indexPath.section].items[indexPath.row] - - switch item { - case .version: - break - default: - delegate?.aboutViewController(self, didSelectItem: item) - } - } -} diff --git a/SwiftRadio/ViewControllers/BaseController.swift b/SwiftRadio/ViewControllers/BaseController.swift deleted file mode 100755 index bf46879e8..000000000 --- a/SwiftRadio/ViewControllers/BaseController.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// BaseController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-12-03. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit - -class BaseController: UIViewController { - - let gradientBackgroundView: GradientBackgroundView = { - let view = GradientBackgroundView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - override func loadView() { - super.loadView() - setupViews() - } - - func setupViews() { - view.addSubview(gradientBackgroundView) - - NSLayoutConstraint.activate([ - gradientBackgroundView.topAnchor.constraint(equalTo: view.topAnchor), - gradientBackgroundView.rightAnchor.constraint(equalTo: view.rightAnchor), - gradientBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - gradientBackgroundView.leftAnchor.constraint(equalTo: view.leftAnchor) - ]) - } -} diff --git a/SwiftRadio/ViewControllers/BottomSheetViewController.swift b/SwiftRadio/ViewControllers/BottomSheetViewController.swift deleted file mode 100644 index 859346e53..000000000 --- a/SwiftRadio/ViewControllers/BottomSheetViewController.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// BottomSheetViewController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2024-01-14. -// Copyright 2024 matthewfecher.com. All rights reserved. -// - -import UIKit -import FRadioPlayer - -protocol BottomSheetViewControllerDelegate: AnyObject { - func bottomSheet(_ controller: BottomSheetViewController, didSelect option: BottomSheetViewController.Option) -} - -class BottomSheetViewController: UIViewController { - - enum Section: Int, CaseIterable { - case stationInfo - case music - case share - var title: String? { - return nil - } - } - - enum Option { - case info - case share(UIImage?) - case website - case openInMusic(URL?) - - var title: String { - switch self { - case .info: return Content.BottomSheet.aboutStation - case .share: return Content.BottomSheet.shareNowPlaying - case .website: return Content.BottomSheet.stationWebsite - case .openInMusic: return Content.BottomSheet.playInMusicApp - } - } - - var image: UIImage? { - switch self { - case .info: return UIImage(systemName: "info.circle") - case .share: return UIImage(systemName: "square.and.arrow.up") - case .website: return UIImage(systemName: "safari") - case .openInMusic: return UIImage(systemName: "music.note") - } - } - } - - weak var delegate: BottomSheetViewControllerDelegate? - private let station: RadioStation - private let player = FRadioPlayer.shared - - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .insetGrouped) - table.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - table.delegate = self - table.dataSource = self - table.translatesAutoresizingMaskIntoConstraints = false - return table - }() - - init(station: RadioStation) { - self.station = station - super.init(nibName: nil, bundle: nil) - - if let sheet = sheetPresentationController { - sheet.prefersGrabberVisible = true - sheet.delegate = self - sheet.prefersScrollingExpandsWhenScrolledToEdge = false - sheet.detents = [.medium()] - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func getOptions(for section: Section) -> [Option] { - switch section { - case .stationInfo: - var options: [Option] = [.info] - if station.hasValidWebsite { - options.append(.website) - } - return options - case .music: - return [.openInMusic(nil)] - case .share: - return [.share(nil)] - } - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - setupViews() - - player.addObserver(self) - } - - private func setupViews() { - view.addSubview(tableView) - - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - if let sheet = sheetPresentationController { - let contentHeight = tableView.contentSize.height + view.safeAreaInsets.top + view.safeAreaInsets.bottom - sheet.detents = [.custom { _ in contentHeight }] - sheet.animateChanges { - sheet.selectedDetentIdentifier = sheet.detents.first?.identifier - } - } - } -} - -extension BottomSheetViewController: UITableViewDataSource, UITableViewDelegate { - - func numberOfSections(in tableView: UITableView) -> Int { - return Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section = Section(rawValue: section)! - return getOptions(for: section).count - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - let section = Section(rawValue: section)! - return section.title - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let section = Section(rawValue: indexPath.section)! - let option = getOptions(for: section)[indexPath.row] - - var config = cell.defaultContentConfiguration() - config.text = option.title - config.image = option.image - - // Disable OpenInMusic cell if no metadata - if case .openInMusic = option { - let hasMetadata = player.currentArtworkURL != nil - cell.isUserInteractionEnabled = hasMetadata - // Update text color - config.textProperties.color = hasMetadata ? .label : .tertiaryLabel - config.imageProperties.tintColor = hasMetadata ? .label : .tertiaryLabel - } - - cell.contentConfiguration = config - cell.tintColor = .label - - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - let section = Section(rawValue: indexPath.section)! - let option = getOptions(for: section)[indexPath.row] - delegate?.bottomSheet(self, didSelect: option) - dismiss(animated: true) - } -} - -extension BottomSheetViewController: UISheetPresentationControllerDelegate { - func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { - // Handle detent changes if needed - } -} - -extension BottomSheetViewController: FRadioPlayerObserver { - - func radioPlayer(_ player: FRadioPlayer, artworkDidChange artworkURL: URL?) { - // Reload the music section to update cell state - if let musicSection = Section.allCases.firstIndex(of: .music) { - tableView.reloadSections(IndexSet(integer: musicSection), with: .none) - } - } -} diff --git a/SwiftRadio/ViewControllers/ContributorsViewController.swift b/SwiftRadio/ViewControllers/ContributorsViewController.swift deleted file mode 100644 index efb079426..000000000 --- a/SwiftRadio/ViewControllers/ContributorsViewController.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// ContributorsViewController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 1/25/25. -// Copyright © 2025 matthewfecher.com. All rights reserved. -// - -import UIKit -import SafariServices - -final class ContributorCell: UICollectionViewCell { - - static let reuseIdentifier = "ContributorCell" - - // Avatar - private let avatarImageView: UIImageView = { - let iv = UIImageView() - iv.contentMode = .scaleAspectFill - iv.layer.masksToBounds = true - iv.layer.cornerRadius = 36 - return iv - }() - - // Rank & Username: e.g., "#1 fethica" - private let rankUsernameLabel: UILabel = { - let label = UILabel() - label.font = .boldSystemFont(ofSize: 14) - label.textAlignment = .center - label.numberOfLines = 1 - return label - }() - - // Commits: e.g., "42 commits" - private let commitsLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 12) - label.textColor = .secondaryLabel - label.textAlignment = .center - label.numberOfLines = 1 - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - - contentView.addSubview(avatarImageView) - contentView.addSubview(rankUsernameLabel) - contentView.addSubview(commitsLabel) - - // Example Auto Layout - avatarImageView.translatesAutoresizingMaskIntoConstraints = false - rankUsernameLabel.translatesAutoresizingMaskIntoConstraints = false - commitsLabel.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - // Avatar: center top, fixed size - avatarImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), - avatarImageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - avatarImageView.widthAnchor.constraint(equalToConstant: 72), - avatarImageView.heightAnchor.constraint(equalToConstant: 72), - - // Rank & username below the avatar - rankUsernameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 8), - rankUsernameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 4), - rankUsernameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4), - - // Commits below username - commitsLabel.topAnchor.constraint(equalTo: rankUsernameLabel.bottomAnchor, constant: 4), - commitsLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 4), - commitsLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4) - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(rank: Int, contributor: Contributor, avatar: UIImage?) { - // #rank username - rankUsernameLabel.text = "#\(rank) \(contributor.login)" - - // e.g., "42 commits" - commitsLabel.text = String(format: Content.Common.commitsFormat, contributor.contributions) - - // Set placeholder and load image using cache - avatarImageView.image = UIImage(systemName: "person.crop.circle") - avatarImageView.load(url: contributor.avatarURL, placeholder: UIImage(systemName: "person.crop.circle")) - } -} - -final class ContributorsViewController: UICollectionViewController { - - // MARK: - Public - let owner: String - let repo: String - - // MARK: - Private - private var contributors: [Contributor] = [] - - private let spinner = UIActivityIndicatorView(style: .large) - - // MARK: - Init - - init(owner: String, repo: String) { - self.owner = owner - self.repo = repo - - // 3-column flow layout - let layout = UICollectionViewFlowLayout() - layout.minimumLineSpacing = 16 - layout.minimumInteritemSpacing = 8 - - super.init(collectionViewLayout: layout) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - title = Content.Contributors.title - navigationItem.largeTitleDisplayMode = .automatic - - // Add section insets for top spacing and horizontal margins - if let layout = collectionViewLayout as? UICollectionViewFlowLayout { - layout.sectionInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - } - - collectionView.backgroundColor = .systemGroupedBackground - collectionView.register( - ContributorCell.self, - forCellWithReuseIdentifier: ContributorCell.reuseIdentifier - ) - - collectionView.backgroundView = spinner - spinner.startAnimating() - - Task { - await fetchContributors() - } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - if let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout { - let totalSpacing: CGFloat = flowLayout.minimumInteritemSpacing * 2 - let horizontalInsets: CGFloat = flowLayout.sectionInset.left + flowLayout.sectionInset.right - let availableWidth = collectionView.bounds.width - horizontalInsets - totalSpacing - let itemWidth = floor(availableWidth / 3) - flowLayout.itemSize = CGSize(width: itemWidth, height: 140) - } - } - - // MARK: - Data Fetch - - private func fetchContributors() async { - do { - let decoded = try await NetworkService.fetchContributors(owner: owner, repo: repo) - let sortedContribs = decoded.sorted { $0.contributions > $1.contributions } - - await MainActor.run { - self.contributors = sortedContribs - self.spinner.stopAnimating() - self.collectionView.reloadData() - } - } catch { - print("Failed to fetch contributors:", error) - await MainActor.run { - self.spinner.stopAnimating() - } - } - } - - // MARK: - CollectionView DataSource - - override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - contributors.count - } - - override func collectionView(_ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: ContributorCell.reuseIdentifier, - for: indexPath - ) as? ContributorCell else { - return UICollectionViewCell() - } - - let contributor = contributors[indexPath.item] - let rank = indexPath.item + 1 - - // Configure cell with placeholder - cell.configure(rank: rank, contributor: contributor, avatar: nil) - - // Style cell background - cell.contentView.backgroundColor = .secondarySystemBackground - cell.contentView.layer.cornerRadius = 8 - cell.contentView.layer.masksToBounds = true - - return cell - } - - // MARK: - CollectionView Delegate - - override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let contributor = contributors[indexPath.item] - let safariVC = SFSafariViewController(url: contributor.htmlURL) - present(safariVC, animated: true) - } -} diff --git a/SwiftRadio/ViewControllers/FeaturesViewController.swift b/SwiftRadio/ViewControllers/FeaturesViewController.swift deleted file mode 100644 index 247f97ef7..000000000 --- a/SwiftRadio/ViewControllers/FeaturesViewController.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// FeaturesViewController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2024-01-25. -// Copyright © 2024 matthewfecher.com. All rights reserved. -// - -import UIKit - -class FeaturesViewController: UITableViewController { - - // MARK: - Properties - - private let features = Config.Features.items - - // MARK: - Init - - init() { - super.init(style: .insetGrouped) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - setupUI() - setupTableView() - } - - // MARK: - Setup - - private func setupUI() { - title = Content.Features.title - navigationItem.largeTitleDisplayMode = .automatic - } - - private func setupTableView() { - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 60 - } - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 20 - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return UIView() - } - - // MARK: - UITableViewDataSource - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - features.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let feature = features[indexPath.row] - - var content = cell.defaultContentConfiguration() - - // Title - content.text = feature.title - content.textProperties.numberOfLines = 0 - content.textProperties.font = .boldSystemFont(ofSize: 17) - - // Subtitle - content.secondaryText = feature.subtitle - content.secondaryTextProperties.numberOfLines = 0 - content.secondaryTextProperties.font = .systemFont(ofSize: 15) - content.secondaryTextProperties.color = .secondaryLabel - - // Icon (SF Symbol) - if let symbolImage = UIImage(systemName: feature.icon) { - content.image = symbolImage - content.imageProperties.tintColor = .label - } - - // Configure cell - cell.contentConfiguration = content - cell.selectionStyle = .none - - return cell - } -} diff --git a/SwiftRadio/ViewControllers/InfoDetailViewController.swift b/SwiftRadio/ViewControllers/InfoDetailViewController.swift deleted file mode 100755 index c480f5d9f..000000000 --- a/SwiftRadio/ViewControllers/InfoDetailViewController.swift +++ /dev/null @@ -1,234 +0,0 @@ -// -// InfoDetailViewController.swift -// Swift Radio -// -// Created by Matthew Fecher on 7/9/15. -// Copyright (c) 2015 MatthewFecher.com. All rights reserved. -// - -import UIKit -import SafariServices - -class InfoDetailViewController: BaseController { - - private struct Link { - let title: String - let image: UIImage? - let url: URL - } - - private let station: RadioStation - private var links: [Link] = [] - - private let scrollView: UIScrollView = { - let sv = UIScrollView() - sv.translatesAutoresizingMaskIntoConstraints = false - return sv - }() - - private let contentStack: UIStackView = { - let stack = UIStackView() - stack.axis = .vertical - stack.spacing = 0 - stack.translatesAutoresizingMaskIntoConstraints = false - return stack - }() - - private let stationImageView: UIImageView = { - let iv = UIImageView(image: UIImage(named: "stationImage")) - iv.contentMode = .scaleAspectFill - iv.layer.cornerRadius = 20 - iv.clipsToBounds = true - iv.translatesAutoresizingMaskIntoConstraints = false - return iv - }() - - private let nameLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .title2, compatibleWith: .init(legibilityWeight: .bold)) - label.textColor = .white - label.textAlignment = .center - label.numberOfLines = 2 - return label - }() - - private let descLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .subheadline) - label.textColor = .init(white: 1, alpha: 0.7) - label.textAlignment = .center - label.numberOfLines = 2 - return label - }() - - private let longDescLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .body) - label.textColor = .init(white: 1, alpha: 0.85) - label.numberOfLines = 0 - return label - }() - - init(station: RadioStation) { - self.station = station - super.init(nibName: nil, bundle: nil) - - if station.hasValidWebsite, - let website = station.website, - let url = URL(string: website) { - links.append(Link(title: Content.StationDetail.visitWebsite, image: UIImage(systemName: "safari"), url: url)) - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - title = Content.StationDetail.title - setupLayout() - populateData() - } - - // MARK: - Layout - - private func setupLayout() { - view.addSubview(scrollView) - scrollView.addSubview(contentStack) - - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor), - contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), - contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), - contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor), - ]) - - // -- Image -- - let imageContainer = UIView() - imageContainer.addSubview(stationImageView) - NSLayoutConstraint.activate([ - stationImageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 24), - stationImageView.centerXAnchor.constraint(equalTo: imageContainer.centerXAnchor), - stationImageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor), - stationImageView.widthAnchor.constraint(equalToConstant: 160), - stationImageView.heightAnchor.constraint(equalToConstant: 160), - ]) - contentStack.addArrangedSubview(imageContainer) - - // -- Name & Desc -- - let titleStack = UIStackView(arrangedSubviews: [nameLabel, descLabel]) - titleStack.axis = .vertical - titleStack.spacing = 4 - titleStack.alignment = .center - contentStack.addArrangedSubview(titleStack) - contentStack.setCustomSpacing(16, after: imageContainer) - - // -- Separator -- - contentStack.setCustomSpacing(24, after: titleStack) - contentStack.addArrangedSubview(makeSeparator()) - - // -- Long Description -- - let descContainer = UIView() - longDescLabel.translatesAutoresizingMaskIntoConstraints = false - descContainer.addSubview(longDescLabel) - NSLayoutConstraint.activate([ - longDescLabel.topAnchor.constraint(equalTo: descContainer.topAnchor, constant: 20), - longDescLabel.leadingAnchor.constraint(equalTo: descContainer.leadingAnchor, constant: 20), - longDescLabel.trailingAnchor.constraint(equalTo: descContainer.trailingAnchor, constant: -20), - longDescLabel.bottomAnchor.constraint(equalTo: descContainer.bottomAnchor, constant: -20), - ]) - contentStack.addArrangedSubview(descContainer) - - // -- Links -- - if !links.isEmpty { - contentStack.addArrangedSubview(makeSeparator()) - - for (index, link) in links.enumerated() { - let button = makeLinkButton(link, tag: index) - contentStack.addArrangedSubview(button) - } - } - } - - // MARK: - Data - - private func populateData() { - nameLabel.text = station.name - descLabel.text = station.desc - - let text = station.longDesc.isEmpty - ? Content.StationDetail.defaultDescription - : station.longDesc - longDescLabel.text = text - - station.getImage { [weak self] image in - self?.stationImageView.image = image - } - } - - // MARK: - Helpers - - private func makeSeparator() -> UIView { - let separator = UIView() - separator.translatesAutoresizingMaskIntoConstraints = false - separator.backgroundColor = .init(white: 1, alpha: 0.15) - separator.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale).isActive = true - - let container = UIView() - separator.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), - separator.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), - separator.topAnchor.constraint(equalTo: container.topAnchor), - separator.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - return container - } - - private func makeLinkButton(_ link: Link, tag: Int) -> UIView { - var config = UIButton.Configuration.plain() - var titleAttr = AttributedString(link.title) - titleAttr.font = .preferredFont(forTextStyle: .body, compatibleWith: .init(legibilityWeight: .bold)) - config.attributedTitle = titleAttr - config.image = link.image - config.baseForegroundColor = .white - config.imagePadding = 10 - config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20) - - let button = UIButton(configuration: config) - button.tag = tag - button.contentHorizontalAlignment = .leading - button.addTarget(self, action: #selector(linkTapped(_:)), for: .touchUpInside) - - // Add a chevron on the trailing side - let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) - chevron.tintColor = Config.tintColor.withAlphaComponent(0.3) - chevron.translatesAutoresizingMaskIntoConstraints = false - chevron.setContentHuggingPriority(.required, for: .horizontal) - button.addSubview(chevron) - NSLayoutConstraint.activate([ - chevron.centerYAnchor.constraint(equalTo: button.centerYAnchor), - chevron.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -20), - ]) - - return button - } - - @objc func dismissSelf() { - dismiss(animated: true) - } - - @objc private func linkTapped(_ sender: UIButton) { - guard sender.tag < links.count else { return } - let safariVC = SFSafariViewController(url: links[sender.tag].url) - present(safariVC, animated: true) - } -} diff --git a/SwiftRadio/ViewControllers/LibrariesViewController.swift b/SwiftRadio/ViewControllers/LibrariesViewController.swift deleted file mode 100644 index c947e055f..000000000 --- a/SwiftRadio/ViewControllers/LibrariesViewController.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// LibrariesViewController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 1/25/25. -// Copyright © 2025 matthewfecher.com. All rights reserved. -// - -import UIKit -import SafariServices - -final class LibrariesViewController: UITableViewController { - - // MARK: - Internal Model - - struct LibraryInfo { - let owner: String - let repo: String - var name: String - var description: String? - - var htmlURL: URL { - URL(string: "https://github.com/\(owner)/\(repo)")! - } - var ownerURL: URL { - URL(string: "https://github.com/\(owner)")! - } - } - - // MARK: - Properties - - private var libraries: [LibraryInfo] = Config.Libraries.items.map { - LibraryInfo(owner: $0.owner, repo: $0.repo, name: "", description: nil) - } - - // A simple activity indicator for loading - private let spinner = UIActivityIndicatorView(style: .large) - - // MARK: - Lifecycle - - init() { - super.init(style: .insetGrouped) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - title = Content.Libraries.title - navigationItem.largeTitleDisplayMode = .automatic - - // Register a default cell - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - - // Add some space at the top via a minimal header - addSpaceAtTopOfTable(24) - - // Setup spinner in the background view - spinner.hidesWhenStopped = true - tableView.backgroundView = spinner - spinner.startAnimating() - - // Fetch data (in parallel) from GitHub - Task { - await fetchLibrariesData() - } - } - - private func addSpaceAtTopOfTable(_ height: CGFloat) { - let headerView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: height)) - headerView.backgroundColor = .clear - tableView.tableHeaderView = headerView - } - - // MARK: - Data Fetch - - /// Fetch name/description/html_url for each library from GitHub. - private func fetchLibrariesData() async { - do { - // Run tasks in parallel with ThrowingTaskGroup - try await withThrowingTaskGroup(of: (Int, String, String?).self) { group in - for (index, lib) in libraries.enumerated() { - group.addTask { - let repo = try await NetworkService.fetchRepository(owner: lib.owner, repo: lib.repo) - return (index, repo.name, repo.description) - } - } - - for try await (index, name, desc) in group { - libraries[index].name = name - libraries[index].description = desc - } - } - - // Reload the table on the main thread - DispatchQueue.main.async { - self.spinner.stopAnimating() - self.tableView.reloadData() - } - - } catch { - // If any repo fetch fails, handle error here - print("Error fetching library info:", error) - DispatchQueue.main.async { - self.spinner.stopAnimating() - // Optionally show an alert or fallback UI - } - } - } - - // MARK: - UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - libraries.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 2 - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let library = libraries[indexPath.section] - - var content = cell.defaultContentConfiguration() - content.textProperties.numberOfLines = 0 - content.secondaryTextProperties.numberOfLines = 0 - - switch indexPath.row { - case 0: - content.text = library.name.isEmpty ? "\(library.owner)/\(library.repo)" : library.name - content.textProperties.font = .boldSystemFont(ofSize: 17) - content.secondaryText = library.description ?? Content.Common.noDescription - cell.selectionStyle = .default - cell.accessoryType = .disclosureIndicator - - case 1: - content.text = "@\(library.owner)" - content.image = UIImage(systemName: "person.circle") - content.imageProperties.tintColor = .label - cell.selectionStyle = .default - cell.accessoryType = .disclosureIndicator - default: - break - } - - cell.contentConfiguration = content - return cell - } - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let library = libraries[indexPath.section] - tableView.deselectRow(at: indexPath, animated: true) - - switch indexPath.row { - case 0: - let safariVC = SFSafariViewController(url: library.htmlURL) - present(safariVC, animated: true) - case 1: - let safariVC = SFSafariViewController(url: library.ownerURL) - present(safariVC, animated: true) - default: - break - } - } -} diff --git a/SwiftRadio/ViewControllers/LoaderController.swift b/SwiftRadio/ViewControllers/LoaderController.swift deleted file mode 100755 index f85d06854..000000000 --- a/SwiftRadio/ViewControllers/LoaderController.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// LoaderController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2022-12-03. -// Copyright © 2022 matthewfecher.com. All rights reserved. -// - -import UIKit - -protocol LoaderControllerDelegate: AnyObject { - func didFinishLoading(_ controller: LoaderController, stations: [RadioStation]) -} - -class LoaderController: BaseController { - - weak var delegate: LoaderControllerDelegate? - - private let manager = StationsManager.shared - - private let activityIndicatorView: UIActivityIndicatorView = { - let view = UIActivityIndicatorView(style: .medium) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let errorTitleLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - label.font = UIFont.preferredFont(forTextStyle: .headline) - label.numberOfLines = 0 - label.text = Content.Loader.errorTitle - return label - }() - - private let errorMessageLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - label.numberOfLines = 0 - label.font = UIFont.preferredFont(forTextStyle: .footnote) - return label - }() - - private let stackView: UIStackView = { - let view = UIStackView() - view.translatesAutoresizingMaskIntoConstraints = false - view.axis = .vertical - view.alignment = .center - view.spacing = 16 - return view - }() - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - fetchStations() - } - - private func handle(_ error: Error) { - stackView.isHidden = false - errorMessageLabel.text = error.localizedDescription - } - - private func fetchStations() { - stackView.isHidden = true - activityIndicatorView.startAnimating() - - manager.fetch { [weak self] result in - guard let self = self else { return } - - self.activityIndicatorView.stopAnimating() - - switch result { - case .success(let stations): - self.delegate?.didFinishLoading(self, stations: stations) - case .failure(let error): - self.handle(error) - } - } - } - - override func setupViews() { - super.setupViews() - - // Logo Image - let logoImage = UIImage(named: "logo") - let logoImageView = UIImageView(image: logoImage) - logoImageView.contentMode = .scaleAspectFit - logoImageView.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(logoImageView) - - NSLayoutConstraint.activate([ - logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - logoImageView.widthAnchor.constraint(equalToConstant: 220), - logoImageView.heightAnchor.constraint(equalToConstant: 220), - ]) - - // Activity Indicator - view.addSubview(activityIndicatorView) - - NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - activityIndicatorView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 32) - ]) - - // Retry button - let retryButton = UIButton(type: .system) - retryButton.setTitle(Content.Loader.retryButton, for: .normal) - retryButton.addTarget(self, action: #selector(handleRetry), for: .touchUpInside) - - // Stack view - stackView.addArrangedSubview(errorTitleLabel) - stackView.addArrangedSubview(errorMessageLabel) - stackView.addArrangedSubview(retryButton) - - view.addSubview(stackView) - - NSLayoutConstraint.activate([ - stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - stackView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 32), - stackView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.7) - ]) - } - - @objc private func handleRetry() { - fetchStations() - } -} diff --git a/SwiftRadio/ViewControllers/NowPlayingViewController.swift b/SwiftRadio/ViewControllers/NowPlayingViewController.swift deleted file mode 100755 index 4978f01ee..000000000 --- a/SwiftRadio/ViewControllers/NowPlayingViewController.swift +++ /dev/null @@ -1,358 +0,0 @@ -// -// NowPlayingViewControllerWIP.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2024-01-13. -// Copyright © 2024 matthewfecher.com. All rights reserved. -// - -import UIKit -import MediaPlayer -import AVKit -import FRadioPlayer -import NVActivityIndicatorView -import LNPopupController - -protocol NowPlayingViewControllerDelegate: AnyObject { - func didSelectBottomSheetOption(_ option: BottomSheetViewController.Option, from controller: NowPlayingViewController) - func didTapCompanyButton(_ nowPlayingViewController: NowPlayingViewController) -} - -class NowPlayingViewController: UIViewController { - - // MARK: - Delegate - weak var delegate: NowPlayingViewControllerDelegate? - - // MARK: - UI - private let backgroundImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - }() - - private let backgroundBlurView: UIVisualEffectView = { - let blur = UIBlurEffect(style: .systemThickMaterialDark) - let view = UIVisualEffectView(effect: blur) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let backgroundDimView: UIView = { - let view = UIView() - view.backgroundColor = UIColor.black.withAlphaComponent(0.3) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let albumArtworkView = AlbumArtworkView() - private let controlsView = ControlsView() - private var topConstraint: NSLayoutConstraint? - - // MARK: - Popup Bar - private lazy var playPauseButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: UIImage(systemName: "play.fill"), style: .plain, target: self, action: #selector(popupBarPlayPauseTapped)) - return button - }() - - // MARK: - Properties - private let player = FRadioPlayer.shared - private let manager = StationsManager.shared - - // MARK: - Lifecycle Methods - - override func viewDidLoad() { - super.viewDidLoad() - - player.addObserver(self) - manager.addObserver(self) - - setupViews() - stationDidChange() - isPlayingDidChange(player.isPlaying) - controlsView.setLive(player.duration == 0) - - // Popup bar button items - popupItem.barButtonItems = [playPauseButton] - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - topConstraint?.constant = navigationController != nil ? 16 : 48 - } - - private func isPlayingDidChange(_ isPlaying: Bool) { - controlsView.setPlaying(isPlaying) - albumArtworkView.setPlaying(isPlaying) - updatePopupBarPlayPauseButton(isPlaying: isPlaying) - } - - func stationDidChange() { - albumArtworkView.setImage(nil) - updateBackground(with: nil) - manager.currentStation?.getImage { [weak self] image in - self?.albumArtworkView.setImage(image) - self?.updateBackground(with: image) - self?.updatePopupBarImage(image) - } - - updatePopupBarMetadata() - updateLabels() - controlsView.setLive(player.duration == 0) - } - - func updateLabels() { - controlsView.updateNowPlaying( - song: player.currentMetadata?.trackName, - artist: player.currentMetadata?.artistName, - stationName: manager.currentStation?.name, - stationDesc: manager.currentStation?.desc - ) - } - - func playbackStateDidChange(_ playbackState: FRadioPlayer.PlaybackState) { - isPlayingDidChange(player.isPlaying) - if playbackState == .playing, player.state == .loading { - albumArtworkView.setBuffering(true) - } - } - - func playerStateDidChange(_ state: FRadioPlayer.State) { - switch state { - case .loading where player.playbackState != .stopped: - albumArtworkView.setBuffering(true) - case .readyToPlay, .loadingFinished: - albumArtworkView.setBuffering(false) - playbackStateDidChange(player.playbackState) - default: - albumArtworkView.setBuffering(false) - } - } - - // MARK: - Setup Methods - - private func setupViews() { - // Dynamic blurred background - view.addSubview(backgroundImageView) - view.addSubview(backgroundBlurView) - view.addSubview(backgroundDimView) - - NSLayoutConstraint.activate([ - backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor), - backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - backgroundBlurView.topAnchor.constraint(equalTo: view.topAnchor), - backgroundBlurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - backgroundBlurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - backgroundBlurView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - backgroundDimView.topAnchor.constraint(equalTo: view.topAnchor), - backgroundDimView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - backgroundDimView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - backgroundDimView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - let mainStackView = UIStackView(arrangedSubviews: [albumArtworkView, controlsView]) - mainStackView.axis = .vertical - mainStackView.spacing = 24 - mainStackView.distribution = .fill - mainStackView.translatesAutoresizingMaskIntoConstraints = false - - controlsView.playingAction = { [unowned self] in - if player.isPlaying, player.duration == 0 { - player.stop() - } else { - player.togglePlaying() - } - } - - controlsView.nextAction = { [unowned self] in - manager.setNext() - } - - controlsView.previousAction = { [unowned self] in - manager.setPrevious() - } - - controlsView.moreAction = { [unowned self] in - handleMoreMenu() - } - - controlsView.timeAction = { [unowned self] slider, event in - handleTimeSlider(slider: slider, event: event) - } - - view.addSubview(mainStackView) - - topConstraint = mainStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8) - - let artworkHeight = albumArtworkView.heightAnchor.constraint(equalTo: mainStackView.heightAnchor, multiplier: 0.55) - artworkHeight.priority = .defaultHigh - - NSLayoutConstraint.activate([ - topConstraint!, - mainStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), - mainStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), - mainStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), - artworkHeight, - ]) - } - - // MARK: - Dynamic Background - - private func updateBackground(with image: UIImage?) { - UIView.transition( - with: backgroundImageView, - duration: 0.5, - options: .transitionCrossDissolve - ) { - self.backgroundImageView.image = image - } - } - - func updateTrackArtwork() { - getTrackArtwork { [weak self] image in - DispatchQueue.main.async { - self?.albumArtworkView.setImage(image, animated: true) - self?.updateBackground(with: image) - self?.updatePopupBarImage(image) - } - } - } - - private func getTrackArtwork(completion: @escaping (UIImage?) -> Void) { - guard let artworkURL = player.currentArtworkURL else { - manager.currentStation?.getImage { image in - completion(image) - } - return - } - - UIImage.image(from: artworkURL) { image in - completion(image) - } - } - - func handleMoreMenu() { - guard let station = manager.currentStation else { return } - let bottomSheet = BottomSheetViewController(station: station) - bottomSheet.delegate = self - present(bottomSheet, animated: true) - } - - private func handleTimeSlider(slider: UISlider, event: UIControl.Event) { - - guard player.duration != 0 else { return } - - let seekTime = TimeInterval(slider.value) * player.duration - - switch event { - case .valueChanged: - controlsView.setCurrentTime(seekTime) - controlsView.setTotalTime(player.duration - seekTime) - case .touchUpInside: - player.seek(to: seekTime) { [weak self] in - self?.controlsView.isSliderSliding = false - } - default: - break - } - } - - // MARK: - Popup Bar - - private func updatePopupBarMetadata() { - if let trackName = player.currentMetadata?.trackName { - let artistName = player.currentMetadata?.artistName - popupItem.title = [trackName, artistName].compactMap { $0 }.joined(separator: " — ") - popupItem.subtitle = manager.currentStation?.name - } else { - popupItem.title = manager.currentStation?.name - popupItem.subtitle = manager.currentStation?.desc - } - } - - private func updatePopupBarImage(_ image: UIImage?) { - popupItem.image = image - DispatchQueue.main.async { - self.popupPresentationContainer?.popupBar.imageView.contentMode = .scaleAspectFill - self.popupPresentationContainer?.popupBar.imageView.clipsToBounds = true - } - } - - private func updatePopupBarPlayPauseButton(isPlaying: Bool) { - let isLive = player.duration == 0 - let imageName = isPlaying ? (isLive ? "stop.fill" : "pause.fill") : "play.fill" - playPauseButton.image = UIImage(systemName: imageName) - } - - @objc private func popupBarPlayPauseTapped() { - if player.isPlaying, player.duration == 0 { - player.stop() - } else { - player.togglePlaying() - } - } -} - -extension NowPlayingViewController: FRadioPlayerObserver { - - func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) { - playerStateDidChange(state) - } - - func radioPlayer(_ player: FRadioPlayer, playbackStateDidChange state: FRadioPlayer.PlaybackState) { - playbackStateDidChange(state) - } - - func radioPlayer(_ player: FRadioPlayer, metadataDidChange metadata: FRadioPlayer.Metadata?) { - updateLabels() - updatePopupBarMetadata() - } - - func radioPlayer(_ player: FRadioPlayer, artworkDidChange artworkURL: URL?) { - updateTrackArtwork() - } - - func radioPlayer(_ player: FRadioPlayer, durationDidChange duration: TimeInterval) { - controlsView.setLive(player.duration == 0) - } - - func radioPlayer(_ player: FRadioPlayer, playTimeDidChange currentTime: TimeInterval, duration: TimeInterval) { - guard !controlsView.isSliderSliding, player.duration != 0 else { return } - - // Update timer labels - controlsView.setCurrentTime(currentTime) - controlsView.setTotalTime(duration - currentTime) - controlsView.setTimeSilder(value: Float(currentTime / duration)) - - // Update popup bar progress - popupItem.progress = Float(currentTime / duration) - } -} - -extension NowPlayingViewController: StationsManagerObserver { - - func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) { - stationDidChange() - } -} - -extension NowPlayingViewController: BottomSheetViewControllerDelegate { - - func bottomSheet(_ controller: BottomSheetViewController, didSelect option: BottomSheetViewController.Option) { - if case .share = option { - getTrackArtwork { [weak self] image in - guard let self = self else { return } - self.delegate?.didSelectBottomSheetOption(.share(image), from: self) - } - } else if case .openInMusic = option { - delegate?.didSelectBottomSheetOption(.openInMusic(manager.currentStation?.musicSearchURL), from: self) - } else { - delegate?.didSelectBottomSheetOption(option, from: self) - } - } -} diff --git a/SwiftRadio/ViewControllers/StationsViewController.swift b/SwiftRadio/ViewControllers/StationsViewController.swift deleted file mode 100755 index 7d184e351..000000000 --- a/SwiftRadio/ViewControllers/StationsViewController.swift +++ /dev/null @@ -1,322 +0,0 @@ -// -// StationsViewController.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2023-06-24. -// Copyright © 2023 matthewfecher.com. All rights reserved. -// - -import UIKit -import FRadioPlayer -import NVActivityIndicatorView - -protocol StationsViewControllerDelegate: AnyObject { - func didSelectStation(_ station: RadioStation, from stationsViewController: StationsViewController) - func didTapNowPlaying(_ stationsViewController: StationsViewController) - func presentAbout(_ stationsViewController: StationsViewController) -} - -class StationsViewController: BaseController, Handoffable { - - // MARK: - Delegate - weak var delegate: StationsViewControllerDelegate? - - // MARK: - Properties - private let player = FRadioPlayer.shared - private let manager = StationsManager.shared - - // MARK: - UI - - private lazy var refreshControl: UIRefreshControl = { - let refreshControl = UIRefreshControl() - refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) - return refreshControl - }() - - private let searchController: UISearchController = { - let controller = UISearchController(searchResultsController: nil) - controller.obscuresBackgroundDuringPresentation = false - controller.hidesNavigationBarDuringPresentation = true - return controller - }() - - private var isBuffering = false - - private let equalizerView: NVActivityIndicatorView = { - let view = NVActivityIndicatorView(frame: .zero, type: .audioEqualizer, color: Config.tintColor, padding: nil) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let bufferingView: NVActivityIndicatorView = { - let view = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: Config.tintColor, padding: nil) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private lazy var nowPlayingBarButton: UIBarButtonItem = { - let barButton = UIBarButtonItem(customView: nowPlayingIndicator) - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(nowPlayingBarButtonPressed)) - nowPlayingIndicator.addGestureRecognizer(tapGesture) - nowPlayingIndicator.isUserInteractionEnabled = true - return barButton - }() - - private lazy var nowPlayingIndicator: UIView = { - let container = UIView() - container.addSubview(equalizerView) - container.addSubview(bufferingView) - NSLayoutConstraint.activate([ - container.widthAnchor.constraint(equalToConstant: 30), - container.heightAnchor.constraint(equalToConstant: 20), - equalizerView.centerXAnchor.constraint(equalTo: container.centerXAnchor), - equalizerView.centerYAnchor.constraint(equalTo: container.centerYAnchor), - equalizerView.widthAnchor.constraint(equalTo: container.widthAnchor), - equalizerView.heightAnchor.constraint(equalTo: container.heightAnchor), - bufferingView.centerXAnchor.constraint(equalTo: container.centerXAnchor), - bufferingView.centerYAnchor.constraint(equalTo: container.centerYAnchor), - bufferingView.widthAnchor.constraint(equalTo: container.widthAnchor), - bufferingView.heightAnchor.constraint(equalTo: container.heightAnchor), - ]) - return container - }() - - private lazy var tableView: UITableView = { - let tableView = UITableView() - tableView.backgroundColor = .clear - tableView.backgroundView = nil - tableView.separatorStyle = .none - let cellNib = UINib(nibName: "NothingFoundCell", bundle: nil) - tableView.register(cellNib, forCellReuseIdentifier: "NothingFound") - tableView.register(StationTableViewCell.self) - tableView.dataSource = self - tableView.delegate = self - tableView.translatesAutoresizingMaskIntoConstraints = false - return tableView - }() - - override func loadView() { - super.loadView() - setupViews() - } - - override func viewDidLoad() { - super.viewDidLoad() - navigationController?.navigationBar.prefersLargeTitles = true - navigationItem.backButtonDisplayMode = .minimal - - // NavigationBar items - navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal"), style: .plain, target: self, action: #selector(handleMenuTap)) - - // Setup Player - player.addObserver(self) - manager.addObserver(self) - - // Setup Handoff User Activity - setupHandoffUserActivity() - - // Setup Search Bar - setupSearchController() - - // Set defaults station if the app started from CarPlay - updateNowPlayingBarButton(station: manager.currentStation) - updateHandoffUserActivity(userActivity, station: manager.currentStation) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - title = Content.Stations.title - } - - @objc func refresh(sender: AnyObject) { - // Pull to Refresh - manager.fetch() - - // Wait 2 seconds then refresh screen - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - self?.refreshControl.endRefreshing() - self?.view.setNeedsDisplay() - } - } - - private func updateNowPlayingBarButton(station: RadioStation?) { - guard station != nil else { - navigationItem.rightBarButtonItem = nil - return - } - updateNowPlayingAnimation() - } - - private func updateNowPlayingAnimation() { - if isBuffering { - equalizerView.stopAnimating() - bufferingView.startAnimating() - navigationItem.rightBarButtonItem = nowPlayingBarButton - } else if player.isPlaying { - bufferingView.stopAnimating() - equalizerView.startAnimating() - navigationItem.rightBarButtonItem = nowPlayingBarButton - } else { - equalizerView.stopAnimating() - bufferingView.stopAnimating() - navigationItem.rightBarButtonItem = nil - } - } - - private func updateVisibleCellsNowPlaying() { - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - self?.updateVisibleCellsNowPlaying() - } - return - } - - for case let cell as StationTableViewCell in tableView.visibleCells { - guard let indexPath = tableView.indexPath(for: cell) else { continue } - let station = searchController.isActive ? manager.searchedStations[indexPath.row] : manager.stations[indexPath.row] - let isCurrentStation = station == manager.currentStation - cell.setNowPlaying(isPlaying: player.isPlaying, isBuffering: isBuffering, isCurrentStation: isCurrentStation) - } - } - - @objc private func nowPlayingBarButtonPressed() { - delegate?.didTapNowPlaying(self) - } - - @objc func handleMenuTap() { - delegate?.presentAbout(self) - } - - private func selectStation(_ station: RadioStation) { - delegate?.didSelectStation(station, from: self) - } - - override func setupViews() { - super.setupViews() - - tableView.addSubview(refreshControl) - view.addSubview(tableView) - - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor) - ]) - } -} - -// MARK: - TableViewDataSource - -extension StationsViewController: UITableViewDataSource { - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 104.0 - } - - func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if searchController.isActive { - return manager.searchedStations.count - } else { - return manager.stations.isEmpty ? 1 : manager.stations.count - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - if manager.stations.isEmpty { - let cell = tableView.dequeueReusableCell(withIdentifier: "NothingFound", for: indexPath) - cell.backgroundColor = .clear - cell.selectionStyle = .none - if let label = cell.contentView.viewWithTag(100) as? UILabel { - label.text = Content.Stations.loadingMessage - } - return cell - } else { - let cell = tableView.dequeueReusableCell(for: indexPath) as StationTableViewCell - - let station = searchController.isActive ? manager.searchedStations[indexPath.row] : manager.stations[indexPath.row] - cell.configureStationCell(station: station) - let isCurrentStation = station == manager.currentStation - cell.setNowPlaying(isPlaying: player.isPlaying, isBuffering: isBuffering, isCurrentStation: isCurrentStation) - return cell - } - } -} - -// MARK: - TableViewDelegate - -extension StationsViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - let station = searchController.isActive ? manager.searchedStations[indexPath.item] : manager.stations[indexPath.item] - - selectStation(station) - } -} - -// MARK: - UISearchControllerDelegate / Setup - -extension StationsViewController: UISearchResultsUpdating { - - func setupSearchController() { - guard Config.searchable else { return } - - searchController.searchResultsUpdater = self - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = true - navigationItem.preferredSearchBarPlacement = .stacked - } - - func updateSearchResults(for searchController: UISearchController) { - guard let filter = searchController.searchBar.text else { return } - manager.updateSearch(with: filter) - tableView.reloadData() - } -} - -// MARK: - FRadioPlayerObserver - -extension StationsViewController: FRadioPlayerObserver { - - func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) { - switch state { - case .loading where player.playbackState != .stopped: - isBuffering = true - default: - isBuffering = false - } - updateNowPlayingAnimation() - updateVisibleCellsNowPlaying() - } - - func radioPlayer(_ player: FRadioPlayer, playbackStateDidChange state: FRadioPlayer.PlaybackState) { - if state == .playing, player.state == .loading { - isBuffering = true - } - updateNowPlayingAnimation() - updateVisibleCellsNowPlaying() - } - - func radioPlayer(_ player: FRadioPlayer, metadataDidChange metadata: FRadioPlayer.Metadata?) { - updateHandoffUserActivity(userActivity, station: manager.currentStation) - } -} - -extension StationsViewController: StationsManagerObserver { - - func stationsManager(_ manager: StationsManager, stationsDidUpdate stations: [RadioStation]) { - tableView.reloadData() - } - - func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) { - updateVisibleCellsNowPlaying() - updateNowPlayingBarButton(station: station) - } -} diff --git a/SwiftRadio/Views/AlbumArtworkView.swift b/SwiftRadio/Views/AlbumArtworkView.swift deleted file mode 100755 index 07225f09d..000000000 --- a/SwiftRadio/Views/AlbumArtworkView.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// AlbumArtworkView.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2024-01-13. -// Copyright 2024 matthewfecher.com. All rights reserved. -// - -import UIKit -import NVActivityIndicatorView - -class AlbumArtworkView: UIView { - - private let artworkCornerRadius: CGFloat = 20 - - private let containerView: UIView = { - let view = UIView() - view.clipsToBounds = true - return view - }() - - private let imageView: UIImageView = { - let view = UIImageView() - view.contentMode = .scaleAspectFill - view.clipsToBounds = true - return view - }() - - private let bufferingOverlay: UIView = { - let view = UIView() - view.backgroundColor = UIColor.black.withAlphaComponent(0.4) - view.alpha = 0 - return view - }() - - private let bufferingIndicator: NVActivityIndicatorView = { - let view = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: .white, padding: nil) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - override init(frame: CGRect) { - super.init(frame: frame) - setupViews() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setImage(_ image: UIImage?, animated: Bool = false) { - guard animated else { - imageView.image = image - return - } - UIView.transition(with: imageView, duration: 0.3, options: .transitionCrossDissolve) { - self.imageView.image = image - } - } - - func setBuffering(_ isBuffering: Bool) { - if isBuffering { - bufferingIndicator.startAnimating() - UIView.animate(withDuration: 0.3) { self.bufferingOverlay.alpha = 1 } - } else { - UIView.animate(withDuration: 0.3) { self.bufferingOverlay.alpha = 0 } - bufferingIndicator.stopAnimating() - } - } - - func setPlaying(_ isPlaying: Bool) { - let scale: CGFloat = isPlaying ? 1.0 : 0.85 - UIView.animate( - withDuration: 0.5, - delay: 0, - usingSpringWithDamping: 0.7, - initialSpringVelocity: 0, - options: .allowUserInteraction - ) { - self.containerView.transform = CGAffineTransform(scaleX: scale, y: scale) - } - } - - private func setupViews() { - // Shadow on outer view - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.4 - layer.shadowOffset = CGSize(width: 0, height: 10) - layer.shadowRadius = 20 - - containerView.layer.cornerRadius = artworkCornerRadius - containerView.translatesAutoresizingMaskIntoConstraints = false - - imageView.translatesAutoresizingMaskIntoConstraints = false - bufferingOverlay.translatesAutoresizingMaskIntoConstraints = false - - containerView.addSubview(imageView) - containerView.addSubview(bufferingOverlay) - bufferingOverlay.addSubview(bufferingIndicator) - addSubview(containerView) - - NSLayoutConstraint.activate([ - // Container fills the view - containerView.topAnchor.constraint(equalTo: topAnchor), - containerView.leadingAnchor.constraint(equalTo: leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: bottomAnchor), - - // Keep container square - containerView.widthAnchor.constraint(equalTo: containerView.heightAnchor), - - // Image fills container - imageView.topAnchor.constraint(equalTo: containerView.topAnchor), - imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - - // Buffering overlay fills container - bufferingOverlay.topAnchor.constraint(equalTo: containerView.topAnchor), - bufferingOverlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - bufferingOverlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - bufferingOverlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - bufferingIndicator.centerXAnchor.constraint(equalTo: bufferingOverlay.centerXAnchor), - bufferingIndicator.centerYAnchor.constraint(equalTo: bufferingOverlay.centerYAnchor), - bufferingIndicator.widthAnchor.constraint(equalToConstant: 40), - bufferingIndicator.heightAnchor.constraint(equalToConstant: 30), - ]) - } -} diff --git a/SwiftRadio/Views/ControlsView.swift b/SwiftRadio/Views/ControlsView.swift deleted file mode 100755 index e56411b43..000000000 --- a/SwiftRadio/Views/ControlsView.swift +++ /dev/null @@ -1,411 +0,0 @@ -// -// ControlsView.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2024-01-14. -// Copyright © 2024 matthewfecher.com. All rights reserved. -// - -import UIKit -import AVKit -import MarqueeLabel - -class ControlsView: UIView { - - var timeAction: ((UISlider, UIControl.Event) -> Void)? - - var playingAction: (() -> Void)? - var nextAction: (() -> Void)? - var previousAction: (() -> Void)? - - var moreAction: (() -> Void)? - - var isSliderSliding = false - - private var isLive = true - - // Row 1: song — artist (or station name when no metadata) - private let titleLabel: MarqueeLabel = { - let label = MarqueeLabel(frame: .zero, rate: 30, fadeLength: 10) - label.font = UIFont.preferredFont(forTextStyle: .title2).bold() - label.textAlignment = .center - label.setContentCompressionResistancePriority(.required, for: .vertical) - label.trailingBuffer = 30 - return label - }() - - // Row 2: station name (or station desc when no metadata) - private let subtitleLabel: UILabel = { - let label = UILabel() - label.font = UIFont.preferredFont(forTextStyle: .title3) - label.textAlignment = .center - label.setContentCompressionResistancePriority(.required, for: .vertical) - label.alpha = 0.7 - return label - }() - - private let timeSlider: ThinSlider = { - let slider = ThinSlider() - slider.value = 0.0 - slider.translatesAutoresizingMaskIntoConstraints = false - slider.minimumTrackTintColor = Config.tintColor - slider.maximumTrackTintColor = Config.tintColor.withAlphaComponent(0.3) - return slider - }() - - private let currentTimeLabel: UILabel = { - let label = UILabel() - label.text = "00:00" - label.font = UIFont.systemFont(ofSize: 10, weight: .medium) - label.setContentCompressionResistancePriority(.required, for: .vertical) - label.setContentCompressionResistancePriority(.required, for: .horizontal) - label.textAlignment = .left - label.alpha = 0.8 - return label - }() - - private let totalTimeLabel: UILabel = { - let label = UILabel() - label.text = "00:00" - label.font = UIFont.systemFont(ofSize: 10, weight: .medium) - label.setContentCompressionResistancePriority(.required, for: .vertical) - label.setContentCompressionResistancePriority(.required, for: .horizontal) - label.textAlignment = .right - label.alpha = 0.8 - return label - }() - - private let liveBadge: UIView = { - let container = UIView() - container.isHidden = true - - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterialDark)) - blur.layer.cornerRadius = 6 - blur.clipsToBounds = true - blur.translatesAutoresizingMaskIntoConstraints = false - - let label = UILabel() - label.text = Content.Player.liveBadge - label.font = UIFont.systemFont(ofSize: 11, weight: .semibold) - label.textColor = .white.withAlphaComponent(0.9) - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - - blur.contentView.addSubview(label) - container.addSubview(blur) - - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: blur.contentView.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.contentView.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.contentView.leadingAnchor, constant: 12), - label.trailingAnchor.constraint(equalTo: blur.contentView.trailingAnchor, constant: -12), - blur.centerXAnchor.constraint(equalTo: container.centerXAnchor), - blur.centerYAnchor.constraint(equalTo: container.centerYAnchor), - ]) - - return container - }() - - private let playPauseButton: UIButton = { - let button = UIButton() - let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .regular) - button.setImage(UIImage(systemName: "play.circle.fill", withConfiguration: config), for: .normal) - button.setImage(UIImage(systemName: "stop.circle.fill", withConfiguration: config), for: .selected) - button.tintColor = Config.tintColor - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - private let nextButton: UIButton = { - let button = UIButton() - let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .bold) - button.setImage(UIImage(systemName: "forward.fill", withConfiguration: config), for: .normal) - button.tintColor = Config.tintColor.withAlphaComponent(0.7) - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - private let previousButton: UIButton = { - let button = UIButton() - let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .bold) - button.setImage(UIImage(systemName: "backward.fill", withConfiguration: config), for: .normal) - button.tintColor = Config.tintColor.withAlphaComponent(0.7) - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - private let airPlayButton: AVRoutePickerView = { - let button = AVRoutePickerView() - button.activeTintColor = Config.tintColor - button.tintColor = Config.tintColor.withAlphaComponent(0.7) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: 44), - button.heightAnchor.constraint(equalToConstant: 44), - ]) - return button - }() - - private let moreButton: UIButton = { - let button = UIButton() - button.setImage(UIImage(systemName: "list.dash"), for: .normal) - button.tintColor = Config.tintColor.withAlphaComponent(0.7) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: 44), - button.heightAnchor.constraint(equalToConstant: 44), - ]) - return button - }() - - override init(frame: CGRect) { - super.init(frame: frame) - setupViews() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setPlaying(_ isPlaying: Bool) { - playPauseButton.isSelected = isPlaying - } - - func setLive(_ isLive: Bool) { - self.isLive = isLive - timeSlider.isEnabled = !isLive - timeSlider.showThumb = !isLive - currentTimeLabel.isHidden = isLive - totalTimeLabel.isHidden = isLive - liveBadge.isHidden = !isLive - updatePlayPauseImages() - } - - func setCurrentTime(_ secounds: TimeInterval) { - currentTimeLabel.text = formatSecondsToString(secounds) - } - - func setTotalTime(_ secounds: TimeInterval) { - totalTimeLabel.text = "-" + formatSecondsToString(secounds) - } - - func setTimeSilder(value: Float) { - timeSlider.value = value - } - - /// Update the two-row labels, mirroring the popup bar layout. - /// - When metadata exists: row 1 = "Song — Artist", row 2 = station name - /// - When no metadata: row 1 = station name, row 2 = station description - func updateNowPlaying(song: String?, artist: String?, stationName: String?, stationDesc: String?) { - if let song { - titleLabel.text = [song, artist].compactMap { $0 }.joined(separator: " — ") - subtitleLabel.text = stationName - } else { - titleLabel.text = stationName - subtitleLabel.text = stationDesc - } - } - - // MARK: - Private - - private func updatePlayPauseImages() { - let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .regular) - playPauseButton.setImage(UIImage(systemName: "play.circle.fill", withConfiguration: config), for: .normal) - let selectedName = isLive ? "stop.circle.fill" : "pause.circle.fill" - playPauseButton.setImage(UIImage(systemName: selectedName, withConfiguration: config), for: .selected) - } - - private func formatSecondsToString(_ secounds: TimeInterval) -> String { - guard secounds != 0 else { return "00:00" } - let min = Int(secounds / 60) - let sec = Int(secounds.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", min, sec) - } - - private func setupViews() { - let mainStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, sliderContainerView, buttonsStackView, menuStackView]) - mainStackView.axis = .vertical - mainStackView.spacing = 8 - mainStackView.alignment = .fill - - mainStackView.setCustomSpacing(4, after: titleLabel) - mainStackView.setCustomSpacing(20, after: subtitleLabel) - - mainStackView.translatesAutoresizingMaskIntoConstraints = false - - addSubview(mainStackView) - - NSLayoutConstraint.activate([ - mainStackView.topAnchor.constraint(equalTo: topAnchor), - mainStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - mainStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - mainStackView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - } - - // MARK: - StackViews - - /// Slider with LIVE badge overlaid above it, and time labels below. - private var sliderContainerView: UIView { - let spacer1 = UIView() - spacer1.setContentHuggingPriority(.defaultLow, for: .horizontal) - let spacer2 = UIView() - spacer2.setContentHuggingPriority(.defaultLow, for: .horizontal) - - let timeLabelsStackView = UIStackView(arrangedSubviews: [currentTimeLabel, spacer1, spacer2, totalTimeLabel]) - timeLabelsStackView.axis = .horizontal - timeLabelsStackView.distribution = .fillEqually - timeLabelsStackView.alignment = .fill - - let vStackView = UIStackView(arrangedSubviews: [timeSlider, timeLabelsStackView]) - vStackView.axis = .vertical - vStackView.distribution = .fill - vStackView.alignment = .fill - vStackView.spacing = 4 - - // Wrap in a container so we can overlay the LIVE badge - let container = UIView() - vStackView.translatesAutoresizingMaskIntoConstraints = false - liveBadge.translatesAutoresizingMaskIntoConstraints = false - - container.addSubview(vStackView) - container.addSubview(liveBadge) - - NSLayoutConstraint.activate([ - vStackView.topAnchor.constraint(equalTo: container.topAnchor), - vStackView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - vStackView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - vStackView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - - liveBadge.centerXAnchor.constraint(equalTo: container.centerXAnchor), - liveBadge.centerYAnchor.constraint(equalTo: timeSlider.centerYAnchor), - ]) - - return container - } - - private var buttonsStackView: UIStackView { - let hStackView = UIStackView(arrangedSubviews: [previousButton, playPauseButton, nextButton]) - hStackView.axis = .horizontal - hStackView.spacing = 40 - hStackView.alignment = .center - - playPauseButton.addTarget(self, action: #selector(playingPressed), for: .touchUpInside) - nextButton.addTarget(self, action: #selector(nextPressed), for: .touchUpInside) - previousButton.addTarget(self, action: #selector(previousPressed), for: .touchUpInside) - - nextButton.isHidden = Config.hideNextPreviousButtons - previousButton.isHidden = Config.hideNextPreviousButtons - - let vStackView = UIStackView(arrangedSubviews: [hStackView]) - vStackView.axis = .vertical - vStackView.distribution = .fill - vStackView.alignment = .center - - return vStackView - } - - private var menuStackView: UIStackView { - let stackView = UIStackView(arrangedSubviews: [airPlayButton, moreButton]) - stackView.axis = .horizontal - stackView.spacing = 40 - stackView.alignment = .center - stackView.distribution = .fill - - moreButton.addTarget(self, action: #selector(morePressed), for: .touchUpInside) - timeSlider.addTarget(self, action: #selector(timeSliderTouchBegan(sender:)), for: .touchDown) - timeSlider.addTarget(self, action: #selector(timeSliderValueChanged(sender:)), for: .valueChanged) - timeSlider.addTarget(self, action: #selector(timeSliderTouchEnded(sender:)), for: [.touchUpInside, .touchCancel, .touchUpOutside]) - - let containerStack = UIStackView(arrangedSubviews: [stackView]) - containerStack.axis = .vertical - containerStack.alignment = .center - - return containerStack - } - - // MARK: - Actions - - @objc private func playingPressed(_ sender: Any) { - playingAction?() - } - - @objc private func nextPressed(_ sender: Any) { - nextAction?() - } - - @objc private func previousPressed(_ sender: Any) { - previousAction?() - } - - @objc private func morePressed(_ sender: Any) { - moreAction?() - } - - @objc private func timeSliderTouchBegan(sender: UISlider) { - isSliderSliding = true - timeAction?(sender, .touchDown) - } - - @objc private func timeSliderValueChanged(sender: UISlider) { - timeAction?(sender, .valueChanged) - } - - @objc private func timeSliderTouchEnded(sender: UISlider) { - timeAction?(sender, .touchUpInside) - } -} - -// MARK: - UIFont Extension - -private extension UIFont { - func bold() -> UIFont { - guard let descriptor = fontDescriptor.withSymbolicTraits(.traitBold) else { return self } - return UIFont(descriptor: descriptor, size: 0) - } -} - -// MARK: - ThinSlider - -private class ThinSlider: UISlider { - - private let trackHeight: CGFloat = 2 - private let normalThumbSize: CGFloat = 10 - private let highlightedThumbSize: CGFloat = 16 - - var showThumb: Bool = false { - didSet { updateThumbImages() } - } - - override init(frame: CGRect) { - super.init(frame: frame) - updateThumbImages() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func trackRect(forBounds bounds: CGRect) -> CGRect { - let center = bounds.midY - return CGRect(x: bounds.minX, y: center - trackHeight / 2, width: bounds.width, height: trackHeight) - } - - private func updateThumbImages() { - if showThumb { - setThumbImage(makeThumbImage(size: normalThumbSize), for: .normal) - setThumbImage(makeThumbImage(size: highlightedThumbSize), for: .highlighted) - } else { - setThumbImage(UIImage(), for: .normal) - setThumbImage(UIImage(), for: .highlighted) - } - } - - private func makeThumbImage(size: CGFloat) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) - return renderer.image { context in - Config.tintColor.setFill() - context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) - } - } -} diff --git a/SwiftRadio/Views/GradientBackgroundView.swift b/SwiftRadio/Views/GradientBackgroundView.swift deleted file mode 100644 index a9a7debf2..000000000 --- a/SwiftRadio/Views/GradientBackgroundView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// GradientBackgroundView.swift -// SwiftRadio -// -// Created by Fethi El Hassasna on 2025-02-07. -// Copyright © 2025 matthewfecher.com. All rights reserved. -// - -import UIKit - -class GradientBackgroundView: UIView { - - private let gradientLayer: CAGradientLayer = { - let layer = CAGradientLayer() - layer.startPoint = CGPoint(x: 0, y: 1) - layer.endPoint = CGPoint(x: 1, y: 0) - return layer - }() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .black - updateGradientColors() - layer.addSublayer(gradientLayer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - gradientLayer.frame = bounds - } - - private func updateGradientColors() { - let color = Config.gradientColor - gradientLayer.colors = [ - color.withAlphaComponent(0.3).cgColor, - color.withAlphaComponent(0.15).cgColor, - color.withAlphaComponent(0.05).cgColor, - UIColor.clear.cgColor - ] - gradientLayer.locations = [0.0, 0.3, 0.6, 1.0] - } -} diff --git a/SwiftRadio/Views/LogoShareView.swift b/SwiftRadio/Views/LogoShareView.swift deleted file mode 100755 index a0e6666b8..000000000 --- a/SwiftRadio/Views/LogoShareView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// LogoShareView.swift -// SwiftRadio -// -// Created by Cameron Mcleod on 2019-07-12. -// Copyright © 2019 matthewfecher.com. All rights reserved. -// - -import UIKit - -class LogoShareView: UIView { - - @IBOutlet weak var albumArtImageView: UIImageView! - @IBOutlet weak var radioShoutoutLabel: UILabel! - @IBOutlet weak var trackTitleLabel: UILabel! - @IBOutlet weak var trackArtistLabel: UILabel! - @IBOutlet weak var logoImageView: UIImageView! - - class func instanceFromNib() -> LogoShareView { - return UINib(nibName: "LogoShareView", bundle: nil).instantiate(withOwner: nil, options: nil)[0] as! LogoShareView - } - - func shareSetup(albumArt : UIImage, radioShoutout: String, trackTitle: String, trackArtist: String) { - self.albumArtImageView.image = albumArt - self.radioShoutoutLabel.text = radioShoutout - self.trackTitleLabel.text = trackTitle - self.trackArtistLabel.text = trackArtist - self.logoImageView.image = UIImage(named: "logo") - } -} diff --git a/SwiftRadio/Views/LogoShareView.xib b/SwiftRadio/Views/LogoShareView.xib deleted file mode 100755 index 1394f9fcb..000000000 --- a/SwiftRadio/Views/LogoShareView.xib +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftRadioUITests/SwiftRadioUITests.swift b/SwiftRadioUITests/SwiftRadioUITests.swift index 7344418a0..ccf61e5a6 100755 --- a/SwiftRadioUITests/SwiftRadioUITests.swift +++ b/SwiftRadioUITests/SwiftRadioUITests.swift @@ -8,16 +8,381 @@ import XCTest -class SwiftRadioUITests: XCTestCase { - - override func setUp() { - super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - super.tearDown() - } +/// Exercises the real SwiftUI shell and the streaming adapter on the iOS 27 simulator. +@MainActor final class SwiftRadioUITests: XCTestCase { + + /// Cheap smoke test for CI: the bundled catalog renders under a large title and nothing plays yet. + func testStationsListRenders() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launch() + + let station = app.staticTexts["Absolute Country Hits"].firstMatch + XCTAssertTrue(station.waitForExistence(timeout: 30)) + XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 6) + + let navigationBar = app.navigationBars["Swift Radio"] + XCTAssertTrue(navigationBar.exists) + // A large title is laid out below the bar buttons, so it is taller than a compact one. + let title = navigationBar.staticTexts["Swift Radio"].firstMatch + XCTAssertTrue(title.exists) + XCTAssertGreaterThan(title.frame.height, 30) + + XCTAssertFalse(app.otherElements["popupBar"].exists, "The bar appears only after a selection") + capture("stations-list", app: app) + } + + /// Full shell flow: select a station, open the popup, close it, then pull to refresh. + func testPopupNavigationAndRefresh() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launch() + + let station = app.staticTexts["Absolute Country Hits"].firstMatch + XCTAssertTrue(station.waitForExistence(timeout: 30)) + XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 6) + XCTAssertTrue(app.navigationBars["Swift Radio"].exists) + XCTAssertFalse(app.buttons["playbackToggle"].exists) + capture("list", app: app) + + // Selecting a station raises the popup bar and starts the live stream. + station.tap() + let toggle = app.buttons["playbackToggle"].firstMatch + XCTAssertTrue(toggle.waitForExistence(timeout: 30)) + let playingLive = NSPredicate(format: "label == %@", "Stop") + expectation(for: playingLive, evaluatedWith: toggle) + waitForExpectations(timeout: 60) + capture("popup-bar", app: app) + + // Open the popup from the bar's leading half, clear of the transport button. + let bar = app.otherElements["popupBar"].firstMatch + XCTAssertTrue(bar.waitForExistence(timeout: 10)) + bar.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.5)).tap() + // The adaptive player exposes a ScrollView; don't couple the contract to XCUI's + // element classification of the container (the old fixed ZStack was an Other). + let content = app.descendants(matching: .any)["nowPlayingContent"].firstMatch + XCTAssertTrue(content.waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["playerTransport"].isHittable) + capture("popup-open", app: app) + + // Drag the artwork down past the dismissal threshold to close the popup. + content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + .press(forDuration: 0.1, + thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))) + XCTAssertTrue(app.navigationBars["Swift Radio"].waitForExistence(timeout: 10)) + XCTAssertTrue(station.waitForExistence(timeout: 10)) + XCTAssertTrue(toggle.waitForExistence(timeout: 10), "Closing the popup restores the bar") + capture("popup-closed", app: app) + + // Pull to refresh reloads the catalog without tearing down the popup bar. + app.collectionViews.firstMatch.swipeDown(velocity: .slow) + XCTAssertTrue(station.waitForExistence(timeout: 15)) + XCTAssertEqual(app.collectionViews.firstMatch.cells.count, 6) + XCTAssertTrue(toggle.exists, "Refresh must keep the popup/navigation shell mounted") + capture("after-refresh", app: app) + } + + private func capture(_ name: String, app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + /// Real UI + deterministic engine: transitions are independent of stream availability. + func testStopResumeMarqueeAndReopen() throws { + continueAfterFailure = false + let app = fixtureApp() + app.staticTexts["Absolute Country Hits"].firstMatch.tap() + let toggle = app.buttons["playbackToggle"].firstMatch + assertLabel("Stop", on: toggle) + let indicator = app.buttons["showNowPlaying"].firstMatch + XCTAssertTrue(indicator.waitForExistence(timeout: 10)) + assertMoves(indicator, "The toolbar equalizer animates while playing") + capture("live-playing-indicators", app: app) + toggle.tap() + assertLabel("Play", on: toggle) + assertGone(indicator, "The equalizer leaves the toolbar when playback stops") + capture("live-stopped-indicators", app: app) + toggle.tap() + assertLabel("Stop", on: toggle) + openPopup(app) + let title = app.staticTexts["nowPlayingTitle"].firstMatch + XCTAssertTrue(title.waitForExistence(timeout: 10)) + // The fixture title is longer than the screen, so a working marquee must move. + XCTAssertTrue(title.label.contains("deliberately long")) + assertMoves(title, "The long title scrolls") + app.buttons["playerTransport"].tap() + assertLabel("Play", on: app.buttons["playerTransport"]) + closePopup(app) + assertGone(indicator, "No equalizer while stopped") + openPopup(app) + XCTAssertTrue(title.waitForExistence(timeout: 10)) + assertMoves(title, "The marquee restarts when the popup reopens") + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(title.waitForExistence(timeout: 10)) + assertMoves(title, "The marquee restarts after returning to the foreground") + capture("marquee-foreground", app: app) + } + + func testFilePauseSeekAndShareReturnToPlayer() throws { + continueAfterFailure = false + let app = fixtureApp() + let station = app.staticTexts["MP3 file sample"].firstMatch + if !station.isHittable { app.collectionViews.firstMatch.swipeUp() } + station.tap() + let toggle = app.buttons["playbackToggle"].firstMatch + assertLabel("Pause", on: toggle) + toggle.tap() + assertLabel("Play", on: toggle) + assertGone(app.buttons["showNowPlaying"].firstMatch, "The equalizer leaves the toolbar when paused") + capture("file-paused-indicators", app: app) + openPopup(app) + let slider = app.sliders["playbackSeek"] + XCTAssertTrue(slider.waitForExistence(timeout: 10)) + slider.adjust(toNormalizedSliderPosition: 0.5) + assertLabel("Pause", on: app.buttons["playerTransport"]) + let elapsed = app.staticTexts["playbackElapsed"].label.split(separator: ":").compactMap { Int($0) } + XCTAssertEqual(elapsed.count, 2) + let seconds = elapsed.count == 2 ? elapsed[0] * 60 + elapsed[1] : -1 + // XCUI's normalized drag is approximate; exact engine seek values are core-tested. + XCTAssertGreaterThanOrEqual(seconds, 75) + XCTAssertLessThanOrEqual(seconds, 105) + capture("file-seek-completed", app: app) + // Repeat cancellation: the second presentation must not reuse stale sheet state. + for iteration in 1...2 { + app.buttons["playerOptions"].tap() + let share = app.buttons["Share Now Playing"].firstMatch + XCTAssertTrue(share.waitForExistence(timeout: 10)) + share.tap() + let activity = app.otherElements["ActivityListView"].firstMatch + XCTAssertTrue(activity.waitForExistence(timeout: 10)) + capture("share-presented-\(iteration)", app: app) + let close = shareSheetCloseButton(app) + XCTAssertTrue(close.exists) + close.tap() + // The share sheet's dismissal animation keeps the player covered for a moment after the + // button already exists; wait for it to become hittable rather than asserting at once. + let options = app.buttons["playerOptions"].firstMatch + let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: options) + XCTAssertEqual(XCTWaiter.wait(for: [hittable], timeout: 10), .completed, "Options button is back after cancel") + XCTAssertFalse(app.buttons["Share Now Playing"].exists, "Cancel returns to player, not Options") + capture("share-returned-\(iteration)", app: app) + } + } + + func testLibraryLinkFeedbackAndReturn() throws { + continueAfterFailure = false + let app = fixtureApp() + app.buttons["About Swift Radio"].firstMatch.tap() + let libraries = app.buttons["Open Source Libraries"].firstMatch + for _ in 0..<3 { + if libraries.isHittable { break } + app.collectionViews.firstMatch.swipeUp() + } + XCTAssertTrue(libraries.isHittable) + libraries.tap() + let library = app.buttons.containing(.staticText, identifier: "FRadioPlayer").firstMatch + XCTAssertTrue(library.waitForExistence(timeout: 15)) + capture("library-disclosures", app: app) + library.tap() + let done = app.buttons.matching(NSPredicate(format: "label IN %@", ["Done", "Close"])).firstMatch + XCTAssertTrue(done.waitForExistence(timeout: 15)) + capture("library-safari", app: app) + done.tap() + XCTAssertTrue(library.waitForExistence(timeout: 10)) + XCTAssertTrue(library.isHittable) + } + + private func fixtureApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["--ui-test-playback"] + app.launch() + XCTAssertTrue(app.staticTexts["Absolute Country Hits"].firstMatch.waitForExistence(timeout: 30)) + return app + } + + func testReselectionPreviousNextAndWebsiteHandoff() throws { + continueAfterFailure = false + let app = fixtureApp() + let station = app.staticTexts["Absolute Country Hits"].firstMatch + // A held row dragged into scrolling must cancel, not start a station. + station.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).press(forDuration: 0.3, + thenDragTo: app.collectionViews.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))) + // The fixture starts playback about 0.7 s after a selection, so wait past that. + assertStaysAbsent(app.buttons["playbackToggle"].firstMatch, for: 1.5) + app.collectionViews.firstMatch.swipeDown() + XCTAssertTrue(station.isHittable) + station.tap() + let toggle = app.buttons["playbackToggle"].firstMatch + assertLabel("Stop", on: toggle) + toggle.tap() + assertLabel("Play", on: toggle) + station.tap() // Selecting the stopped current row resumes, without opening the popup. + assertLabel("Stop", on: toggle) + XCTAssertFalse(app.buttons["playerTransport"].exists) + station.tap() // Selecting the playing current row opens the popup. + let transport = app.buttons["playerTransport"] + XCTAssertTrue(transport.waitForExistence(timeout: 10)) + app.buttons["Previous station"].tap() // First wraps to the file sample. + assertLabel("Pause", on: transport) + XCTAssertTrue(app.sliders["playbackSeek"].waitForExistence(timeout: 5)) + capture("previous-wraps-to-file", app: app) + app.buttons["Next station"].tap() + assertLabel("Stop", on: transport) + assertGone(app.sliders["playbackSeek"].firstMatch, "Live streams have no scrubber") + capture("next-wraps-to-live", app: app) + app.buttons["playerOptions"].tap() + let website = app.buttons["Station Website"].firstMatch + XCTAssertTrue(website.waitForExistence(timeout: 10)) + website.tap() + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Done", "Close"])).firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 15)) + capture("options-website-handoff", app: app) + close.tap() + XCTAssertTrue(app.navigationBars["Swift Radio"].waitForExistence(timeout: 10)) + XCTAssertTrue(toggle.isHittable) + assertGone(transport, "Closing the website returns to the list, not the player") + } + + /// Motion is asserted by comparing element screenshots over time. Polling instead of one fixed + /// delay keeps the test fast when the animation is running and bounded when it is not. + private func assertMoves(_ element: XCUIElement, _ message: String, within timeout: TimeInterval = 5, + file: StaticString = #filePath, line: UInt = #line) { + // Let presentation transitions and artwork crossfades finish so they are not read as motion. + RunLoop.current.run(until: Date().addingTimeInterval(0.8)) + let first = element.screenshot().pngRepresentation + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + if element.screenshot().pngRepresentation != first { return } + } + XCTFail(message, file: file, line: line) + } + + /// Waits for removal instead of checking right after a state change, which races the toolbar + /// and popup transitions. + private func assertGone(_ element: XCUIElement, _ message: String, + file: StaticString = #filePath, line: UInt = #line) { + let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [gone], timeout: 10), .completed, message, file: file, line: line) + } + + private func assertStaysAbsent(_ element: XCUIElement, for duration: TimeInterval, + file: StaticString = #filePath, line: UInt = #line) { + let appears = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true"), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [appears], timeout: duration), .timedOut, file: file, line: line) + } + + /// Recent iOS releases identify the share sheet's close button as `header.closeButton`; older + /// ones only label it, and the label has changed between releases. + private func shareSheetCloseButton(_ app: XCUIApplication) -> XCUIElement { + let byIdentifier = app.buttons["header.closeButton"].firstMatch + if byIdentifier.waitForExistence(timeout: 5) { return byIdentifier } + let byLabel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Cancel"])) + // The sheet is the frontmost presentation, so its button comes last. + return byLabel.element(boundBy: max(0, byLabel.count - 1)) + } + + private func assertLabel(_ label: String, on element: XCUIElement) { + let expected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", label), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [expected], timeout: 15), .completed) + } + + private func openPopup(_ app: XCUIApplication) { + let bar = app.otherElements["popupBar"].firstMatch + XCTAssertTrue(bar.waitForExistence(timeout: 10)) + bar.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.5)).tap() + XCTAssertTrue(app.buttons["playerTransport"].waitForExistence(timeout: 10)) + } + + private func closePopup(_ app: XCUIApplication) { + let content = app.descendants(matching: .any)["nowPlayingContent"].firstMatch + content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + .press(forDuration: 0.1, + thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))) + XCTAssertTrue(app.navigationBars["Swift Radio"].waitForExistence(timeout: 10)) + } + + /// Layout/navigation do not need a remote stream to become ready. Keep this regression + /// separate from the live-playback integration test so a station outage cannot mask it. + func testPlayerControlsFitAndOptionsNavigate() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launch() + let station = app.staticTexts["MP3 file sample"].firstMatch + XCTAssertTrue(station.waitForExistence(timeout: 30)) + if !station.isHittable { app.collectionViews.firstMatch.swipeUp() } + station.tap() + let indicator = app.buttons["showNowPlaying"] + XCTAssertTrue(indicator.waitForExistence(timeout: 10)) + XCTAssertTrue(app.otherElements["popupBar"].firstMatch.waitForExistence(timeout: 10)) + capture("popup-bar-collapsed", app: app) + indicator.tap() + let transport = app.buttons["playerTransport"] + let options = app.buttons["playerOptions"] + XCTAssertTrue(transport.waitForExistence(timeout: 10)) + XCTAssertTrue(transport.isHittable) + XCTAssertTrue(options.isHittable, "Options must not overflow the popup's screen bounds") + XCTAssertTrue(app.frame.contains(transport.frame)) + XCTAssertTrue(app.frame.contains(options.frame)) + capture("player-controls-visible", app: app) + options.tap() + let info = app.buttons["About Station"].firstMatch + XCTAssertTrue(info.waitForExistence(timeout: 10)) + // The sheet's height is measured from its content; the capture shows whether it clips or gaps. + let share = app.buttons["Share Now Playing"].firstMatch + let sheetSettled = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isHittable == true"), object: info) + let shareVisible = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isHittable == true"), object: share) + XCTAssertEqual(XCTWaiter.wait(for: [sheetSettled, shareVisible], timeout: 10), .completed) + XCTAssertTrue(app.frame.contains(share.frame), "The last option must not be clipped") + capture("options-sheet", app: app) + info.tap() + XCTAssertTrue(app.navigationBars["About Station"].waitForExistence(timeout: 10), + "The station push must run after options and popup have dismissed") + let website = app.buttons["stationWebsite"].firstMatch + XCTAssertTrue(website.waitForExistence(timeout: 10)) + let websiteIsReady = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isHittable == true"), object: website) + XCTAssertEqual(XCTWaiter.wait(for: [websiteIsReady], timeout: 10), .completed) + capture("station-info-from-options", app: app) + } + + func testAboutFeaturesNavigationAndDismissal() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launch() + let about = app.buttons["About Swift Radio"].firstMatch + XCTAssertTrue(about.waitForExistence(timeout: 30)) + about.tap() + let introduction = app.staticTexts["aboutIntroduction"] + XCTAssertTrue(introduction.waitForExistence(timeout: 10)) + XCTAssertTrue(app.frame.contains(introduction.frame)) + capture("about-introduction", app: app) + app.buttons["Features"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Features"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Swift Codebase"].exists) + capture("about-features", app: app) + app.navigationBars["Features"].buttons.firstMatch.tap() + let list = app.collectionViews.firstMatch + let logo = app.images["aboutFooterLogo"] + for _ in 0..<4 { + if logo.isHittable { break } + list.swipeUp() + } + XCTAssertTrue(logo.isHittable) + let versionRow = list.cells.containing(.staticText, identifier: "App Version").firstMatch + XCTAssertTrue(versionRow.exists) + let footerGap = logo.frame.minY - versionRow.frame.maxY + XCTAssertGreaterThanOrEqual(footerGap, 16) + XCTAssertLessThanOrEqual(footerGap, 56, "Do not double up grouped-section and footer padding") + capture("about-footer-spacing", app: app) + let close = app.buttons["Close"].firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 10)) + close.tap() + XCTAssertTrue(app.navigationBars["Swift Radio"].waitForExistence(timeout: 10)) + } } From f473298998b0cbed55bf5ff1efdae02a66018819 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 16:07:14 -0400 Subject: [PATCH 02/11] Run regression tests and validate unsigned archives in CI --- .github/scripts/package-unsigned-app.py | 52 +++++++++++++++++++++ .github/scripts/select-simulator.py | 15 ++++++ .github/workflows/archive.yml | 51 +++++++++++++++++++++ .github/workflows/carplay.yml | 37 ++++++--------- .github/workflows/ios.yml | 49 ++++++-------------- .github/workflows/tests.yml | 61 +++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 58 deletions(-) create mode 100644 .github/scripts/package-unsigned-app.py create mode 100644 .github/scripts/select-simulator.py create mode 100644 .github/workflows/archive.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/scripts/package-unsigned-app.py b/.github/scripts/package-unsigned-app.py new file mode 100644 index 000000000..f52c2982b --- /dev/null +++ b/.github/scripts/package-unsigned-app.py @@ -0,0 +1,52 @@ +"""Package and verify the same unsigned phone IPA in PR CI and releases.""" + +import argparse +from pathlib import Path +import plistlib +import shutil +import subprocess +import tempfile +import zipfile + +parser = argparse.ArgumentParser() +parser.add_argument("archive", type=Path) +parser.add_argument("output", type=Path) +parser.add_argument("version") +parser.add_argument("build") +args = parser.parse_args() +app = args.archive / "Products/Applications/SwiftRadio.app" +info = plistlib.loads((app / "Info.plist").read_bytes()) +expected = {"CFBundleIdentifier": "com.matthewfecher.SwiftRadio", + "CFBundleShortVersionString": args.version, "CFBundleVersion": args.build} +for key, value in expected.items(): + if info.get(key) != value: + raise SystemExit(f"Archive {key}: expected {value}, got {info.get(key)}") +binary = app / info["CFBundleExecutable"] +architectures = subprocess.check_output(["lipo", "-archs", str(binary)], text=True).split() +if architectures != ["arm64"]: + raise SystemExit(f"Expected device arm64 binary, got {architectures}") +framework = app / "Frameworks/LNPopupUI.framework/LNPopupUI" +if not framework.is_file(): + raise SystemExit("Archive is missing the embedded LNPopupUI framework") +linkage = subprocess.check_output(["otool", "-L", str(binary)], text=True) +if "@rpath/LNPopupUI.framework/LNPopupUI" not in linkage: + raise SystemExit("App does not link its embedded LNPopupUI framework") +output = args.output.resolve() +output.parent.mkdir(parents=True, exist_ok=True) +if output.exists(): + raise SystemExit(f"Refusing to replace existing package: {output}") +with tempfile.TemporaryDirectory(prefix="swiftradio-package-") as scratch: + payload = Path(scratch) / "Payload" + payload.mkdir() + shutil.copytree(app, payload / app.name, symlinks=True) + subprocess.run(["zip", "-qry", str(output), "Payload"], cwd=scratch, check=True) +with zipfile.ZipFile(output) as package: + if package.testzip() is not None: + raise SystemExit("IPA failed its ZIP integrity check") + prefix = "Payload/SwiftRadio.app/" + packaged_info = plistlib.loads(package.read(prefix + "Info.plist")) + if any(packaged_info.get(key) != value for key, value in expected.items()): + raise SystemExit("Packaged version differs from the archive") + package.getinfo(prefix + "Frameworks/LNPopupUI.framework/LNPopupUI") + package.getinfo(prefix + info["CFBundleExecutable"]) +print(f"Verified unsigned device IPA: {output.name} ({args.version}, build {args.build})") diff --git a/.github/scripts/select-simulator.py b/.github/scripts/select-simulator.py new file mode 100644 index 000000000..767b5058c --- /dev/null +++ b/.github/scripts/select-simulator.py @@ -0,0 +1,15 @@ +"""Select an installed iPhone simulator for the pinned Xcode 27 test job.""" + +import json +import subprocess + +devices = json.loads(subprocess.check_output( + ["xcrun", "simctl", "list", "devices", "available", "--json"], text=True +))["devices"] +candidates = [device for runtime, entries in devices.items() + if runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-27-") + for device in entries if device["name"].startswith("iPhone")] +if not candidates: + raise SystemExit("No installed iOS 27 iPhone simulator; check the runner image.") +candidates.sort(key=lambda device: (device["name"] != "iPhone 18 Pro", device["name"])) +print(candidates[0]["udid"]) diff --git a/.github/workflows/archive.yml b/.github/workflows/archive.yml new file mode 100644 index 000000000..36aa72a02 --- /dev/null +++ b/.github/workflows/archive.yml @@ -0,0 +1,51 @@ +name: Archive + +on: + push: + branches: [master, dev] + pull_request: + branches: [master, dev] + +permissions: + contents: read + +jobs: + archive: + name: Unsigned archive and IPA (Xcode 26.6) + runs-on: macos-26 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.6' + - name: Read project version + run: | + xcodebuild -showBuildSettings -json -project SwiftRadio.xcodeproj \ + -scheme SwiftRadio -configuration Release > "$RUNNER_TEMP/settings.json" + python3 - "$RUNNER_TEMP/settings.json" >> "$GITHUB_ENV" <<'PY' + import json, sys + settings = next(item['buildSettings'] for item in json.load(open(sys.argv[1])) if item['target'] == 'SwiftRadio') + print('VERSION_NAME=' + settings['MARKETING_VERSION']) + print('BUILD_NUMBER=' + settings['CURRENT_PROJECT_VERSION']) + PY + - name: Build unsigned Release archive + run: | + set -o pipefail + mkdir -p artifacts + xcodebuild archive -project SwiftRadio.xcodeproj -scheme SwiftRadio \ + -configuration Release -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/SwiftRadio.xcarchive" \ + -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ + CODE_SIGNING_ALLOWED=NO 2>&1 | tee artifacts/archive.log + - name: Package and validate IPA + run: | + python3 .github/scripts/package-unsigned-app.py \ + "$RUNNER_TEMP/SwiftRadio.xcarchive" artifacts/SwiftRadio-unsigned.ipa \ + "$VERSION_NAME" "$BUILD_NUMBER" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: unsigned-archive-validation + path: artifacts/ + retention-days: 14 diff --git a/.github/workflows/carplay.yml b/.github/workflows/carplay.yml index 172e01a1e..5ccabe70d 100755 --- a/.github/workflows/carplay.yml +++ b/.github/workflows/carplay.yml @@ -2,13 +2,17 @@ name: CarPlay build on: push: - branches: [ "master", "dev" ] + branches: [master, dev] pull_request: - branches: [ "master", "dev" ] + branches: [master, dev] + +permissions: + contents: read jobs: build: name: Build SwiftRadio-CarPlay (Xcode ${{ matrix.xcode }}) + timeout-minutes: 25 strategy: fail-fast: false matrix: @@ -18,32 +22,19 @@ jobs: - runner: xcode-27 xcode: '27.0' runs-on: ${{ matrix.runner }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + - uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ matrix.xcode }} - name: Verify toolchain run: | xcodebuild -version - xcodebuild -showsdks swift --version - - name: Set Default Scheme - run: | - scheme_list=$(xcodebuild -list -json | tr -d "\n") - default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][1]") - echo $default | cat >default - echo Using default scheme: $default - - name: Build - env: - scheme: ${{ 'default' }} + - name: Build for testing run: | - # Generic simulator destination: no dependency on which simulator - # runtimes the runner image ships (device-name matching is flaky) - if [ $scheme = default ]; then scheme=$(cat default); fi - if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi - file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` - xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "generic/platform=iOS Simulator" + xcodebuild build-for-testing \ + -project SwiftRadio.xcodeproj -scheme SwiftRadio-CarPlay \ + -destination 'generic/platform=iOS Simulator' \ + -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index ed9f17843..1b652180f 100755 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,13 +2,17 @@ name: iOS build on: push: - branches: [ "master", "dev" ] + branches: [master, dev] pull_request: - branches: [ "master", "dev" ] + branches: [master, dev] + +permissions: + contents: read jobs: build: name: Build SwiftRadio (Xcode ${{ matrix.xcode }}) + timeout-minutes: 25 strategy: fail-fast: false matrix: @@ -18,44 +22,19 @@ jobs: - runner: xcode-27 xcode: '27.0' runs-on: ${{ matrix.runner }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + - uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ matrix.xcode }} - name: Verify toolchain run: | xcodebuild -version - xcodebuild -showsdks swift --version - - name: Set Default Scheme - run: | - scheme_list=$(xcodebuild -list -json | tr -d "\n") - default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]") - echo $default | cat >default - echo Using default scheme: $default - - name: Build - env: - scheme: ${{ 'default' }} + - name: Build for testing run: | - # Generic simulator destination: no dependency on which simulator - # runtimes the runner image ships (device-name matching is flaky) - if [ $scheme = default ]; then scheme=$(cat default); fi - if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi - file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` - xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "generic/platform=iOS Simulator" -# Disable testing for now -# - name: Test -# env: -# scheme: ${{ 'default' }} -# platform: ${{ 'iOS Simulator' }} -# run: | -# # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) -# device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` -# if [ $scheme = default ]; then scheme=$(cat default); fi -# if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi -# file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` -# xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" + xcodebuild build-for-testing \ + -project SwiftRadio.xcodeproj -scheme SwiftRadio \ + -destination 'generic/platform=iOS Simulator' \ + -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..5dbeac43b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,61 @@ +name: Tests + +on: + push: + branches: [master, dev] + pull_request: + branches: [master, dev] + +permissions: + contents: read + +jobs: + test: + name: Core and UI tests (iOS 27) + runs-on: xcode-27 + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '27.0' + - name: Boot available iOS 27 simulator + run: | + device_id=$(python3 .github/scripts/select-simulator.py) + xcrun simctl bootstatus "$device_id" -b + echo "IOS_SIMULATOR_ID=$device_id" >> "$GITHUB_ENV" + mkdir -p artifacts + - name: Core and real-engine tests + working-directory: Packages/SwiftRadioCore + run: | + set -o pipefail + xcodebuild test -scheme SwiftRadioCore \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_ID" \ + -derivedDataPath "$RUNNER_TEMP/SwiftRadioCore" \ + -resultBundlePath "$GITHUB_WORKSPACE/artifacts/Core.xcresult" \ + -onlyUsePackageVersionsFromResolvedFile \ + -parallel-testing-enabled NO -collect-test-diagnostics never \ + -test-timeouts-enabled YES -maximum-test-execution-time-allowance 180 \ + -jobs 2 CODE_SIGNING_ALLOWED=NO 2>&1 | tee "$GITHUB_WORKSPACE/artifacts/core.log" + - name: UI regression tests + # Live radio availability is a separate integration check. All seven + # remaining UI tests run; failures are retained rather than retried away. + run: | + set -o pipefail + xcodebuild test -project SwiftRadio.xcodeproj -scheme SwiftRadio \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_ID" \ + -derivedDataPath "$RUNNER_TEMP/SwiftRadioUI" \ + -resultBundlePath "$GITHUB_WORKSPACE/artifacts/UI.xcresult" \ + -skip-testing:SwiftRadioUITests/SwiftRadioUITests/testPopupNavigationAndRefresh \ + -onlyUsePackageVersionsFromResolvedFile \ + -parallel-testing-enabled NO -collect-test-diagnostics never \ + -test-timeouts-enabled YES -default-test-execution-time-allowance 120 \ + -maximum-test-execution-time-allowance 180 \ + -jobs 2 CODE_SIGNING_ALLOWED=NO 2>&1 | tee artifacts/ui.log + - name: Retain test results and screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-ios27 + path: artifacts/ + retention-days: 14 From eb26cfc88f871695ca574186aececa21a32ed0e3 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 16:09:05 -0400 Subject: [PATCH 03/11] Prepare Swift Radio 4.0.0 release metadata and notes --- .github/workflows/release.yml | 60 +++++++++++----------------- Documentation/Releases/4.0.0.md | 29 ++++++++++++++ SwiftRadio.xcodeproj/project.pbxproj | 16 ++++---- 3 files changed, 61 insertions(+), 44 deletions(-) create mode 100644 Documentation/Releases/4.0.0.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f11c6eb25..de492daa8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,7 @@ name: Release -# Pushing a tag like v3.1.0 builds the app, packages an unsigned .ipa, -# and creates a GitHub release with generated notes. No signing identity -# or Apple developer account is required: the .ipa is unsigned and meant -# for sideloading (AltStore, Sideloadly), where the installer tool -# re-signs it with the user's own Apple ID. - +# A reviewed version tag on master packages an unsigned IPA and publishes +# the corresponding versioned notes. PRs only run archive validation. on: push: tags: ["v*"] @@ -16,48 +12,40 @@ permissions: jobs: release: runs-on: macos-26 - + timeout-minutes: 25 steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select latest Xcode - uses: maxim-lobanov/setup-xcode@v1 + - uses: actions/checkout@v4 with: - xcode-version: latest-stable - - # Tags must be vMAJOR.MINOR.PATCH. Build number packs the tag into a - # monotonically increasing integer: v3.1.0 -> 30100. - - name: Derive version from tag + fetch-depth: 0 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.6' + - name: Validate release tag and notes run: | + [[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + git merge-base --is-ancestor "$GITHUB_SHA" origin/master VERSION_NAME="${GITHUB_REF_NAME#v}" IFS=. read -r MAJOR MINOR PATCH <<< "$VERSION_NAME" - BUILD_NUMBER=$((MAJOR * 10000 + MINOR * 100 + ${PATCH:-0})) + BUILD_NUMBER=$((MAJOR * 10000 + MINOR * 100 + PATCH)) + test -s "Documentation/Releases/$VERSION_NAME.md" echo "VERSION_NAME=$VERSION_NAME" >> "$GITHUB_ENV" echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" - - - name: Build unsigned archive + - name: Build unsigned Release archive run: | - xcodebuild archive \ - -project SwiftRadio.xcodeproj \ - -scheme SwiftRadio \ - -destination 'generic/platform=iOS' \ + xcodebuild archive -project SwiftRadio.xcodeproj -scheme SwiftRadio \ + -configuration Release -destination 'generic/platform=iOS' \ -archivePath build/SwiftRadio.xcarchive \ - CODE_SIGNING_ALLOWED=NO \ - MARKETING_VERSION="$VERSION_NAME" \ - CURRENT_PROJECT_VERSION="$BUILD_NUMBER" - - - name: Package unsigned IPA + -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ + CODE_SIGNING_ALLOWED=NO + - name: Package and validate unsigned IPA run: | - mkdir -p Payload - cp -R "build/SwiftRadio.xcarchive/Products/Applications/SwiftRadio.app" Payload/ - zip -qr "swift-radio-$GITHUB_REF_NAME-unsigned.ipa" Payload - + python3 .github/scripts/package-unsigned-app.py \ + build/SwiftRadio.xcarchive "swift-radio-$GITHUB_REF_NAME-unsigned.ipa" \ + "$VERSION_NAME" "$BUILD_NUMBER" - name: Create GitHub release run: | gh release create "$GITHUB_REF_NAME" "swift-radio-$GITHUB_REF_NAME-unsigned.ipa" \ - --title "Swift Radio $GITHUB_REF_NAME" \ - --generate-notes \ - --notes "The attached \`.ipa\` is unsigned. Install it by sideloading with [AltStore](https://altstore.io) or [Sideloadly](https://sideloadly.io), which re-sign it with your own Apple ID. Or clone the repo and run it from Xcode with your personal team." + --verify-tag --title "Swift Radio $GITHUB_REF_NAME" \ + --notes-file "Documentation/Releases/$VERSION_NAME.md" env: GH_TOKEN: ${{ github.token }} diff --git a/Documentation/Releases/4.0.0.md b/Documentation/Releases/4.0.0.md new file mode 100644 index 000000000..fd87c6f83 --- /dev/null +++ b/Documentation/Releases/4.0.0.md @@ -0,0 +1,29 @@ +# Swift Radio v4.0.0: SwiftUI and Swift 6 + +Swift Radio's stations list, Now Playing, station information and About screens have been rebuilt in SwiftUI, preserving the familiar station cards, artwork, marquee titles and popup player. + +## Important changes + +- **SwiftUI navigation and player:** Rebuilt screen navigation, popup presentation and options-sheet transitions, including returning directly to the player after sharing. +- **Playback reliability:** Improved coordination of loading, playback and audio-session activation. File scrubbing resumes playback, while a later Pause, Stop or station selection takes precedence over an older seek. +- **Consistent station state:** Phone and CarPlay share playback and catalog state. Stale artwork and metadata cannot replace the current station's presentation. +- **CarPlay catalogs:** Added loading/retry states and pagination for larger catalogs within CarPlay's list and navigation limits. + +## Additional notable updates + +- Added `SwiftRadioCore`, a local package containing playback, catalog loading, artwork and system-media integration. +- Adopted Swift 6 language mode and expanded core, real-engine and UI regression coverage. +- Integrated published **FRadioPlayer 0.4.0**, **LNPopupUI 4.0.1** and **LNPopupController 4.5.12**. +- Expanded configuration, architecture and localization documentation; refreshed the sample station catalog. + +## Also on Android + +Our Android counterpart, [Swift Radio Android](https://github.com/fethica/Swift-Radio-Android), uses Kotlin, Jetpack Compose and the same station JSON format. We've added [compose-popupbar](https://github.com/fethica/compose-popupbar) for its expandable Now Playing bar and full player, bringing a similar popup experience to the iOS version's [LNPopupUI](https://github.com/LeoNatan/LNPopupUI), which wraps [LNPopupController](https://github.com/LeoNatan/LNPopupController). + +## Requirements and upgrading + +Requires **iOS 17+** and **Xcode 26.6+**. Existing forks should port custom UIKit controllers/coordinators into the new SwiftUI structure. The station JSON format remains compatible; branding and content remain configurable through `Config`, assets and the String Catalog. + +The attached IPA is unsigned. It can be sideloaded using [AltStore](https://altstore.io) or [Sideloadly](https://sideloadly.io), which re-sign it with your own Apple ID, or the project can be built directly in Xcode. + +**Full changelog:** [v3.1.0...v4.0.0](https://github.com/analogcode/Swift-Radio-Pro/compare/v3.1.0...v4.0.0) diff --git a/SwiftRadio.xcodeproj/project.pbxproj b/SwiftRadio.xcodeproj/project.pbxproj index ea7078dd3..736654143 100755 --- a/SwiftRadio.xcodeproj/project.pbxproj +++ b/SwiftRadio.xcodeproj/project.pbxproj @@ -516,7 +516,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 40000; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 6G96AERJ7Q; ENABLE_MODULE_VERIFIER = YES; @@ -530,7 +530,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.0; + MARKETING_VERSION = 4.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu99 gnu++11"; PRODUCT_BUNDLE_IDENTIFIER = com.matthewfecher.SwiftRadio; PRODUCT_NAME = SwiftRadio; @@ -549,7 +549,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 40000; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 6G96AERJ7Q; ENABLE_MODULE_VERIFIER = YES; @@ -563,7 +563,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.0; + MARKETING_VERSION = 4.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu99 gnu++11"; PRODUCT_BUNDLE_IDENTIFIER = com.matthewfecher.SwiftRadio; PRODUCT_NAME = SwiftRadio; @@ -584,7 +584,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 40000; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6G96AERJ7Q; @@ -599,7 +599,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.0; + MARKETING_VERSION = 4.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu99 gnu++11"; OTHER_SWIFT_FLAGS = "-D CarPlay"; PRODUCT_BUNDLE_IDENTIFIER = com.fethica.SwiftRadio; @@ -622,7 +622,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 40000; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6G96AERJ7Q; @@ -637,7 +637,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.0; + MARKETING_VERSION = 4.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu99 gnu++11"; OTHER_SWIFT_FLAGS = "-D CarPlay"; PRODUCT_BUNDLE_IDENTIFIER = com.fethica.SwiftRadio; From a792b11a484b8a8061da2d25f8b1b8ec7ff9a6ff Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 16:33:47 -0400 Subject: [PATCH 04/11] Allow cold simulator graphics setup before file playback checks --- SwiftRadioUITests/SwiftRadioUITests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/SwiftRadioUITests/SwiftRadioUITests.swift b/SwiftRadioUITests/SwiftRadioUITests.swift index ccf61e5a6..e6d2c416f 100755 --- a/SwiftRadioUITests/SwiftRadioUITests.swift +++ b/SwiftRadioUITests/SwiftRadioUITests.swift @@ -133,7 +133,9 @@ import XCTest if !station.isHittable { app.collectionViews.firstMatch.swipeUp() } station.tap() let toggle = app.buttons["playbackToggle"].firstMatch - assertLabel("Pause", on: toggle) + // A cold CI simulator can spend over 25 seconds compiling its first graphics pipeline + // on the main thread. Allow that one-time setup before exercising transport transitions. + assertLabel("Pause", on: toggle, timeout: 60) toggle.tap() assertLabel("Play", on: toggle) assertGone(app.buttons["showNowPlaying"].firstMatch, "The equalizer leaves the toolbar when paused") @@ -285,9 +287,10 @@ import XCTest return byLabel.element(boundBy: max(0, byLabel.count - 1)) } - private func assertLabel(_ label: String, on element: XCUIElement) { + private func assertLabel(_ label: String, on element: XCUIElement, timeout: TimeInterval = 15, + file: StaticString = #filePath, line: UInt = #line) { let expected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", label), object: element) - XCTAssertEqual(XCTWaiter.wait(for: [expected], timeout: 15), .completed) + XCTAssertEqual(XCTWaiter.wait(for: [expected], timeout: timeout), .completed, file: file, line: line) } private func openPopup(_ app: XCUIApplication) { From c07075f0510a16008d2da7ae2a89a01e35b1c6d3 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 17:10:43 -0400 Subject: [PATCH 05/11] Wait for the share sheet before cancelling UI tests --- SwiftRadioUITests/SwiftRadioUITests.swift | 30 +++++++++++++++-------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/SwiftRadioUITests/SwiftRadioUITests.swift b/SwiftRadioUITests/SwiftRadioUITests.swift index e6d2c416f..76df37c9f 100755 --- a/SwiftRadioUITests/SwiftRadioUITests.swift +++ b/SwiftRadioUITests/SwiftRadioUITests.swift @@ -128,6 +128,7 @@ import XCTest func testFilePauseSeekAndShareReturnToPlayer() throws { continueAfterFailure = false + executionTimeAllowance = 180 let app = fixtureApp() let station = app.staticTexts["MP3 file sample"].firstMatch if !station.isHittable { app.collectionViews.firstMatch.swipeUp() } @@ -160,10 +161,10 @@ import XCTest share.tap() let activity = app.otherElements["ActivityListView"].firstMatch XCTAssertTrue(activity.waitForExistence(timeout: 10)) + let close = try shareSheetCloseButton(app) capture("share-presented-\(iteration)", app: app) - let close = shareSheetCloseButton(app) - XCTAssertTrue(close.exists) close.tap() + assertGone(activity, "Cancelling Share dismisses the activity sheet") // The share sheet's dismissal animation keeps the player covered for a moment after the // button already exists; wait for it to become hittable rather than asserting at once. let options = app.buttons["playerOptions"].firstMatch @@ -277,14 +278,23 @@ import XCTest XCTAssertEqual(XCTWaiter.wait(for: [appears], timeout: duration), .timedOut, file: file, line: line) } - /// Recent iOS releases identify the share sheet's close button as `header.closeButton`; older - /// ones only label it, and the label has changed between releases. - private func shareSheetCloseButton(_ app: XCUIApplication) -> XCUIElement { - let byIdentifier = app.buttons["header.closeButton"].firstMatch - if byIdentifier.waitForExistence(timeout: 5) { return byIdentifier } - let byLabel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Cancel"])) - // The sheet is the frontmost presentation, so its button comes last. - return byLabel.element(boundBy: max(0, byLabel.count - 1)) + /// The activity container can exist before its remote content finishes rendering on CI. + /// Never mistake the popup's covered Close button for a ready share-sheet control. + private func shareSheetCloseButton(_ app: XCUIApplication, + file: StaticString = #filePath, line: UInt = #line) throws -> XCUIElement { + let activity = app.otherElements["ActivityListView"].firstMatch + let candidates = app.buttons.matching(NSPredicate( + format: "identifier == %@ OR label IN %@", "header.closeButton", ["Close", "Cancel"])) + let deadline = Date().addingTimeInterval(60) + while Date() < deadline, activity.exists { + for button in candidates.allElementsBoundByIndex.reversed() where button.isHittable { + return button + } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + capture("share-close-unavailable", app: app) + return try XCTUnwrap(nil as XCUIElement?, "Share must render a hittable close control before cancellation", + file: file, line: line) } private func assertLabel(_ label: String, on element: XCUIElement, timeout: TimeInterval = 15, From e48d56f30010dc10f951559dcd1493685afe47a7 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 17:31:26 -0400 Subject: [PATCH 06/11] Preserve playback across activation timeouts and catalog updates --- .../SwiftRadioCore/Models/RadioStation.swift | 8 +- .../Playback/PlayerService.swift | 34 ++++- .../Stations/StationsStore.swift | 52 ++++++-- .../PlayerServiceTests.swift | 51 +++++++ .../StationActivationTests.swift | 60 +++++++-- .../StationsRefreshTests.swift | 125 ++++++++++++++++++ 6 files changed, 303 insertions(+), 27 deletions(-) diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift index 00f347b6d..bb5c688ea 100644 --- a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Models/RadioStation.swift @@ -80,7 +80,13 @@ public struct RadioStation: Codable, Hashable, Sendable, Identifiable { ["http", "https"].contains(url.scheme?.lowercased() ?? "") && !(url.host ?? "").isEmpty } - // Preserve the UIKit catalog's unchanged-list behavior: website is deliberately ignored. + /// Full catalog comparison, separate from the legacy equality/hash contract below. + func hasSameCatalogContent(as other: Self) -> Bool { + self == other && website == other.website + } + + // Preserve legacy model equality: website is deliberately ignored. Catalog refreshes use + // hasSameCatalogContent(as:) so website-only edits are still installed. public static func == (lhs: Self, rhs: Self) -> Bool { lhs.name == rhs.name && lhs.streamURL == rhs.streamURL && lhs.imageURL == rhs.imageURL && lhs.desc == rhs.desc && lhs.longDesc == rhs.longDesc diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift index b8020fc0d..f7ebb81d7 100644 --- a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Playback/PlayerService.swift @@ -45,8 +45,11 @@ import FRadioPlayer /// Session arbitration can stall in the audio server; past this deadline the engine starts /// anyway (the pre-ownership behaviour) rather than leaving Play dead. Tests shorten it. @ObservationIgnored var activationDeadline: Duration = .seconds(4) - /// The latest activation; each one waits for the previous so session calls never overlap. + /// The latest bounded playback request; each one waits for the previous request. @ObservationIgnored private(set) var activation: Task? + /// The actual session operation outlives its deadline. Later requests share it until it ends, + /// because cancelling a task cannot interrupt a blocked AVAudioSession.setActive call. + @ObservationIgnored private(set) var underlyingActivation: Task? /// A load, play or seek is waiting for the session and will start playback when it is active. @ObservationIgnored private var hasPendingStart = false /// A stream selected while the session was activating that the engine has not received yet. @@ -101,6 +104,14 @@ import FRadioPlayer } } + /// Refreshes the selected station's presentation without disturbing transport, a pending seek, + /// stream metadata or artwork. Artwork loaders replace the old image when the new one is ready. + func refreshStation(_ station: RadioStation) { + guard self.station?.id == station.id else { return } + self.station = station + publish() + } + /// Rejects artwork that finished after selection or track artwork identity changed. public func updateArtwork(_ image: UIImage?, for stationID: String, artworkURL: URL?) { guard station?.id == stationID else { return } @@ -226,7 +237,8 @@ import FRadioPlayer guard self?.startGeneration == generation else { return } let failure: (any Error)? let deadline = self?.activationDeadline ?? .seconds(4) - do { try await Self.awaitActivation(activateAudioSession, deadline: deadline); failure = nil } + guard let operation = self?.sessionActivation(activateAudioSession) else { return } + do { try await Self.awaitActivation(operation, deadline: deadline); failure = nil } catch { failure = error } guard let self, self.startGeneration == generation else { return } self.hasPendingStart = false @@ -234,10 +246,20 @@ import FRadioPlayer } } + private func sessionActivation(_ activate: @escaping @MainActor () async throws -> Void) -> Task { + if let underlyingActivation { return underlyingActivation } + let operation = Task { @MainActor [weak self] in + defer { self?.underlyingActivation = nil } + try await activate() + } + underlyingActivation = operation + return operation + } + /// Races the activation against `deadline`. A timeout is not a failure: the engine starts - /// without confirmed ownership and the next command tries the session again. Awaiting a - /// blocked `setActive` cannot be cancelled, so the loser simply finds the continuation gone. - private static func awaitActivation(_ activate: @escaping @MainActor () async throws -> Void, + /// without confirmed ownership. Later commands await the same underlying operation while it + /// remains blocked; only a completed operation allows a fresh activation attempt. + private static func awaitActivation(_ operation: Task, deadline: Duration) async throws { let activated: Bool = try await withCheckedThrowingContinuation { continuation in let slot = OSAllocatedUnfairLock?>(initialState: continuation) @@ -248,7 +270,7 @@ import FRadioPlayer }?.resume(with: result) } Task { @MainActor in - do { try await activate(); resume(.success(true)) } catch { resume(.failure(error)) } + do { try await operation.value; resume(.success(true)) } catch { resume(.failure(error)) } } Task { try? await Task.sleep(for: deadline) diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift index a1462159c..c3da8a78b 100644 --- a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift @@ -40,13 +40,29 @@ import Observation let loaded = try await loader.load() try Task.checkCancellation() guard generation == loadGeneration else { return } - if loaded != stations { - stations = loaded - if let currentStation, !loaded.contains(currentStation) { - self.currentStation = nil - // Clearing selection must not leave audio playing behind hidden controls, nor - // leave the removed stream loaded and resumable from a remote Play command. - player.unload() + if !loaded.elementsEqual(stations, by: { $0.hasSameCatalogContent(as: $1) }) { + if loaded == stations { + // Legacy equality omits website. Observation would store website-only edits + // without notifying the UI, so explicitly invalidate this content change. + withMutation(keyPath: \.stations) { stations = loaded } + } else { + stations = loaded + } + if let currentStation { + if let refreshed = loaded.first(where: { $0.id == currentStation.id }) { + if !currentStation.hasSameCatalogContent(as: refreshed) { + if currentStation == refreshed { + withMutation(keyPath: \.currentStation) { self.currentStation = refreshed } + } else { + self.currentStation = refreshed + } + player.refreshStation(refreshed) + } + } else { + self.currentStation = nil + // Removed streams must not keep playing or remain resumable by remote Play. + player.unload() + } } } loadState = .loaded @@ -80,12 +96,14 @@ import Observation /// reports a buffering stream as playing, so that opens the player, as in UIKit. /// - `.carPlay` never restarts or cancels: playing or loading is left alone, paused or stopped plays. @discardableResult public func activate(_ station: RadioStation, on surface: ActivationSurface) -> Activation { - guard station == currentStation else { return select(station) ? .selected : .unavailable } + guard let currentStation, station.id == currentStation.id else { + return select(station) ? .selected : .unavailable + } switch (player.state, surface) { case (.playing, _): return .alreadyActive case (.failed, _): - return select(station) ? .selected : .unavailable + return select(currentStation) ? .selected : .unavailable case (.idle, .carPlay) where player.isBuffering: return .alreadyActive // The pending load starts once the session is active. case (_, .carPlay): @@ -106,7 +124,7 @@ import Observation private func selectRelative(offset: Int) -> Bool { let count = stations.count guard count > 0 else { return false } - guard let currentStation, let index = stations.firstIndex(of: currentStation) else { + guard let currentStation, let index = stations.firstIndex(where: { $0.id == currentStation.id }) else { return stations.contains { select($0) } } for step in 1.. URL? { - guard let url = URL(string: station.streamURL), url.scheme != nil else { return nil } - return url + guard let url = URL(string: station.streamURL), let scheme = url.scheme?.lowercased() else { return nil } + switch scheme { + case "http", "https": + return (url.host ?? "").isEmpty ? nil : url + case "file": + // Local files are supported by the engine; a relative path or remote file host is not. + guard url.path.hasPrefix("/"), url.path != "/", + ["", "localhost"].contains(url.host?.lowercased() ?? "") else { return nil } + return url + default: + return nil + } } } diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift index d30f554f1..0fe313b61 100644 --- a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/PlayerServiceTests.swift @@ -46,6 +46,57 @@ import Testing gate.release() } + /// A deadline bounds playback's wait, not the lifetime of the blocking session call. Play and + /// selection after that deadline must reuse it, and its late success or failure cannot undo Stop. + @Test(arguments: [false, true]) + func commandsAfterTimeoutShareTheUnderlyingActivation(lateFailure: Bool) async throws { + let engine = FakeRadioPlayer(fidelity: .vendor) + let gate = ActivationGate() + let service = Self.gated(engine, gate: gate) + service.activationDeadline = .milliseconds(50) + let stations = try RadioStationTests.fixtures() + let first = try #require(URL(string: stations[0].streamURL)) + let second = try #require(URL(string: stations[1].streamURL)) + service.updateStation(stations[0]) + service.load(url: first) + await gate.waitForPending(1) + let operation = try #require(service.underlyingActivation) + await service.activation?.value + #expect(engine.radioURL == first) + + service.pause() + service.play() + await service.activation?.value + #expect(engine.isPlaying) + #expect(gate.requests == 1) + #expect(gate.pendingCount == 1) + + service.updateStation(stations[1]) + service.load(url: second) + await service.activation?.value + #expect(engine.loadedURLs == [first, second]) + #expect(gate.requests == 1) + #expect(gate.pendingCount == 1) + + service.stop() + let calls = engine.calls + if lateFailure { gate.fail() } else { gate.release() } + _ = await operation.result + #expect(service.underlyingActivation == nil) + #expect(engine.calls == calls) + #expect(service.state == .stopped) + #expect(engine.radioURL == second) + + // A completed operation permits a fresh attempt, including after a late failure. + service.play() + await gate.waitForPending(1) + #expect(gate.requests == 2) + gate.release() + await service.activation?.value + #expect(service.state == .playing) + #expect(engine.radioURL == second) + } + /// A non-mixable session activated early would silence other apps just by opening this one, /// and the session only applies its category when it activates, so the engine must not get a /// stream, Play or a seek until activation has finished. diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift index e77f929e6..60a6c4f97 100644 --- a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift @@ -20,9 +20,13 @@ import Testing return (store, player) } - private static func unusable(_ name: String) -> RadioStation { - // No scheme: URL(string:) succeeds but the engine cannot open it. - RadioStation(name: name, streamURL: "stream.example.com/live", imageURL: "", desc: "") + nonisolated private static let invalidStreamURLs = [ + "stream.example.com/live", "https://", "http:///live", "ftp://example.com/live", + "custom://example.com/live", "file:relative.mp3", "file://server/music.mp3", "file:///" + ] + + private static func unusable(_ name: String, url: String = "stream.example.com/live") -> RadioStation { + RadioStation(name: name, streamURL: url, imageURL: "", desc: "") } @Test(arguments: StationsStore.ActivationSurface.allCases) func activatingADifferentStationSelectsIt(surface: StationsStore.ActivationSurface) async throws { @@ -119,30 +123,70 @@ import Testing #expect(engine.radioURL == URL(string: stations[0].streamURL)) } - @Test(arguments: StationsStore.ActivationSurface.allCases) func unusableStationIsNotSelectedAndDoesNotPlayThePreviousOne(surface: StationsStore.ActivationSurface) async throws { + @Test(arguments: StationsStore.ActivationSurface.allCases, StationActivationTests.invalidStreamURLs) + func unusableStationIsNotSelectedAndDoesNotPlayThePreviousOne(surface: StationsStore.ActivationSurface, + streamURL: String) async throws { var stations = try RadioStationTests.fixtures() - stations.insert(Self.unusable("Broken"), at: 1) + stations.insert(Self.unusable("Broken", url: streamURL), at: 1) let engine = FakeRadioPlayer() let (store, player) = await Self.store(stations, engine: engine) store.activate(stations[0], on: surface) player.pause() let calls = engine.calls + let loadedURLs = engine.loadedURLs #expect(store.select(stations[1]) == false) #expect(store.activate(stations[1], on: surface) == .unavailable) #expect(store.currentStation == stations[0]) #expect(engine.calls == calls) + #expect(engine.loadedURLs == loadedURLs) + #expect(player.state == .paused) #expect(engine.radioURL == URL(string: stations[0].streamURL)) } + @Test(arguments: StationsStore.ActivationSurface.allCases, + ["http://example.com/live", "https://example.com/live", "HTTPS://example.com/live", + "file:///tmp/sample.mp3", "file://localhost/tmp/sample.mp3"]) + func supportedStreamURLsSelectAndActivate(surface: StationsStore.ActivationSurface, streamURL: String) async { + let station = RadioStation(name: "Supported", streamURL: streamURL, imageURL: "", desc: "") + let engine = FakeRadioPlayer() + let (store, _) = await Self.store([station], engine: engine) + #expect(store.select(station)) + #expect(engine.radioURL == URL(string: streamURL)) + let second = RadioStation(name: "Another", streamURL: streamURL, imageURL: "", desc: "") + #expect(store.activate(second, on: surface) == .selected) + #expect(store.currentStation?.id == second.id) + #expect(engine.radioURL == URL(string: streamURL)) + } + + @Test(arguments: StationsStore.ActivationSurface.allCases) + func staleMetadataDoesNotReloadTheSameStation(surface: StationsStore.ActivationSurface) async throws { + let old = try RadioStationTests.fixtures()[0] + var updated = old + updated.desc = "New metadata" + let engine = FakeRadioPlayer(fidelity: .vendor) + let (store, player) = await Self.store([updated], engine: engine) + store.select(updated) + let calls = engine.calls + let loadedURLs = engine.loadedURLs + #expect(store.activate(old, on: surface) == .alreadyActive) + #expect(store.currentStation?.desc == updated.desc) + #expect(engine.calls == calls) + #expect(engine.loadedURLs == loadedURLs) + player.playerStateDidChange(.failed("offline")) + #expect(store.activate(old, on: surface) == .selected) + #expect(store.currentStation?.desc == updated.desc) + #expect(engine.loadedURLs.count == loadedURLs.count + 1) + } + @Test func nextAndPreviousStepOverUnusableStations() async throws { var stations = try RadioStationTests.fixtures() - stations.insert(Self.unusable("Broken A"), at: 1) - stations.insert(Self.unusable("Broken B"), at: 2) + let unusable = Self.invalidStreamURLs.enumerated().map { Self.unusable("Broken \($0.offset)", url: $0.element) } + stations.insert(contentsOf: unusable, at: 1) let engine = FakeRadioPlayer() let (store, _) = await Self.store(stations, engine: engine) store.select(stations[0]) store.selectNext() - #expect(store.currentStation == stations[3]) + #expect(store.currentStation == stations[unusable.count + 1]) store.selectPrevious() #expect(store.currentStation == stations[0]) store.selectPrevious() // Wraps around to the last usable entry. diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift index dc2650b1b..bb6038f08 100644 --- a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationsRefreshTests.swift @@ -8,11 +8,136 @@ // See LICENSE in the repository root. // +import MediaPlayer +import Observation import Testing +import UIKit @testable import SwiftRadioCore /// Verifies late or cancelled requests cannot overwrite the latest catalog state. @MainActor struct StationsRefreshTests { + @Test(arguments: ["description", "artwork", "website"], [false, true]) + func selectedMetadataRefreshPreservesPlayback(field: String, paused: Bool) async throws { + let stations = try RadioStationTests.fixtures() + let loader = FakeStationsLoader(stations: stations) + let engine = FakeRadioPlayer(fidelity: .vendor) + var info: [String: Any]? + var publications = 0 + let player = PlayerService(player: engine, + nowPlaying: NowPlayingInfoPublisher(publish: { info = $0; publications += 1 }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let store = StationsStore(loader: loader, player: player) + await store.load() + store.select(stations[0]) + engine.announceDuration(180) + player.playerStateDidChange(.readyToPlay) + player.playTimeDidChange(42, duration: 180) + if paused { player.pause() } + let artwork = try #require(UIImage(named: "Fixtures/station", in: .module, compatibleWith: nil)) + player.updateArtwork(artwork, for: stations[0].id, artworkURL: nil) + let calls = engine.calls + let loadedURLs = engine.loadedURLs + var updated = stations + switch field { + case "description": updated[0].desc = "Updated tagline"; updated[0].longDesc = "Updated details" + case "artwork": updated[0].imageURL = "https://example.com/new-artwork.png" + default: updated[0].website = "https://example.com/new-website" + } + let catalogChanges = ObservationCounter() + let selectionChanges = ObservationCounter() + withObservationTracking { _ = store.stations } onChange: { catalogChanges.increment() } + withObservationTracking { _ = store.currentStation } onChange: { selectionChanges.increment() } + await loader.replace(with: updated) + await store.refresh() + + #expect(catalogChanges.value == 1) + #expect(selectionChanges.value == 1) + let selected = try #require(store.currentStation) + #expect(selected.id == updated[0].id) + #expect(selected.desc == updated[0].desc) + #expect(selected.longDesc == updated[0].longDesc) + #expect(selected.imageURL == updated[0].imageURL) + #expect(selected.website == updated[0].website) + #expect(store.stations[0].website == updated[0].website) + #expect(store.stations[0].imageURL == updated[0].imageURL) + #expect(engine.calls == calls) + #expect(engine.loadedURLs == loadedURLs) + #expect(player.state == (paused ? .paused : .playing)) + #expect(player.elapsed == 42) + #expect(player.duration == 180) + #expect(info?[MPMediaItemPropertyTitle] as? String == updated[0].name) + #expect(info?[MPMediaItemPropertyArtist] as? String == updated[0].desc) + let retainedArtwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(retainedArtwork.bounds.size == artwork.size) + + let changes = ObservationCounter() + withObservationTracking { _ = store.stations; _ = store.currentStation } onChange: { changes.increment() } + let before = publications + await store.refresh() + #expect(changes.value == 0) + #expect(publications == before) + #expect(store.selectNext()) + #expect(store.currentStation?.id == stations[1].id) + #expect(store.selectPrevious()) + #expect(store.currentStation?.id == updated[0].id) + #expect(store.currentStation?.desc == updated[0].desc) + #expect(store.currentStation?.website == updated[0].website) + } + + @Test func metadataRefreshKeepsTrackArtworkAndPendingSeek() async throws { + let stations = try RadioStationTests.fixtures() + let loader = FakeStationsLoader(stations: stations) + let engine = FakeRadioPlayer(fidelity: .vendor) + engine.completesSeeksImmediately = false + var info: [String: Any]? + let player = PlayerService(player: engine, nowPlaying: NowPlayingInfoPublisher(publish: { info = $0 }), + remoteCommands: RemoteCommandsController(handler: FakeRemoteCommandHandler()), + activateAudioSession: nil) + let store = StationsStore(loader: loader, player: player) + await store.load() + store.select(stations[0]) + engine.announceDuration(180) + player.metadataDidChange(artist: "Artist", track: "Track") + let artworkURL = try #require(URL(string: "https://example.com/track.png")) + player.artworkDidChange(artworkURL) + let artwork = try #require(UIImage(named: "Fixtures/placeholder", in: .module, compatibleWith: nil)) + player.updateArtwork(artwork, for: stations[0].id, artworkURL: artworkURL) + player.seek(to: 90) + let calls = engine.calls + var updated = stations + updated[0].imageURL = "new-station-artwork" + updated[0].desc = "New description" + await loader.replace(with: updated) + await store.refresh() + #expect(engine.calls == calls) + #expect(player.isSeeking) + #expect(player.elapsed == 90) + #expect(player.track == "Track") + #expect(player.artist == "Artist") + #expect(player.artworkURL == artworkURL) + let retainedArtwork = try #require(info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + #expect(retainedArtwork.bounds.size == artwork.size) + engine.pendingSeeks.removeFirst()() + #expect(!player.isSeeking) + #expect(player.state == .playing) + } + + @Test func websiteOnlyEditToAnotherStationUpdatesCatalogWithoutChangingSelection() async throws { + var stations = try RadioStationTests.fixtures() + let loader = FakeStationsLoader(stations: stations) + let store = StationsStore(loader: loader, player: PlayerServiceTests.service()) + await store.load() + store.select(stations[0]) + let changes = ObservationCounter() + withObservationTracking { _ = store.currentStation } onChange: { changes.increment() } + stations[1].website = "https://example.com/updated" + await loader.replace(with: stations) + await store.refresh() + #expect(store.stations[1].website == stations[1].website) + #expect(changes.value == 0) + } + @Test func latestRequestWins() async throws { let loader = ControlledStationsLoader() let store = StationsStore(loader: loader, player: PlayerServiceTests.service()) From 46f02ae77b36b47b0d43743290dbb6051582d3cf Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 17:31:28 -0400 Subject: [PATCH 07/11] Clarify SwiftRadioCore architecture and dependency ownership --- Documentation/Architecture.md | 52 +++++++++++++++++++++++ Packages/SwiftRadioCore/README.md | 4 +- README.md | 69 ++++++------------------------- 3 files changed, 67 insertions(+), 58 deletions(-) create mode 100644 Documentation/Architecture.md diff --git a/Documentation/Architecture.md b/Documentation/Architecture.md new file mode 100644 index 000000000..65898d18c --- /dev/null +++ b/Documentation/Architecture.md @@ -0,0 +1,52 @@ +# Architecture + +Screens and navigation use SwiftUI. UIKit remains at focused boundaries: CarPlay, the popup library, system presentations (Safari, mail, AirPlay and sharing), and small wrappers that keep the reference appearance and native accessibility: the seek slider, the gradient, the marquee title (`MarqueeText`), the equalizer and buffering indicators (`PlaybackActivityIndicator`), and the artwork containers (`CrossfadeImage`, `PlaybackArtworkImage`). Playback state stays outside those wrappers. + +``` +SwiftRadio/ + App/ SwiftRadioApp (@main), AppDelegate, AppEnvironment, RootView, + ConfiguredStationsLoader, ArtworkLoaderKey, UITestRadioPlayer (Debug only) + CarPlay/ CarPlaySceneDelegate and its observation helper + Config/ Config, Content (String Catalog keys), InfoSection + Data/ stations.json + Features/ One folder per screen: Loading, Stations, NowPlaying, StationInfo, About + Helpers/ Small Foundation extensions + UI/ Views shared across screens: artwork, gradient, marquee, equalizer, + AirPlay, Safari, mail, share sheet, share card + Images.xcassets, Localizable.xcstrings, Info.plist, Info-CarPlay.plist + +Packages/SwiftRadioCore/ + Models/ RadioStation, StationsResponse + Stations/ StationsStore, the loader protocol, bundle and remote loaders, + CatalogListContent (what a size-limited list shows) + Playback/ PlayerService, the FRadioPlayer boundary, audio session, now playing info, + remote commands + Images/ ArtworkLoader + Handoff/ HandoffActivity +``` + +- **`AppEnvironment`**: builds one `PlayerService`, one `StationsStore`, and one `ArtworkLoader` at launch. The phone scene and the CarPlay scene share those instances, so a station picked in the car updates the phone, the lock screen, and Control Center. +- **`RootView`**: shows `LoadingScreen` until the first catalog load settles, then a `NavigationStack` holding the station list, a `navigationDestination` for station info, and the popup player. +- **`SwiftRadioCore`**: a local Swift package that holds the models, the stores, and the only import of FRadioPlayer. It has no SwiftUI in it and carries the unit tests. +- **CarPlay**: the `SwiftRadio-CarPlay` target compiles the same sources with `-D CarPlay` and swaps in `Info-CarPlay.plist`. `CarPlaySceneDelegate` renders a `CPListTemplate` from the shared store: + - **Catalog states**: a loading message while the first load runs, and a Retry row when loading failed or the catalog is empty, so the driver can recover without the phone. + - **Item limit**: catalogs are paged within `CPListTemplate.maximumItemCount`, with a More row and at most four list pages so Now Playing fits the template-depth limit. Limits can change while driving. + - **Selection**: tapping a station pushes the Now Playing template. Tapping the station that is already playing or buffering doesn't restart its stream, and a station whose stream URL can't be opened is ignored. + +## The popup player + +[LNPopupUI](https://github.com/LeoNatan/LNPopupUI) draws the bar above the station list and expands it into the full player. + +- **Presentation**: `RootView` attaches `.popup(isBarPresented:isPopupOpen:)` to the `NavigationStack`. The bar shows whenever `StationsStore.currentStation` is set, including when CarPlay made the selection. +- **Bar content**: `NowPlayingView` feeds the title, artwork, progress, and play/pause button to the bar through `.popupTitle`, `.popupImage`, `.popupProgress`, and `.popupBarButtons`. +- **Full player**: the same view's body is the expanded content: blurred artwork backdrop, marquee title, transport controls, and the options sheet. +- **Sequencing**: options that leave the player (station info, station website) wait for the options sheet's `onDismiss`, close the popup, then run from `onClose`, so a push or a sheet never fights either dismissal animation. + +## Audio session and system controls + +- **Session**: `PlayerService` activates a non-mixable `.playback` session right before it loads, plays or seeks, never at launch. Opening the app doesn't stop other apps' audio; starting a station does. +- **Mixing**: set `Config.mixesWithOtherAudio = true` to keep other audio playing under the radio. A mixable session can't own the lock screen, Control Center or CarPlay Now Playing, so those controls stop working. +- **Remote commands**: live streams enable Stop and disable Pause, files do the opposite. Play, play/pause, next and previous stay enabled. With no station selected every command reports `.noActionableNowPlayingItem` and does nothing. +- **Now Playing info**: each update replaces the whole dictionary, so a live stream never keeps a file's scrubber or an old track's artwork. Clock ticks don't republish it: the system advances elapsed time from the published rate. + +See the [SwiftRadioCore guide](../Packages/SwiftRadioCore/README.md) for service contracts and tests. diff --git a/Packages/SwiftRadioCore/README.md b/Packages/SwiftRadioCore/README.md index ac8cb592b..d81d089be 100644 --- a/Packages/SwiftRadioCore/README.md +++ b/Packages/SwiftRadioCore/README.md @@ -15,11 +15,11 @@ The app project's generated package scheme does not include the package test act ## Composition and ownership -Create one app-lifetime `PlayerService`, `StationsStore`, `ArtworkLoader`, `NowPlayingInfoPublisher`, and `RemoteCommandsController`. Phone and CarPlay must share them. Do not activate the audio session at launch or on scene activation: `PlayerService` activates a non-mixable `.playback` session itself when `load(url:)`, `play()` or `seek(to:)` is called, so opening the app never stops other apps' audio. Those calls stay synchronous for callers, but the engine only receives the stream, Play or seek after the activation has finished: the session applies its category when it activates. Activations run one at a time, and any later transport command supersedes a pending one, so Stop, Pause or a new selection during activation wins and the superseded request never reaches the engine (a stream stopped this way is loaded by the next `play()`). While a new station waits, the previous stream is stopped and the player reads idle and buffering. If activation fails, the player stops and reports `.failed`, never Playing; the next tap or Play command retries. The adapter sets `FRadioPlayer.configuresAudioSession = false` before first accessing `shared`; SwiftRadio alone owns category setup and activation. The category is re-applied on each activation. Pass `mixesWithOtherAudio: true` only if the app should play under other audio; a mixable session cannot own lock-screen, Control Center or CarPlay Now Playing. +Create one app-lifetime `PlayerService`, `StationsStore`, `ArtworkLoader`, `NowPlayingInfoPublisher`, and `RemoteCommandsController`. Phone and CarPlay must share them. Do not activate the audio session at launch or on scene activation: `PlayerService` activates a non-mixable `.playback` session itself when `load(url:)`, `play()` or `seek(to:)` is called, so opening the app never stops other apps' audio. Those calls stay synchronous for callers, but the engine waits for activation before receiving the stream, Play or seek. If system arbitration exceeds four seconds, playback proceeds without confirmed session ownership. The underlying activation remains retained until it actually finishes; later commands share that operation instead of starting overlapping system calls. Activations run one at a time, and any later transport command supersedes a pending one, so Stop, Pause or a new selection during activation wins and the superseded request never reaches the engine (a stream stopped this way is loaded by the next `play()`). While a new station waits, the previous stream is stopped and the player reads idle and buffering. If activation fails, the player stops and reports `.failed`, never Playing; the next tap or Play command retries. The adapter sets `FRadioPlayer.configuresAudioSession = false` before first accessing `shared`; SwiftRadio alone owns category setup and activation. The category is re-applied on each activation. Pass `mixesWithOtherAudio: true` only if the app should play under other audio; a mixable session cannot own lock-screen, Control Center or CarPlay Now Playing. Inject the engine, publisher, and commands into `PlayerService`, then inject that service and a loader into `StationsStore`. Bind commands once with `bind(player:stations:)`: without a current station every command reports `.noActionableNowPlayingItem` and does nothing, so a stray headset press cannot start the radio. The controller removes registrations on `unbind()` or destruction. Do not run the legacy audio setup and new composition simultaneously: the vendor engine and system media centers are process-wide resources. -`StationsStore.select` supplies station display fallbacks and starts loading the stream. When a reload drops the current station the store calls `PlayerService.unload()`, which stops transport, clears the engine's `radioURL`, and removes system Now Playing info (nil, not an empty-title media card). Late metadata callbacks cannot recreate that card without a selection. `play()` then does nothing until another station is selected, so a late remote Play cannot resume a removed stream. Keep the navigation shell alive after initial loading; refresh state is not navigation state. +`StationsStore.select` accepts HTTP(S) URLs with a nonempty host and absolute local file URLs. Invalid entries leave selection and playback untouched, and Next/Previous skip them. Selection supplies station display fallbacks and starts loading the stream. Catalog refresh compares all fields, including website, and reconciles the selected station by its existing name-and-stream identity. Metadata edits update presentation without stopping or reloading playback, resetting a pending seek, or discarding current track artwork. When a reload drops the current station the store calls `PlayerService.unload()`, which stops transport, clears the engine's `radioURL`, and removes system Now Playing info (nil, not an empty-title media card). Late metadata callbacks cannot recreate that card without a selection. `play()` then does nothing until another station is selected, so a late remote Play cannot resume a removed stream. Keep the navigation shell alive after initial loading; refresh state is not navigation state. After awaiting station artwork, compare the current station ID and call `updateArtwork(_:for:artworkURL:)` with a nil URL. For track artwork, compare both station ID and the requested metadata URL, then pass that URL. The service rejects obsolete IDs/URLs and immediately reveals cached station artwork when the track URL becomes nil. FRadioPlayer 0.4 rejects superseded lookups. The app retains its own station/track identity checks for image downloads and injected late events, and keys accepted artwork to the station that was current when it arrived. `ArtworkLoader` shares one download per URL and cancels it when the last waiting caller is cancelled. Asset resolution uses the app bundle by default; tests can inject a bundle. diff --git a/README.md b/README.md index 52a0b02be..8bc194a6b 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![iOS build](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/ios.yml/badge.svg)](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/ios.yml) [![CarPlay build](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/carplay.yml/badge.svg)](https://github.com/analogcode/Swift-Radio-Pro/actions/workflows/carplay.yml) -Open-source radio station app built entirely in Swift and SwiftUI. Used by **80+ apps** on the App Store. +Open-source iOS radio app with a SwiftUI interface. Used by **80+ apps** on the App Store.

Swift Radio @@ -29,7 +29,7 @@ Built on [FRadioPlayer](https://github.com/fethica/FRadioPlayer) for streaming, - **iOS 17 or later**: the deployment target of the app target, the CarPlay target, and the `SwiftRadioCore` package. - **Xcode 26.6 or later**: CI builds the app and CarPlay targets with Xcode 26.6 and 27.0. The test suites are run with Xcode 27. -- **Swift 6 language mode**: every target compiles with `SWIFT_VERSION = 6.0` and `SWIFT_STRICT_CONCURRENCY = complete`. +- **Swift 6 language mode** for the app targets and `SwiftRadioCore`, with complete concurrency checking. Dependencies resolve through Swift Package Manager, so there's nothing to install before the first build. @@ -78,71 +78,28 @@ All user-facing strings are managed through `Localizable.xcstrings` (the String ## Architecture -Screens and navigation use SwiftUI. UIKit remains at focused boundaries: CarPlay, the popup library, system presentations (Safari, mail, AirPlay and sharing), and small wrappers that keep the reference appearance and native accessibility: the seek slider, the gradient, the marquee title (`MarqueeText`), the equalizer and buffering indicators (`PlaybackActivityIndicator`), and the artwork containers (`CrossfadeImage`, `PlaybackArtworkImage`). Playback state stays outside those wrappers. +SwiftUI owns the app's screens and navigation. `AppEnvironment` creates shared services for the phone and CarPlay, so both interfaces use the same station selection and playback state. -``` -SwiftRadio/ - App/ SwiftRadioApp (@main), AppDelegate, AppEnvironment, RootView, - ConfiguredStationsLoader, ArtworkLoaderKey, UITestRadioPlayer (Debug only) - CarPlay/ CarPlaySceneDelegate and its observation helper - Config/ Config, Content (String Catalog keys), InfoSection - Data/ stations.json - Features/ One folder per screen: Loading, Stations, NowPlaying, StationInfo, About - Helpers/ Small Foundation extensions - UI/ Views shared across screens: artwork, gradient, marquee, equalizer, - AirPlay, Safari, mail, share sheet, share card - Images.xcassets, Localizable.xcstrings, Info.plist, Info-CarPlay.plist - -Packages/SwiftRadioCore/ - Models/ RadioStation, StationsResponse - Stations/ StationsStore, the loader protocol, bundle and remote loaders, - CatalogListContent (what a size-limited list shows) - Playback/ PlayerService, the FRadioPlayer boundary, audio session, now playing info, - remote commands - Images/ ArtworkLoader - Handoff/ HandoffActivity -``` - -- **`AppEnvironment`**: builds one `PlayerService`, one `StationsStore`, and one `ArtworkLoader` at launch. The phone scene and the CarPlay scene share those instances, so a station picked in the car updates the phone, the lock screen, and Control Center. -- **`RootView`**: shows `LoadingScreen` until the first catalog load settles, then a `NavigationStack` holding the station list, a `navigationDestination` for station info, and the popup player. -- **`SwiftRadioCore`**: a local Swift package that holds the models, the stores, and the only import of FRadioPlayer. It has no SwiftUI in it and carries the unit tests. -- **CarPlay**: the `SwiftRadio-CarPlay` target compiles the same sources with `-D CarPlay` and swaps in `Info-CarPlay.plist`. `CarPlaySceneDelegate` renders a `CPListTemplate` from the shared store: - - **Catalog states**: a loading message while the first load runs, and a Retry row when loading failed or the catalog is empty, so the driver can recover without the phone. - - **Item limit**: the list shows at most `CPListTemplate.maximumItemCount` stations, which some cars lower while driving. - - **Selection**: tapping a station pushes the Now Playing template. Tapping the station that is already playing or buffering doesn't restart its stream, and a station whose stream URL can't be opened is ignored. - -### The popup player - -[LNPopupUI](https://github.com/LeoNatan/LNPopupUI) draws the bar above the station list and expands it into the full player. - -- **Presentation**: `RootView` attaches `.popup(isBarPresented:isPopupOpen:)` to the `NavigationStack`. The bar shows whenever `StationsStore.currentStation` is set, including when CarPlay made the selection. -- **Bar content**: `NowPlayingView` feeds the title, artwork, progress, and play/pause button to the bar through `.popupTitle`, `.popupImage`, `.popupProgress`, and `.popupBarButtons`. -- **Full player**: the same view's body is the expanded content: blurred artwork backdrop, marquee title, transport controls, and the options sheet. -- **Sequencing**: options that leave the player (station info, station website) wait for the options sheet's `onDismiss`, close the popup, then run from `onClose`, so a push or a sheet never fights either dismissal animation. - -### Audio session and system controls +`SwiftRadioCore` is a local Swift package that separates playback, station catalogs, artwork and system-media integration from presentation. Its dependency boundaries let us test playback and catalog behavior without launching the UI. Small UIKit adapters remain for the popup player, marquee text, indicators and system presentations. -- **Session**: `PlayerService` activates a non-mixable `.playback` session right before it loads, plays or seeks, never at launch. Opening the app doesn't stop other apps' audio; starting a station does. -- **Mixing**: set `Config.mixesWithOtherAudio = true` to keep other audio playing under the radio. A mixable session can't own the lock screen, Control Center or CarPlay Now Playing, so those controls stop working. -- **Remote commands**: live streams enable Stop and disable Pause, files do the opposite. Play, play/pause, next and previous stay enabled. With no station selected every command reports `.noActionableNowPlayingItem` and does nothing. -- **Now Playing info**: each update replaces the whole dictionary, so a live stream never keeps a file's scrubber or an old track's artwork. Clock ticks don't republish it: the system advances elapsed time from the published rate. +See the [SwiftRadioCore guide](Packages/SwiftRadioCore/README.md) for service boundaries and the [architecture guide](Documentation/Architecture.md) for the source map, popup flow and system controls. -### Testing +## Testing -Run the core package tests from `Packages/SwiftRadioCore` (see its README for the command). They need no network and cover playback, remote commands, Now Playing info, interruptions and the catalog. +Run the core tests from `Packages/SwiftRadioCore` using the [package guide](Packages/SwiftRadioCore/README.md). They cover playback, catalog refresh, remote commands, Now Playing and interruptions without live streams. -For visual changes, use the [visual parity protocol](Documentation/SwiftUIParity.md): matching simulator screenshots, accessibility checks and the UI test commands. The [core package guide](Packages/SwiftRadioCore/README.md) documents dependency ownership. +The [visual parity protocol](Documentation/SwiftUIParity.md) covers screenshots, accessibility and UI regression commands. [CI](https://github.com/analogcode/Swift-Radio-Pro/actions) also builds both app targets and validates an unsigned archive. Physical-device audio routes, lock-screen controls and CarPlay remain separate acceptance checks. ## Dependencies | Library | Purpose | |---------|---------| -| [FRadioPlayer](https://github.com/fethica/FRadioPlayer) | Streaming, metadata parsing, iTunes album art | -| [LNPopupUI](https://github.com/LeoNatan/LNPopupUI) | Now playing popup bar and player | -| [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) | Continuous now-playing title, matching the UIKit app | -| [NVActivityIndicatorView](https://github.com/ninjaprox/NVActivityIndicatorView) | Equalizer and buffering animations, matching the UIKit app | +| [FRadioPlayer](https://github.com/fethica/FRadioPlayer) | Audio playback, stream metadata and artwork lookup, behind SwiftRadioCore | +| [LNPopupUI](https://github.com/LeoNatan/LNPopupUI) | SwiftUI popup bar and expanded player | +| [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) | Scrolling title in the expanded player | +| [NVActivityIndicatorView](https://github.com/ninjaprox/NVActivityIndicatorView) | Playback equalizer and buffering animations | -All are managed by Swift Package Manager. LNPopupUI brings LNPopupController and LNSwiftUIUtils with it. `SwiftRadioCore` lives in this repo as a local package and needs no setup. The two animation libraries are isolated behind small SwiftUI adapters; application state and navigation remain in SwiftUI. +Swift Package Manager manages these libraries. LNPopupUI also brings in LNPopupController, LNSwiftUIUtils and, through LNPopupController, LNSystemMarqueeLabel. `SwiftRadioCore` is included in this repository as a local package. The checked-in lockfiles record the exact resolved versions. ## Contributing From 49605e554f268988ec493b8a83012acfa45e02c1 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 17:34:47 -0400 Subject: [PATCH 08/11] Find the remote share close button by its identifier --- SwiftRadioUITests/SwiftRadioUITests.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/SwiftRadioUITests/SwiftRadioUITests.swift b/SwiftRadioUITests/SwiftRadioUITests.swift index 76df37c9f..48dcf9c6a 100755 --- a/SwiftRadioUITests/SwiftRadioUITests.swift +++ b/SwiftRadioUITests/SwiftRadioUITests.swift @@ -282,17 +282,17 @@ import XCTest /// Never mistake the popup's covered Close button for a ready share-sheet control. private func shareSheetCloseButton(_ app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line) throws -> XCUIElement { - let activity = app.otherElements["ActivityListView"].firstMatch - let candidates = app.buttons.matching(NSPredicate( - format: "identifier == %@ OR label IN %@", "header.closeButton", ["Close", "Cancel"])) - let deadline = Date().addingTimeInterval(60) - while Date() < deadline, activity.exists { - for button in candidates.allElementsBoundByIndex.reversed() where button.isHittable { - return button - } - RunLoop.current.run(until: Date().addingTimeInterval(0.5)) - } + // The share controls live in a remote process. Binding a mixed app/remote query by index + // can count both Close buttons, then resolve index 1 against just the remote match. + let close = app.buttons["header.closeButton"].firstMatch + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == true AND isHittable == true"), object: close) + if XCTWaiter.wait(for: [ready], timeout: 60) == .completed { return close } capture("share-close-unavailable", app: app) + let hierarchy = XCTAttachment(string: app.debugDescription) + hierarchy.name = "share-close-unavailable-hierarchy" + hierarchy.lifetime = .keepAlways + add(hierarchy) return try XCTUnwrap(nil as XCUIElement?, "Share must render a hittable close control before cancellation", file: file, line: line) } From c5560f1678eb10fa1993eb86c12daff516f0686b Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 18:15:58 -0400 Subject: [PATCH 09/11] Preserve tag versions in release archives and validate overrides --- .github/scripts/archive-unsigned-app.sh | 14 +++++++++++ .github/workflows/archive.yml | 31 +++++++++++++++++-------- .github/workflows/release.yml | 7 ++---- 3 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/archive-unsigned-app.sh diff --git a/.github/scripts/archive-unsigned-app.sh b/.github/scripts/archive-unsigned-app.sh new file mode 100644 index 000000000..fc9e8b6a3 --- /dev/null +++ b/.github/scripts/archive-unsigned-app.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +archive_path="${1:?archive path required}" +version_name="${2:?marketing version required}" +build_number="${3:?build number required}" + +# Releases and PR validation use this same command so a tag's version reaches the binary. +xcodebuild archive -project SwiftRadio.xcodeproj -scheme SwiftRadio \ + -configuration Release -destination 'generic/platform=iOS' \ + -archivePath "$archive_path" \ + -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ + CODE_SIGNING_ALLOWED=NO \ + MARKETING_VERSION="$version_name" CURRENT_PROJECT_VERSION="$build_number" diff --git a/.github/workflows/archive.yml b/.github/workflows/archive.yml index 36aa72a02..dc956cd1f 100644 --- a/.github/workflows/archive.yml +++ b/.github/workflows/archive.yml @@ -11,33 +11,44 @@ permissions: jobs: archive: - name: Unsigned archive and IPA (Xcode 26.6) + name: Unsigned archive and IPA (${{ matrix.version_source }}, Xcode 26.6) runs-on: macos-26 timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + version_source: [project, next-tag] steps: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '26.6' - name: Read project version + env: + VERSION_SOURCE: ${{ matrix.version_source }} run: | xcodebuild -showBuildSettings -json -project SwiftRadio.xcodeproj \ -scheme SwiftRadio -configuration Release > "$RUNNER_TEMP/settings.json" python3 - "$RUNNER_TEMP/settings.json" >> "$GITHUB_ENV" <<'PY' - import json, sys + import json, os, sys settings = next(item['buildSettings'] for item in json.load(open(sys.argv[1])) if item['target'] == 'SwiftRadio') - print('VERSION_NAME=' + settings['MARKETING_VERSION']) - print('BUILD_NUMBER=' + settings['CURRENT_PROJECT_VERSION']) + version, build = settings['MARKETING_VERSION'], settings['CURRENT_PROJECT_VERSION'] + if os.environ['VERSION_SOURCE'] == 'next-tag': + # Exercise a tag version different from the checked-in project without creating a tag. + major, minor, patch = map(int, version.split('.')) + patch += 1 + version = f'{major}.{minor}.{patch}' + build = str(major * 10000 + minor * 100 + patch) + print('VERSION_NAME=' + version) + print('BUILD_NUMBER=' + build) PY - name: Build unsigned Release archive run: | set -o pipefail mkdir -p artifacts - xcodebuild archive -project SwiftRadio.xcodeproj -scheme SwiftRadio \ - -configuration Release -destination 'generic/platform=iOS' \ - -archivePath "$RUNNER_TEMP/SwiftRadio.xcarchive" \ - -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ - CODE_SIGNING_ALLOWED=NO 2>&1 | tee artifacts/archive.log + bash .github/scripts/archive-unsigned-app.sh \ + "$RUNNER_TEMP/SwiftRadio.xcarchive" "$VERSION_NAME" "$BUILD_NUMBER" \ + 2>&1 | tee artifacts/archive.log - name: Package and validate IPA run: | python3 .github/scripts/package-unsigned-app.py \ @@ -46,6 +57,6 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: unsigned-archive-validation + name: unsigned-archive-validation-${{ matrix.version_source }} path: artifacts/ retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de492daa8..4747a33cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,11 +32,8 @@ jobs: echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" - name: Build unsigned Release archive run: | - xcodebuild archive -project SwiftRadio.xcodeproj -scheme SwiftRadio \ - -configuration Release -destination 'generic/platform=iOS' \ - -archivePath build/SwiftRadio.xcarchive \ - -onlyUsePackageVersionsFromResolvedFile -jobs 2 \ - CODE_SIGNING_ALLOWED=NO + bash .github/scripts/archive-unsigned-app.sh \ + build/SwiftRadio.xcarchive "$VERSION_NAME" "$BUILD_NUMBER" - name: Package and validate unsigned IPA run: | python3 .github/scripts/package-unsigned-app.py \ From 0deb320d033dc818c2f88eaefd4610562146f0ff Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 18:58:13 -0400 Subject: [PATCH 10/11] Resolve cached station selections against current catalog metadata --- .../Stations/StationsStore.swift | 3 ++ .../StationActivationTests.swift | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift index c3da8a78b..7c6cd4f38 100644 --- a/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift +++ b/Packages/SwiftRadioCore/Sources/SwiftRadioCore/Stations/StationsStore.swift @@ -96,6 +96,9 @@ import Observation /// reports a buffering stream as playing, so that opens the player, as in UIKit. /// - `.carPlay` never restarts or cancels: playing or loading is left alone, paused or stopped plays. @discardableResult public func activate(_ station: RadioStation, on surface: ActivationSurface) -> Activation { + // Cached phone and CarPlay rows may retain an older value after a metadata-only refresh. + // Resolve their stable identity at tap time before selecting a different station too. + let station = stations.first(where: { $0.id == station.id }) ?? station guard let currentStation, station.id == currentStation.id else { return select(station) ? .selected : .unavailable } diff --git a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift index 60a6c4f97..11d433a22 100644 --- a/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift +++ b/Packages/SwiftRadioCore/Tests/SwiftRadioCoreTests/StationActivationTests.swift @@ -178,6 +178,43 @@ import Testing #expect(engine.loadedURLs.count == loadedURLs.count + 1) } + @Test(arguments: StationsStore.ActivationSurface.allCases, [false, true]) + func cachedRowSelectionUsesLatestWebsiteAfterRefresh(surface: StationsStore.ActivationSurface, + hasSelection: Bool) async throws { + for website: String? in ["https://example.com/new-website", nil] { + var stations = try RadioStationTests.fixtures() + stations[1].website = "https://example.com/old-website" + let old = stations[1] + let loader = FakeStationsLoader(stations: stations) + let engine = FakeRadioPlayer(fidelity: .vendor) + let player = PlayerServiceTests.service(engine) + let store = StationsStore(loader: loader, player: player) + await store.load() + if hasSelection { store.select(stations[0]) } + let cachedAction = { store.activate(old, on: surface) } + let cachedContent = CatalogListContent(stations: store.stations, loadState: .loaded, + maximumItemCount: 12) + stations[1].website = website + await loader.replace(with: stations) + await store.refresh() + + // Website is not displayed by either row. Their equality/cache may retain the old + // value and action, but activating it must select the current catalog metadata. + #expect(old == stations[1]) + #expect(cachedContent == CatalogListContent(stations: store.stations, loadState: .loaded, + maximumItemCount: 12)) + #expect(store.stations[1].website == website) + let loads = engine.loadedURLs.count + #expect(cachedAction() == .selected) + #expect(store.currentStation?.id == old.id) + #expect(store.currentStation?.website == website) + #expect(store.stations[1].website == website) + #expect(engine.loadedURLs.count == loads + 1) + #expect(engine.radioURL == URL(string: old.streamURL)) + #expect(player.state == .playing) + } + } + @Test func nextAndPreviousStepOverUnusableStations() async throws { var stations = try RadioStationTests.fixtures() let unusable = Self.invalidStreamURLs.enumerated().map { Self.unusable("Broken \($0.offset)", url: $0.element) } From c38cacc89702dc99f3fed85c46fc8b41305b12e5 Mon Sep 17 00:00:00 2001 From: Fethi El Hassasna Date: Sun, 4 Oct 2026 20:39:23 -0400 Subject: [PATCH 11/11] Separate website handoff from transport UI coverage --- .github/workflows/tests.yml | 2 +- SwiftRadioUITests/SwiftRadioUITests.swift | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5dbeac43b..9e05b504b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,7 @@ jobs: -test-timeouts-enabled YES -maximum-test-execution-time-allowance 180 \ -jobs 2 CODE_SIGNING_ALLOWED=NO 2>&1 | tee "$GITHUB_WORKSPACE/artifacts/core.log" - name: UI regression tests - # Live radio availability is a separate integration check. All seven + # Live radio availability is a separate integration check. All # remaining UI tests run; failures are retained rather than retried away. run: | set -o pipefail diff --git a/SwiftRadioUITests/SwiftRadioUITests.swift b/SwiftRadioUITests/SwiftRadioUITests.swift index 48dcf9c6a..51f1d3e7c 100755 --- a/SwiftRadioUITests/SwiftRadioUITests.swift +++ b/SwiftRadioUITests/SwiftRadioUITests.swift @@ -206,7 +206,7 @@ import XCTest return app } - func testReselectionPreviousNextAndWebsiteHandoff() throws { + func testReselectionPreviousAndNext() throws { continueAfterFailure = false let app = fixtureApp() let station = app.staticTexts["Absolute Country Hits"].firstMatch @@ -236,6 +236,20 @@ import XCTest assertLabel("Stop", on: transport) assertGone(app.sliders["playbackSeek"].firstMatch, "Live streams have no scrubber") capture("next-wraps-to-live", app: app) + } + + // Safari's remote view startup and accessibility queries can consume much of CI's + // per-test allowance. Keep its handoff/return contract independent of transport coverage. + func testWebsiteHandoffReturnsToStationList() throws { + continueAfterFailure = false + let app = fixtureApp() + let station = app.staticTexts["Absolute Country Hits"].firstMatch + station.tap() + let toggle = app.buttons["playbackToggle"].firstMatch + assertLabel("Stop", on: toggle) + station.tap() + let transport = app.buttons["playerTransport"] + XCTAssertTrue(transport.waitForExistence(timeout: 10)) app.buttons["playerOptions"].tap() let website = app.buttons["Station Website"].firstMatch XCTAssertTrue(website.waitForExistence(timeout: 10))