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