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 0000000..2d7eb38 Binary files /dev/null and b/ios/BlastoisePing/BlastoisePing/Fonts/pixelify_sans.ttf differ 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.