diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index fadb598..5169b71 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -487,7 +487,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.4.2; + MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -535,7 +535,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.4.2; + MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -613,7 +613,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.2; + MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -632,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -643,7 +643,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.2; + MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index e1c62aa..b8fa1db 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/ContentView.swift b/ios/Ascently/ContentView.swift index 52dd10a..9e178ef 100644 --- a/ios/Ascently/ContentView.swift +++ b/ios/Ascently/ContentView.swift @@ -44,6 +44,7 @@ struct ContentView: View { .tag(4) } .environmentObject(dataManager) + .environmentObject(MusicService.shared) .onChange(of: scenePhase) { oldPhase, newPhase in if newPhase == .active { // Add slight delay to ensure app is fully loaded diff --git a/ios/Ascently/Info.plist b/ios/Ascently/Info.plist index 9a5b5a9..f0d3170 100644 --- a/ios/Ascently/Info.plist +++ b/ios/Ascently/Info.plist @@ -15,5 +15,7 @@ This app needs access to save your climbing workouts to Apple Health. NSHealthUpdateUsageDescription This app needs access to save your climbing workouts to Apple Health. + NSAppleMusicUsageDescription + This app (optionally) needs access to your music library to play your selected playlist during climbing sessions. diff --git a/ios/Ascently/Services/MusicService.swift b/ios/Ascently/Services/MusicService.swift new file mode 100644 index 0000000..cc0fd7e --- /dev/null +++ b/ios/Ascently/Services/MusicService.swift @@ -0,0 +1,197 @@ +import MusicKit +import AVFoundation +import SwiftUI +import Combine + +@MainActor +class MusicService: ObservableObject { + static let shared = MusicService() + + @Published var isAuthorized = false + @Published var playlists: MusicItemCollection = [] + @Published var selectedPlaylistId: String? { + didSet { + UserDefaults.standard.set(selectedPlaylistId, forKey: "ascently_selected_playlist_id") + } + } + @Published var isMusicEnabled: Bool { + didSet { + UserDefaults.standard.set(isMusicEnabled, forKey: "ascently_music_enabled") + if !isMusicEnabled { + // Genuinely unsure what I want to do with this but we should account for it at some point + } + } + } + @Published var isAutoPlayEnabled: Bool { + didSet { + UserDefaults.standard.set(isAutoPlayEnabled, forKey: "ascently_music_autoplay_enabled") + } + } + @Published var isAutoStopEnabled: Bool { + didSet { + UserDefaults.standard.set(isAutoStopEnabled, forKey: "ascently_music_autostop_enabled") + } + } + @Published var isPlaying = false + + private var cancellables = Set() + private var hasStartedSessionPlayback = false + private var currentPlaylistTrackIds: Set = [] + + private init() { + self.selectedPlaylistId = UserDefaults.standard.string(forKey: "ascently_selected_playlist_id") + self.isMusicEnabled = UserDefaults.standard.bool(forKey: "ascently_music_enabled") + self.isAutoPlayEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autoplay_enabled") + self.isAutoStopEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autostop_enabled") + + if isMusicEnabled { + Task { + await checkAuthorizationStatus() + } + } + + setupObservers() + } + + private func setupObservers() { + SystemMusicPlayer.shared.state.objectWillChange + .sink { [weak self] _ in + self?.updatePlaybackStatus() + } + .store(in: &cancellables) + + SystemMusicPlayer.shared.queue.objectWillChange + .sink { [weak self] _ in + self?.checkQueueConsistency() + } + .store(in: &cancellables) + } + + private func updatePlaybackStatus() { + Task { @MainActor [weak self] in + self?.isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing + } + } + + private func checkQueueConsistency() { + guard hasStartedSessionPlayback else { return } + + Task { @MainActor [weak self] in + guard let self = self else { return } + if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry, + let item = currentEntry.item { + if !self.currentPlaylistTrackIds.isEmpty && !self.currentPlaylistTrackIds.contains(item.id) { + self.hasStartedSessionPlayback = false + } + } + } + } + + func toggleMusicEnabled(_ enabled: Bool) { + isMusicEnabled = enabled + if enabled { + Task { + await checkAuthorizationStatus() + } + } + } + + func checkAuthorizationStatus() async { + let status = await MusicAuthorization.request() + self.isAuthorized = status == .authorized + if isAuthorized { + await fetchPlaylists() + } + } + + func fetchPlaylists() async { + guard isAuthorized else { return } + do { + var request = MusicLibraryRequest() + request.sort(by: \.name, ascending: true) + let response = try await request.response() + self.playlists = response.items + } catch { + print("Error fetching playlists: \(error)") + } + } + + func playSelectedPlaylistIfHeadphonesConnected() { + guard isMusicEnabled, isAutoPlayEnabled, let playlistId = selectedPlaylistId else { return } + + if isHeadphonesConnected() { + playPlaylist(id: playlistId) + } + } + + func resetSessionPlaybackState() { + hasStartedSessionPlayback = false + currentPlaylistTrackIds.removeAll() + } + + func playPlaylist(id: String) { + print("Attempting to play playlist \(id)") + Task { + do { + if playlists.isEmpty { + await fetchPlaylists() + } + + var targetPlaylist: Playlist? + + if let playlist = playlists.first(where: { $0.id.rawValue == id }) { + targetPlaylist = playlist + } else { + var request = MusicLibraryRequest() + request.filter(matching: \.id, equalTo: MusicItemID(id)) + let response = try await request.response() + targetPlaylist = response.items.first + } + + if let playlist = targetPlaylist { + let detailedPlaylist = try await playlist.with([.tracks]) + if let tracks = detailedPlaylist.tracks { + self.currentPlaylistTrackIds = Set(tracks.map { $0.id }) + } + + SystemMusicPlayer.shared.queue = [playlist] + try await SystemMusicPlayer.shared.play() + hasStartedSessionPlayback = true + } + } catch { + print("Error playing playlist: \(error)") + } + } + } + + func stopPlaybackIfEnabled() { + guard isMusicEnabled, isAutoStopEnabled else { return } + SystemMusicPlayer.shared.stop() + } + + func togglePlayback() { + Task { + if isPlaying { + SystemMusicPlayer.shared.pause() + } else { + if let playlistId = selectedPlaylistId, !hasStartedSessionPlayback { + playPlaylist(id: playlistId) + } else { + try? await SystemMusicPlayer.shared.play() + } + } + } + } + + private func isHeadphonesConnected() -> Bool { + let route = AVAudioSession.sharedInstance().currentRoute + return route.outputs.contains { port in + port.portType == .headphones || + port.portType == .bluetoothA2DP || + port.portType == .bluetoothLE || + port.portType == .bluetoothHFP || + port.portType == .usbAudio || + port.portType == .airPlay + } + } +} diff --git a/ios/Ascently/ViewModels/ClimbingDataManager.swift b/ios/Ascently/ViewModels/ClimbingDataManager.swift index 246d123..4e16ec8 100644 --- a/ios/Ascently/ViewModels/ClimbingDataManager.swift +++ b/ios/Ascently/ViewModels/ClimbingDataManager.swift @@ -38,6 +38,7 @@ class ClimbingDataManager: ObservableObject { let syncService = SyncService() let healthKitService = HealthKitService.shared + let musicService = MusicService.shared @Published var isSyncing = false private enum Keys { @@ -437,13 +438,15 @@ class ClimbingDataManager: ObservableObject { saveSessions() DataStateManager.shared.updateDataState() - // MARK: - Start Live Activity for new session if let gym = gym(withId: gymId) { await LiveActivityManager.shared.startLiveActivity( for: newSession, gymName: gym.name) } + musicService.resetSessionPlaybackState() + musicService.playSelectedPlaylistIfHeadphonesConnected() + if healthKitService.isEnabled { do { try await healthKitService.startWorkout( @@ -488,8 +491,12 @@ class ClimbingDataManager: ObservableObject { // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) - // MARK: - End Live Activity after session ends await LiveActivityManager.shared.endLiveActivity() + + if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") { + musicService.stopPlaybackIfEnabled() + } + musicService.stopPlaybackIfEnabled() if healthKitService.isEnabled { do { diff --git a/ios/Ascently/ViewModels/LiveActivityManager.swift b/ios/Ascently/ViewModels/LiveActivityManager.swift index 3bce362..825256f 100644 --- a/ios/Ascently/ViewModels/LiveActivityManager.swift +++ b/ios/Ascently/ViewModels/LiveActivityManager.swift @@ -207,7 +207,7 @@ final class LiveActivityManager { func startHealthChecks() { stopHealthChecks() // Stop any existing timer - AppLogger.debug("🩺 Starting Live Activity health checks", tag: Self.logTag) + AppLogger.debug("Starting Live Activity health checks", tag: Self.logTag) healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in @@ -233,7 +233,7 @@ final class LiveActivityManager { // Only perform health check if it's been at least 25 seconds guard timeSinceLastCheck >= 25 else { return } - AppLogger.debug("🩺 Performing Live Activity health check", tag: Self.logTag) + AppLogger.debug("Performing Live Activity health check", tag: Self.logTag) lastHealthCheck = now let activities = Activity.activities diff --git a/ios/Ascently/Views/CalendarView.swift b/ios/Ascently/Views/CalendarView.swift index cd252e8..7660750 100644 --- a/ios/Ascently/Views/CalendarView.swift +++ b/ios/Ascently/Views/CalendarView.swift @@ -58,6 +58,7 @@ struct CalendarView: View { let gym = dataManager.gym(withId: activeSession.gymId) { ActiveSessionBanner(session: activeSession, gym: gym) + .environmentObject(MusicService.shared) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) diff --git a/ios/Ascently/Views/Detail/SessionDetailView.swift b/ios/Ascently/Views/Detail/SessionDetailView.swift index 4f42d80..f35620e 100644 --- a/ios/Ascently/Views/Detail/SessionDetailView.swift +++ b/ios/Ascently/Views/Detail/SessionDetailView.swift @@ -1,10 +1,12 @@ import Combine +import MusicKit import SwiftUI struct SessionDetailView: View { let sessionId: UUID @EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var themeManager: ThemeManager + @EnvironmentObject var musicService: MusicService @Environment(\.dismiss) private var dismiss @State private var showingDeleteAlert = false @State private var showingAddAttempt = false @@ -41,13 +43,19 @@ struct SessionDetailView: View { Section { SessionHeaderCard( session: session, gym: gym, stats: sessionStats) - .listRowInsets(EdgeInsets()) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .padding(.bottom, 8) + + if session.status == .active && musicService.isMusicEnabled && musicService.isAuthorized { + MusicControlCard() + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } SessionStatsCard(stats: sessionStats) - .listRowInsets(EdgeInsets()) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } @@ -76,6 +84,7 @@ struct SessionDetailView: View { ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } else { ForEach(attemptsWithProblems.indices, id: \.self) { index in let (attempt, problem) = attemptsWithProblems[index] @@ -442,6 +451,53 @@ struct SessionStats { let uniqueProblemsCompleted: Int } +struct MusicControlCard: View { + @EnvironmentObject var musicService: MusicService + + var body: some View { + HStack { + Image(systemName: "music.note") + .font(.title2) + .foregroundColor(.pink) + .frame(width: 40, height: 40) + .background(Color.pink.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text("Music") + .font(.headline) + + if let playlistId = musicService.selectedPlaylistId, + let playlist = musicService.playlists.first(where: { $0.id.rawValue == playlistId }) { + Text(playlist.name) + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("No playlist selected") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + Button(action: { + musicService.togglePlayback() + }) { + Image(systemName: musicService.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 32)) + .foregroundColor(.pink) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.secondarySystemGroupedBackground)) + .shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2) + ) + } +} + #Preview { NavigationView { SessionDetailView(sessionId: UUID()) diff --git a/ios/Ascently/Views/SessionsView.swift b/ios/Ascently/Views/SessionsView.swift index da39144..1b13aa2 100644 --- a/ios/Ascently/Views/SessionsView.swift +++ b/ios/Ascently/Views/SessionsView.swift @@ -122,6 +122,7 @@ struct SessionsList: View { { Section { ActiveSessionBanner(session: activeSession, gym: gym) + .environmentObject(MusicService.shared) .padding(.horizontal, 16) .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0)) .listRowBackground(Color.clear) @@ -178,6 +179,17 @@ struct ActiveSessionBanner: View { let gym: Gym @EnvironmentObject var dataManager: ClimbingDataManager @State private var navigateToDetail = false + + // Access MusicService via DataManager if possible, or EnvironmentObject if injected + // Since DataManager holds MusicService, we can access it through there if we expose it or inject it. + // In SettingsView we saw .environmentObject(dataManager.musicService). + // We should probably inject it here too or access via dataManager if it's public. + // Let's check ClimbingDataManager again. It has `let musicService = MusicService.shared`. + // But it's not @Published so it won't trigger updates unless we observe the service itself. + // The best way is to use @EnvironmentObject var musicService: MusicService + // and ensure it's injected in the parent view. + + @EnvironmentObject var musicService: MusicService var body: some View { HStack { @@ -201,6 +213,19 @@ struct ActiveSessionBanner: View { .foregroundColor(.secondary) .monospacedDigit() } + + if musicService.isMusicEnabled && musicService.isAuthorized { + Button(action: { + musicService.togglePlayback() + }) { + HStack(spacing: 4) { + Image(systemName: musicService.isPlaying ? "pause.fill" : "play.fill") + .font(.caption) + } + .foregroundColor(.pink) + .padding(.top, 2) + } + } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index 52fb964..e2480fe 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -1,4 +1,5 @@ import HealthKit +import MusicKit import SwiftUI import UniformTypeIdentifiers @@ -21,6 +22,9 @@ struct SettingsView: View { HealthKitSection() .environmentObject(dataManager.healthKitService) + MusicSection() + .environmentObject(dataManager.musicService) + AppearanceSection() DataManagementSection( @@ -1099,6 +1103,53 @@ struct HealthKitSection: View { } } +struct MusicSection: View { + @EnvironmentObject var musicService: MusicService + + var body: some View { + Section { + Toggle(isOn: Binding( + get: { musicService.isMusicEnabled }, + set: { musicService.toggleMusicEnabled($0) } + )) { + HStack { + Image(systemName: "music.note") + .foregroundColor(.pink) + Text("Apple Music Integration") + } + } + + if musicService.isMusicEnabled { + if !musicService.isAuthorized { + Button("Connect Apple Music") { + Task { + await musicService.checkAuthorizationStatus() + } + } + } else { + Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled) + Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled) + + Picker("Playlist", selection: $musicService.selectedPlaylistId) { + Text("None").tag(nil as String?) + ForEach(musicService.playlists, id: \.id) { playlist in + Text(playlist.name).tag(playlist.id.rawValue as String?) + } + } + + if musicService.isAutoPlayEnabled { + Text("Music will only auto-play if headphones are connected when you start a session.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } header: { + Text("Music") + } + } +} + #Preview { SettingsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift b/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift index 7fd2326..18aa5f8 100644 --- a/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift +++ b/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift @@ -42,6 +42,7 @@ struct SessionStatusLiveLiveActivity: Widget { .foregroundColor(.secondary) .lineLimit(1) } + .padding(.leading, 8) } DynamicIslandExpandedRegion(.trailing) { VStack(alignment: .trailing, spacing: 4) { @@ -61,6 +62,7 @@ struct SessionStatusLiveLiveActivity: Widget { .foregroundColor(.secondary) } } + .padding(.trailing, 8) } DynamicIslandExpandedRegion(.bottom) { HStack { @@ -72,6 +74,8 @@ struct SessionStatusLiveLiveActivity: Widget { .font(.caption2) .foregroundColor(.secondary) } + .padding(.horizontal, 12) + .padding(.bottom, 4) } } compactLeading: { Image(systemName: "figure.climbing")