From 910b25e7c75fb6659561bba785486bc72d291f7b Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sun, 7 Jun 2026 17:49:42 -0700 Subject: [PATCH] blastoise ios --- .gitignore | 20 + .../BlastoisePing.xcodeproj/project.pbxproj | 316 +++ .../BlastoisePing/BlastoisePingApp.swift | 10 + .../BlastoisePing/ContentView.swift | 2425 +++++++++++++++++ .../BlastoisePing/Fonts/pixelify_sans.ttf | Bin 0 -> 79160 bytes ios/BlastoisePing/BlastoisePing/Info.plist | 63 + ios/BlastoisePing/README.md | 21 + 7 files changed, 2855 insertions(+) create mode 100644 ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj create mode 100644 ios/BlastoisePing/BlastoisePing/BlastoisePingApp.swift create mode 100644 ios/BlastoisePing/BlastoisePing/ContentView.swift create mode 100644 ios/BlastoisePing/BlastoisePing/Fonts/pixelify_sans.ttf create mode 100644 ios/BlastoisePing/BlastoisePing/Info.plist create mode 100644 ios/BlastoisePing/README.md diff --git a/.gitignore b/.gitignore index cbbde16..4ecb99e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,26 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +# Xcode +DerivedData/ +ios/**/build/ +*.xcuserstate +*.xcscmblueprint +*.xccheckout +*.moved-aside +xcuserdata/ +*.xcresult +*.xcarchive +*.app +*.appex +*.dSYM +*.dSYM.zip +*.ipa + +# Swift Package Manager / Xcode package scratch +.build/ +.swiftpm/ + tmp/ library_cache.db musicroom.db diff --git a/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj b/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dbc20ce --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj @@ -0,0 +1,316 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */; }; + 1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000102 /* ContentView.swift */; }; + 1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1A2B3C4D5E6F700000000100 /* BlastoisePing.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlastoisePing.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlastoisePingApp.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000103 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Fonts/pixelify_sans.ttf; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A2B3C4D5E6F700000000200 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A2B3C4D5E6F700000000300 = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000301 /* BlastoisePing */, + 1A2B3C4D5E6F700000000302 /* Products */, + ); + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000301 /* BlastoisePing */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */, + 1A2B3C4D5E6F700000000102 /* ContentView.swift */, + 1A2B3C4D5E6F700000000103 /* Info.plist */, + 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */, + ); + path = BlastoisePing; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000302 /* Products */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000100 /* BlastoisePing.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A2B3C4D5E6F700000000400 /* BlastoisePing */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */; + buildPhases = ( + 1A2B3C4D5E6F700000000500 /* Sources */, + 1A2B3C4D5E6F700000000600 /* Resources */, + 1A2B3C4D5E6F700000000200 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BlastoisePing; + productName = BlastoisePing; + productReference = 1A2B3C4D5E6F700000000100 /* BlastoisePing.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A2B3C4D5E6F700000000800 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 1A2B3C4D5E6F700000000400 = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A2B3C4D5E6F700000000300; + productRefGroup = 1A2B3C4D5E6F700000000302 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A2B3C4D5E6F700000000400 /* BlastoisePing */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A2B3C4D5E6F700000000600 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A2B3C4D5E6F700000000500 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */, + 1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1A2B3C4D5E6F700000000900 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = 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)", + ); + 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; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1A2B3C4D5E6F700000000901 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1A2B3C4D5E6F700000000902 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = BlastoisePing/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1A2B3C4D5E6F700000000903 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = BlastoisePing/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F700000000900 /* Debug */, + 1A2B3C4D5E6F700000000901 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F700000000902 /* Debug */, + 1A2B3C4D5E6F700000000903 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1A2B3C4D5E6F700000000800 /* Project object */; +} diff --git a/ios/BlastoisePing/BlastoisePing/BlastoisePingApp.swift b/ios/BlastoisePing/BlastoisePing/BlastoisePingApp.swift new file mode 100644 index 0000000..b58c6d6 --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/BlastoisePingApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct BlastoisePingApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/BlastoisePing/BlastoisePing/ContentView.swift b/ios/BlastoisePing/BlastoisePing/ContentView.swift new file mode 100644 index 0000000..36fa264 --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/ContentView.swift @@ -0,0 +1,2425 @@ +import AVFoundation +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @StateObject private var model = AppModel() + @State private var username = "" + @State private var password = "" + @State private var selectedTab: MainTab = .rooms + + var body: some View { + NavigationStack { + ZStack { + Theme.background.ignoresSafeArea() + + if model.authState == .signedIn { + mainApp + } else { + AuthView( + model: model, + username: $username, + password: $password + ) + } + } + .navigationTitle("Blastoise") + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbarBackground(Theme.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .font(Theme.bodyFont) + .buttonBorderShape(.roundedRectangle(radius: Theme.corner)) + } + } + + private var mainApp: some View { + ScrollView { + VStack(spacing: 14) { + HeaderView(model: model) + PlayerDeckView(model: model) + tabStrip + selectedPanel + DebugFooterView(model: model) + } + .padding(.horizontal, 14) + .padding(.bottom, 18) + } + } + + private var tabStrip: some View { + HStack(spacing: 8) { + ForEach(MainTab.allCases) { tab in + Button { + selectedTab = tab + if tab == .library { + Task { await model.loadLibraryIfNeeded() } + } else if tab == .playlists { + Task { await model.loadPlaylistsIfNeeded() } + } + } label: { + Label(tab.title, systemImage: tab.icon) + .labelStyle(.iconOnly) + .frame(width: 44, height: 40) + .background(selectedTab == tab ? Theme.accent : Theme.panel2) + .foregroundStyle(selectedTab == tab ? Theme.background : Theme.text) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + .accessibilityLabel(tab.title) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var selectedPanel: some View { + switch selectedTab { + case .rooms: + RoomsPanel(model: model) + case .queue: + QueuePanel(model: model) + case .people: + PeoplePanel(model: model) + case .library: + LibraryPanel(model: model) + case .playlists: + PlaylistsPanel(model: model) + case .debug: + DebugPanel(model: model) + } + } +} + +private enum MainTab: String, CaseIterable, Identifiable { + case rooms + case queue + case people + case library + case playlists + case debug + + var id: String { rawValue } + + var title: String { + switch self { + case .rooms: return "Rooms" + case .queue: return "Queue" + case .people: return "People" + case .library: return "Library" + case .playlists: return "Lists" + case .debug: return "Debug" + } + } + + var icon: String { + switch self { + case .rooms: return "radio" + case .queue: return "list.bullet" + case .people: return "person.2" + case .library: return "music.note.list" + case .playlists: return "rectangle.stack" + case .debug: return "waveform.path.ecg" + } + } +} + +private enum SourceMode: String { + case radio = "RADIO" + case library = "LIBRARY" +} + +private enum AuthState: String { + case checking = "CHECKING" + case signedOut = "SIGNED OUT" + case signedIn = "SIGNED IN" +} + +private struct Theme { + static let background = Color(red: 0.055, green: 0.052, blue: 0.067) + static let panel = Color(red: 0.112, green: 0.105, blue: 0.135) + static let panel2 = Color(red: 0.170, green: 0.157, blue: 0.205) + static let stroke = Color(red: 0.475, green: 0.425, blue: 0.545) + static let text = Color(red: 0.965, green: 0.930, blue: 0.760) + static let muted = Color(red: 0.640, green: 0.585, blue: 0.710) + static let accent = Color(red: 1.000, green: 0.812, blue: 0.176) + static let ready = Color(red: 0.350, green: 0.820, blue: 1.000) + static let amber = Color(red: 1.000, green: 0.570, blue: 0.240) + static let red = Color(red: 1.000, green: 0.310, blue: 0.340) + + static let corner: CGFloat = 0 + static let smallCorner: CGFloat = 0 + + static func pixel(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + .custom("PixelifySans-Regular", size: size).weight(weight) + } + + static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + pixel(size, weight: weight).monospacedDigit() + } + + static let bodyFont = pixel(16) + static let headlineFont = pixel(19, weight: .semibold) + static let captionFont = pixel(13) + static let microFont = mono(11, weight: .semibold) + static func display(_ size: CGFloat) -> Font { pixel(size, weight: .bold) } +} + +@MainActor +private final class AppModel: ObservableObject { + private let defaultServer = "http://mhsgroove.peterino.com:3001" + private let userAgent = "BlastoiseiOSSketch/0.1" + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private let player = AVPlayer() + + private var cookieHeader: String { + didSet { UserDefaults.standard.set(cookieHeader, forKey: "blastoise.cookie") } + } + private var webSocket: URLSessionWebSocketTask? + private var receiveTask: Task? + private var reconnectTask: Task? + private var tickerTask: Task? + private var endObserver: NSObjectProtocol? + private var reconnectAttempts = 0 + private var intentionalDisconnect = false + private var lastLibraryEndTrackId: String? + + @Published var serverURL: String { + didSet { UserDefaults.standard.set(serverURL, forKey: "blastoise.serverURL") } + } + @Published var authState: AuthState = .checking + @Published var currentUser: UserSession? + @Published var status = "Starting" + @Published var channels: [ChannelInfo] = [] + @Published var libraryTracks: [Track] = [] + @Published var libraryLoaded = false + @Published var myPlaylists: [Playlist] = [] + @Published var sharedPlaylists: [Playlist] = [] + @Published var playlistsLoaded = false + @Published var selectedPlaylistId: String? + @Published var selectedPlaylist: Playlist? + @Published var sourceMode: SourceMode = .radio + @Published var currentChannelId: String? + @Published var currentTrackId: String? + @Published var localLibraryIndex = -1 + @Published var listeners: [String] = [] + @Published var paused = true + @Published var queue: [Track] = [] + @Published var queueLoaded = false + @Published var currentIndex = 0 + @Published var playbackMode = "repeat-all" + @Published var channelName = "No room" + @Published var trackTitle = "No track" + @Published var trackDuration: TimeInterval = 0 + @Published var serverTimestampMs: Int64 = 0 + @Published var stateMonotonicTime: TimeInterval = 0 + @Published var expectedPositionMs: Int64 = 0 + @Published var playerPositionMs: Int64 = 0 + @Published var driftMs: Int64 = 0 + @Published var playbackState = "idle" + @Published var isPlaying = false + @Published var debugEvents: [String] = [] + @Published var isUploading = false + @Published var isFetching = false + @Published var importStatus = "" + @Published var pendingFetchPlaylist: FetchPlaylistResponse? + + var allPlaylists: [Playlist] { myPlaylists + sharedPlaylists } + + init() { + serverURL = UserDefaults.standard.string(forKey: "blastoise.serverURL") ?? defaultServer + cookieHeader = UserDefaults.standard.string(forKey: "blastoise.cookie") ?? "" + configureAudioSession() + startTicker() + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.handlePlaybackEnded() + } + } + Task { await validateSession() } + } + + deinit { + tickerTask?.cancel() + receiveTask?.cancel() + reconnectTask?.cancel() + webSocket?.cancel(with: .goingAway, reason: nil) + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + } + + func validateSession() async { + if cookieHeader.isEmpty { + authState = .signedOut + status = "Sign in required" + return + } + + authState = .checking + status = "Checking session" + do { + let session = try await me() + if let user = session, !user.isGuest { + await becomeSignedIn(user) + } else { + clearSession() + status = "Sign in required" + } + } catch { + clearSession() + status = "Session expired" + addDebug("me failed: \(error.localizedDescription)") + } + } + + func signIn(username: String, password: String) async { + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + status = "Username required" + return + } + authState = .checking + status = "Signing in" + do { + let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password] + let envelope: AuthEnvelope = try await request("/api/auth/login", method: "POST", json: body) + if let user = envelope.user { + await becomeSignedIn(user) + } else { + status = "Login returned no user" + authState = .signedOut + } + } catch { + authState = .signedOut + status = "Login failed" + addDebug("login failed: \(error.localizedDescription)") + } + } + + func signUp(username: String, password: String) async { + guard password.count >= 6 else { + status = "Password must be 6+ chars" + return + } + authState = .checking + status = "Signing up" + do { + let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password] + let envelope: AuthEnvelope = try await request("/api/auth/signup", method: "POST", json: body) + if let user = envelope.user { + await becomeSignedIn(user) + } else { + status = "Signup returned no user" + authState = .signedOut + } + } catch { + authState = .signedOut + status = "Signup failed" + addDebug("signup failed: \(error.localizedDescription)") + } + } + + func logout() async { + intentionalDisconnect = true + closeSocket() + player.pause() + player.replaceCurrentItem(with: nil) + _ = try? await requestRaw("/api/auth/logout", method: "POST", json: [:]) + clearSession() + resetPlayback() + status = "Signed out" + } + + func connectToServer() async { + if authState == .signedIn { + status = "Reloading" + await loadInitialData() + } else { + await validateSession() + } + } + + func loadLibraryIfNeeded() async { + if !libraryLoaded { + await loadLibrary() + } + } + + func loadPlaylistsIfNeeded() async { + if !playlistsLoaded { + await loadPlaylists() + } + } + + func loadLibrary() async { + guard requireSignedIn("Sign in to browse library") else { return } + do { + libraryTracks = try await request("/api/library") + libraryLoaded = true + status = "Library loaded" + addDebug("library \(libraryTracks.count)") + } catch { + status = "Library failed" + addDebug("library failed: \(error.localizedDescription)") + } + } + + func loadPlaylists() async { + guard requireSignedIn("Sign in to load playlists") else { return } + do { + let bundle: PlaylistBundle = try await request("/api/playlists") + myPlaylists = bundle.mine + sharedPlaylists = bundle.shared + playlistsLoaded = true + if selectedPlaylistId == nil { + selectedPlaylistId = allPlaylists.first?.id + } + if let id = selectedPlaylistId { + await loadPlaylist(id) + } + status = "Playlists loaded" + } catch { + status = "Playlists failed" + addDebug("playlists failed: \(error.localizedDescription)") + } + } + + func loadPlaylist(_ id: String) async { + guard requireSignedIn("Sign in to load playlists") else { return } + selectedPlaylistId = id + selectedPlaylist = nil + do { + selectedPlaylist = try await request("/api/playlists/\(encodePath(id))") + } catch { + status = "Playlist failed" + addDebug("playlist failed: \(error.localizedDescription)") + } + } + + func uploadFiles(_ urls: [URL]) async { + guard requireSignedIn("Sign in to upload") else { return } + guard !urls.isEmpty else { return } + + isUploading = true + importStatus = "Uploading \(urls.count) file(s)" + var uploaded = 0 + var failed = 0 + + for url in urls { + do { + try await uploadFile(url) + uploaded += 1 + importStatus = "Uploaded \(uploaded)/\(urls.count)" + } catch { + failed += 1 + importStatus = "Upload failed: \(error.localizedDescription)" + addDebug("upload failed: \(url.lastPathComponent)") + } + } + + isUploading = false + importStatus = failed == 0 ? "Uploaded \(uploaded) file(s)" : "Uploaded \(uploaded), failed \(failed)" + if uploaded > 0 { + await loadLibrary() + } + } + + func fetchFromWebsite(_ urlString: String) async { + guard requireSignedIn("Sign in to fetch URLs") else { return } + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + importStatus = "Enter a URL" + return + } + + isFetching = true + pendingFetchPlaylist = nil + importStatus = "Checking URL" + do { + let response: FetchResponse = try await request("/api/fetch", method: "POST", json: ["url": trimmed]) + switch response { + case .single(let item): + importStatus = "Queued: \(item.title)" + addDebug("fetch queued \(item.title.prefix(36))") + case .playlist(let playlist): + pendingFetchPlaylist = playlist + importStatus = "Playlist found: \(playlist.title)" + } + } catch { + importStatus = "Fetch failed" + addDebug("fetch failed: \(error.localizedDescription)") + } + isFetching = false + } + + func confirmFetchPlaylist() async { + guard let playlist = pendingFetchPlaylist else { return } + guard requireSignedIn("Sign in to fetch URLs") else { return } + + isFetching = true + importStatus = "Queueing playlist" + do { + let body: [String: Any] = [ + "items": playlist.items.map { ["url": $0.url, "title": $0.title] }, + "playlistTitle": playlist.title + ] + let response: FetchConfirmResponse = try await request("/api/fetch/confirm", method: "POST", json: body) + pendingFetchPlaylist = nil + importStatus = "\(response.message) -> \(response.playlistName ?? "playlist")" + await loadPlaylists() + } catch { + importStatus = "Playlist fetch failed" + addDebug("fetch confirm failed: \(error.localizedDescription)") + } + isFetching = false + } + + func cancelFetchPlaylist() { + pendingFetchPlaylist = nil + importStatus = "Playlist fetch cancelled" + } + + func joinChannel(_ id: String) async { + guard requireSignedIn("Sign in to join rooms") else { return } + sourceMode = .radio + intentionalDisconnect = false + localLibraryIndex = -1 + currentChannelId = id + status = "Joining" + addDebug("join \(id)") + connectWebSocket(channelId: id) + await loadChannelState(id) + } + + func playLibraryTrack(_ track: Track) { + guard let index = libraryTracks.firstIndex(where: { $0.id == track.id }) else { return } + sourceMode = .library + intentionalDisconnect = true + closeSocket() + localLibraryIndex = index + currentTrackId = track.id + trackTitle = track.title + trackDuration = track.duration + channelName = "Library" + paused = false + status = "Local" + serverTimestampMs = 0 + stateMonotonicTime = 0 + lastLibraryEndTrackId = nil + replacePlayerItem(track: track, positionMs: 0) + player.play() + addDebug("local play \(track.title.prefix(36))") + tick() + } + + func togglePlay() { + if sourceMode == .radio { + sendSocket(["action": paused ? "unpause" : "pause"]) + status = paused ? "Unpause sent" : "Pause sent" + return + } + + if player.timeControlStatus == .playing { + player.pause() + paused = true + } else { + if localLibraryIndex < 0, let first = libraryTracks.first { + playLibraryTrack(first) + return + } + player.play() + paused = false + } + tick() + } + + func previous() { + if sourceMode == .radio { + jumpRadio(delta: -1) + } else { + guard !libraryTracks.isEmpty else { return } + let nextIndex: Int + if playbackMode == "shuffle", libraryTracks.count > 1 { + nextIndex = randomLibraryIndex() + } else if localLibraryIndex <= 0 { + nextIndex = libraryTracks.count - 1 + } else { + nextIndex = localLibraryIndex - 1 + } + playLibraryTrack(libraryTracks[nextIndex]) + } + } + + func next() { + if sourceMode == .radio { + jumpRadio(delta: 1) + } else if !libraryTracks.isEmpty { + playLibraryTrack(libraryTracks[nextLibraryIndex()]) + } + } + + func seekBy(seconds: Int) { + let durationMs = max(Int64(trackDuration * 1000), playerPositionMs) + let target = max(Int64(0), min(durationMs, playerPositionMs + Int64(seconds * 1000))) + seek(to: Double(target) / 1000) + } + + func seek(to seconds: Double) { + let target = max(0, seconds) + if sourceMode == .radio { + sendSocket(["action": "seek", "timestamp": target]) + status = "Seek sent" + } else { + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) + } + tick() + } + + func cyclePlaybackMode() async { + let modes = ["once", "repeat-all", "repeat-one", "shuffle"] + let current = modes.firstIndex(of: playbackMode) ?? 0 + let next = modes[(current + 1) % modes.count] + playbackMode = next + + if sourceMode == .radio { + guard let channelId = currentChannelId else { + status = "No room" + return + } + do { + let _: ModeResponse = try await request( + "/api/channels/\(encodePath(channelId))/mode", + method: "POST", + json: ["mode": next] + ) + status = "Mode \(modeLabel(next))" + } catch { + status = "Mode failed" + addDebug("mode failed: \(error.localizedDescription)") + } + } else { + status = "Mode \(modeLabel(next))" + } + } + + func queueCurrent(playNext: Bool) async { + guard let trackId = currentTrackId else { + status = "No track" + return + } + await queueTrackIds([trackId], playNext: playNext) + } + + func queueTrack(_ track: Track, playNext: Bool) async { + await queueTrackIds([track.id], playNext: playNext) + } + + func addPlaylistToQueue(_ playlist: Playlist, playNext: Bool) async { + guard !playlist.trackIds.isEmpty else { + status = "Empty playlist" + return + } + await queueTrackIds(playlist.trackIds, playNext: playNext) + } + + func jumpToQueueIndex(_ index: Int) { + guard index >= 0, index < queue.count else { return } + sendSocket(["action": "jump", "index": index]) + status = "Jump sent" + } + + func removeQueueIndex(_ index: Int) async { + guard let channelId = currentChannelId else { + status = "No room" + return + } + do { + let _: QueueResponse = try await request( + "/api/channels/\(encodePath(channelId))/queue", + method: "PATCH", + json: ["remove": [index]] + ) + status = "Removed" + await loadChannelState(channelId) + } catch { + status = "Remove failed" + addDebug("remove failed: \(error.localizedDescription)") + } + } + + func stopAndExit() { + intentionalDisconnect = true + closeSocket() + player.pause() + player.replaceCurrentItem(with: nil) + resetPlayback() + status = "Stopped" + } + + func track(for id: String) -> Track? { + libraryTracks.first { $0.id == id } ?? queue.first { $0.id == id } + } + + private func becomeSignedIn(_ user: UserSession) async { + currentUser = user + authState = user.isGuest ? .signedOut : .signedIn + if user.isGuest { + status = "Sign in required" + return + } + status = "Signed in as \(user.username)" + addDebug("auth \(user.username)") + await loadInitialData() + } + + private func loadInitialData() async { + async let channelsLoad: Void = loadChannels() + async let libraryLoad: Void = loadLibrary() + async let playlistsLoad: Void = loadPlaylists() + _ = await (channelsLoad, libraryLoad, playlistsLoad) + } + + private func loadChannels() async { + guard requireSignedIn("Sign in to load rooms") else { return } + do { + channels = try await request("/api/channels") + addDebug("rooms \(channels.count)") + let target = currentChannelId + ?? channels.first(where: { $0.isDefault })?.id + ?? channels.first?.id + if let target, currentChannelId == nil { + await joinChannel(target) + } + } catch { + status = "Rooms failed" + addDebug("rooms failed: \(error.localizedDescription)") + } + } + + private func loadChannelState(_ channelId: String) async { + do { + let state: ChannelState = try await request("/api/channels/\(encodePath(channelId))") + handleChannelState(state) + } catch { + addDebug("room state failed: \(error.localizedDescription)") + } + } + + private func queueTrackIds(_ trackIds: [String], playNext: Bool) async { + guard requireSignedIn("Sign in to change queue") else { return } + guard let channelId = currentChannelId + ?? channels.first(where: { $0.isDefault })?.id + ?? channels.first?.id else { + status = "No room" + return + } + let insertAt = playNext ? max(0, currentIndex + 1) : nil + var body: [String: Any] = ["add": trackIds] + if let insertAt { + body["insertAt"] = insertAt + } + do { + let _: QueueResponse = try await request( + "/api/channels/\(encodePath(channelId))/queue", + method: "PATCH", + json: body + ) + status = playNext ? "Queued next" : "Queued" + currentChannelId = channelId + await loadChannelState(channelId) + await loadChannels() + } catch { + status = "Queue failed" + addDebug("queue failed: \(error.localizedDescription)") + } + } + + private func me() async throws -> UserSession? { + let envelope: AuthEnvelope = try await request("/api/auth/me") + return envelope.user + } + + private func request( + _ path: String, + method: String = "GET", + json: Any? = nil + ) async throws -> T { + let data = try await requestRaw(path, method: method, json: json) + return try decoder.decode(T.self, from: data) + } + + private func requestRaw( + _ path: String, + method: String = "GET", + json: Any? = nil + ) async throws -> Data { + guard let url = URL(string: normalizedBaseURL() + path) else { + throw APIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 12 + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + if let json { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: json) + } + + let (data, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse { + captureCookie(http) + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw APIError.http(http.statusCode, text) + } + } + return data + } + + private func uploadFile(_ fileURL: URL) async throws { + guard let url = URL(string: normalizedBaseURL() + "/api/upload") else { + throw APIError.invalidURL + } + + let didAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + let fileData = try Data(contentsOf: fileURL) + let boundary = "BlastoiseBoundary-\(UUID().uuidString)" + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 120 + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + + let body = multipartBody( + boundary: boundary, + fieldName: "file", + filename: fileURL.lastPathComponent, + contentType: contentType(for: fileURL), + data: fileData + ) + + let (data, response) = try await URLSession.shared.upload(for: request, from: body) + if let http = response as? HTTPURLResponse { + captureCookie(http) + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw APIError.http(http.statusCode, text) + } + } + } + + private func multipartBody( + boundary: String, + fieldName: String, + filename: String, + contentType: String, + data: Data + ) -> Data { + var body = Data() + body.appendString("--\(boundary)\r\n") + body.appendString("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\r\n") + body.appendString("Content-Type: \(contentType)\r\n\r\n") + body.append(data) + body.appendString("\r\n--\(boundary)--\r\n") + return body + } + + private func contentType(for url: URL) -> String { + switch url.pathExtension.lowercased() { + case "mp3": return "audio/mpeg" + case "ogg", "opus": return "audio/ogg" + case "flac": return "audio/flac" + case "wav": return "audio/wav" + case "m4a", "aac": return "audio/mp4" + case "mp4": return "video/mp4" + case "wma": return "audio/x-ms-wma" + default: return "application/octet-stream" + } + } + + private func connectWebSocket(channelId: String) { + closeSocket() + guard let url = webSocketURL(channelId: channelId) else { + status = "Bad WebSocket URL" + return + } + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + + let task = URLSession.shared.webSocketTask(with: request) + webSocket = task + task.resume() + status = "Connected" + addDebug("ws connect \(channelId)") + reconnectAttempts = 0 + + receiveTask = Task { [weak self, task] in + while !Task.isCancelled { + do { + let message = try await task.receive() + await MainActor.run { + self?.handleWebSocketMessage(message) + } + } catch { + await MainActor.run { + self?.handleSocketError(error) + } + break + } + } + } + } + + private func closeSocket() { + receiveTask?.cancel() + receiveTask = nil + reconnectTask?.cancel() + reconnectTask = nil + webSocket?.cancel(with: .goingAway, reason: nil) + webSocket = nil + } + + private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { + let data: Data + switch message { + case .string(let text): + data = Data(text.utf8) + case .data(let payload): + data = payload + @unknown default: + addDebug("ws unknown message") + return + } + + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + addDebug("ws bad json") + return + } + + if let type = object["type"] as? String, !type.isEmpty { + handleTypedSocketMessage(type, object: object, data: data) + return + } + + do { + let state = try decoder.decode(ChannelState.self, from: data) + handleChannelState(state) + } catch { + addDebug("ws state decode failed") + } + } + + private func handleTypedSocketMessage(_ type: String, object: [String: Any], data: Data) { + switch type { + case "channel_list": + if let channelData = try? JSONSerialization.data(withJSONObject: object["channels"] ?? []), + let nextChannels = try? decoder.decode([ChannelInfo].self, from: channelData) { + channels = nextChannels + } + case "switched": + currentChannelId = object["channelId"] as? String + status = "Tuned" + case "kick": + status = "Kicked" + addDebug("kick \(object["reason"] as? String ?? "")") + case "error": + status = object["message"] as? String ?? "Server error" + addDebug("ws error \(status)") + default: + addDebug("ws event \(type)") + } + } + + private func handleSocketError(_ error: Error) { + guard !intentionalDisconnect, sourceMode == .radio else { return } + status = "Disconnected" + addDebug("ws fail: \(error.localizedDescription)") + scheduleReconnect() + } + + private func scheduleReconnect() { + guard let channelId = currentChannelId else { return } + reconnectTask?.cancel() + let delaySeconds = min(3 * (reconnectAttempts + 1), 30) + reconnectAttempts += 1 + addDebug("reconnect in \(delaySeconds)s") + reconnectTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) + await MainActor.run { + guard let self, self.sourceMode == .radio, !self.intentionalDisconnect else { return } + self.connectWebSocket(channelId: channelId) + } + if let self { + await self.loadChannelState(channelId) + } + } + } + + private func sendSocket(_ dictionary: [String: Any]) { + guard let webSocket else { + status = "Socket not connected" + scheduleReconnect() + return + } + guard let data = try? JSONSerialization.data(withJSONObject: dictionary), + let payload = String(data: data, encoding: .utf8) else { + return + } + webSocket.send(.string(payload)) { [weak self] error in + if let error { + Task { @MainActor in + self?.addDebug("ws send failed: \(error.localizedDescription)") + } + } + } + } + + private func handleChannelState(_ state: ChannelState) { + guard sourceMode == .radio else { return } + reconnectAttempts = 0 + currentChannelId = state.channelId.isEmpty ? currentChannelId : state.channelId + channelName = state.channelName.isEmpty ? "Room" : state.channelName + paused = state.paused + playbackMode = state.playbackMode.isEmpty ? playbackMode : state.playbackMode + currentIndex = state.currentIndex + listeners = state.listeners + if let stateQueue = state.queue { + queue = stateQueue + queueLoaded = true + } + serverTimestampMs = max(0, Int64(state.currentTimestamp * 1000)) + stateMonotonicTime = ProcessInfo.processInfo.systemUptime + + guard let track = state.track else { + currentTrackId = nil + trackTitle = "No track" + trackDuration = 0 + player.pause() + tick() + return + } + + trackTitle = track.title + trackDuration = track.duration + let targetMs = expectedPosition() + + if currentTrackId != track.id { + currentTrackId = track.id + replacePlayerItem(track: track, positionMs: targetMs) + if state.paused { + player.pause() + } else { + player.play() + } + tick() + return + } + + let drift = playerPosition() - targetMs + if abs(drift) >= 2000 { + seekPlayer(toMs: targetMs) + addDebug("drift correction \(drift)ms") + } + + if state.paused, player.timeControlStatus == .playing { + player.pause() + } else if !state.paused, player.timeControlStatus != .playing { + player.play() + } + tick() + } + + private func replacePlayerItem(track: Track, positionMs: Int64) { + guard let url = trackURL(track.id) else { + status = "Bad track URL" + return + } + var headers = ["User-Agent": userAgent] + if !cookieHeader.isEmpty { + headers["Cookie"] = cookieHeader + } + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + let item = AVPlayerItem(asset: asset) + player.replaceCurrentItem(with: item) + seekPlayer(toMs: positionMs) + } + + private func seekPlayer(toMs ms: Int64) { + let seconds = Double(max(0, ms)) / 1000 + player.seek( + to: CMTime(seconds: seconds, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + private func tick() { + playerPositionMs = playerPosition() + expectedPositionMs = expectedPosition() + driftMs = sourceMode == .radio && currentTrackId != nil ? playerPositionMs - expectedPositionMs : 0 + isPlaying = player.timeControlStatus == .playing + playbackState = playbackStateLabel() + + if sourceMode == .radio, currentTrackId != nil, !paused, abs(driftMs) >= 2000 { + seekPlayer(toMs: expectedPositionMs) + addDebug("tick drift correction \(driftMs)ms") + } + + if sourceMode == .library { + paused = !isPlaying + if let duration = player.currentItem?.duration.seconds, duration.isFinite, duration > 0 { + trackDuration = duration + } + } + } + + private func expectedPosition() -> Int64 { + guard sourceMode == .radio, stateMonotonicTime > 0 else { + return playerPosition() + } + if paused { + return serverTimestampMs + } + let elapsedMs = Int64((ProcessInfo.processInfo.systemUptime - stateMonotonicTime) * 1000) + return serverTimestampMs + max(0, elapsedMs) + } + + private func playerPosition() -> Int64 { + let seconds = player.currentTime().seconds + guard seconds.isFinite, seconds >= 0 else { return 0 } + return Int64(seconds * 1000) + } + + private func handlePlaybackEnded() { + guard sourceMode == .library else { return } + guard currentTrackId != lastLibraryEndTrackId else { return } + lastLibraryEndTrackId = currentTrackId + switch playbackMode { + case "repeat-one": + seekPlayer(toMs: 0) + player.play() + lastLibraryEndTrackId = nil + case "repeat-all", "shuffle": + if !libraryTracks.isEmpty { + playLibraryTrack(libraryTracks[nextLibraryIndex()]) + } + default: + paused = true + tick() + } + } + + private func jumpRadio(delta: Int) { + guard !queue.isEmpty else { return } + let target = (currentIndex + delta + queue.count) % queue.count + sendSocket(["action": "jump", "index": target]) + status = "Jump sent" + } + + private func nextLibraryIndex() -> Int { + guard !libraryTracks.isEmpty else { return 0 } + if playbackMode == "shuffle", libraryTracks.count > 1 { + return randomLibraryIndex() + } + return localLibraryIndex < 0 ? 0 : (localLibraryIndex + 1) % libraryTracks.count + } + + private func randomLibraryIndex() -> Int { + guard libraryTracks.count > 1 else { return 0 } + var next = Int.random(in: 0.. Bool { + if authState == .signedIn, currentUser?.isGuest == false { return true } + status = message + addDebug("auth required") + return false + } + + private func startTicker() { + tickerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 500_000_000) + await MainActor.run { + self?.tick() + } + } + } + } + + private func configureAudioSession() { + #if os(iOS) + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + addDebug("audio session failed") + } + #endif + } + + private func playbackStateLabel() -> String { + switch player.timeControlStatus { + case .paused: + return player.currentItem == nil ? "idle" : "paused" + case .waitingToPlayAtSpecifiedRate: + return "buffering" + case .playing: + return "playing" + @unknown default: + return "unknown" + } + } + + private func modeLabel(_ mode: String) -> String { + switch mode { + case "once": return "ONCE" + case "repeat-all": return "LOOP ALL" + case "repeat-one": return "LOOP ONE" + case "shuffle": return "SHUFFLE" + default: return mode.uppercased() + } + } + + private func captureCookie(_ response: HTTPURLResponse) { + for (key, value) in response.allHeaderFields { + guard String(describing: key).lowercased() == "set-cookie" else { continue } + let raw = String(describing: value) + if let cookie = raw + .components(separatedBy: ";") + .first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("blastoise_session=") }) { + let clean = cookie.trimmingCharacters(in: .whitespaces) + cookieHeader = clean == "blastoise_session=" ? "" : clean + } + } + } + + private func normalizedBaseURL() -> String { + var value = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + value = defaultServer + } + if !value.contains("://") { + value = "http://\(value)" + } + return value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private func webSocketURL(channelId: String) -> URL? { + guard var components = URLComponents(string: normalizedBaseURL()) else { return nil } + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/api/channels/\(encodePath(channelId))/ws" + return components.url + } + + private func trackURL(_ trackId: String) -> URL? { + URL(string: normalizedBaseURL() + "/api/tracks/\(encodePath(trackId))") + } + + private func encodePath(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: ":/?#[]@!$&'()*+,;=") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func addDebug(_ message: String) { + let stamp = Date().formatted(.dateTime.hour().minute().second()) + debugEvents.append("\(stamp) \(message)") + while debugEvents.count > 12 { + debugEvents.removeFirst() + } + } +} + +private struct AuthView: View { + @ObservedObject var model: AppModel + @Binding var username: String + @Binding var password: String + @FocusState private var focused: Field? + + private enum Field { + case server + case username + case password + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + Text("BLASTOISE") + .font(Theme.display(40)) + .foregroundStyle(Theme.text) + Text("Tune into a shared room, stream the queue, and keep your local player in sync.") + .foregroundStyle(Theme.muted) + } + + VStack(alignment: .leading, spacing: 12) { + Label("Server", systemImage: "server.rack") + .foregroundStyle(Theme.text) + .font(Theme.headlineFont) + field("http://host:3001", text: $model.serverURL, field: .server) + + HStack(spacing: 10) { + Button { + model.serverURL = "http://mhsgroove.peterino.com:3001" + } label: { + Label("Default", systemImage: "radio") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + model.serverURL = "http://localhost:3001" + } label: { + Label("Local", systemImage: "desktopcomputer") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .panel() + + VStack(alignment: .leading, spacing: 12) { + Label("Account", systemImage: "person.crop.circle") + .foregroundStyle(Theme.text) + .font(Theme.headlineFont) + field("username", text: $username, field: .username) + SecureField("password", text: $password) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focused, equals: .password) + .textFieldStyle() + + HStack(spacing: 10) { + Button { + focused = nil + Task { await model.signIn(username: username, password: password) } + } label: { + Label("Sign In", systemImage: "arrow.right.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + + Button { + focused = nil + Task { await model.signUp(username: username, password: password) } + } label: { + Label("Sign Up", systemImage: "person.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .panel() + + StatusStrip(model: model) + } + .padding(18) + .frame(maxWidth: 640, alignment: .topLeading) + } + } + + private func field(_ placeholder: String, text: Binding, field: Field) -> some View { + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(field == .server ? .URL : .default) + .focused($focused, equals: field) + .textFieldStyle() + } +} + +private struct HeaderView: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(spacing: 10) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("BLASTOISE") + .font(Theme.display(28)) + .foregroundStyle(Theme.text) + Text(model.currentUser?.username ?? "signed out") + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + } + + Spacer() + + Button { + Task { await model.connectToServer() } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 38, height: 36) + } + .buttonStyle(.bordered) + + Button(role: .destructive) { + Task { await model.logout() } + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + .frame(width: 38, height: 36) + } + .buttonStyle(.bordered) + } + + HStack(spacing: 8) { + Image(systemName: "server.rack") + .foregroundStyle(Theme.amber) + TextField("server", text: $model.serverURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(Theme.mono(12)) + .foregroundStyle(Theme.text) + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + .padding(14) + .background(Theme.background) + } +} + +private struct PlayerDeckView: View { + @ObservedObject var model: AppModel + + var progress: Double { + guard model.trackDuration > 0 else { return 0 } + return min(1, max(0, Double(model.playerPositionMs) / (model.trackDuration * 1000))) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(model.sourceMode.rawValue) + .font(Theme.mono(12, weight: .bold)) + .foregroundStyle(Theme.accent) + Text(model.channelName) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + Text(model.trackTitle) + .font(Theme.pixel(22, weight: .bold)) + .foregroundStyle(Theme.text) + .lineLimit(2) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(model.playbackMode.uppercased()) + .font(Theme.mono(12)) + .foregroundStyle(Theme.amber) + Text(model.playbackState.uppercased()) + .font(Theme.mono(12)) + .foregroundStyle(model.isPlaying ? Theme.ready : Theme.muted) + } + } + + VStack(alignment: .leading, spacing: 8) { + ProgressView(value: progress) + .tint(Theme.ready) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) + + HStack { + Text(formatTime(model.playerPositionMs)) + Spacer() + Text(formatDuration(model.trackDuration)) + } + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + } + + HStack(spacing: 8) { + iconButton("backward.end.fill") { model.previous() } + iconButton("gobackward.15") { model.seekBy(seconds: -15) } + Button { + model.togglePlay() + } label: { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(Theme.pixel(24, weight: .bold)) + .frame(width: 58, height: 48) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + iconButton("goforward.15") { model.seekBy(seconds: 15) } + iconButton("forward.end.fill") { model.next() } + } + + HStack(spacing: 8) { + actionButton("Mode", icon: "repeat") { + Task { await model.cyclePlaybackMode() } + } + actionButton("Queue", icon: "text.badge.plus") { + Task { await model.queueCurrent(playNext: false) } + } + actionButton("Next", icon: "text.line.first.and.arrowtriangle.forward") { + Task { await model.queueCurrent(playNext: true) } + } + actionButton("Stop", icon: "power") { + model.stopAndExit() + } + } + + HStack(spacing: 12) { + meter("DRIFT", "\(model.driftMs)ms", model.sourceMode == .radio && abs(model.driftMs) > 1800 ? Theme.amber : Theme.ready) + meter("ROOMS", "\(model.channels.count)", Theme.text) + meter("QUEUE", "\(model.queue.count)", Theme.text) + } + } + .panel() + } + + private func iconButton(_ systemName: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + } + + private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: icon) + .labelStyle(.iconOnly) + .frame(maxWidth: .infinity, minHeight: 38) + } + .buttonStyle(.bordered) + .accessibilityLabel(title) + } + + private func meter(_ label: String, _ value: String, _ color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(Theme.microFont) + .foregroundStyle(Theme.muted) + Text(value) + .font(Theme.mono(13, weight: .bold)) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } +} + +private struct RoomsPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Rooms", icon: "radio") + if model.channels.isEmpty { + EmptyLine("No rooms loaded") + } else { + ForEach(model.channels) { channel in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(channel.name) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + if channel.isDefault { + Text("DEFAULT") + .font(Theme.microFont) + .foregroundStyle(Theme.background) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Theme.amber) + .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) + } + } + Text(channel.description.isEmpty ? "\(channel.trackCount) tracks" : channel.description) + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + Text("\(channel.listenerCount) listener(s)") + .font(Theme.mono(12)) + .foregroundStyle(Theme.ready) + } + Spacer() + Button { + Task { await model.joinChannel(channel.id) } + } label: { + Image(systemName: model.currentChannelId == channel.id ? "checkmark.circle.fill" : "dot.radiowaves.left.and.right") + .frame(width: 44, height: 38) + } + .buttonStyle(.borderedProminent) + .tint(model.currentChannelId == channel.id ? Theme.ready : Theme.accent) + } + .rowStyle(isActive: model.currentChannelId == channel.id) + } + } + } + .panel() + } +} + +private struct QueuePanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Queue", icon: "list.bullet") + if model.queue.isEmpty { + EmptyLine(model.queueLoaded ? "Queue is empty" : "Queue not loaded") + } else { + ForEach(Array(model.queue.prefix(80).enumerated()), id: \.offset) { index, track in + TrackLine( + track: track, + isActive: index == model.currentIndex, + subtitle: "#\(index + 1) \(formatDuration(track.duration))" + ) { + Button { + model.jumpToQueueIndex(index) + } label: { + Image(systemName: "play.fill") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + + Button(role: .destructive) { + Task { await model.removeQueueIndex(index) } + } label: { + Image(systemName: "trash") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + } + } + } + .panel() + } +} + +private struct PeoplePanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("People", icon: "person.2") + if model.listeners.isEmpty { + EmptyLine("No listener names in this room yet") + } else { + ForEach(model.listeners, id: \.self) { listener in + HStack { + Image(systemName: listener == model.currentUser?.username ? "person.fill.checkmark" : "person.fill") + .foregroundStyle(listener == model.currentUser?.username ? Theme.ready : Theme.muted) + Text(listener) + .foregroundStyle(Theme.text) + Spacer() + if listener == model.currentUser?.username { + Text("YOU") + .font(Theme.microFont) + .foregroundStyle(Theme.ready) + } + } + .rowStyle(isActive: listener == model.currentUser?.username) + } + } + } + .panel() + } +} + +private struct LibraryPanel: View { + @ObservedObject var model: AppModel + @State private var query = "" + @State private var fetchURL = "" + @State private var fileImporterPresented = false + + var matches: [Track] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let base = model.libraryTracks + if trimmed.isEmpty { + return Array(base.prefix(80)) + } + return Array(base.filter { + $0.title.lowercased().contains(trimmed) || + $0.filename.lowercased().contains(trimmed) || + ($0.artist ?? "").lowercased().contains(trimmed) + }.prefix(80)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Library", icon: "music.note.list") + importTools + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.muted) + TextField("Search tracks", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Theme.text) + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + + if !model.libraryLoaded { + EmptyLine("Loading library") + } else if matches.isEmpty { + EmptyLine("No matching tracks") + } else { + ForEach(matches) { track in + TrackLine( + track: track, + isActive: model.sourceMode == .library && model.currentTrackId == track.id, + subtitle: track.artist ?? track.filename + ) { + Button { + model.playLibraryTrack(track) + } label: { + Image(systemName: "play.fill") + .frame(width: 38, height: 34) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + + Menu { + Button("Add to Queue") { + Task { await model.queueTrack(track, playNext: false) } + } + Button("Play Next") { + Task { await model.queueTrack(track, playNext: true) } + } + } label: { + Image(systemName: "plus") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + } + } + } + .panel() + .fileImporter( + isPresented: $fileImporterPresented, + allowedContentTypes: [.audio, .movie], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + Task { await model.uploadFiles(urls) } + case .failure(let error): + model.importStatus = "File picker failed: \(error.localizedDescription)" + } + } + } + + private var importTools: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Button { + fileImporterPresented = true + } label: { + Label(model.isUploading ? "Uploading" : "Upload Files", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + .disabled(model.isUploading) + + Button { + Task { await model.loadLibrary() } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 42, height: 34) + } + .buttonStyle(.bordered) + .accessibilityLabel("Reload Library") + } + + HStack(spacing: 8) { + Image(systemName: "link") + .foregroundStyle(Theme.muted) + TextField("Fetch from website URL", text: $fetchURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .foregroundStyle(Theme.text) + Button { + Task { await model.fetchFromWebsite(fetchURL) } + } label: { + Image(systemName: model.isFetching ? "hourglass" : "arrow.down.circle") + .frame(width: 40, height: 34) + } + .buttonStyle(.bordered) + .disabled(model.isFetching) + .accessibilityLabel("Fetch URL") + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + + if let playlist = model.pendingFetchPlaylist { + VStack(alignment: .leading, spacing: 8) { + Text("Playlist found") + .font(Theme.mono(12, weight: .bold)) + .foregroundStyle(Theme.amber) + Text("\(playlist.title) · \(playlist.count) items") + .font(Theme.pixel(16, weight: .semibold)) + .foregroundStyle(Theme.text) + .lineLimit(2) + HStack(spacing: 8) { + Button { + Task { await model.confirmFetchPlaylist() } + } label: { + Label("Queue Playlist", systemImage: "checkmark.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Theme.ready) + .disabled(model.isFetching) + + Button { + model.cancelFetchPlaylist() + } label: { + Label("Cancel", systemImage: "xmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + + if !model.importStatus.isEmpty { + Text(model.importStatus) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + .lineLimit(2) + } + } + } +} + +private struct PlaylistsPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PanelTitle("Playlists", icon: "rectangle.stack") + if model.allPlaylists.isEmpty { + EmptyLine(model.playlistsLoaded ? "No playlists" : "Loading playlists") + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(model.allPlaylists.prefix(40)) { playlist in + let isSelected = model.selectedPlaylistId == playlist.id + Button { + Task { await model.loadPlaylist(playlist.id) } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(playlist.name) + .font(Theme.pixel(16, weight: .bold)) + .foregroundStyle(isSelected ? Theme.background : Theme.text) + .lineLimit(1) + Text("\(playlist.trackIds.count) tracks") + .font(Theme.mono(12)) + .foregroundStyle(isSelected ? Theme.background.opacity(0.72) : Theme.muted) + } + .frame(width: 150, alignment: .leading) + .padding(10) + } + .buttonStyle(.plain) + .background(isSelected ? Theme.accent : Theme.panel2) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(isSelected ? Theme.text : Theme.stroke.opacity(0.38), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + } + } + } + + if let playlist = model.selectedPlaylist { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(playlist.name) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + Text(playlist.ownerName.isEmpty ? "\(playlist.trackIds.count) tracks" : "by \(playlist.ownerName)") + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + } + Spacer() + Button { + Task { await model.addPlaylistToQueue(playlist, playNext: false) } + } label: { + Image(systemName: "text.badge.plus") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + Button { + Task { await model.addPlaylistToQueue(playlist, playNext: true) } + } label: { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + + ForEach(Array(playlist.trackIds.prefix(80).enumerated()), id: \.offset) { index, trackId in + let track = model.track(for: trackId) ?? Track(id: trackId, filename: trackId, title: trackId, duration: 0) + TrackLine( + track: track, + isActive: model.currentTrackId == track.id, + subtitle: "#\(index + 1)" + ) { + Button { + Task { await model.queueTrack(track, playNext: false) } + } label: { + Image(systemName: "plus") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + Button { + Task { await model.queueTrack(track, playNext: true) } + } label: { + Image(systemName: "arrow.up.to.line") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + } + } + } + .panel() + } +} + +private struct DebugPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Diagnostics", icon: "waveform.path.ecg") + debugRow("Server", model.serverURL) + debugRow("Auth", model.authState.rawValue) + debugRow("User", model.currentUser?.username ?? "-") + debugRow("Room", model.currentChannelId ?? "-") + debugRow("Track", model.currentTrackId ?? "-") + debugRow("Expected", "\(model.expectedPositionMs)ms") + debugRow("Player", "\(model.playerPositionMs)ms") + debugRow("Drift", "\(model.driftMs)ms") + Divider().overlay(Theme.stroke) + ForEach(model.debugEvents, id: \.self) { event in + Text(event) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .panel() + } + + private func debugRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .top) { + Text(label) + .foregroundStyle(Theme.muted) + .frame(width: 78, alignment: .leading) + Text(value) + .foregroundStyle(Theme.text) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .font(Theme.mono(12)) + } +} + +private struct DebugFooterView: View { + @ObservedObject var model: AppModel + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(model.authState == .signedIn ? Theme.ready : Theme.amber) + .frame(width: 8, height: 8) + Text(model.status) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 2) + } +} + +private struct StatusStrip: View { + @ObservedObject var model: AppModel + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(model.authState == .checking ? Theme.amber : model.authState == .signedIn ? Theme.ready : Theme.red) + .frame(width: 10, height: 10) + Text(model.status) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + Spacer() + } + .panel() + } +} + +private struct PanelTitle: View { + private let title: String + private let icon: String + + init(_ title: String, icon: String) { + self.title = title + self.icon = icon + } + + var body: some View { + Label(title, systemImage: icon) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + } +} + +private struct EmptyLine: View { + private let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } +} + +private struct TrackLine: View { + let track: Track + let isActive: Bool + let subtitle: String + @ViewBuilder let actions: () -> Actions + + var body: some View { + HStack(spacing: 10) { + Rectangle() + .fill(isActive ? Theme.ready : Theme.amber) + .frame(width: 4) + .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) + + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(Theme.pixel(16, weight: .semibold)) + .foregroundStyle(Theme.text) + .lineLimit(2) + Text(subtitle) + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + .lineLimit(1) + } + + Spacer(minLength: 8) + actions() + } + .rowStyle(isActive: isActive) + } +} + +private extension View { + func panel() -> some View { + self + .padding(14) + .background(Theme.panel) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(Theme.stroke, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + + func rowStyle(isActive: Bool = false) -> some View { + self + .padding(10) + .background(isActive ? Theme.panel2.opacity(1.0) : Theme.panel2.opacity(0.76)) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(isActive ? Theme.accent : Theme.stroke.opacity(0.38), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + + func textFieldStyle() -> some View { + self + .padding(12) + .foregroundStyle(Theme.text) + .background(Theme.panel2) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(Theme.stroke, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } +} + +private func formatTime(_ ms: Int64) -> String { + let total = max(0, Int(ms / 1000)) + return "\(total / 60):" + String(format: "%02d", total % 60) +} + +private func formatDuration(_ duration: TimeInterval) -> String { + guard duration.isFinite, duration > 0 else { return "--:--" } + return formatTime(Int64(duration * 1000)) +} + +private enum APIError: LocalizedError { + case invalidURL + case http(Int, String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .http(let status, let body): + return "HTTP \(status): \(body)" + } + } +} + +private struct Track: Codable, Hashable, Identifiable { + var id: String + var filename: String + var title: String + var duration: Double + var artist: String? + var album: String? + var available: Bool? + + init( + id: String, + filename: String, + title: String, + duration: Double, + artist: String? = nil, + album: String? = nil, + available: Bool? = nil + ) { + self.id = id + self.filename = filename + self.title = title + self.duration = duration + self.artist = artist + self.album = album + self.available = available + } +} + +private struct ChannelInfo: Decodable, Identifiable { + let id: String + let name: String + let description: String + let listenerCount: Int + let isDefault: Bool + let trackCount: Int + let listeners: [String] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case listenerCount + case isDefault + case trackCount + case listeners + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Room" + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0 + isDefault = try c.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + trackCount = try c.decodeIfPresent(Int.self, forKey: .trackCount) ?? 0 + listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? [] + } +} + +private struct ChannelState: Decodable { + let track: Track? + let currentTimestamp: Double + let channelName: String + let channelId: String + let paused: Bool + let queue: [Track]? + let currentIndex: Int + let playbackMode: String + let listenerCount: Int + let listeners: [String] + + enum CodingKeys: String, CodingKey { + case track + case currentTimestamp + case channelName + case channelId + case paused + case queue + case currentIndex + case playbackMode + case listenerCount + case listeners + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + track = try c.decodeIfPresent(Track.self, forKey: .track) + currentTimestamp = try c.decodeIfPresent(Double.self, forKey: .currentTimestamp) ?? 0 + channelName = try c.decodeIfPresent(String.self, forKey: .channelName) ?? "" + channelId = try c.decodeIfPresent(String.self, forKey: .channelId) ?? "" + paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? true + queue = try c.decodeIfPresent([Track].self, forKey: .queue) + currentIndex = try c.decodeIfPresent(Int.self, forKey: .currentIndex) ?? 0 + playbackMode = try c.decodeIfPresent(String.self, forKey: .playbackMode) ?? "repeat-all" + listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0 + listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? [] + } +} + +private struct PlaylistBundle: Decodable { + let mine: [Playlist] + let shared: [Playlist] +} + +private struct Playlist: Decodable, Identifiable { + let id: String + let name: String + let description: String + let ownerId: Int + let ownerName: String + let isPublic: Bool + let shareToken: String? + let trackIds: [String] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case ownerId + case ownerName + case isPublic + case shareToken + case trackIds + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Playlist" + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + ownerId = try c.decodeIfPresent(Int.self, forKey: .ownerId) ?? 0 + ownerName = try c.decodeIfPresent(String.self, forKey: .ownerName) ?? "" + isPublic = try c.decodeIfPresent(Bool.self, forKey: .isPublic) ?? false + shareToken = try c.decodeIfPresent(String.self, forKey: .shareToken) + trackIds = try c.decodeIfPresent([String].self, forKey: .trackIds) ?? [] + } +} + +private struct UserSession: Decodable { + let id: Int + let username: String + let isAdmin: Bool + let isGuest: Bool + let permissions: [Permission] + + enum CodingKeys: String, CodingKey { + case id + case username + case isAdmin + case isAdminSnake = "is_admin" + case isGuest + case isGuestSnake = "is_guest" + case permissions + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0 + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "guest" + isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) + ?? c.decodeIfPresent(Bool.self, forKey: .isAdminSnake) + ?? false + isGuest = try c.decodeIfPresent(Bool.self, forKey: .isGuest) + ?? c.decodeIfPresent(Bool.self, forKey: .isGuestSnake) + ?? false + permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? [] + } +} + +private struct Permission: Decodable { + let resourceType: String + let resourceId: String? + let permission: String + + enum CodingKeys: String, CodingKey { + case resourceType + case resourceTypeSnake = "resource_type" + case resourceId + case resourceIdSnake = "resource_id" + case permission + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + resourceType = try c.decodeIfPresent(String.self, forKey: .resourceType) + ?? c.decodeIfPresent(String.self, forKey: .resourceTypeSnake) + ?? "" + resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId) + ?? c.decodeIfPresent(String.self, forKey: .resourceIdSnake) + permission = try c.decodeIfPresent(String.self, forKey: .permission) ?? "" + } +} + +private struct AuthEnvelope: Decodable { + let user: UserSession? + let permissions: [Permission] + + enum CodingKeys: String, CodingKey { + case user + case permissions + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + user = try c.decodeIfPresent(UserSession.self, forKey: .user) + permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? [] + } +} + +private struct QueueResponse: Decodable { + let success: Bool? + let queueLength: Int? +} + +private struct ModeResponse: Decodable { + let success: Bool? + let playbackMode: String? +} + +private struct FetchItem: Codable, Hashable { + let id: String? + let url: String + let title: String +} + +private struct FetchPlaylistResponse: Decodable { + let type: String + let title: String + let count: Int + let items: [FetchItem] + let requiresConfirmation: Bool? +} + +private struct FetchSingleResponse: Decodable { + let type: String + let id: String? + let title: String + let queueType: String? +} + +private enum FetchResponse: Decodable { + case single(FetchSingleResponse) + case playlist(FetchPlaylistResponse) + + enum CodingKeys: String, CodingKey { + case type + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let type = try c.decodeIfPresent(String.self, forKey: .type) + switch type { + case "playlist": + self = .playlist(try FetchPlaylistResponse(from: decoder)) + default: + self = .single(try FetchSingleResponse(from: decoder)) + } + } +} + +private struct FetchConfirmResponse: Decodable { + let message: String + let queueType: String? + let estimatedTime: String? + let playlistId: String? + let playlistName: String? + let items: [FetchItem]? +} + +private extension Data { + mutating func appendString(_ value: String) { + append(Data(value.utf8)) + } +} diff --git a/ios/BlastoisePing/BlastoisePing/Fonts/pixelify_sans.ttf b/ios/BlastoisePing/BlastoisePing/Fonts/pixelify_sans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2d7eb388ee499fbcf3e992723ec409c095640104 GIT binary patch literal 79160 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 literal 0 HcmV?d00001 diff --git a/ios/BlastoisePing/BlastoisePing/Info.plist b/ios/BlastoisePing/BlastoisePing/Info.plist new file mode 100644 index 0000000..fbade64 --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Info.plist @@ -0,0 +1,63 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Blastoise + 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 + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSLocalNetworkUsageDescription + Blastoise Ping can check a server running on your local network. + UIBackgroundModes + + audio + + UIAppFonts + + pixelify_sans.ttf + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/BlastoisePing/README.md b/ios/BlastoisePing/README.md new file mode 100644 index 0000000..211f6cc --- /dev/null +++ b/ios/BlastoisePing/README.md @@ -0,0 +1,21 @@ +# Blastoise iOS Sketch + +Native SwiftUI sketch for the Blastoise/MusicRoom server. + +## What It Does + +- Defaults to `http://mhsgroove.peterino.com:3001`. +- Signs in or signs up with the server. +- Loads rooms, queue state, people, library, and playlists. +- Connects to a room WebSocket and streams `/api/tracks/:id` through `AVPlayer`. +- Applies server timestamp sync and drift correction. +- Supports local library playback, queue/play-next actions, queue jumps/removes, and playback mode cycling. +- Uses one compact broadcast-console theme. + +## Open + +```bash +open ios/BlastoisePing/BlastoisePing.xcodeproj +``` + +The app currently allows arbitrary HTTP loads in `Info.plist` so it can reach the existing plain-HTTP test server and local development servers. Narrow that before any public distribution.