iOS 2.5.0 - Apple Music Integration
This commit is contained in:
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
<string>This app needs access to save your climbing workouts to Apple Health.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>This app needs access to save your climbing workouts to Apple Health.</string>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>This app (optionally) needs access to your music library to play your selected playlist during climbing sessions.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
197
ios/Ascently/Services/MusicService.swift
Normal file
197
ios/Ascently/Services/MusicService.swift
Normal file
@@ -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<Playlist> = []
|
||||
@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<AnyCancellable>()
|
||||
private var hasStartedSessionPlayback = false
|
||||
private var currentPlaylistTrackIds: Set<MusicItemID> = []
|
||||
|
||||
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<Playlist>()
|
||||
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<Playlist>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,9 +491,13 @@ 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 {
|
||||
try await healthKitService.endWorkout(
|
||||
|
||||
@@ -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<SessionActivityAttributes>.activities
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -179,6 +180,17 @@ struct ActiveSessionBanner: View {
|
||||
@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 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user