Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Spotifly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
156 changes: 156 additions & 0 deletions Spotifly/AudioSessionManager.swift
Original file line number Diff line number Diff line change
@@ -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")
}
8 changes: 8 additions & 0 deletions Spotifly/Spotifly-iOS.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- iOS apps don't use app sandbox entitlements -->
<!-- Network access is allowed by default on iOS -->
</dict>
</plist>
13 changes: 13 additions & 0 deletions Spotifly/SpotiflyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -165,3 +177,4 @@ struct SpotiflyCommands: Commands {
}
}
}
#endif
10 changes: 10 additions & 0 deletions Spotifly/SpotifyAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions Spotifly/SpotifyPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import Foundation
import SpotiflyRust
#if canImport(UIKit)
import UIKit
#endif

/// Queue item metadata
struct QueueItem: Sendable, Identifiable {
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion Spotifly/Store/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading