diff --git a/Spotifly.xcodeproj/project.pbxproj b/Spotifly.xcodeproj/project.pbxproj index 783239a..6093098 100644 --- a/Spotifly.xcodeproj/project.pbxproj +++ b/Spotifly.xcodeproj/project.pbxproj @@ -439,7 +439,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Spotifly; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CODE_SIGN_ENTITLEMENTS = Spotifly/Spotifly.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = "Spotifly/Spotifly-iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]" = "Spotifly/Spotifly-iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Spotifly/Spotifly.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 89S4HZY343; diff --git a/Spotifly/AudioSessionManager.swift b/Spotifly/AudioSessionManager.swift new file mode 100644 index 0000000..ba1069b --- /dev/null +++ b/Spotifly/AudioSessionManager.swift @@ -0,0 +1,156 @@ +// +// AudioSessionManager.swift +// Spotifly +// +// Manages audio session configuration for iOS/iPadOS +// Handles background playback and interruptions +// + +import Combine +import Foundation +#if canImport(AVFoundation) +import AVFoundation +#endif + +@MainActor +final class AudioSessionManager: ObservableObject { + static let shared = AudioSessionManager() + + @Published var isSessionActive = false + + private init() {} + + /// Sets up the audio session for playback + /// Required for iOS/iPadOS background audio and proper audio routing + func setupAudioSession() { + #if os(iOS) + let session = AVAudioSession.sharedInstance() + + do { + // .playback ensures audio continues in silent mode and background + try session.setCategory(.playback, mode: .default, options: []) + + // Activate the session + try session.setActive(true) + + isSessionActive = true + print("Audio session configured for playback") + + setupInterruptionHandling() + } catch { + print("Failed to configure audio session: \(error)") + isSessionActive = false + } + #else + // macOS doesn't require audio session configuration + isSessionActive = true + #endif + } + + /// Deactivates the audio session + func deactivateAudioSession() { + #if os(iOS) + do { + try AVAudioSession.sharedInstance().setActive(false) + isSessionActive = false + print("Audio session deactivated") + } catch { + print("Failed to deactivate audio session: \(error)") + } + #endif + } + + #if os(iOS) + /// Sets up handling for audio interruptions (phone calls, alarms, etc.) + private func setupInterruptionHandling() { + NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] notification in + guard let self else { return } + + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { + return + } + + switch type { + case .began: + // Audio was interrupted (e.g., incoming phone call) + print("Audio interruption began - playback will pause") + // The system automatically pauses audio playback + // Post notification so PlaybackViewModel can update its state + NotificationCenter.default.post( + name: .audioInterruptionBegan, + object: nil + ) + + case .ended: + // Interruption ended + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + print("Audio interruption ended - can resume playback") + // Post notification so PlaybackViewModel can resume if needed + NotificationCenter.default.post( + name: .audioInterruptionEnded, + object: nil, + userInfo: ["shouldResume": true] + ) + } else { + print("Audio interruption ended - should not auto-resume") + NotificationCenter.default.post( + name: .audioInterruptionEnded, + object: nil, + userInfo: ["shouldResume": false] + ) + } + } + + @unknown default: + break + } + } + + // Handle route changes (headphones plugged/unplugged, etc.) + NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { notification in + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) + else { + return + } + + switch reason { + case .oldDeviceUnavailable: + // Headphones were unplugged - pause playback + print("Audio route changed - old device unavailable (e.g., headphones unplugged)") + NotificationCenter.default.post( + name: .audioRouteChanged, + object: nil, + userInfo: ["shouldPause": true] + ) + + default: + print("Audio route changed: \(reason.rawValue)") + break + } + } + } + #endif +} + +// MARK: - Notification Names + +extension Notification.Name { + static let audioInterruptionBegan = Notification.Name("audioInterruptionBegan") + static let audioInterruptionEnded = Notification.Name("audioInterruptionEnded") + static let audioRouteChanged = Notification.Name("audioRouteChanged") +} diff --git a/Spotifly/Spotifly-iOS.entitlements b/Spotifly/Spotifly-iOS.entitlements new file mode 100644 index 0000000..0df9103 --- /dev/null +++ b/Spotifly/Spotifly-iOS.entitlements @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Spotifly/SpotiflyApp.swift b/Spotifly/SpotiflyApp.swift index 6f6e300..02a173e 100644 --- a/Spotifly/SpotiflyApp.swift +++ b/Spotifly/SpotiflyApp.swift @@ -5,7 +5,9 @@ // Created by Ralph von der Heyden on 30.12.25. // +#if canImport(AppKit) import AppKit +#endif import SwiftUI // MARK: - Focused Values for Menu Commands @@ -55,11 +57,14 @@ struct SpotiflyApp: App { @StateObject private var windowState = WindowState() init() { + #if os(macOS) // Set activation policy to regular to support media keys NSApplication.shared.setActivationPolicy(.regular) + #endif } var body: some Scene { + #if os(macOS) WindowGroup { ContentView() .environmentObject(windowState) @@ -72,11 +77,18 @@ struct SpotiflyApp: App { Settings { PreferencesView() } + #else + WindowGroup { + ContentView() + .environmentObject(windowState) + } + #endif } } // MARK: - Menu Commands +#if os(macOS) struct SpotiflyCommands: Commands { @FocusedValue(\.navigationSelection) var navigationSelection @FocusedValue(\.searchFieldFocused) var searchFieldFocused @@ -165,3 +177,4 @@ struct SpotiflyCommands: Commands { } } } +#endif diff --git a/Spotifly/SpotifyAuth.swift b/Spotifly/SpotifyAuth.swift index 04d85e5..3633c0c 100644 --- a/Spotifly/SpotifyAuth.swift +++ b/Spotifly/SpotifyAuth.swift @@ -8,6 +8,9 @@ import AuthenticationServices import CryptoKit import Foundation +#if canImport(UIKit) +import UIKit +#endif /// Actor that manages Spotify authentication and player operations @globalActor @@ -181,9 +184,16 @@ enum SpotifyAuth { } // Get the presentation anchor + #if os(macOS) guard let anchor = NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first else { throw SpotifyAuthError.authenticationFailed } + #else + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let anchor = windowScene.windows.first else { + throw SpotifyAuthError.authenticationFailed + } + #endif // Create session manager and start auth let authSession = AuthenticationSession(anchor: anchor) diff --git a/Spotifly/SpotifyPlayer.swift b/Spotifly/SpotifyPlayer.swift index 7a94bc7..f929a94 100644 --- a/Spotifly/SpotifyPlayer.swift +++ b/Spotifly/SpotifyPlayer.swift @@ -7,6 +7,9 @@ import Foundation import SpotiflyRust +#if canImport(UIKit) +import UIKit +#endif /// Queue item metadata struct QueueItem: Sendable, Identifiable { @@ -58,6 +61,9 @@ enum SpotifyPlayer { // Sync playback settings from UserDefaults before initializing syncSettingsFromUserDefaults() + // Note: iOS spoofing as macOS happens at the librespot level + // See patches in librespot/core/src/connection/handshake.rs and mod.rs + let result = await Task.detached { accessToken.withCString { tokenPtr in spotifly_init_player(tokenPtr) diff --git a/Spotifly/Store/AppStore.swift b/Spotifly/Store/AppStore.swift index 2160100..f8f58cb 100644 --- a/Spotifly/Store/AppStore.swift +++ b/Spotifly/Store/AppStore.swift @@ -10,6 +10,11 @@ import Foundation import MediaPlayer import QuartzCore import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif // MARK: - Recent Item @@ -802,11 +807,17 @@ final class AppStore { Task { do { let (data, _) = try await URLSession.shared.data(from: url) + #if os(macOS) guard let image = NSImage(data: data) else { return } + let imageSize = image.size + #else + guard let image = UIImage(data: data) else { return } + let imageSize = image.size + #endif await MainActor.run { var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] - info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { @Sendable _ in + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: imageSize) { @Sendable _ in image } MPNowPlayingInfoCenter.default().nowPlayingInfo = info diff --git a/Spotifly/ViewModels/PlaybackViewModel.swift b/Spotifly/ViewModels/PlaybackViewModel.swift index 04d5c78..ede6713 100644 --- a/Spotifly/ViewModels/PlaybackViewModel.swift +++ b/Spotifly/ViewModels/PlaybackViewModel.swift @@ -7,7 +7,11 @@ import QuartzCore import SwiftUI - +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif import MediaPlayer // MARK: - Drift Correction Timer @@ -102,6 +106,12 @@ final class PlaybackViewModel { initialInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 MPNowPlayingInfoCenter.default().nowPlayingInfo = initialInfo + // Set up audio session for iOS/iPadOS + AudioSessionManager.shared.setupAudioSession() + + // Set up audio interruption handling + setupAudioInterruptionHandling() + // Start position update timer startPositionTimer() } @@ -446,16 +456,20 @@ final class PlaybackViewModel { Task { do { let (data, _) = try await URLSession.shared.data(from: url) + #if os(macOS) guard let image = NSImage(data: data) else { return } + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + #else + guard let image = UIImage(data: data) else { return } + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + #endif // Update Now Playing on main actor await MainActor.run { var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] // Mark closure as @Sendable to fix crash - MPNowPlayingInfoCenter executes // the closure on an internal dispatch queue, not on MainActor - info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { @Sendable _ in - image - } + info[MPMediaItemPropertyArtwork] = artwork MPNowPlayingInfoCenter.default().nowPlayingInfo = info } } catch { @@ -601,6 +615,70 @@ final class PlaybackViewModel { return String(components[2]) } + // MARK: - Audio Interruption Handling + + private func setupAudioInterruptionHandling() { + #if os(iOS) + // Handle audio interruptions (phone calls, etc.) + NotificationCenter.default.addObserver( + forName: .audioInterruptionBegan, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + // Pause playback when interrupted + if self.isPlaying { + SpotifyPlayer.pause() + self.isPlaying = false + self.updateNowPlayingInfo() + } + } + } + + NotificationCenter.default.addObserver( + forName: .audioInterruptionEnded, + object: nil, + queue: .main + ) { [weak self] notification in + // Extract userInfo before Task to avoid data race + let shouldResume = notification.userInfo?["shouldResume"] as? Bool ?? false + + Task { @MainActor [weak self] in + guard let self else { return } + // Optionally resume playback when interruption ends + if shouldResume { + // Auto-resume only if the system recommends it + // User can manually resume if they prefer + print("Audio interruption ended - can resume playback") + } + } + } + + // Handle route changes (headphones unplugged, etc.) + NotificationCenter.default.addObserver( + forName: .audioRouteChanged, + object: nil, + queue: .main + ) { [weak self] notification in + // Extract userInfo before Task to avoid data race + let shouldPause = notification.userInfo?["shouldPause"] as? Bool ?? false + + Task { @MainActor [weak self] in + guard let self else { return } + if shouldPause { + // Pause when headphones are unplugged + if self.isPlaying { + SpotifyPlayer.pause() + self.isPlaying = false + self.updateNowPlayingInfo() + } + } + } + } + #endif + } + // MARK: - Volume Persistence private func saveVolume() { diff --git a/Spotifly/Views/AlbumDetailView.swift b/Spotifly/Views/AlbumDetailView.swift index 2831846..edb5a05 100644 --- a/Spotifly/Views/AlbumDetailView.swift +++ b/Spotifly/Views/AlbumDetailView.swift @@ -5,8 +5,12 @@ // Shows details for an album with track list, using normalized store // -import AppKit import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif struct AlbumDetailView: View { let album: SearchAlbum @@ -178,7 +182,11 @@ struct AlbumDetailView: View { } } } - .background(Color(NSColor.controlBackgroundColor)) + #if os(macOS) + .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } @@ -244,8 +252,12 @@ struct AlbumDetailView: View { private func copyToClipboard() { guard let externalUrl = album.externalUrl else { return } + #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(externalUrl, forType: .string) + #else + UIPasteboard.general.string = externalUrl + #endif } } diff --git a/Spotifly/Views/ArtistDetailView.swift b/Spotifly/Views/ArtistDetailView.swift index f0b16c3..ca20b26 100644 --- a/Spotifly/Views/ArtistDetailView.swift +++ b/Spotifly/Views/ArtistDetailView.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif struct ArtistDetailView: View { let artist: SearchArtist @@ -119,7 +124,11 @@ struct ArtistDetailView: View { } } } - .background(Color(NSColor.controlBackgroundColor)) + #if os(macOS) + .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } diff --git a/Spotifly/Views/ContentView.swift b/Spotifly/Views/ContentView.swift index fe4e589..482a6c0 100644 --- a/Spotifly/Views/ContentView.swift +++ b/Spotifly/Views/ContentView.swift @@ -15,12 +15,20 @@ struct ContentView: View { Group { if viewModel.isLoading { ProgressView(String(localized: "auth.loading")) + #if os(macOS) .frame(minWidth: 500, minHeight: 400) + #else + .frame(maxWidth: .infinity, maxHeight: .infinity) + #endif } else if let authResult = viewModel.authResult { LoggedInView(authResult: authResult, onLogout: { viewModel.logout() }) } else { loginView + #if os(macOS) .frame(minWidth: 500, minHeight: 400) + #else + .frame(maxWidth: .infinity, maxHeight: .infinity) + #endif } } } diff --git a/Spotifly/Views/FullScreenPlayerView.swift b/Spotifly/Views/FullScreenPlayerView.swift new file mode 100644 index 0000000..09c4edf --- /dev/null +++ b/Spotifly/Views/FullScreenPlayerView.swift @@ -0,0 +1,224 @@ +// +// FullScreenPlayerView.swift +// Spotifly +// +// Full-screen player view for iOS/iPadOS +// Displays enhanced playback controls, album art, and track information +// + +import SwiftUI + +struct FullScreenPlayerView: View { + let authResult: SpotifyAuthResult + @Bindable var playbackViewModel: PlaybackViewModel + @Environment(\.dismiss) private var dismiss + + // Helper function for time formatting + private func formatTime(_ milliseconds: UInt32) -> String { + let totalSeconds = Int(milliseconds / 1000) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + var body: some View { + ZStack { + // Background gradient + LinearGradient( + colors: [.black.opacity(0.8), .black], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + // Top bar with close button + HStack { + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "chevron.down") + .font(.title2) + .foregroundStyle(.white) + .padding() + } + } + .padding(.horizontal) + + Spacer() + + // Album art + if let artURL = playbackViewModel.currentAlbumArtURL, + !artURL.isEmpty, + let url = URL(string: artURL) + { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 300, height: 300) + case let .success(image): + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 350, maxHeight: 350) + .cornerRadius(12) + .shadow(radius: 20) + case .failure: + Image(systemName: "music.note") + .font(.system(size: 100)) + .foregroundStyle(.white.opacity(0.3)) + .frame(width: 300, height: 300) + @unknown default: + EmptyView() + } + } + } else { + Image(systemName: "music.note") + .font(.system(size: 100)) + .foregroundStyle(.white.opacity(0.3)) + .frame(width: 300, height: 300) + } + + Spacer() + + // Track info + VStack(spacing: 8) { + if let trackName = playbackViewModel.currentTrackName { + Text(trackName) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + .lineLimit(2) + .multilineTextAlignment(.center) + } + + if let artistName = playbackViewModel.currentArtistName { + Text(artistName) + .font(.title3) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + } + } + .padding(.horizontal, 32) + + Spacer() + + // Progress bar and time + VStack(spacing: 8) { + Slider( + value: Binding( + get: { Double(playbackViewModel.currentPositionMs) }, + set: { newValue in + let positionMs = UInt32(max(0, newValue)) + do { + try SpotifyPlayer.seek(positionMs: positionMs) + playbackViewModel.currentPositionMs = positionMs + playbackViewModel.updateNowPlayingInfo() + } catch { + playbackViewModel.errorMessage = error.localizedDescription + } + } + ), + in: Double(0)...Double(max(playbackViewModel.trackDurationMs, 1)) + ) + .tint(.green) + + HStack { + Text(formatTime(playbackViewModel.currentPositionMs)) + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .monospacedDigit() + + Spacer() + + Text(formatTime(playbackViewModel.trackDurationMs)) + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .monospacedDigit() + } + } + .padding(.horizontal, 32) + + Spacer() + + // Playback controls + HStack(spacing: 40) { + Button { + playbackViewModel.previous() + } label: { + Image(systemName: "backward.fill") + .font(.system(size: 32)) + .foregroundStyle(.white) + } + .disabled(!playbackViewModel.hasPrevious) + + Button { + if playbackViewModel.isPlaying { + SpotifyPlayer.pause() + playbackViewModel.isPlaying = false + } else { + SpotifyPlayer.resume() + playbackViewModel.isPlaying = true + } + playbackViewModel.updateNowPlayingInfo() + } label: { + Image(systemName: playbackViewModel.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 72)) + .foregroundStyle(.white) + } + + Button { + playbackViewModel.next() + } label: { + Image(systemName: "forward.fill") + .font(.system(size: 32)) + .foregroundStyle(.white) + } + .disabled(!playbackViewModel.hasNext) + } + .padding(.vertical, 24) + + // Additional controls + HStack(spacing: 32) { + // Favorite button + Button { + Task { + await playbackViewModel.toggleCurrentTrackFavorite(accessToken: authResult.accessToken) + } + } label: { + Image(systemName: playbackViewModel.isCurrentTrackFavorited ? "heart.fill" : "heart") + .font(.title2) + .foregroundStyle(playbackViewModel.isCurrentTrackFavorited ? .red : .white.opacity(0.7)) + } + + Spacer() + + // Queue position + Text("\(playbackViewModel.currentIndex + 1)/\(playbackViewModel.queueLength)") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + + Spacer() + + // Volume control + HStack(spacing: 12) { + Image(systemName: playbackViewModel.volume == 0 ? "speaker.fill" : playbackViewModel.volume < 0.5 ? "speaker.wave.1.fill" : "speaker.wave.3.fill") + .font(.title3) + .foregroundStyle(.white.opacity(0.7)) + + Slider( + value: $playbackViewModel.volume, + in: 0 ... 1 + ) + .tint(.green) + .frame(width: 100) + } + } + .padding(.horizontal, 32) + .padding(.bottom, 32) + } + } + } +} diff --git a/Spotifly/Views/LoggedInView.swift b/Spotifly/Views/LoggedInView.swift index 18b8f8e..a1d29ba 100644 --- a/Spotifly/Views/LoggedInView.swift +++ b/Spotifly/Views/LoggedInView.swift @@ -5,7 +5,9 @@ // Created by Ralph von der Heyden on 30.12.25. // +#if canImport(AppKit) import AppKit +#endif import SwiftUI struct LoggedInView: View { @@ -134,10 +136,14 @@ struct LoggedInView: View { // Now Playing Bar (always visible at bottom) NowPlayingBarView( playbackViewModel: playbackViewModel, - windowState: windowState, + windowState: windowState ) } + #if os(macOS) .background(windowState.isMiniPlayerMode ? Color(NSColor.windowBackgroundColor) : Color.clear) + #else + .ignoresSafeArea(.all, edges: .bottom) + #endif .searchShortcuts(searchFieldFocused: $searchFieldFocused) .environment(session) .environment(deviceService) @@ -150,10 +156,20 @@ struct LoggedInView: View { .environment(playlistService) .environment(albumService) .environment(artistService) + #if os(macOS) .focusedValue(\.navigationSelection, $selectedNavigationItem) .focusedValue(\.searchFieldFocused, $searchFieldFocused) .focusedValue(\.session, session) .focusedValue(\.recentlyPlayedService, recentlyPlayedService) + #endif + #if !os(macOS) + .fullScreenCover(isPresented: $windowState.isMiniPlayerMode) { + FullScreenPlayerView( + authResult: authResult, + playbackViewModel: playbackViewModel + ) + } + #endif .task { // Load favorite track IDs on startup so heart indicators work everywhere let token = await session.validAccessToken() diff --git a/Spotifly/Views/NowPlayingBarView.swift b/Spotifly/Views/NowPlayingBarView.swift index c25259e..f522b2b 100644 --- a/Spotifly/Views/NowPlayingBarView.swift +++ b/Spotifly/Views/NowPlayingBarView.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif struct NowPlayingBarView: View { @Environment(SpotifySession.self) private var session @@ -62,9 +67,14 @@ struct NowPlayingBarView: View { barHeight = newValue } } + #if os(macOS) .background(windowState.isMiniPlayerMode ? Color(NSColor.windowBackgroundColor) : Color(NSColor.controlBackgroundColor)) .frame(height: windowState.isMiniPlayerMode ? nil : barHeight) .frame(maxHeight: windowState.isMiniPlayerMode ? .infinity : nil) + #else + .background(Color(UIColor.secondarySystemBackground)) + .frame(height: barHeight) + #endif } .task(id: playbackViewModel.currentTrackId) { // Check favorite status when track changes @@ -78,10 +88,19 @@ struct NowPlayingBarView: View { private func compactTopRow(showVolume: Bool) -> some View { HStack(spacing: 12) { - albumArt(size: 40) + // Album art and track info - tappable on iOS to open full screen player + Group { + albumArt(size: 40) - trackInfo - .frame(minWidth: 100, alignment: .leading) + trackInfo + .frame(minWidth: 100, alignment: .leading) + } + #if !os(macOS) + .contentShape(Rectangle()) + .onTapGesture { + windowState.toggleMiniPlayerMode() + } + #endif Spacer() @@ -93,7 +112,9 @@ struct NowPlayingBarView: View { queuePosition + #if os(macOS) miniPlayerToggle + #endif if showVolume { volumeControl @@ -105,10 +126,19 @@ struct NowPlayingBarView: View { private var wideLayout: some View { HStack(spacing: 16) { - albumArt(size: 50) + // Album art and track info - tappable on iOS to open full screen player + Group { + albumArt(size: 50) - trackInfo - .frame(minWidth: 150, alignment: .leading) + trackInfo + .frame(minWidth: 150, alignment: .leading) + } + #if !os(macOS) + .contentShape(Rectangle()) + .onTapGesture { + windowState.toggleMiniPlayerMode() + } + #endif Spacer() @@ -149,7 +179,9 @@ struct NowPlayingBarView: View { queuePosition + #if os(macOS) miniPlayerToggle + #endif volumeControl } diff --git a/Spotifly/Views/PlaylistDetailView.swift b/Spotifly/Views/PlaylistDetailView.swift index e4b6275..6bb19f2 100644 --- a/Spotifly/Views/PlaylistDetailView.swift +++ b/Spotifly/Views/PlaylistDetailView.swift @@ -7,6 +7,11 @@ import SwiftUI import UniformTypeIdentifiers +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif struct PlaylistDetailView: View { let playlist: SearchPlaylist @@ -279,7 +284,11 @@ struct PlaylistDetailView: View { } } } + #if os(macOS) .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) .padding(.bottom, 80) @@ -325,7 +334,11 @@ struct PlaylistDetailView: View { } } } + #if os(macOS) .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } diff --git a/Spotifly/Views/PreferencesView.swift b/Spotifly/Views/PreferencesView.swift index ec7a5e0..25e0074 100644 --- a/Spotifly/Views/PreferencesView.swift +++ b/Spotifly/Views/PreferencesView.swift @@ -7,6 +7,7 @@ import SwiftUI +#if os(macOS) struct PreferencesView: View { var body: some View { TabView { @@ -118,3 +119,4 @@ struct InfoView: View { .padding(.bottom, 24) } } +#endif diff --git a/Spotifly/Views/RecentTracksDetailView.swift b/Spotifly/Views/RecentTracksDetailView.swift index c6c3035..cc4aa72 100644 --- a/Spotifly/Views/RecentTracksDetailView.swift +++ b/Spotifly/Views/RecentTracksDetailView.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif struct RecentTracksDetailView: View { let tracks: [Track] @@ -61,7 +66,11 @@ struct RecentTracksDetailView: View { } } } - .background(Color(NSColor.controlBackgroundColor)) + #if os(macOS) + .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } diff --git a/Spotifly/Views/SearchTracksDetailView.swift b/Spotifly/Views/SearchTracksDetailView.swift index 1ed0b24..c38000e 100644 --- a/Spotifly/Views/SearchTracksDetailView.swift +++ b/Spotifly/Views/SearchTracksDetailView.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif struct SearchTracksDetailView: View { let tracks: [SearchTrack] @@ -62,7 +67,11 @@ struct SearchTracksDetailView: View { } } } - .background(Color(NSColor.controlBackgroundColor)) + #if os(macOS) + .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } diff --git a/Spotifly/Views/StartpageView.swift b/Spotifly/Views/StartpageView.swift index 25d6fac..a8454cf 100644 --- a/Spotifly/Views/StartpageView.swift +++ b/Spotifly/Views/StartpageView.swift @@ -5,7 +5,11 @@ // Startpage with playback controls and track lookup // +#if canImport(AppKit) import AppKit +#elseif canImport(UIKit) +import UIKit +#endif import SwiftUI struct StartpageView: View { @@ -153,7 +157,11 @@ struct StartpageView: View { .foregroundStyle(.tertiary) } .padding() + #if os(macOS) .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } @@ -173,9 +181,13 @@ struct StartpageView: View { } private func copyTokenToClipboard() { + #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(session.accessToken, forType: .string) + #else + UIPasteboard.general.string = session.accessToken + #endif } } @@ -224,7 +236,11 @@ struct RecentTracksSection: View { } .buttonStyle(.plain) } + #if os(macOS) .background(Color(NSColor.controlBackgroundColor)) + #else + .background(Color(UIColor.secondarySystemBackground)) + #endif .cornerRadius(8) .padding(.horizontal) } diff --git a/Spotifly/Views/TrackRow.swift b/Spotifly/Views/TrackRow.swift index 04cc332..29d39ad 100644 --- a/Spotifly/Views/TrackRow.swift +++ b/Spotifly/Views/TrackRow.swift @@ -6,6 +6,11 @@ // import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif /// Data needed to display a track row struct TrackRowData: Identifiable { @@ -310,9 +315,13 @@ struct TrackRow: View { private func copyToClipboard() { guard let externalUrl = track.externalUrl else { return } + #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(externalUrl, forType: .string) + #else + UIPasteboard.general.string = externalUrl + #endif } private func handleDoubleTap() { diff --git a/Spotifly/WindowState.swift b/Spotifly/WindowState.swift index e348251..313bf49 100644 --- a/Spotifly/WindowState.swift +++ b/Spotifly/WindowState.swift @@ -2,31 +2,46 @@ // WindowState.swift // Spotifly // -// Manages window state for mini player mode +// Manages playback focus mode state +// - macOS: Mini player mode with resizable window +// - iOS/iPadOS: Full screen player view // +#if os(macOS) import AppKit +#endif import Combine import SwiftUI @MainActor class WindowState: ObservableObject { + /// On macOS: mini player mode (compact window) + /// On iOS/iPadOS: full screen player view (maxi player) @Published var isMiniPlayerMode: Bool = false + #if os(macOS) // Store the previous window frame to restore when exiting mini player private var savedWindowFrame: NSRect? static let miniPlayerSize = NSSize(width: 600, height: 120) static let defaultSize = NSSize(width: 800, height: 600) + #endif func toggleMiniPlayerMode() { + #if os(macOS) if isMiniPlayerMode { exitMiniPlayerMode() } else { enterMiniPlayerMode() } + #else + // On iOS/iPadOS, just toggle the state + // The UI will handle showing/hiding the full screen player + isMiniPlayerMode.toggle() + #endif } + #if os(macOS) private func enterMiniPlayerMode() { guard let window = NSApp.mainWindow ?? NSApp.windows.first else { return } @@ -48,7 +63,7 @@ class WindowState: ObservableObject { let newWidth = Self.miniPlayerSize.width let newOrigin = NSPoint( x: currentFrame.origin.x, - y: currentFrame.origin.y + currentFrame.height - newHeight, + y: currentFrame.origin.y + currentFrame.height - newHeight ) let newFrame = NSRect(origin: newOrigin, size: NSSize(width: newWidth, height: newHeight)) @@ -68,7 +83,7 @@ class WindowState: ObservableObject { let currentFrame = window.frame let newOrigin = NSPoint( x: currentFrame.origin.x, - y: currentFrame.origin.y + currentFrame.height - savedFrame.height, + y: currentFrame.origin.y + currentFrame.height - savedFrame.height ) let newFrame = NSRect(origin: newOrigin, size: savedFrame.size) window.setFrame(newFrame, display: true, animate: true) @@ -80,4 +95,5 @@ class WindowState: ObservableObject { // after the window is big enough to contain them isMiniPlayerMode = false } + #endif } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e52584d..7225e69 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1091,8 +1091,6 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "librespot-audio" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3fe76acb49f58165484303edf0e7bd778f0e6d96f5c59e9d6b6fde1a90d36ff" dependencies = [ "aes", "bytes", @@ -1111,8 +1109,6 @@ dependencies = [ [[package]] name = "librespot-core" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168bbe1c416980ddd9a969ebd6b50fb6c924eb1a3ded194285fa8ec0e2b1c68b" dependencies = [ "aes", "base64", @@ -1168,8 +1164,6 @@ dependencies = [ [[package]] name = "librespot-metadata" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a9c688aa2acd3ed2498e31a95d6f2be49c0f18128db8958450ffd628aa88532" dependencies = [ "async-trait", "bytes", @@ -1186,8 +1180,6 @@ dependencies = [ [[package]] name = "librespot-oauth" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d686417d49c9d2c363392ffe28d6e469daca20a82dc414740930e078f5829661" dependencies = [ "log", "oauth2", @@ -1200,8 +1192,6 @@ dependencies = [ [[package]] name = "librespot-playback" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88258620bf3e6808ea1fadd11639648d77c06280b9f5a4c9d14ea79f6f998af6" dependencies = [ "cpal", "form_urlencoded", @@ -1224,8 +1214,6 @@ dependencies = [ [[package]] name = "librespot-protocol" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e01f0b2d39f83fa162eb91d4a16313bcf99e77daf258abe8f7b7bcb1160b084" dependencies = [ "protobuf", "protobuf-codegen", @@ -3597,3 +3585,7 @@ name = "zmij" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06" + +[[patch.unused]] +name = "librespot-discovery" +version = "0.8.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ab6d078..076b11c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,3 +19,15 @@ serde_json = "1.0" [profile.release] opt-level = 3 lto = true + +# Use local patched librespot that spoofs iOS as macOS +# Spotify rejects iPhone platform identifiers, so we report as macOS x86_64 +# See: https://github.com/librespot-org/librespot/issues/1399 +[patch.crates-io] +librespot-core = { path = "../../librespot/core" } +librespot-metadata = { path = "../../librespot/metadata" } +librespot-playback = { path = "../../librespot/playback" } +librespot-audio = { path = "../../librespot/audio" } +librespot-oauth = { path = "../../librespot/oauth" } +librespot-protocol = { path = "../../librespot/protocol" } +librespot-discovery = { path = "../../librespot/discovery" } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b2f90ce..48e176c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -120,23 +120,22 @@ fn get_album_art_url(track: &Track) -> String { // Try to get largest album cover from track metadata track.album.covers.iter() .max_by_key(|img| img.width * img.height) - .and_then(|img| { - img.id.to_base16().ok().map(|file_id_hex| { - format!("https://i.scdn.co/image/{}", file_id_hex) - }) + .map(|img| { + let file_id_hex = img.id.to_base16(); + format!("https://i.scdn.co/image/{}", file_id_hex) }) .unwrap_or_default() } // Helper function to extract album ID from track fn get_album_id(track: &Track) -> Option { - Some(track.album.id.to_id().ok()?) + Some(track.album.id.to_id()) } // Helper function to extract first artist ID from track fn get_artist_id(track: &Track) -> Option { track.artists.first() - .and_then(|a| a.id.to_id().ok()) + .map(|a| a.id.to_id()) } // Helper function to build external URL from track URI