diff --git a/.gitignore b/.gitignore index 82c4a6f..0782ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ venv/ alchemyfm-backup/ cookies.txt login_out.txt + +# Google Cast SDK — 141 MB, fetched by ios/Vendor/fetch-cast-sdk.sh +ios/Vendor/GoogleCast.xcframework/ diff --git a/ios/AlchemyFM.xcodeproj/project.pbxproj b/ios/AlchemyFM.xcodeproj/project.pbxproj new file mode 100644 index 0000000..193eba0 --- /dev/null +++ b/ios/AlchemyFM.xcodeproj/project.pbxproj @@ -0,0 +1,331 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1A0000000000000000000012 /* GoogleCast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000011 /* GoogleCast.xcframework */; }; + 1A0000000000000000000014 /* GoogleCast.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000011 /* GoogleCast.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 1A0000000000000000000015 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 1A0000000000000000000014 /* GoogleCast.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1A0000000000000000000004 /* AlchemyFM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AlchemyFM.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A0000000000000000000011 /* GoogleCast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCast.xcframework; path = Vendor/GoogleCast.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 1A0000000000000000000010 /* Exceptions for "AlchemyFM" folder in "AlchemyFM" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 1A0000000000000000000006 /* AlchemyFM */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1A0000000000000000000005 /* AlchemyFM */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 1A0000000000000000000010 /* Exceptions for "AlchemyFM" folder in "AlchemyFM" target */, + ); + path = AlchemyFM; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A0000000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A0000000000000000000012 /* GoogleCast.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A0000000000000000000002 = { + isa = PBXGroup; + children = ( + 1A0000000000000000000005 /* AlchemyFM */, + 1A0000000000000000000013 /* Frameworks */, + 1A0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + 1A0000000000000000000013 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1A0000000000000000000011 /* GoogleCast.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 1A0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 1A0000000000000000000004 /* AlchemyFM.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A0000000000000000000006 /* AlchemyFM */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A000000000000000000000B /* Build configuration list for PBXNativeTarget "AlchemyFM" */; + buildPhases = ( + 1A0000000000000000000007 /* Sources */, + 1A0000000000000000000008 /* Frameworks */, + 1A0000000000000000000009 /* Resources */, + 1A0000000000000000000015 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 1A0000000000000000000005 /* AlchemyFM */, + ); + name = AlchemyFM; + packageProductDependencies = ( + ); + productName = AlchemyFM; + productReference = 1A0000000000000000000004 /* AlchemyFM.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + 1A0000000000000000000006 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = 1A000000000000000000000A /* Build configuration list for PBXProject "AlchemyFM" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A0000000000000000000002; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 1A0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A0000000000000000000006 /* AlchemyFM */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A0000000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A0000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1A000000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1A000000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1A000000000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ZMUB88RZ5D; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AlchemyFM/Info.plist; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = fm.alchemy.AlchemyFM; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1A000000000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ZMUB88RZ5D; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AlchemyFM/Info.plist; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = fm.alchemy.AlchemyFM; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A000000000000000000000A /* Build configuration list for PBXProject "AlchemyFM" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000000000000000000000C /* Debug */, + 1A000000000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A000000000000000000000B /* Build configuration list for PBXNativeTarget "AlchemyFM" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000000000000000000000E /* Debug */, + 1A000000000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1A0000000000000000000001 /* Project object */; +} diff --git a/ios/AlchemyFM.xcodeproj/xcshareddata/xcschemes/AlchemyFM.xcscheme b/ios/AlchemyFM.xcodeproj/xcshareddata/xcschemes/AlchemyFM.xcscheme new file mode 100644 index 0000000..36405ad --- /dev/null +++ b/ios/AlchemyFM.xcodeproj/xcshareddata/xcschemes/AlchemyFM.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/AlchemyFM/AlchemyFMApp.swift b/ios/AlchemyFM/AlchemyFMApp.swift new file mode 100644 index 0000000..74eca14 --- /dev/null +++ b/ios/AlchemyFM/AlchemyFMApp.swift @@ -0,0 +1,29 @@ +import SwiftUI + +@main +struct AlchemyFMApp: App { + @State private var server = ServerConfig() + @State private var player = RadioPlayer() + @State private var favorites = FavoritesStore() + @State private var admin = AdminSession() + + @AppStorage(DisplayPreference.appearance) private var appearance = AppearanceMode.system.rawValue + + init() { + // Before any Cast UI exists, since GCKUICastButton needs the shared + // context to have been configured. + CastController.shared.start() + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(server) + .environment(player) + .environment(favorites) + .environment(admin) + // nil follows the system setting. + .preferredColorScheme(AppearanceMode.resolve(appearance).colorScheme) + } + } +} diff --git a/ios/AlchemyFM/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/AlchemyFM/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..ab840e1 --- /dev/null +++ b/ios/AlchemyFM/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.180", + "green" : "0.690", + "red" : "0.980" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AlchemyFM/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/AlchemyFM/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..208a1fb Binary files /dev/null and b/ios/AlchemyFM/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/ios/AlchemyFM/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/AlchemyFM/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/ios/AlchemyFM/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AlchemyFM/Assets.xcassets/Contents.json b/ios/AlchemyFM/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/AlchemyFM/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AlchemyFM/Info.plist b/ios/AlchemyFM/Info.plist new file mode 100644 index 0000000..a013c2f --- /dev/null +++ b/ios/AlchemyFM/Info.plist @@ -0,0 +1,72 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Alchemy FM + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + + UIBackgroundModes + + audio + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + + + NSLocalNetworkUsageDescription + Alchemy FM connects to your self-hosted radio server on your local network to list stations and play the live stream, and finds Chromecast devices to cast to. + + + NSBonjourServices + + _googlecast._tcp + _CC1AD845._googlecast._tcp + + + diff --git a/ios/AlchemyFM/Models/Station.swift b/ios/AlchemyFM/Models/Station.swift new file mode 100644 index 0000000..56aeaae --- /dev/null +++ b/ios/AlchemyFM/Models/Station.swift @@ -0,0 +1,118 @@ +import Foundation + +// Mirrors backend/app/schemas.py. Only the listener-facing fields are modelled — +// Codable ignores everything else the API sends, so admin-only fields can be +// added server-side without breaking the app. + +struct KnowledgeSource: Codable, Hashable { + let url: String + var title: String = "" +} + +struct KnowledgeFact: Codable, Hashable { + let category: String + let text: String + var confidence: Double = 0 + var sources: [KnowledgeSource] = [] +} + +struct KnowledgeBlock: Codable, Hashable { + var facts: [KnowledgeFact] = [] + var rotationIntervalSec: Int = 15 +} + +struct NowPlaying: Codable, Hashable { + let title: String + let artist: String + var itemId: String? + var coverUrl: String? + var artistBio: String? + var artistBioUrl: String? + var knowledge: KnowledgeBlock? + /// Only sent to a signed-in operator, so nil doubles as "not an admin" — + /// which is exactly what gates the heart button. + var hearted: Bool? + + /// Identity for "did the track change?" checks. `item_id` is the real key + /// when present; title+artist covers stations whose source has no ids. + var trackKey: String { + itemId ?? "\(artist)|\(title)" + } +} + +struct TrackRef: Codable, Hashable { + let itemId: String + let title: String + let artist: String + var coverUrl: String? + /// Only set on recently-played rows. + var playedAt: Date? +} + +/// `GET /api/stations` +struct StationSummary: Codable, Hashable, Identifiable { + let slug: String + let name: String + var description: String = "" + var artworkUrl: String = "" + /// Direct Icecast URL. Preferred over the `/listen` redirect for playback — + /// it skips the backend proxy hop and its 20/min rate limit. + var streamUrl: String = "" + var listeners: Int = 0 + var onAir: Bool = false + /// Bumped when the broadcast restarts; used to cache-bust reconnects. + var streamEpoch: Int = 0 + var nowPlaying: NowPlaying? + var featured: Bool = false + + var id: String { slug } + + /// Stations without uploaded artwork report `artwork_url` as an empty + /// string, which is common — fall back to the current track's cover so the + /// grid isn't a wall of placeholders. + var listArtwork: String? { + artworkUrl.isEmpty ? nowPlaying?.coverUrl : artworkUrl + } +} + +/// `GET /api/stations/{slug}` +struct StationDetail: Codable, Hashable, Identifiable { + let slug: String + let name: String + var description: String = "" + var artworkUrl: String = "" + var streamUrl: String = "" + var listeners: Int = 0 + var onAir: Bool = false + var streamEpoch: Int = 0 + var nowPlaying: NowPlaying? + var upNext: [TrackRef] = [] + var recentlyPlayed: [TrackRef] = [] + + var id: String { slug } + + var summary: StationSummary { + StationSummary( + slug: slug, + name: name, + description: description, + artworkUrl: artworkUrl, + streamUrl: streamUrl, + listeners: listeners, + onAir: onAir, + streamEpoch: streamEpoch, + nowPlaying: nowPlaying + ) + } +} + +/// `GET /api/health` — used to validate a server URL before saving it. +struct ServerHealth: Codable { + let status: String + var stationsEnabled: Int = 0 + var encodeFormat: String = "mp3" + var bitrate: Int = 0 + var gitSha: String = "unknown" + /// One of the accents in backend/app/themes.py. + var defaultTheme: String = "amber" +} diff --git a/ios/AlchemyFM/Networking/AdminCredentialStore.swift b/ios/AlchemyFM/Networking/AdminCredentialStore.swift new file mode 100644 index 0000000..287f661 --- /dev/null +++ b/ios/AlchemyFM/Networking/AdminCredentialStore.swift @@ -0,0 +1,58 @@ +import Foundation +import Security + +/// Admin credentials, kept in the Keychain. +/// +/// Deliberately not `UserDefaults`, where the other preferences live: that is a +/// plist in the app container, readable from a file-system backup. This is an +/// operator's password for their own server. +/// +/// Keyed by host, so pointing the app at a different server doesn't silently +/// reuse credentials meant for the previous one. +enum AdminCredentialStore { + private static let service = "fm.alchemy.AlchemyFM.admin" + + struct Credentials: Codable, Equatable { + let username: String + let password: String + } + + static func save(_ credentials: Credentials, host: String) { + guard let data = try? JSONEncoder().encode(credentials) else { return } + delete(host: host) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: host, + kSecValueData as String: data, + // Needs to be readable while the app runs in the background to + // re-authenticate, but never leaves this device. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + SecItemAdd(query as CFDictionary, nil) + } + + static func load(host: String) -> Credentials? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: host, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return try? JSONDecoder().decode(Credentials.self, from: data) + } + + static func delete(host: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: host, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/AlchemyFM/Networking/AdminSession.swift b/ios/AlchemyFM/Networking/AdminSession.swift new file mode 100644 index 0000000..266f473 --- /dev/null +++ b/ios/AlchemyFM/Networking/AdminSession.swift @@ -0,0 +1,81 @@ +import Foundation +import Observation + +/// Optional operator sign-in. +/// +/// Everything the app does by default is public and unauthenticated. Signing in +/// unlocks exactly one thing — hearting the current track, which is an admin +/// action in the web player too. Nothing else changes, and the app is fully +/// usable without ever touching this. +@MainActor +@Observable +final class AdminSession { + enum State: Equatable { + case signedOut + case signingIn + case signedIn(username: String) + case failed(String) + } + + private(set) var state: State = .signedOut + + var username: String? { + if case .signedIn(let name) = state { return name } + return nil + } + + var isSignedIn: Bool { username != nil } + + /// Restores a stored sign-in for this host, if there is one. + /// + /// The session cookie usually outlives a launch, but re-authenticating is a + /// single cheap request and avoids the case where it has expired server + /// side and the heart button silently stops working. + func restore(for baseURL: URL) async { + guard let host = baseURL.host(), + let credentials = AdminCredentialStore.load(host: host) + else { + state = .signedOut + return + } + await signIn( + username: credentials.username, + password: credentials.password, + baseURL: baseURL, + persist: false + ) + } + + func signIn(username: String, password: String, baseURL: URL, persist: Bool = true) async { + guard let host = baseURL.host() else { return } + state = .signingIn + do { + let name = try await AlchemyAPI(baseURL: baseURL) + .adminLogin(username: username, password: password) + if persist { + AdminCredentialStore.save( + .init(username: username, password: password), host: host + ) + } + state = .signedIn(username: name) + } catch { + // A stored credential that no longer works shouldn't keep silently + // retrying on every launch. + if persist == false { AdminCredentialStore.delete(host: host) } + state = .failed(error.localizedDescription) + } + } + + func signOut(baseURL: URL?) { + if let host = baseURL?.host() { + AdminCredentialStore.delete(host: host) + } + // Drops the admin_session cookie so the station poll stops returning + // heart state immediately, rather than until it expires. + if let baseURL, let storage = HTTPCookieStorage.shared.cookies(for: baseURL) { + storage.filter { $0.name == "admin_session" } + .forEach(HTTPCookieStorage.shared.deleteCookie) + } + state = .signedOut + } +} diff --git a/ios/AlchemyFM/Networking/AlchemyAPI.swift b/ios/AlchemyFM/Networking/AlchemyAPI.swift new file mode 100644 index 0000000..512d0bc --- /dev/null +++ b/ios/AlchemyFM/Networking/AlchemyAPI.swift @@ -0,0 +1,196 @@ +import Foundation + +enum APIError: LocalizedError { + case badURL + case http(status: Int) + case notAlchemyServer + + var errorDescription: String? { + switch self { + case .badURL: + return "That doesn't look like a valid address." + case .http(let status) where status == 401: + return "Wrong username or password." + case .http(let status) where status == 404: + return "Not found on this server (404)." + case .http(let status) where status == 429: + return "Too many attempts — wait a few minutes and try again." + case .http(let status) where status == 503: + return "Admin is disabled on this server (no ADMIN_PASSWORD set)." + case .http(let status): + return "The server returned HTTP \(status)." + case .notAlchemyServer: + return "Reachable, but it doesn't look like an Alchemy FM server." + } + } +} + +struct AlchemyAPI: Sendable { + let baseURL: URL + + private static let session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 20 + // Deliberately off: with it on, an unreachable server doesn't fail, it + // waits — which leaves the setup screen spinning forever on a typo'd + // address, and stalls the poll loop instead of letting it retry. + config.waitsForConnectivity = false + // Station/now-playing responses are all `Cache-Control: no-store` + // server-side; skipping the local cache avoids a redundant disk hit on + // a 750ms poll loop. + config.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: config) + }() + + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + // FastAPI emits naive ISO-8601 for `played_at`, sometimes with + // fractional seconds, sometimes with a timezone. A malformed or + // unexpected timestamp must not fail the whole station payload. + d.dateDecodingStrategy = .custom { decoder in + let raw = try decoder.singleValueContainer().decode(String.self) + return Self.parseTimestamp(raw) ?? .distantPast + } + return d + }() + + private static let timestampFormatters: [ISO8601DateFormatter] = { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return [withFraction, plain] + }() + + private static func parseTimestamp(_ raw: String) -> Date? { + // Naive timestamps (no trailing Z/offset) are UTC on this backend. + let candidates = raw.hasSuffix("Z") || raw.contains("+") ? [raw] : [raw + "Z", raw] + for candidate in candidates { + for formatter in timestampFormatters { + if let date = formatter.date(from: candidate) { return date } + } + } + return nil + } + + // MARK: - Endpoints + + func stations() async throws -> [StationSummary] { + try await get("/api/stations") + } + + func station(slug: String) async throws -> StationDetail { + let escaped = slug.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? slug + return try await get("/api/stations/\(escaped)") + } + + func health() async throws -> ServerHealth { + let health: ServerHealth = try await get("/api/health") + guard health.status == "ok" else { throw APIError.notAlchemyServer } + return health + } + + // MARK: - Admin (optional) + + /// Exchanges credentials for the `admin_session` cookie. + /// + /// The cookie, not HTTP Basic, is what unlocks the heart feature: the + /// station endpoint decides whether to include `hearted` by looking for a + /// session cookie specifically (`admin_user_from_request`), even though + /// `require_admin` would also accept Basic auth on the write endpoint. + /// URLSession stores and replays the cookie for us. + @discardableResult + func adminLogin(username: String, password: String) async throws -> String { + struct Body: Encodable { + let username: String + let password: String + } + struct Reply: Decodable { + let username: String + } + let reply: Reply = try await post( + "/api/admin/login", body: Body(username: username, password: password) + ) + return reply.username + } + + /// `POST /api/admin/navidrome/songs/{item_id}/heart` + @discardableResult + func setHeart(itemId: String, hearted: Bool) async throws -> Bool { + struct Body: Encodable { let hearted: Bool } + struct Reply: Decodable { let hearted: Bool } + let escaped = itemId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? itemId + let reply: Reply = try await post( + "/api/admin/navidrome/songs/\(escaped)/heart", body: Body(hearted: hearted) + ) + return reply.hearted + } + + private func post(_ path: String, body: Body) async throws -> T { + guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.badURL } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, response) = try await Self.session.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw APIError.http(status: http.statusCode) + } + return try Self.decoder.decode(T.self, from: data) + } + + /// The stream URL to hand `AVPlayer`. + /// + /// Deliberately the backend's same-origin `/listen` endpoint rather than + /// the direct Icecast `stream_url`. Before opening a real playback + /// connection, iOS sniffs a media resource with a tiny `Range: bytes=0-1` + /// probe. Icecast ignores the range and answers with an endless `200`, so + /// the probe never terminates and playback never starts — the connection + /// registers as a listener while producing no audio. `/listen` answers the + /// probe with a bounded `206` (see `_probe_range_end` in + /// backend/app/routers/stations.py) and streams normally otherwise. + func listenURL(slug: String) -> URL? { + let escaped = slug.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? slug + return URL(string: "/api/stations/\(escaped)/listen", relativeTo: baseURL)?.absoluteURL + } + + private func get(_ path: String) async throws -> T { + guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.badURL } + let (data, response) = try await Self.session.data(from: url) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw APIError.http(status: http.statusCode) + } + do { + return try Self.decoder.decode(T.self, from: data) + } catch is DecodingError { + // Reaching a live web server that isn't Alchemy FM is the common + // case here (a router admin page, a Traefik 404 page, …). + throw APIError.notAlchemyServer + } + } + + // MARK: - URL helpers + + /// Resolves a URL the API handed us. `artwork_url` and `stream_url` arrive + /// absolute (built from the request host), but `cover_url` is a same-origin + /// path like `/api/cover/abc?size=300`, so both forms have to work. + func resolve(_ value: String?) -> URL? { + guard let value, !value.isEmpty else { return nil } + if value.hasPrefix("http://") || value.hasPrefix("https://") { + return URL(string: value) + } + return URL(string: value, relativeTo: baseURL)?.absoluteURL + } + + /// Fetches image bytes (cover art, station artwork) off the shared session. + func imageData(at url: URL) async throws -> Data { + let (data, response) = try await Self.session.data(from: url) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw APIError.http(status: http.statusCode) + } + return data + } +} diff --git a/ios/AlchemyFM/Networking/FavoritesStore.swift b/ios/AlchemyFM/Networking/FavoritesStore.swift new file mode 100644 index 0000000..81e9c44 --- /dev/null +++ b/ios/AlchemyFM/Networking/FavoritesStore.swift @@ -0,0 +1,51 @@ +import Foundation +import Observation + +/// Favourite stations, stored on the device. +/// +/// Deliberately local. The API already sorts `featured` stations first, but +/// that is the operator's choice for every listener; this is one person's, on +/// one phone, and the two shouldn't overwrite each other. +@MainActor +@Observable +final class FavoritesStore { + private static let key = "alchemyfm.favorites" + + private(set) var slugs: Set = [] + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + slugs = Set(defaults.stringArray(forKey: Self.key) ?? []) + } + + func isFavorite(_ slug: String) -> Bool { + slugs.contains(slug) + } + + func toggle(_ slug: String) { + if slugs.contains(slug) { + slugs.remove(slug) + } else { + slugs.insert(slug) + } + defaults.set(Array(slugs), forKey: Self.key) + } + + /// Favourites first, each group keeping the server's ordering — so the + /// operator's `featured`/`sort_order` still decides what leads within each + /// group rather than being replaced by insertion order. + func partition(_ stations: [StationSummary]) -> (favorites: [StationSummary], others: [StationSummary]) { + var favorites: [StationSummary] = [] + var others: [StationSummary] = [] + for station in stations { + if slugs.contains(station.slug) { + favorites.append(station) + } else { + others.append(station) + } + } + return (favorites, others) + } +} diff --git a/ios/AlchemyFM/Networking/ServerConfig.swift b/ios/AlchemyFM/Networking/ServerConfig.swift new file mode 100644 index 0000000..d4b4680 --- /dev/null +++ b/ios/AlchemyFM/Networking/ServerConfig.swift @@ -0,0 +1,83 @@ +import Foundation +import Observation + +/// The backend base URL. Alchemy FM is self-hosted, so there is no default — +/// first run asks for the address. +@MainActor +@Observable +final class ServerConfig { + private static let defaultsKey = "alchemyfm.serverURL" + + private(set) var baseURL: URL? + + /// The accent this server tells its listeners to use, so the app can match + /// the web player by default instead of picking its own colour. + private(set) var serverTheme: String? + + func refreshServerTheme() async { + guard let api else { return } + serverTheme = try? await api.health().defaultTheme + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let stored = defaults.string(forKey: Self.defaultsKey) { + baseURL = URL(string: stored) + } + } + + private let defaults: UserDefaults + + var isConfigured: Bool { baseURL != nil } + + var api: AlchemyAPI? { + baseURL.map { AlchemyAPI(baseURL: $0) } + } + + /// Display form for the settings field — no trailing slash noise. + var displayString: String { + baseURL?.absoluteString.trimmingTrailingSlash ?? "" + } + + func save(_ url: URL) { + baseURL = url + defaults.set(url.absoluteString, forKey: Self.defaultsKey) + } + + func clear() { + baseURL = nil + defaults.removeObject(forKey: Self.defaultsKey) + } + + /// Turns whatever the user typed into a usable base URL. + /// `alchemy.local:8080` and `http://10.0.0.5:8080/` both normalise fine. + static func normalize(_ raw: String) -> URL? { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if !text.contains("://") { text = "http://" + text } + text = text.trimmingTrailingSlash + + guard let components = URLComponents(string: text), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = components.host, !host.isEmpty + else { return nil } + + // A base URL must end in "/" for relative-path resolution to keep any + // subpath (e.g. https://host/radio/ + api/health). + return URL(string: text + "/") + } + + /// Confirms a candidate URL is actually an Alchemy FM server before saving. + static func validate(_ url: URL) async throws -> ServerHealth { + try await AlchemyAPI(baseURL: url).health() + } +} + +extension String { + var trimmingTrailingSlash: String { + var copy = self + while copy.hasSuffix("/") { copy.removeLast() } + return copy + } +} diff --git a/ios/AlchemyFM/Networking/StationDetailLoader.swift b/ios/AlchemyFM/Networking/StationDetailLoader.swift new file mode 100644 index 0000000..d55de33 --- /dev/null +++ b/ios/AlchemyFM/Networking/StationDetailLoader.swift @@ -0,0 +1,43 @@ +import Foundation +import Observation + +/// Polls one station's detail for a page you're *browsing* rather than +/// listening to. +/// +/// The player already polls the tuned station hard (750ms) to drive the lock +/// screen. Browsing a different station shouldn't disturb that or start any +/// audio, so this runs its own slower loop and touches nothing else. +@MainActor +@Observable +final class StationDetailLoader { + private(set) var detail: StationDetail? + + @ObservationIgnored private var task: Task? + @ObservationIgnored private var loadedSlug: String? + + func start(slug: String, api: AlchemyAPI) { + guard loadedSlug != slug else { return } + stop() + loadedSlug = slug + detail = nil + task = Task { [weak self] in + while !Task.isCancelled { + do { + let detail = try await api.station(slug: slug) + guard !Task.isCancelled else { return } + self?.detail = detail + } catch { + // Transient — the loop is the retry. + } + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .seconds(5)) + } + } + } + + func stop() { + task?.cancel() + task = nil + loadedSlug = nil + } +} diff --git a/ios/AlchemyFM/Networking/StationStore.swift b/ios/AlchemyFM/Networking/StationStore.swift new file mode 100644 index 0000000..0901004 --- /dev/null +++ b/ios/AlchemyFM/Networking/StationStore.swift @@ -0,0 +1,51 @@ +import Foundation +import Observation + +/// Backs the station grid. Refreshes on the same 15s cadence as the web home +/// page so listener counts and on-air badges don't go stale while the app sits +/// open. +@MainActor +@Observable +final class StationStore { + private(set) var stations: [StationSummary] = [] + private(set) var errorMessage: String? + private(set) var hasLoaded = false + + @ObservationIgnored private var refreshTask: Task? + + func start(api: AlchemyAPI) { + guard refreshTask == nil else { return } + refreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refresh(api: api) + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .seconds(15)) + } + } + } + + func stop() { + refreshTask?.cancel() + refreshTask = nil + } + + func refresh(api: AlchemyAPI) async { + do { + stations = try await api.stations() + errorMessage = nil + } catch { + // Keep whatever we already showed — a dropped poll shouldn't blank + // the grid out from under someone who is listening. + errorMessage = stations.isEmpty ? error.localizedDescription : nil + } + hasLoaded = true + } + + /// Called when the server URL changes, so stale stations don't linger. + func reset() { + stop() + stations = [] + errorMessage = nil + hasLoaded = false + } +} diff --git a/ios/AlchemyFM/Playback/AudioTapSpike.swift b/ios/AlchemyFM/Playback/AudioTapSpike.swift new file mode 100644 index 0000000..806710c --- /dev/null +++ b/ios/AlchemyFM/Playback/AudioTapSpike.swift @@ -0,0 +1,95 @@ +import AVFoundation +import MediaToolbox + +/// SPIKE — throwaway. Answers one question: can we get PCM samples out of the +/// live Icecast stream without touching the playback pipeline? +/// +/// MTAudioProcessingTap is the only route to samples from AVPlayer. It's known +/// not to work with HLS; ours is a progressive ICY stream, so it may. This logs +/// whether the process callback ever fires with real audio, and whether +/// attaching the tap disturbs playback. +enum AudioTapSpike { + + private static let process: MTAudioProcessingTapProcessCallback = { + tap, frames, flags, bufferListInOut, framesOut, flagsOut in + + let status = MTAudioProcessingTapGetSourceAudio( + tap, frames, bufferListInOut, flagsOut, nil, framesOut + ) + guard status == noErr else { + NSLog("[tap] GetSourceAudio failed: %d", Int(status)) + return + } + + let buffers = UnsafeMutableAudioBufferListPointer(bufferListInOut) + guard let first = buffers.first, + let data = first.mData?.assumingMemoryBound(to: Float.self) + else { return } + + let count = Int(framesOut.pointee) + guard count > 0 else { return } + + var sum: Float = 0 + var peak: Float = 0 + for i in 0.. 0 + } + + var remoteMediaClient: GCKRemoteMediaClient? { + GCKCastContext.sharedInstance().sessionManager.currentCastSession?.remoteMediaClient + } + + /// Hands a station to the receiver. The Chromecast pulls the stream from + /// the server directly, so this only has to describe it. + func load(streamURL: URL, title: String, artist: String, station: String, artwork: URL?) { + guard let client = remoteMediaClient else { return } + + let metadata = GCKMediaMetadata(metadataType: .musicTrack) + metadata.setString(title, forKey: kGCKMetadataKeyTitle) + metadata.setString(artist, forKey: kGCKMetadataKeyArtist) + metadata.setString(station, forKey: kGCKMetadataKeyAlbumTitle) + if let artwork { + metadata.addImage(GCKImage(url: artwork, width: 300, height: 300)) + } + + let media = GCKMediaInformationBuilder(contentURL: streamURL) + // Live, so the receiver shows no scrubber and doesn't try to seek. + media.streamType = .live + media.contentType = "audio/mpeg" + media.metadata = metadata + + let request = GCKMediaLoadRequestDataBuilder() + request.mediaInformation = media.build() + request.autoplay = true + + client.loadMedia(with: request.build()) + } + + /// Updates what the receiver displays without interrupting playback. + func updateMetadata(title: String, artist: String, station: String, artwork: URL?) { + // The Default Media Receiver takes its display metadata from the load + // request, so refreshing it means re-issuing one — which would restart + // the stream. Not worth a gap in audio every time a track changes; the + // receiver keeps showing the track it started on. + } + + /// Stops the audio but keeps the session, so the next station can start + /// on the same speakers without reconnecting to them. + func stopPlayback() { + remoteMediaClient?.stop() + } + + /// Disconnects from the receiver entirely. + func endSession() { + remoteMediaClient?.stop() + GCKCastContext.sharedInstance().sessionManager.endSession() + } +} + +extension CastController: GCKSessionManagerListener { + nonisolated func sessionManager( + _ sessionManager: GCKSessionManager, didStart session: GCKCastSession + ) { + Task { @MainActor in + self.isCasting = true + self.deviceName = session.device.friendlyName + } + } + + nonisolated func sessionManager( + _ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession + ) { + Task { @MainActor in + self.isCasting = true + self.deviceName = session.device.friendlyName + } + } + + nonisolated func sessionManager( + _ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error? + ) { + Task { @MainActor in + self.isCasting = false + self.deviceName = nil + } + } + + nonisolated func sessionManager( + _ sessionManager: GCKSessionManager, + didFailToStart session: GCKCastSession, + withError error: Error + ) { + Task { @MainActor in + self.isCasting = false + self.deviceName = nil + } + } +} + +extension CastController: GCKDiscoveryManagerListener { + nonisolated func didUpdateDeviceList() { + Task { @MainActor in + self.hasDevices = GCKCastContext.sharedInstance().discoveryManager.deviceCount > 0 + } + } +} diff --git a/ios/AlchemyFM/Playback/RadioPlayer.swift b/ios/AlchemyFM/Playback/RadioPlayer.swift new file mode 100644 index 0000000..afdecab --- /dev/null +++ b/ios/AlchemyFM/Playback/RadioPlayer.swift @@ -0,0 +1,722 @@ +import AVFoundation +import Foundation +import MediaPlayer +import Network +import Observation +import UIKit + +/// Owns the live stream: one `AVPlayer`, the audio session, the lock-screen +/// controls, the now-playing poll loop, and — the whole point of the native +/// app — reconnect logic that keeps working while backgrounded. +/// +/// Alchemy FM is live radio, not a music library: there is no seeking, no +/// skipping, and "play" always means *re-tune to live*, never *resume a +/// buffer*. See docs/IOS_APP_HANDOFF.md. +@MainActor +@Observable +final class RadioPlayer { + + enum State: Equatable { + case idle + case connecting + case playing + case reconnecting(attempt: Int) + /// No network path at all. Distinct from reconnecting: there is + /// nothing to retry against, and saying "Reconnecting… (17)" while + /// someone is in a tunnel is both wrong and alarming. + case offline + case interrupted + case failed(String) + } + + private(set) var state: State = .idle + private(set) var station: StationSummary? + private(set) var detail: StationDetail? + + /// True while the user wants audio. Drives the watchdog and every reconnect + /// decision — nothing retries once the user has actually stopped. + private(set) var wantsLive = false + + @ObservationIgnored var api: AlchemyAPI? + + /// The station list in display order, kept current by the station list + /// screen. Backs the lock screen's ⏮/⏭, which tune between stations. + @ObservationIgnored private var stationOrder: [StationSummary] = [] + + func updateStationOrder(_ stations: [StationSummary]) { + stationOrder = stations + } + + // MARK: Private state + + @ObservationIgnored private var player: AVPlayer? + @ObservationIgnored private var observations: [NSKeyValueObservation] = [] + @ObservationIgnored private var notificationObservers: [NSObjectProtocol] = [] + @ObservationIgnored private var reconnectTask: Task? + @ObservationIgnored private var watchdogTask: Task? + @ObservationIgnored private var pollTask: Task? + @ObservationIgnored private var artworkTask: Task? + @ObservationIgnored private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + @ObservationIgnored private var attempt = 0 + @ObservationIgnored private var playingEpoch = 0 + @ObservationIgnored private var lastProgressMarker: Double? + @ObservationIgnored private var frozenSamples = 0 + @ObservationIgnored private var currentArtworkKey: String? + @ObservationIgnored private var remoteCommandsInstalled = false + @ObservationIgnored private var isInterrupted = false + @ObservationIgnored private let pathMonitor = NWPathMonitor() + @ObservationIgnored private var isOnline = true + + init() { + pathMonitor.pathUpdateHandler = { [weak self] path in + let satisfied = path.status == .satisfied + Task { @MainActor [weak self] in self?.networkChanged(online: satisfied) } + } + pathMonitor.start(queue: DispatchQueue(label: "fm.alchemy.path")) + } + + /// Losing the network tears the player down and stops retrying — there is + /// nothing to retry against, and the backoff would only mean waiting up to + /// 15s after the connection returns. Regaining it reconnects immediately, + /// which is the behaviour a radio should have: walk back into signal and it + /// comes back on its own. + private func networkChanged(online: Bool) { + guard isOnline != online else { return } + isOnline = online + + guard wantsLive, !CastController.shared.isCasting else { return } + + if online { + NSLog("[AlchemyFM] network back — resuming") + attempt = 0 + cancelReconnect() + openStream() + } else { + NSLog("[AlchemyFM] network lost — holding") + cancelReconnect() + teardownPlayer() + endBackgroundTask() + state = .offline + } + } + + // MARK: - Derived state for the UI + + var isPlaying: Bool { state == .playing } + + var isBusy: Bool { + switch state { + case .connecting, .reconnecting: return true + default: return false + } + } + + var nowPlaying: NowPlaying? { detail?.nowPlaying ?? station?.nowPlaying } + + var statusText: String { + switch state { + case .idle: return "Stopped" + case .connecting: return "Tuning in…" + case .playing: return "Live" + // Deliberately stops counting. Live radio keeps trying indefinitely, + // and an escalating counter reads as a broken app rather than a + // patient one. + case .reconnecting(let n): + return n <= 3 ? "Reconnecting…" : "Can't reach the station — still trying" + case .offline: return "Waiting for a connection" + case .interrupted: return "Interrupted" + case .failed(let message): return message + } + } + + func isTuned(to slug: String) -> Bool { station?.slug == slug } + + // MARK: - Tuning + + func tune(to station: StationSummary, api: AlchemyAPI) { + self.api = api + if self.station?.slug != station.slug { + // Only when switching *between* stations while listening — not on + // the first tune-in, and not when casting, where the audio is + // coming out of a speaker this burst wouldn't reach. + if wantsLive, self.station != nil, !CastController.shared.isCasting, + UserDefaults.standard.object(forKey: DisplayPreference.tuningStatic) as? Bool ?? true + { + TuningStatic.shared.play() + } + teardownPlayer() + pollTask?.cancel() + pollTask = nil + detail = nil + } + self.station = station + installRemoteCommands() + startPolling() + play() + } + + func play() { + guard station != nil else { return } + wantsLive = true + isInterrupted = false + attempt = 0 + cancelReconnect() + openStream() + } + + func stop() { + wantsLive = false + isInterrupted = false + cancelReconnect() + teardownPlayer() + // Stops the audio but keeps the Cast session, so the next station + // starts on the same speakers without reconnecting to them. + CastController.shared.stopPlayback() + state = .idle + deactivateSession() + endBackgroundTask() + updateNowPlayingInfo() + } + + /// Called when a Cast session starts or ends, to move audio between the + /// phone and the receiver without the user having to press anything. + func castStateChanged() { + guard wantsLive else { return } + cancelReconnect() + attempt = 0 + openStream() + } + + func toggle() { + if wantsLive { stop() } else { play() } + } + + /// Fully leaves the station — stops audio and drops the poll loop. + func leave() { + stop() + pollTask?.cancel() + pollTask = nil + station = nil + detail = nil + currentArtworkKey = nil + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + + // MARK: - Stream lifecycle + + private func openStream() { + guard let station else { return } + playingEpoch = detail?.streamEpoch ?? station.streamEpoch + guard let url = streamURL(for: station) else { + state = .failed("This station has no stream URL.") + return + } + + // While casting, the receiver fetches the stream itself. Nothing local + // to build, and nothing local to watch — the watchdog and reconnect + // logic below are AVPlayer's, and there is no AVPlayer. + if CastController.shared.isCasting { + teardownPlayer() + deactivateSession() + CastController.shared.load( + streamURL: url, + title: nowPlaying?.title ?? station.name, + artist: nowPlaying?.artist ?? "", + station: station.name, + artwork: api?.resolve(nowPlaying?.coverUrl) ?? api?.resolve(station.artworkUrl) + ) + state = .playing + return + } + + teardownPlayer() + state = attempt == 0 ? .connecting : .reconnecting(attempt: attempt) + activateSession() + + let item = AVPlayerItem(url: url) + let player = AVPlayer(playerItem: item) + // Left at its default (true) on purpose. Setting it false means a + // `play()` issued before the item reaches `.readyToPlay` is dropped and + // never retried — the player then buffers forever at rate 0, which + // looks exactly like a working connection that produces no sound. + player.allowsExternalPlayback = true + self.player = player + + observe(player: player, item: item) + player.play() + + lastProgressMarker = nil + frozenSamples = 0 + startWatchdog() + updateNowPlayingInfo() + } + + /// Cache-busts every connection. The web player uses `stream_epoch` for + /// this; the extra nonce covers repeat attempts *within* one epoch, where an + /// unchanged URL can come back from a stale cached connection. + private func streamURL(for station: StationSummary) -> URL? { + // Falls back to the direct Icecast URL only if we somehow have no API + // client — see `listenURL` for why that is the worse option on iOS. + let candidate = api?.listenURL(slug: station.slug) + ?? api?.resolve(station.streamUrl) + ?? URL(string: station.streamUrl) + guard let base = candidate else { + return nil + } + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: true) else { + return base + } + var query = components.queryItems ?? [] + query.append(URLQueryItem(name: "_e", value: String(playingEpoch))) + query.append(URLQueryItem(name: "_t", value: String(Int(Date().timeIntervalSince1970)))) + components.queryItems = query + return components.url ?? base + } + + private func teardownPlayer() { + // Observers first: tearing down otherwise fires a spurious `.paused` + // transition that would look like a stall and trigger a reconnect. + observations.forEach { $0.invalidate() } + observations.removeAll() + notificationObservers.forEach { NotificationCenter.default.removeObserver($0) } + notificationObservers.removeAll() + watchdogTask?.cancel() + watchdogTask = nil + + player?.pause() + player?.replaceCurrentItem(with: nil) + player = nil + lastProgressMarker = nil + frozenSamples = 0 + } + + // MARK: - Observation + + private func observe(player: AVPlayer, item: AVPlayerItem) { + observations = [ + item.observe(\.status, options: [.new]) { [weak self] item, _ in + Task { @MainActor [weak self] in self?.handleItemStatus(item) } + }, + player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in + Task { @MainActor [weak self] in self?.handleTimeControlStatus(player) } + }, + ] + + let center = NotificationCenter.default + let session = AVAudioSession.sharedInstance() + notificationObservers = [ + center.addObserver( + forName: AVPlayerItem.playbackStalledNotification, object: item, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.scheduleReconnect(reason: "playback stalled") } + }, + center.addObserver( + forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: item, queue: .main + ) { [weak self] note in + let error = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error + let reason = error?.localizedDescription ?? "stream ended" + Task { @MainActor [weak self] in self?.scheduleReconnect(reason: reason) } + }, + center.addObserver( + forName: AVAudioSession.interruptionNotification, object: session, queue: .main + ) { [weak self] note in + Task { @MainActor [weak self] in self?.handleInterruption(note) } + }, + center.addObserver( + forName: AVAudioSession.routeChangeNotification, object: session, queue: .main + ) { [weak self] note in + Task { @MainActor [weak self] in self?.handleRouteChange(note) } + }, + ] + } + + private func handleItemStatus(_ item: AVPlayerItem) { + guard item === player?.currentItem, item.status == .failed else { return } + scheduleReconnect(reason: item.error?.localizedDescription ?? "stream failed") + } + + private func handleTimeControlStatus(_ player: AVPlayer) { + guard player === self.player else { return } + switch player.timeControlStatus { + case .playing: + attempt = 0 + state = .playing + endBackgroundTask() + updateNowPlayingInfo() + case .paused: + // We never pause ourselves without first tearing down observers, so + // reaching here while live means iOS or the network stopped us. + if wantsLive, !isInterrupted { + scheduleReconnect(reason: "playback stopped unexpectedly") + } + case .waitingToPlayAtSpecifiedRate: + break + @unknown default: + break + } + } + + private func handleInterruption(_ note: Notification) { + guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: raw) + else { return } + + switch type { + case .began: + // A call or Siri took the session. Suspend our own retry machinery + // so we aren't fighting the system for it. + isInterrupted = true + cancelReconnect() + watchdogTask?.cancel() + watchdogTask = nil + state = .interrupted + case .ended: + isInterrupted = false + let rawOptions = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 + let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions) + if wantsLive, options.contains(.shouldResume) { + play() + } else { + stop() + } + @unknown default: + break + } + } + + private func handleRouteChange(_ note: Notification) { + guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: raw) + else { return } + // Headphones pulled / Bluetooth dropped — never fall back to the + // speaker at whatever volume the room didn't ask for. + if reason == .oldDeviceUnavailable { stop() } + } + + // MARK: - Recovery + + /// Catches the failure mode AVPlayer doesn't report: it still claims to be + /// playing, but no audio is actually arriving. This is the native + /// replacement for the PWA's `setTimeout` heartbeat, and unlike that one it + /// keeps running while backgrounded. + private func startWatchdog() { + watchdogTask?.cancel() + watchdogTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled, let self else { return } + self.checkForSilentStall() + } + } + } + + /// A number that must keep rising while the stream is healthy. + /// + /// Deliberately *not* `player.currentTime()`: on an Icecast stream the + /// playhead doesn't advance the way a finite asset's does, so treating a + /// static currentTime as a stall tears down a perfectly good connection + /// every few seconds. Bytes transferred is the direct measure of the thing + /// this check is actually named for. + private func streamProgressMarker() -> Double? { + guard let item = player?.currentItem else { return nil } + // Summed across every event, not just the last one: byte counts are + // per-event, so reading only the newest entry makes the total appear to + // drop each time AVPlayer opens a new one — which reads as a stall on a + // stream that is in fact perfectly healthy. + if let events = item.accessLog()?.events, !events.isEmpty { + let total = events.reduce(Int64(0)) { $0 + max(0, $1.numberOfBytesTransferred) } + if total > 0 { return Double(total) } + } + // Before the access log has an entry, the buffered range still grows. + if let range = item.loadedTimeRanges.last?.timeRangeValue { + return CMTimeGetSeconds(CMTimeRangeGetEnd(range)) + } + return nil + } + + private func checkForSilentStall() { + guard wantsLive, state == .playing else { + frozenSamples = 0 + return + } + guard let marker = streamProgressMarker() else { + frozenSamples = 0 + lastProgressMarker = nil + return + } + defer { lastProgressMarker = marker } + + guard let previous = lastProgressMarker else { + frozenSamples = 0 + return + } + if marker > previous { + frozenSamples = 0 + return + } + frozenSamples += 1 + // ~6s of a live stream delivering nothing while it claims to be playing. + if frozenSamples >= 3 { + // Worth logging the playhead too: a frozen marker with a moving + // playhead means the stream died, while both frozen at zero means + // the player never actually started (see openStream). + NSLog( + "[AlchemyFM] stall: marker=%.0f playhead=%.1f rate=%.1f", + marker, CMTimeGetSeconds(player?.currentTime() ?? .zero), player?.rate ?? -1 + ) + scheduleReconnect(reason: "no audio arriving") + } + } + + private func scheduleReconnect(reason: String) { + guard wantsLive, !isInterrupted, reconnectTask == nil else { return } + + // Nothing to reconnect to. networkChanged will restart us the moment a + // path appears, so there is no timer to leave running here. + guard isOnline else { + teardownPlayer() + state = .offline + return + } + + // Buys ~30s of execution time so a reconnect that starts while the + // screen is locked actually gets to finish before iOS suspends us. + beginBackgroundTask() + + attempt += 1 + state = .reconnecting(attempt: attempt) + // Mirrors the web player's backoff: 2s, 4s, 6s … capped at 15s. + let delay = min(15.0, 2.0 + Double(attempt - 1) * 2.0) + NSLog("[AlchemyFM] reconnect in %.0fs (attempt %d): %@", delay, attempt, reason) + + teardownPlayer() + reconnectTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + self.reconnectTask = nil + guard self.wantsLive, !self.isInterrupted else { return } + self.openStream() + } + } + + private func cancelReconnect() { + reconnectTask?.cancel() + reconnectTask = nil + } + + // MARK: - Audio session + + private func activateSession() { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .default, policy: .longFormAudio) + try session.setActive(true) + } catch { + NSLog("[AlchemyFM] audio session activation failed: %@", error.localizedDescription) + } + } + + private func deactivateSession() { + try? AVAudioSession.sharedInstance().setActive( + false, options: .notifyOthersOnDeactivation + ) + } + + private func beginBackgroundTask() { + guard backgroundTask == .invalid else { return } + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "AlchemyFM.reconnect") { + Task { @MainActor [weak self] in self?.endBackgroundTask() } + } + } + + private func endBackgroundTask() { + guard backgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + + // MARK: - Now-playing metadata + + /// The API — not Icecast in-stream metadata — is the source of truth. + private func startPolling() { + guard pollTask == nil else { return } + pollTask = Task { [weak self] in + while !Task.isCancelled { + guard let self, let api = self.api, let slug = self.station?.slug else { return } + do { + let detail = try await api.station(slug: slug) + if !Task.isCancelled { self.apply(detail) } + } catch { + // Transient by assumption — the loop itself is the retry. + } + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .milliseconds(self.pollInterval)) + } + } + } + + private var pollInterval: Int { + guard isOnline else { return 10000 } + guard wantsLive else { return 5000 } + // Foreground mirrors the web player's 750ms now-playing poll. In the + // background the only consumer is the lock screen, which doesn't need + // sub-second updates — and a 750ms network loop behind a locked screen + // is a battery cost with nothing to show for it. + return UIApplication.shared.applicationState == .active ? 750 : 3000 + } + + private func apply(_ detail: StationDetail) { + self.detail = detail + station = detail.summary + updateNowPlayingInfo() + + // The broadcast restarted underneath us: our connection is dead even if + // AVPlayer hasn't worked that out yet. + if wantsLive, detail.streamEpoch != playingEpoch { + playingEpoch = detail.streamEpoch + scheduleReconnect(reason: "stream epoch changed") + } + } + + // MARK: - Lock screen / Control Center + + private func installRemoteCommands() { + guard !remoteCommandsInstalled else { return } + remoteCommandsInstalled = true + + let center = MPRemoteCommandCenter.shared() + center.playCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.play() } + return .success + } + // Live radio has nothing to resume into, so "pause" is really "stop". + center.pauseCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.stop() } + return .success + } + center.stopCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.stop() } + return .success + } + center.togglePlayPauseCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.toggle() } + return .success + } + // Whether iOS surfaces this on the Lock Screen is its call — it's + // reliably shown on CarPlay and the Watch, less so on the phone. Wiring + // it costs little and it's the only route to a heart in the *system* + // now-playing UI; a custom one would mean a Live Activity. + center.likeCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.toggleHeart() } + return .success + } + + // There is no next *track* to skip to — everyone hears the same + // broadcast — so these tune to the next station instead, which is the + // closest thing a radio has to a skip button. + center.nextTrackCommand.isEnabled = true + center.nextTrackCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.tuneToAdjacentStation(offset: 1) } + return .success + } + center.previousTrackCommand.isEnabled = true + center.previousTrackCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.tuneToAdjacentStation(offset: -1) } + return .success + } + + // Seeking stays off: a live stream has no timeline to scrub. + let unsupported: [MPRemoteCommand] = [ + center.skipForwardCommand, center.skipBackwardCommand, + center.seekForwardCommand, center.seekBackwardCommand, + center.changePlaybackPositionCommand, + ] + unsupported.forEach { $0.isEnabled = false } + } + + /// Same action as the lock screen's ⏭ / ⏮, for on-screen controls. + func nextStation() { tuneToAdjacentStation(offset: 1) } + func previousStation() { tuneToAdjacentStation(offset: -1) } + + /// True when there is somewhere to skip to — one station is not a carousel. + var canSkipStations: Bool { stationOrder.count > 1 } + + /// Wraps around, so ⏭ off the end of the list returns to the first station + /// rather than doing nothing and looking broken. + private func tuneToAdjacentStation(offset: Int) { + guard let api, + !stationOrder.isEmpty, + let current = station, + let index = stationOrder.firstIndex(where: { $0.slug == current.slug }) + else { return } + + let count = stationOrder.count + let next = stationOrder[((index + offset) % count + count) % count] + guard next.slug != current.slug else { return } + tune(to: next, api: api) + } + + private func updateNowPlayingInfo() { + let center = MPNowPlayingInfoCenter.default() + guard let station else { + center.nowPlayingInfo = nil + return + } + let track = nowPlaying + var info = center.nowPlayingInfo ?? [:] + info[MPMediaItemPropertyTitle] = track?.title ?? station.name + info[MPMediaItemPropertyArtist] = track?.artist ?? station.description + info[MPMediaItemPropertyAlbumTitle] = station.name + info[MPNowPlayingInfoPropertyIsLiveStream] = true + info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 + center.nowPlayingInfo = info + updateHeartCommand() + refreshArtworkIfNeeded() + } + + /// `hearted` is only sent to a signed-in operator, so its presence is what + /// decides whether the heart is offered at all — same gate as the button on + /// the station page. + private func updateHeartCommand() { + let like = MPRemoteCommandCenter.shared().likeCommand + let hearted = nowPlaying?.hearted + like.isEnabled = hearted != nil && nowPlaying?.itemId != nil + like.isActive = hearted ?? false + like.localizedTitle = (hearted ?? false) ? "Unheart" : "Heart" + } + + /// Exposed for the remote command; the station page has its own optimistic + /// path because it can show the result immediately. + func toggleHeart() { + guard let api, let itemId = nowPlaying?.itemId else { return } + let target = !(nowPlaying?.hearted ?? false) + Task { + _ = try? await api.setHeart(itemId: itemId, hearted: target) + // The next poll brings back the authoritative value. + } + } + + private func refreshArtworkIfNeeded() { + guard let api, let station else { return } + guard let url = api.resolve(nowPlaying?.coverUrl) ?? api.resolve(station.artworkUrl) else { + return + } + let key = url.absoluteString + guard key != currentArtworkKey else { return } + currentArtworkKey = key + + artworkTask?.cancel() + artworkTask = Task { [weak self] in + guard let data = try? await api.imageData(at: url), + let image = UIImage(data: data), + !Task.isCancelled, + let self, self.currentArtworkKey == key + else { return } + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in + image + } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + } +} diff --git a/ios/AlchemyFM/Playback/TuningStatic.swift b/ios/AlchemyFM/Playback/TuningStatic.swift new file mode 100644 index 0000000..363f899 --- /dev/null +++ b/ios/AlchemyFM/Playback/TuningStatic.swift @@ -0,0 +1,89 @@ +import AVFoundation + +/// A short burst of FM-style static, played across a station change. +/// +/// Synthesised rather than shipped as an asset: it's band-passed white noise +/// at a randomised centre frequency, so no two switches sound identical and +/// there's no audio file in the bundle. +/// +/// Notably this doesn't duck the live stream the way the web player must. There +/// the `