diff --git a/Lyric Fever.xcodeproj/project.pbxproj b/Lyric Fever.xcodeproj/project.pbxproj index 34671a8..6eabcc1 100644 --- a/Lyric Fever.xcodeproj/project.pbxproj +++ b/Lyric Fever.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 8F6BD2952A8A61C9008BBF88 /* AmplitudeSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 8F6BD2942A8A61C9008BBF88 /* AmplitudeSwift */; }; 8F6BD2972A8A6278008BBF88 /* amplitudeKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6BD2962A8A6278008BBF88 /* amplitudeKey.swift */; }; 8F6BD2992A8A6B7D008BBF88 /* viewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6BD2982A8A6B7D008BBF88 /* viewModel.swift */; }; + 8F85A8222BBFD9F6004A774D /* AppleMusicScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F85A8212BBFD9F6004A774D /* AppleMusicScripting.swift */; }; + 8FA436AE2BC49D480072016C /* newPermissionMac.gif in Resources */ = {isa = PBXBuildFile; fileRef = 8FA436AD2BC49D480072016C /* newPermissionMac.gif */; }; 8FC8E9492A704EEB00F69915 /* SpotifyLyricsInMenubarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC8E9482A704EEB00F69915 /* SpotifyLyricsInMenubarApp.swift */; }; 8FC8E94D2A704EED00F69915 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8FC8E94C2A704EED00F69915 /* Assets.xcassets */; }; 8FCFD1C32AE35DEA00B22023 /* spotifylogin.gif in Resources */ = {isa = PBXBuildFile; fileRef = 8FCFD1C22AE35DEA00B22023 /* spotifylogin.gif */; }; @@ -22,7 +24,6 @@ 8FF59E2E2A798D2B00F0A382 /* Lyrics.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 8FF59E2C2A798D2B00F0A382 /* Lyrics.xcdatamodeld */; }; 8FFA9F312AA1B1E600BAEC5C /* OnboardingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFA9F302AA1B1E600BAEC5C /* OnboardingWindow.swift */; }; 8FFA9F342AA1B3CB00BAEC5C /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8FFA9F332AA1B3CB00BAEC5C /* SDWebImageSwiftUI */; }; - 8FFA9F372AA1B63500BAEC5C /* spotifyPermissionMac.gif in Resources */ = {isa = PBXBuildFile; fileRef = 8FFA9F352AA1B63500BAEC5C /* spotifyPermissionMac.gif */; }; 8FFA9F382AA1B63500BAEC5C /* crossfade.gif in Resources */ = {isa = PBXBuildFile; fileRef = 8FFA9F362AA1B63500BAEC5C /* crossfade.gif */; }; /* End PBXBuildFile section */ @@ -32,6 +33,8 @@ 8F4F495C2A7FB3D400097888 /* SongObject+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SongObject+CoreDataProperties.swift"; sourceTree = ""; }; 8F6BD2962A8A6278008BBF88 /* amplitudeKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = amplitudeKey.swift; sourceTree = ""; }; 8F6BD2982A8A6B7D008BBF88 /* viewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = viewModel.swift; sourceTree = ""; }; + 8F85A8212BBFD9F6004A774D /* AppleMusicScripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusicScripting.swift; sourceTree = ""; }; + 8FA436AD2BC49D480072016C /* newPermissionMac.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = newPermissionMac.gif; sourceTree = ""; }; 8FC8E9452A704EEB00F69915 /* Lyric Fever.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Lyric Fever.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 8FC8E9482A704EEB00F69915 /* SpotifyLyricsInMenubarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyLyricsInMenubarApp.swift; sourceTree = ""; }; 8FC8E94C2A704EED00F69915 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -41,7 +44,6 @@ 8FE454292A891EBD0039EFA7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8FF59E2D2A798D2B00F0A382 /* Lyrics.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Lyrics.xcdatamodel; sourceTree = ""; }; 8FFA9F302AA1B1E600BAEC5C /* OnboardingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindow.swift; sourceTree = ""; }; - 8FFA9F352AA1B63500BAEC5C /* spotifyPermissionMac.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = spotifyPermissionMac.gif; sourceTree = ""; }; 8FFA9F362AA1B63500BAEC5C /* crossfade.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = crossfade.gif; sourceTree = ""; }; /* End PBXFileReference section */ @@ -72,7 +74,7 @@ 8F4F495B2A7FB3D400097888 /* SongObject+CoreDataClass.swift */, 8FCFD1C22AE35DEA00B22023 /* spotifylogin.gif */, 8FFA9F362AA1B63500BAEC5C /* crossfade.gif */, - 8FFA9F352AA1B63500BAEC5C /* spotifyPermissionMac.gif */, + 8FA436AD2BC49D480072016C /* newPermissionMac.gif */, 8F4F495C2A7FB3D400097888 /* SongObject+CoreDataProperties.swift */, 8FC8E9472A704EEB00F69915 /* SpotifyLyricsInMenubar */, 8FC8E9462A704EEB00F69915 /* Products */, @@ -101,6 +103,7 @@ 8FC8E94C2A704EED00F69915 /* Assets.xcassets */, 8FC8E9512A704EED00F69915 /* SpotifyLyricsInMenubar.entitlements */, 8FE454272A8916C30039EFA7 /* SpotifyScripting.swift */, + 8F85A8212BBFD9F6004A774D /* AppleMusicScripting.swift */, ); path = SpotifyLyricsInMenubar; sourceTree = ""; @@ -173,7 +176,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8FFA9F372AA1B63500BAEC5C /* spotifyPermissionMac.gif in Resources */, + 8FA436AE2BC49D480072016C /* newPermissionMac.gif in Resources */, 8FC8E94D2A704EED00F69915 /* Assets.xcassets in Resources */, 8FFA9F382AA1B63500BAEC5C /* crossfade.gif in Resources */, 8FCFD1C32AE35DEA00B22023 /* spotifylogin.gif in Resources */, @@ -194,6 +197,7 @@ 8F39ED8E2A78EB5900574203 /* lyricJsonStruct.swift in Sources */, 8F6BD2992A8A6B7D008BBF88 /* viewModel.swift in Sources */, 8FC8E9492A704EEB00F69915 /* SpotifyLyricsInMenubarApp.swift in Sources */, + 8F85A8222BBFD9F6004A774D /* AppleMusicScripting.swift in Sources */, 8FE454282A8916C30039EFA7 /* SpotifyScripting.swift in Sources */, 8FFA9F312AA1B1E600BAEC5C /* OnboardingWindow.swift in Sources */, 8F4F495E2A7FB3D400097888 /* SongObject+CoreDataProperties.swift in Sources */, @@ -332,7 +336,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1.6; + CURRENT_PROJECT_VERSION = 1.7; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = 38TP6LZLJ5; ENABLE_APP_SANDBOX = YES; @@ -344,14 +348,14 @@ INFOPLIST_KEY_CFBundleDisplayName = "Lyric Fever"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "I need to access Spotify to play the lyrics properly!"; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "I need to access Spotify / Apple Music to play the lyrics properly."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.aviwadhwa.SpotifyLyricsInMenubar; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -368,7 +372,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1.6; + CURRENT_PROJECT_VERSION = 1.7; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = 38TP6LZLJ5; ENABLE_APP_SANDBOX = YES; @@ -380,14 +384,14 @@ INFOPLIST_KEY_CFBundleDisplayName = "Lyric Fever"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "I need to access Spotify to play the lyrics properly!"; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "I need to access Spotify / Apple Music to play the lyrics properly."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.aviwadhwa.SpotifyLyricsInMenubar; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/SongObject+CoreDataClass.swift b/SongObject+CoreDataClass.swift index d4a8174..14907c8 100644 --- a/SongObject+CoreDataClass.swift +++ b/SongObject+CoreDataClass.swift @@ -27,7 +27,7 @@ public class SongObject: NSManagedObject, Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) if let syncType = try? container.decode(String.self, forKey: .syncType), syncType == "LINE_SYNCED", var lyrics = try? container.decode([LyricLine].self, forKey: .lines) { if !lyrics.isEmpty { - lyrics.append(LyricLine(startTime: duration, words: "Now Playing: \(title)")) + lyrics.append(LyricLine(startTime: duration-1400, words: "Now Playing: \(title)")) } self.lyricsTimestamps = lyrics.map {$0.startTimeMS} self.lyricsWords = lyrics.map {$0.words} diff --git a/SpotifyLyricsInMenubar/AppleMusicScripting.swift b/SpotifyLyricsInMenubar/AppleMusicScripting.swift new file mode 100644 index 0000000..8dae724 --- /dev/null +++ b/SpotifyLyricsInMenubar/AppleMusicScripting.swift @@ -0,0 +1,591 @@ +// +// AppleMusicScripting.swift +// Lyric Fever +// +// Created by Avi Wadhwa on 05/04/24. +// Taken from Jukebox https://github.com/Jaysce/Jukebox/blob/main/Jukebox/ScriptingBridge/MusicApplication.swift + + +import AppKit +import ScriptingBridge + +// MARK: MusicEKnd +@objc public enum MusicEKnd : AEKeyword { + case trackListing = 0x6b54726b /* b'kTrk' */ + case albumListing = 0x6b416c62 /* b'kAlb' */ + case cdInsert = 0x6b434469 /* b'kCDi' */ +} + +// MARK: MusicEnum +@objc public enum MusicEnum : AEKeyword { + case standard = 0x6c777374 /* b'lwst' */ + case detailed = 0x6c776474 /* b'lwdt' */ +} + +// MARK: MusicEPlS +@objc public enum MusicEPlS : AEKeyword { + case stopped = 0x6b505353 /* b'kPSS' */ + case playing = 0x6b505350 /* b'kPSP' */ + case paused = 0x6b505370 /* b'kPSp' */ + case fastForwarding = 0x6b505346 /* b'kPSF' */ + case rewinding = 0x6b505352 /* b'kPSR' */ +} + +// MARK: MusicERpt +@objc public enum MusicERpt : AEKeyword { + case off = 0x6b52704f /* b'kRpO' */ + case one = 0x6b527031 /* b'kRp1' */ + case all = 0x6b416c6c /* b'kAll' */ +} + +// MARK: MusicEShM +@objc public enum MusicEShM : AEKeyword { + case songs = 0x6b536853 /* b'kShS' */ + case albums = 0x6b536841 /* b'kShA' */ + case groupings = 0x6b536847 /* b'kShG' */ +} + +// MARK: MusicESrc +@objc public enum MusicESrc : AEKeyword { + case library = 0x6b4c6962 /* b'kLib' */ + case audioCD = 0x6b414344 /* b'kACD' */ + case mp3CD = 0x6b4d4344 /* b'kMCD' */ + case radioTuner = 0x6b54756e /* b'kTun' */ + case sharedLibrary = 0x6b536864 /* b'kShd' */ + case iTunesStore = 0x6b495453 /* b'kITS' */ + case unknown = 0x6b556e6b /* b'kUnk' */ +} + +// MARK: MusicESrA +@objc public enum MusicESrA : AEKeyword { + case albums = 0x6b53724c /* b'kSrL' */ + case all = 0x6b416c6c /* b'kAll' */ + case artists = 0x6b537252 /* b'kSrR' */ + case composers = 0x6b537243 /* b'kSrC' */ + case displayed = 0x6b537256 /* b'kSrV' */ + case names = 0x6b537253 /* b'kSrS' */ +} + +// MARK: MusicESpK +@objc public enum MusicESpK : AEKeyword { + case none = 0x6b4e6f6e /* b'kNon' */ + case folder = 0x6b537046 /* b'kSpF' */ + case genius = 0x6b537047 /* b'kSpG' */ + case library = 0x6b53704c /* b'kSpL' */ + case music = 0x6b53705a /* b'kSpZ' */ + case purchasedMusic = 0x6b53704d /* b'kSpM' */ +} + +// MARK: MusicEMdK +@objc public enum MusicEMdK : AEKeyword { + case song = 0x6b4d6453 /* b'kMdS' */ + case musicVideo = 0x6b566456 /* b'kVdV' */ + case unknown = 0x6b556e6b /* b'kUnk' */ +} + +// MARK: MusicERtK +@objc public enum MusicERtK : AEKeyword { + case user = 0x6b527455 /* b'kRtU' */ + case computed = 0x6b527443 /* b'kRtC' */ +} + +// MARK: MusicEAPD +@objc public enum MusicEAPD : AEKeyword { + case computer = 0x6b415043 /* b'kAPC' */ + case airPortExpress = 0x6b415058 /* b'kAPX' */ + case appleTV = 0x6b415054 /* b'kAPT' */ + case airPlayDevice = 0x6b41504f /* b'kAPO' */ + case bluetoothDevice = 0x6b415042 /* b'kAPB' */ + case homePod = 0x6b415048 /* b'kAPH' */ + case unknown = 0x6b415055 /* b'kAPU' */ +} + +// MARK: MusicEClS +@objc public enum MusicEClS : AEKeyword { + case unknown = 0x6b556e6b /* b'kUnk' */ + case purchased = 0x6b507572 /* b'kPur' */ + case matched = 0x6b4d6174 /* b'kMat' */ + case uploaded = 0x6b55706c /* b'kUpl' */ + case ineligible = 0x6b52656a /* b'kRej' */ + case removed = 0x6b52656d /* b'kRem' */ + case error = 0x6b457272 /* b'kErr' */ + case duplicate = 0x6b447570 /* b'kDup' */ + case subscription = 0x6b537562 /* b'kSub' */ + case noLongerAvailable = 0x6b526576 /* b'kRev' */ + case notUploaded = 0x6b557050 /* b'kUpP' */ +} + +// MARK: MusicGenericMethods +@objc public protocol MusicGenericMethods { + @objc optional func printPrintDialog(_ printDialog: Bool, withProperties: [AnyHashable : Any]!, kind: MusicEKnd, theme: String!) // Print the specified object(s) + @objc optional func close() // Close an object + @objc optional func delete() // Delete an element from an object + @objc optional func duplicateTo(_ to: SBObject!) -> SBObject // Duplicate one or more object(s) + @objc optional func exists() -> Bool // Verify if an object exists + @objc optional func `open`() // Open the specified object(s) + @objc optional func save() // Save the specified object(s) + @objc optional func playOnce(_ once: Bool) // play the current track or the specified track or file. + @objc optional func select() // select the specified object(s) +} + +// MARK: MusicApplication +@objc public protocol MusicApplication: SBApplicationProtocol { + @objc optional func AirPlayDevices() -> SBElementArray + @objc optional func browserWindows() -> SBElementArray + @objc optional func encoders() -> SBElementArray + @objc optional func EQPresets() -> SBElementArray + @objc optional func EQWindows() -> SBElementArray + @objc optional func miniplayerWindows() -> SBElementArray + @objc optional func playlists() -> SBElementArray + @objc optional func playlistWindows() -> SBElementArray + @objc optional func sources() -> SBElementArray + @objc optional func tracks() -> SBElementArray + @objc optional func videoWindows() -> SBElementArray + @objc optional func visuals() -> SBElementArray + @objc optional func windows() -> SBElementArray + @objc optional var AirPlayEnabled: Bool { get } // is AirPlay currently enabled? + @objc optional var converting: Bool { get } // is a track currently being converted? + @objc optional var currentAirPlayDevices: [MusicAirPlayDevice] { get } // the currently selected AirPlay device(s) + @objc optional var currentEncoder: MusicEncoder { get } // the currently selected encoder (MP3, AIFF, WAV, etc.) + @objc optional var currentEQPreset: MusicEQPreset { get } // the currently selected equalizer preset + @objc optional var currentPlaylist: MusicPlaylist { get } // the playlist containing the currently targeted track + @objc optional var currentStreamTitle: String { get } // the name of the current track in the playing stream (provided by streaming server) + @objc optional var currentStreamURL: String { get } // the URL of the playing stream or streaming web site (provided by streaming server) + @objc optional var currentTrack: MusicTrack { get } // the current targeted track + @objc optional var currentVisual: MusicVisual { get } // the currently selected visual plug-in + @objc optional var EQEnabled: Bool { get } // is the equalizer enabled? + @objc optional var fixedIndexing: Bool { get } // true if all AppleScript track indices should be independent of the play order of the owning playlist. + @objc optional var frontmost: Bool { get } // is this the active application? + @objc optional var fullScreen: Bool { get } // is the application using the entire screen? + @objc optional var name: String { get } // the name of the application + @objc optional var mute: Bool { get } // has the sound output been muted? + @objc optional var playerPosition: Double { get } // the player’s position within the currently playing track in seconds. + @objc optional var playerState: MusicEPlS { get } // is the player stopped, paused, or playing? + @objc optional var selection: SBObject { get } // the selection visible to the user + @objc optional var shuffleEnabled: Bool { get } // are songs played in random order? + @objc optional var shuffleMode: MusicEShM { get } // the playback shuffle mode + @objc optional var songRepeat: MusicERpt { get } // the playback repeat mode + @objc optional var soundVolume: Int { get } // the sound output volume (0 = minimum, 100 = maximum) + @objc optional var version: String { get } // the version of the application + @objc optional var visualsEnabled: Bool { get } // are visuals currently being displayed? + @objc optional func printPrintDialog(_ printDialog: Bool, withProperties: [AnyHashable : Any]!, kind: MusicEKnd, theme: String!) // Print the specified object(s) + @objc optional func run() // Run the application + @objc optional func quit() // Quit the application + @objc optional func add(_ x: [URL]!, to: SBObject!) -> MusicTrack // add one or more files to a playlist + @objc optional func backTrack() // reposition to beginning of current track or go to previous track if already at start of current track + @objc optional func convert(_ x: [SBObject]!) -> MusicTrack // convert one or more files or tracks + @objc optional func fastForward() // skip forward in a playing track + @objc optional func nextTrack() // advance to the next track in the current playlist + @objc optional func pause() // pause playback + @objc optional func playOnce(_ once: Bool) // play the current track or the specified track or file. + @objc optional func playpause() // toggle the playing/paused state of the current track + @objc optional func previousTrack() // return to the previous track in the current playlist + @objc optional func resume() // disable fast forward/rewind and resume playback, if playing. + @objc optional func rewind() // skip backwards in a playing track + @objc optional func stop() // stop playback + @objc optional func openLocation(_ x: String!) // Opens an iTunes Store or audio stream URL + @objc optional func setCurrentAirPlayDevices(_ currentAirPlayDevices: [MusicAirPlayDevice]!) // the currently selected AirPlay device(s) + @objc optional func setCurrentEncoder(_ currentEncoder: MusicEncoder!) // the currently selected encoder (MP3, AIFF, WAV, etc.) + @objc optional func setCurrentEQPreset(_ currentEQPreset: MusicEQPreset!) // the currently selected equalizer preset + @objc optional func setCurrentVisual(_ currentVisual: MusicVisual!) // the currently selected visual plug-in + @objc optional func setEQEnabled(_ EQEnabled: Bool) // is the equalizer enabled? + @objc optional func setFixedIndexing(_ fixedIndexing: Bool) // true if all AppleScript track indices should be independent of the play order of the owning playlist. + @objc optional func setFrontmost(_ frontmost: Bool) // is this the active application? + @objc optional func setFullScreen(_ fullScreen: Bool) // is the application using the entire screen? + @objc optional func setMute(_ mute: Bool) // has the sound output been muted? + @objc optional func setPlayerPosition(_ playerPosition: Double) // the player’s position within the currently playing track in seconds. + @objc optional func setShuffleEnabled(_ shuffleEnabled: Bool) // are songs played in random order? + @objc optional func setShuffleMode(_ shuffleMode: MusicEShM) // the playback shuffle mode + @objc optional func setSongRepeat(_ songRepeat: MusicERpt) // the playback repeat mode + @objc optional func setSoundVolume(_ soundVolume: Int) // the sound output volume (0 = minimum, 100 = maximum) + @objc optional func setVisualsEnabled(_ visualsEnabled: Bool) // are visuals currently being displayed? +} +extension SBApplication: MusicApplication {} + +// MARK: MusicItem +@objc public protocol MusicItem: SBObjectProtocol, MusicGenericMethods { + @objc optional var container: SBObject { get } // the container of the item + @objc optional func id() -> Int // the id of the item + @objc optional var index: Int { get } // the index of the item in internal application order + @objc optional var name: String { get } // the name of the item + @objc optional var persistentID: String { get } // the id of the item as a hexadecimal string. This id does not change over time. + @objc optional var properties: [AnyHashable : Any] { get } // every property of the item + @objc optional func download() // download a cloud track or playlist + @objc optional func reveal() // reveal and select a track or playlist + @objc optional func setName(_ name: String!) // the name of the item + @objc optional func setProperties(_ properties: [AnyHashable : Any]!) // every property of the item +} +extension SBObject: MusicItem {} + +// MARK: MusicAirPlayDevice +@objc public protocol MusicAirPlayDevice: MusicItem { + @objc optional var active: Bool { get } // is the device currently being played to? + @objc optional var available: Bool { get } // is the device currently available? + @objc optional var kind: MusicEAPD { get } // the kind of the device + @objc optional var networkAddress: String { get } // the network (MAC) address of the device + @objc optional func protected() -> Bool // is the device password- or passcode-protected? + @objc optional var selected: Bool { get } // is the device currently selected? + @objc optional var supportsAudio: Bool { get } // does the device support audio playback? + @objc optional var supportsVideo: Bool { get } // does the device support video playback? + @objc optional var soundVolume: Int { get } // the output volume for the device (0 = minimum, 100 = maximum) + @objc optional func setSelected(_ selected: Bool) // is the device currently selected? + @objc optional func setSoundVolume(_ soundVolume: Int) // the output volume for the device (0 = minimum, 100 = maximum) +} +extension SBObject: MusicAirPlayDevice {} + +// MARK: MusicArtwork +@objc public protocol MusicArtwork: MusicItem { + @objc optional var data: NSImage { get } // data for this artwork, in the form of a picture + @objc optional var objectDescription: String { get } // description of artwork as a string + @objc optional var downloaded: Bool { get } // was this artwork downloaded by Music? + @objc optional var format: NSNumber { get } // the data format for this piece of artwork + @objc optional var kind: Int { get } // kind or purpose of this piece of artwork + @objc optional var rawData: Any { get } // data for this artwork, in original format + @objc optional func setData(_ data: NSImage!) // data for this artwork, in the form of a picture + @objc optional func setObjectDescription(_ objectDescription: String!) // description of artwork as a string + @objc optional func setKind(_ kind: Int) // kind or purpose of this piece of artwork + @objc optional func setRawData(_ rawData: Any!) // data for this artwork, in original format +} +extension SBObject: MusicArtwork {} + +// MARK: MusicEncoder +@objc public protocol MusicEncoder: MusicItem { + @objc optional var format: String { get } // the data format created by the encoder +} +extension SBObject: MusicEncoder {} + +// MARK: MusicEQPreset +@objc public protocol MusicEQPreset: MusicItem { + @objc optional var band1: Double { get } // the equalizer 32 Hz band level (-12.0 dB to +12.0 dB) + @objc optional var band2: Double { get } // the equalizer 64 Hz band level (-12.0 dB to +12.0 dB) + @objc optional var band3: Double { get } // the equalizer 125 Hz band level (-12.0 dB to +12.0 dB) + @objc optional var band4: Double { get } // the equalizer 250 Hz band level (-12.0 dB to +12.0 dB) + @objc optional var band5: Double { get } // the equalizer 500 Hz band level (-12.0 dB to +12.0 dB) + @objc optional var band6: Double { get } // the equalizer 1 kHz band level (-12.0 dB to +12.0 dB) + @objc optional var band7: Double { get } // the equalizer 2 kHz band level (-12.0 dB to +12.0 dB) + @objc optional var band8: Double { get } // the equalizer 4 kHz band level (-12.0 dB to +12.0 dB) + @objc optional var band9: Double { get } // the equalizer 8 kHz band level (-12.0 dB to +12.0 dB) + @objc optional var band10: Double { get } // the equalizer 16 kHz band level (-12.0 dB to +12.0 dB) + @objc optional var modifiable: Bool { get } // can this preset be modified? + @objc optional var preamp: Double { get } // the equalizer preamp level (-12.0 dB to +12.0 dB) + @objc optional var updateTracks: Bool { get } // should tracks which refer to this preset be updated when the preset is renamed or deleted? + @objc optional func setBand1(_ band1: Double) // the equalizer 32 Hz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand2(_ band2: Double) // the equalizer 64 Hz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand3(_ band3: Double) // the equalizer 125 Hz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand4(_ band4: Double) // the equalizer 250 Hz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand5(_ band5: Double) // the equalizer 500 Hz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand6(_ band6: Double) // the equalizer 1 kHz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand7(_ band7: Double) // the equalizer 2 kHz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand8(_ band8: Double) // the equalizer 4 kHz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand9(_ band9: Double) // the equalizer 8 kHz band level (-12.0 dB to +12.0 dB) + @objc optional func setBand10(_ band10: Double) // the equalizer 16 kHz band level (-12.0 dB to +12.0 dB) + @objc optional func setPreamp(_ preamp: Double) // the equalizer preamp level (-12.0 dB to +12.0 dB) + @objc optional func setUpdateTracks(_ updateTracks: Bool) // should tracks which refer to this preset be updated when the preset is renamed or deleted? +} +extension SBObject: MusicEQPreset {} + +// MARK: MusicPlaylist +@objc public protocol MusicPlaylist: MusicItem { + @objc optional func tracks() -> SBElementArray + @objc optional func artworks() -> SBElementArray + @objc optional var objectDescription: String { get } // the description of the playlist + @objc optional var disliked: Bool { get } // is this playlist disliked? + @objc optional var duration: Int { get } // the total length of all tracks (in seconds) + @objc optional var name: String { get } // the name of the playlist + @objc optional var loved: Bool { get } // is this playlist loved? + @objc optional var parent: MusicPlaylist { get } // folder which contains this playlist (if any) + @objc optional var size: Int { get } // the total size of all tracks (in bytes) + @objc optional var specialKind: MusicESpK { get } // special playlist kind + @objc optional var time: String { get } // the length of all tracks in MM:SS format + @objc optional var visible: Bool { get } // is this playlist visible in the Source list? + @objc optional func moveTo(_ to: SBObject!) // Move playlist(s) to a new location + @objc optional func searchFor(_ for_: String!, only: MusicESrA) -> MusicTrack // search a playlist for tracks matching the search string. Identical to entering search text in the Search field. + @objc optional func setObjectDescription(_ objectDescription: String!) // the description of the playlist + @objc optional func setDisliked(_ disliked: Bool) // is this playlist disliked? + @objc optional func setName(_ name: String!) // the name of the playlist + @objc optional func setLoved(_ loved: Bool) // is this playlist loved? +} +extension SBObject: MusicPlaylist {} + +// MARK: MusicAudioCDPlaylist +@objc public protocol MusicAudioCDPlaylist: MusicPlaylist { + @objc optional func audioCDTracks() -> SBElementArray + @objc optional var artist: String { get } // the artist of the CD + @objc optional var compilation: Bool { get } // is this CD a compilation album? + @objc optional var composer: String { get } // the composer of the CD + @objc optional var discCount: Int { get } // the total number of discs in this CD’s album + @objc optional var discNumber: Int { get } // the index of this CD disc in the source album + @objc optional var genre: String { get } // the genre of the CD + @objc optional var year: Int { get } // the year the album was recorded/released + @objc optional func setArtist(_ artist: String!) // the artist of the CD + @objc optional func setCompilation(_ compilation: Bool) // is this CD a compilation album? + @objc optional func setComposer(_ composer: String!) // the composer of the CD + @objc optional func setDiscCount(_ discCount: Int) // the total number of discs in this CD’s album + @objc optional func setDiscNumber(_ discNumber: Int) // the index of this CD disc in the source album + @objc optional func setGenre(_ genre: String!) // the genre of the CD + @objc optional func setYear(_ year: Int) // the year the album was recorded/released +} +extension SBObject: MusicAudioCDPlaylist {} + +// MARK: MusicLibraryPlaylist +@objc public protocol MusicLibraryPlaylist: MusicPlaylist { + @objc optional func fileTracks() -> SBElementArray + @objc optional func URLTracks() -> SBElementArray + @objc optional func sharedTracks() -> SBElementArray +} +extension SBObject: MusicLibraryPlaylist {} + +// MARK: MusicRadioTunerPlaylist +@objc public protocol MusicRadioTunerPlaylist: MusicPlaylist { + @objc optional func URLTracks() -> SBElementArray +} +extension SBObject: MusicRadioTunerPlaylist {} + +// MARK: MusicSource +@objc public protocol MusicSource: MusicItem { + @objc optional func audioCDPlaylists() -> SBElementArray + @objc optional func libraryPlaylists() -> SBElementArray + @objc optional func playlists() -> SBElementArray + @objc optional func radioTunerPlaylists() -> SBElementArray + @objc optional func subscriptionPlaylists() -> SBElementArray + @objc optional func userPlaylists() -> SBElementArray + @objc optional var capacity: Int64 { get } // the total size of the source if it has a fixed size + @objc optional var freeSpace: Int64 { get } // the free space on the source if it has a fixed size + @objc optional var kind: MusicESrc { get } +} +extension SBObject: MusicSource {} + +// MARK: MusicSubscriptionPlaylist +@objc public protocol MusicSubscriptionPlaylist: MusicPlaylist { + @objc optional func fileTracks() -> SBElementArray + @objc optional func URLTracks() -> SBElementArray +} +extension SBObject: MusicSubscriptionPlaylist {} + +// MARK: MusicTrack +@objc public protocol MusicTrack: MusicItem { + @objc optional func artworks() -> SBElementArray + @objc optional var album: String { get } // the album name of the track + @objc optional var albumArtist: String { get } // the album artist of the track + @objc optional var albumDisliked: Bool { get } // is the album for this track disliked? + @objc optional var albumLoved: Bool { get } // is the album for this track loved? + @objc optional var albumRating: Int { get } // the rating of the album for this track (0 to 100) + @objc optional var albumRatingKind: MusicERtK { get } // the rating kind of the album rating for this track + @objc optional var artist: String { get } // the artist/source of the track + @objc optional var bitRate: Int { get } // the bit rate of the track (in kbps) + @objc optional var bookmark: Double { get } // the bookmark time of the track in seconds + @objc optional var bookmarkable: Bool { get } // is the playback position for this track remembered? + @objc optional var bpm: Int { get } // the tempo of this track in beats per minute + @objc optional var category: String { get } // the category of the track + @objc optional var cloudStatus: MusicEClS { get } // the iCloud status of the track + @objc optional var comment: String { get } // freeform notes about the track + @objc optional var compilation: Bool { get } // is this track from a compilation album? + @objc optional var composer: String { get } // the composer of the track + @objc optional var databaseID: Int { get } // the common, unique ID for this track. If two tracks in different playlists have the same database ID, they are sharing the same data. + @objc optional var dateAdded: Date { get } // the date the track was added to the playlist + @objc optional var objectDescription: String { get } // the description of the track + @objc optional var discCount: Int { get } // the total number of discs in the source album + @objc optional var discNumber: Int { get } // the index of the disc containing this track on the source album + @objc optional var disliked: Bool { get } // is this track disliked? + @objc optional var downloaderAppleID: String { get } // the Apple ID of the person who downloaded this track + @objc optional var downloaderName: String { get } // the name of the person who downloaded this track + @objc optional var duration: Double { get } // the length of the track in seconds + @objc optional var enabled: Bool { get } // is this track checked for playback? + @objc optional var episodeID: String { get } // the episode ID of the track + @objc optional var episodeNumber: Int { get } // the episode number of the track + @objc optional var EQ: String { get } // the name of the EQ preset of the track + @objc optional var finish: Double { get } // the stop time of the track in seconds + @objc optional var gapless: Bool { get } // is this track from a gapless album? + @objc optional var genre: String { get } // the music/audio genre (category) of the track + @objc optional var grouping: String { get } // the grouping (piece) of the track. Generally used to denote movements within a classical work. + @objc optional var kind: String { get } // a text description of the track + @objc optional var longDescription: String { get } // the long description of the track + @objc optional var loved: Bool { get } // is this track loved? + @objc optional var lyrics: String { get } // the lyrics of the track + @objc optional var mediaKind: MusicEMdK { get } // the media kind of the track + @objc optional var modificationDate: Date { get } // the modification date of the content of this track + @objc optional var movement: String { get } // the movement name of the track + @objc optional var movementCount: Int { get } // the total number of movements in the work + @objc optional var movementNumber: Int { get } // the index of the movement in the work + @objc optional var playedCount: Int { get } // number of times this track has been played + @objc optional var playedDate: Date { get } // the date and time this track was last played + @objc optional var purchaserAppleID: String { get } // the Apple ID of the person who purchased this track + @objc optional var purchaserName: String { get } // the name of the person who purchased this track + @objc optional var rating: Int { get } // the rating of this track (0 to 100) + @objc optional var ratingKind: MusicERtK { get } // the rating kind of this track + @objc optional var releaseDate: Date { get } // the release date of this track + @objc optional var sampleRate: Int { get } // the sample rate of the track (in Hz) + @objc optional var seasonNumber: Int { get } // the season number of the track + @objc optional var shufflable: Bool { get } // is this track included when shuffling? + @objc optional var skippedCount: Int { get } // number of times this track has been skipped + @objc optional var skippedDate: Date { get } // the date and time this track was last skipped + @objc optional var show: String { get } // the show name of the track + @objc optional var sortAlbum: String { get } // override string to use for the track when sorting by album + @objc optional var sortArtist: String { get } // override string to use for the track when sorting by artist + @objc optional var sortAlbumArtist: String { get } // override string to use for the track when sorting by album artist + @objc optional var sortName: String { get } // override string to use for the track when sorting by name + @objc optional var sortComposer: String { get } // override string to use for the track when sorting by composer + @objc optional var sortShow: String { get } // override string to use for the track when sorting by show name + @objc optional var size: Int64 { get } // the size of the track (in bytes) + @objc optional var start: Double { get } // the start time of the track in seconds + @objc optional var time: String { get } // the length of the track in MM:SS format + @objc optional var trackCount: Int { get } // the total number of tracks on the source album + @objc optional var trackNumber: Int { get } // the index of the track on the source album + @objc optional var unplayed: Bool { get } // is this track unplayed? + @objc optional var volumeAdjustment: Int { get } // relative volume adjustment of the track (-100% to 100%) + @objc optional var work: String { get } // the work name of the track + @objc optional var year: Int { get } // the year the track was recorded/released + @objc optional func setAlbum(_ album: String!) // the album name of the track + @objc optional func setAlbumArtist(_ albumArtist: String!) // the album artist of the track + @objc optional func setAlbumDisliked(_ albumDisliked: Bool) // is the album for this track disliked? + @objc optional func setAlbumLoved(_ albumLoved: Bool) // is the album for this track loved? + @objc optional func setAlbumRating(_ albumRating: Int) // the rating of the album for this track (0 to 100) + @objc optional func setArtist(_ artist: String!) // the artist/source of the track + @objc optional func setBookmark(_ bookmark: Double) // the bookmark time of the track in seconds + @objc optional func setBookmarkable(_ bookmarkable: Bool) // is the playback position for this track remembered? + @objc optional func setBpm(_ bpm: Int) // the tempo of this track in beats per minute + @objc optional func setCategory(_ category: String!) // the category of the track + @objc optional func setComment(_ comment: String!) // freeform notes about the track + @objc optional func setCompilation(_ compilation: Bool) // is this track from a compilation album? + @objc optional func setComposer(_ composer: String!) // the composer of the track + @objc optional func setObjectDescription(_ objectDescription: String!) // the description of the track + @objc optional func setDiscCount(_ discCount: Int) // the total number of discs in the source album + @objc optional func setDiscNumber(_ discNumber: Int) // the index of the disc containing this track on the source album + @objc optional func setDisliked(_ disliked: Bool) // is this track disliked? + @objc optional func setEnabled(_ enabled: Bool) // is this track checked for playback? + @objc optional func setEpisodeID(_ episodeID: String!) // the episode ID of the track + @objc optional func setEpisodeNumber(_ episodeNumber: Int) // the episode number of the track + @objc optional func setEQ(_ EQ: String!) // the name of the EQ preset of the track + @objc optional func setFinish(_ finish: Double) // the stop time of the track in seconds + @objc optional func setGapless(_ gapless: Bool) // is this track from a gapless album? + @objc optional func setGenre(_ genre: String!) // the music/audio genre (category) of the track + @objc optional func setGrouping(_ grouping: String!) // the grouping (piece) of the track. Generally used to denote movements within a classical work. + @objc optional func setLongDescription(_ longDescription: String!) // the long description of the track + @objc optional func setLoved(_ loved: Bool) // is this track loved? + @objc optional func setLyrics(_ lyrics: String!) // the lyrics of the track + @objc optional func setMediaKind(_ mediaKind: MusicEMdK) // the media kind of the track + @objc optional func setMovement(_ movement: String!) // the movement name of the track + @objc optional func setMovementCount(_ movementCount: Int) // the total number of movements in the work + @objc optional func setMovementNumber(_ movementNumber: Int) // the index of the movement in the work + @objc optional func setPlayedCount(_ playedCount: Int) // number of times this track has been played + @objc optional func setPlayedDate(_ playedDate: Date!) // the date and time this track was last played + @objc optional func setRating(_ rating: Int) // the rating of this track (0 to 100) + @objc optional func setSeasonNumber(_ seasonNumber: Int) // the season number of the track + @objc optional func setShufflable(_ shufflable: Bool) // is this track included when shuffling? + @objc optional func setSkippedCount(_ skippedCount: Int) // number of times this track has been skipped + @objc optional func setSkippedDate(_ skippedDate: Date!) // the date and time this track was last skipped + @objc optional func setShow(_ show: String!) // the show name of the track + @objc optional func setSortAlbum(_ sortAlbum: String!) // override string to use for the track when sorting by album + @objc optional func setSortArtist(_ sortArtist: String!) // override string to use for the track when sorting by artist + @objc optional func setSortAlbumArtist(_ sortAlbumArtist: String!) // override string to use for the track when sorting by album artist + @objc optional func setSortName(_ sortName: String!) // override string to use for the track when sorting by name + @objc optional func setSortComposer(_ sortComposer: String!) // override string to use for the track when sorting by composer + @objc optional func setSortShow(_ sortShow: String!) // override string to use for the track when sorting by show name + @objc optional func setStart(_ start: Double) // the start time of the track in seconds + @objc optional func setTrackCount(_ trackCount: Int) // the total number of tracks on the source album + @objc optional func setTrackNumber(_ trackNumber: Int) // the index of the track on the source album + @objc optional func setUnplayed(_ unplayed: Bool) // is this track unplayed? + @objc optional func setVolumeAdjustment(_ volumeAdjustment: Int) // relative volume adjustment of the track (-100% to 100%) + @objc optional func setWork(_ work: String!) // the work name of the track + @objc optional func setYear(_ year: Int) // the year the track was recorded/released +} +extension SBObject: MusicTrack {} + +// MARK: MusicAudioCDTrack +@objc public protocol MusicAudioCDTrack: MusicTrack { + @objc optional var location: URL { get } // the location of the file represented by this track +} +extension SBObject: MusicAudioCDTrack {} + +// MARK: MusicFileTrack +@objc public protocol MusicFileTrack: MusicTrack { + @objc optional var location: URL { get } // the location of the file represented by this track + @objc optional func refresh() // update file track information from the current information in the track’s file + @objc optional func setLocation(_ location: URL!) // the location of the file represented by this track +} +extension SBObject: MusicFileTrack {} + +// MARK: MusicSharedTrack +@objc public protocol MusicSharedTrack: MusicTrack { +} +extension SBObject: MusicSharedTrack {} + +// MARK: MusicURLTrack +@objc public protocol MusicURLTrack: MusicTrack { + @objc optional var address: String { get } // the URL for this track + @objc optional func setAddress(_ address: String!) // the URL for this track +} +extension SBObject: MusicURLTrack {} + +// MARK: MusicUserPlaylist +@objc public protocol MusicUserPlaylist: MusicPlaylist { + @objc optional func fileTracks() -> SBElementArray + @objc optional func URLTracks() -> SBElementArray + @objc optional func sharedTracks() -> SBElementArray + @objc optional var shared: Bool { get } // is this playlist shared? + @objc optional var smart: Bool { get } // is this a Smart Playlist? + @objc optional var genius: Bool { get } // is this a Genius Playlist? + @objc optional func setShared(_ shared: Bool) // is this playlist shared? +} +extension SBObject: MusicUserPlaylist {} + +// MARK: MusicFolderPlaylist +@objc public protocol MusicFolderPlaylist: MusicUserPlaylist { +} +extension SBObject: MusicFolderPlaylist {} + +// MARK: MusicVisual +@objc public protocol MusicVisual: MusicItem { +} +extension SBObject: MusicVisual {} + +// MARK: MusicWindow +@objc public protocol MusicWindow: MusicItem { + @objc optional var bounds: NSRect { get } // the boundary rectangle for the window + @objc optional var closeable: Bool { get } // does the window have a close button? + @objc optional var collapseable: Bool { get } // does the window have a collapse button? + @objc optional var collapsed: Bool { get } // is the window collapsed? + @objc optional var fullScreen: Bool { get } // is the window full screen? + @objc optional var position: NSPoint { get } // the upper left position of the window + @objc optional var resizable: Bool { get } // is the window resizable? + @objc optional var visible: Bool { get } // is the window visible? + @objc optional var zoomable: Bool { get } // is the window zoomable? + @objc optional var zoomed: Bool { get } // is the window zoomed? + @objc optional func setBounds(_ bounds: NSRect) // the boundary rectangle for the window + @objc optional func setCollapsed(_ collapsed: Bool) // is the window collapsed? + @objc optional func setFullScreen(_ fullScreen: Bool) // is the window full screen? + @objc optional func setPosition(_ position: NSPoint) // the upper left position of the window + @objc optional func setVisible(_ visible: Bool) // is the window visible? + @objc optional func setZoomed(_ zoomed: Bool) // is the window zoomed? +} +extension SBObject: MusicWindow {} + +// MARK: MusicBrowserWindow +@objc public protocol MusicBrowserWindow: MusicWindow { + @objc optional var selection: SBObject { get } // the selected tracks + @objc optional var view: MusicPlaylist { get } // the playlist currently displayed in the window + @objc optional func setView(_ view: MusicPlaylist!) // the playlist currently displayed in the window +} +extension SBObject: MusicBrowserWindow {} + +// MARK: MusicEQWindow +@objc public protocol MusicEQWindow: MusicWindow { +} +extension SBObject: MusicEQWindow {} + +// MARK: MusicMiniplayerWindow +@objc public protocol MusicMiniplayerWindow: MusicWindow { +} +extension SBObject: MusicMiniplayerWindow {} + +// MARK: MusicPlaylistWindow +@objc public protocol MusicPlaylistWindow: MusicWindow { + @objc optional var selection: SBObject { get } // the selected tracks + @objc optional var view: MusicPlaylist { get } // the playlist displayed in the window +} +extension SBObject: MusicPlaylistWindow {} + +// MARK: MusicVideoWindow +@objc public protocol MusicVideoWindow: MusicWindow { +} +extension SBObject: MusicVideoWindow {} diff --git a/SpotifyLyricsInMenubar/Info.plist b/SpotifyLyricsInMenubar/Info.plist index 6b6e86c..41fc039 100644 --- a/SpotifyLyricsInMenubar/Info.plist +++ b/SpotifyLyricsInMenubar/Info.plist @@ -10,5 +10,7 @@ https://aviwad.github.io/SpotifyLyricsInMenubar/appcast.xml SUPublicEDKey GCYCGGY3mUnG+Zh6d6L9lY3b1F4lpO0yYP009G37PXQ= + NSAppleMusicUsageDescription + This permission is required to interact with your Apple Music library. diff --git a/SpotifyLyricsInMenubar/Lyrics.xcdatamodeld/Lyrics.xcdatamodel/contents b/SpotifyLyricsInMenubar/Lyrics.xcdatamodeld/Lyrics.xcdatamodel/contents index 91c9c16..e51d637 100644 --- a/SpotifyLyricsInMenubar/Lyrics.xcdatamodeld/Lyrics.xcdatamodel/contents +++ b/SpotifyLyricsInMenubar/Lyrics.xcdatamodeld/Lyrics.xcdatamodel/contents @@ -1,5 +1,14 @@ - + + + + + + + + + + @@ -12,4 +21,4 @@ - \ No newline at end of file + diff --git a/SpotifyLyricsInMenubar/OnboardingWindow.swift b/SpotifyLyricsInMenubar/OnboardingWindow.swift index 3b369c1..78203f0 100644 --- a/SpotifyLyricsInMenubar/OnboardingWindow.swift +++ b/SpotifyLyricsInMenubar/OnboardingWindow.swift @@ -16,18 +16,26 @@ struct OnboardingWindow: View { Text("Welcome to Lyric Fever! 🎉") .font(.largeTitle) - Text("Here's a few steps to quickly setup Lyric Fever in your Menubar.") + Text("Please accept the prompts so that Lyric Fever works properly ☺️.") .font(.title) Image("hi") .resizable() .frame(width: 250, height: 250, alignment: .center) - StepView(title: "Make sure Spotify is installed on your mac", description: "Please download the [official Spotify Desktop client](https://www.spotify.com/in-en/download/mac/)") + VStack(alignment: .center, spacing: 8) { + Text("Spotify Users: Make sure Spotify is installed on your mac") + .font(.title2) + .bold() + + Text(.init("Please download the [official Spotify Desktop client](https://www.spotify.com/in-en/download/mac/)")) + .font(.title3) + } + NavigationLink("Next", destination: ZeroView()) .buttonStyle(.borderedProminent) - Text("Email me at [aviwad@gmail.com](mailto:aviwad@gmail.com) for any support\n⚠️ Disclaimer: I do not own the rights to Spotify or the lyric content presented.\nMusixmatch and Spotify own all rights to the lyrics.\nVersion 1.6") + Text("Email me at [aviwad@gmail.com](mailto:aviwad@gmail.com) for any support\n⚠️ Disclaimer: I do not own the rights to Spotify or the lyric content presented.\nMusixmatch and Spotify own all rights to the lyrics.\nVersion 1.7") .multilineTextAlignment(.center) .font(.callout) .padding(.top, 10) @@ -46,7 +54,7 @@ struct ZeroView: View { @State var error = false var body: some View { VStack(alignment: .leading, spacing: 16) { - StepView(title: "1. Spotify login credentials", description: "We need the cookie to make the lyric api calls.") + StepView(title: "1. Spotify Login Credentials (EVEN IF YOU USE APPLE MUSIC!!!)", description: "We need the cookie to make the relevant Lyric API calls. Even if you're using Apple Music, I still download lyrics from Spotify.") HStack { Spacer() @@ -104,7 +112,7 @@ struct ZeroView: View { //isShowingDetailView = true } .buttonStyle(.borderedProminent) - .disabled(isLoading) + .disabled(isLoading || spDcCookie.count == 0) } .padding(.vertical, 5) @@ -185,11 +193,11 @@ struct FirstView: View { @State var isAnimating = true var body: some View { VStack(alignment: .leading, spacing: 16) { - StepView(title: "3. Make sure you give Automation permission", description: "We need this permission to read the current song from Spotify, so that we can play the correct lyrics! Watch the following gif to correctly give permission.") + StepView(title: "3. Make sure you give Automation & Music permission", description: "We need these permissions to read the current song from Spotify & Apple Music, so that we can play the correct lyrics! Watch the following gif to correctly give permission.") HStack { Spacer() - AnimatedImage(name: "spotifyPermissionMac.gif", isAnimating: $isAnimating) + AnimatedImage(name: "newPermissionMac.gif", isAnimating: $isAnimating) .resizable() .frame(width: 531, height: 450) Spacer() @@ -237,7 +245,7 @@ struct SecondView: View { @State var isAnimating = true var body: some View { VStack(alignment: .leading, spacing: 16) { - StepView(title: "4. Make sure you disable crossfades", description: "Because of a glitch within Spotify, crossfades make the lyrics appear out-of-sync on occasion.") + StepView(title: "4. Make sure you disable crossfades (Spotify Users Only)", description: "Because of a glitch within Spotify, crossfades make the lyrics appear out-of-sync on occasion.") HStack { Spacer() diff --git a/SpotifyLyricsInMenubar/SpotifyLyricsInMenubar.entitlements b/SpotifyLyricsInMenubar/SpotifyLyricsInMenubar.entitlements index d61eecc..3fbbf86 100644 --- a/SpotifyLyricsInMenubar/SpotifyLyricsInMenubar.entitlements +++ b/SpotifyLyricsInMenubar/SpotifyLyricsInMenubar.entitlements @@ -10,16 +10,27 @@ com.apple.security.scripting-targets + com.apple.Music + + com.apple.Music.device + com.apple.Music.library.playback + com.apple.Music.user-interface + com.apple.Music.playback + com.apple.Music.playerInfo + com.apple.Music.library.read + com.apple.Music.library.read-write + com.apple.Music.podcast + com.spotify.client com.spotify.playback com.spotify.library -com.apple.security.temporary-exception.mach-lookup.global-name - - $(PRODUCT_BUNDLE_IDENTIFIER)-spks - $(PRODUCT_BUNDLE_IDENTIFIER)-spki - + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + diff --git a/SpotifyLyricsInMenubar/SpotifyLyricsInMenubarApp.swift b/SpotifyLyricsInMenubar/SpotifyLyricsInMenubarApp.swift index 7d71751..0e3dc07 100644 --- a/SpotifyLyricsInMenubar/SpotifyLyricsInMenubarApp.swift +++ b/SpotifyLyricsInMenubar/SpotifyLyricsInMenubarApp.swift @@ -12,6 +12,7 @@ import ServiceManagement struct SpotifyLyricsInMenubarApp: App { @StateObject var viewmodel = viewModel.shared @AppStorage("launchOnLogin") var launchOnLogin: Bool = false + @AppStorage("spotifyOrAppleMusic") var spotifyOrAppleMusic: Bool = false @AppStorage("showLyrics") var showLyrics: Bool = true @AppStorage("hasOnboarded") var hasOnboarded: Bool = false @AppStorage("truncationLength") var truncationLength: Int = 50 @@ -25,16 +26,19 @@ struct SpotifyLyricsInMenubarApp: App { Button(viewmodel.currentlyPlayingLyrics.isEmpty ? "Check For Lyrics Again" : "Refresh Lyrics") { Task { - viewmodel.currentlyPlayingLyrics = try await viewmodel.fetchNetworkLyrics(for: currentlyPlaying, currentlyPlayingName) + if spotifyOrAppleMusic { + try await viewmodel.appleMusicNetworkFetch() + } + viewmodel.currentlyPlayingLyrics = try await viewmodel.fetchNetworkLyrics(for: currentlyPlaying, currentlyPlayingName, spotifyOrAppleMusic) print("HELLOO") if viewmodel.isPlaying, !viewmodel.currentlyPlayingLyrics.isEmpty { - viewmodel.startLyricUpdater() + viewmodel.startLyricUpdater(appleMusicOrSpotify: spotifyOrAppleMusic) } } } } Divider() - Button(launchOnLogin ? "Don't launch at login" : "Automatically launch on login") { + Button(launchOnLogin ? "Don't Launch At Login" : "Automatically Launch On Login") { if launchOnLogin { try? SMAppService.mainApp.unregister() launchOnLogin = false @@ -43,16 +47,50 @@ struct SpotifyLyricsInMenubarApp: App { launchOnLogin = true } } - Button(showLyrics ? "Don't show lyrics" : "Show lyrics") { + Button(showLyrics ? "Don't Show Lyrics" : "Show Lyrics") { if showLyrics { showLyrics = false viewmodel.stopLyricUpdater() } else { showLyrics = true - viewmodel.startLyricUpdater() + viewmodel.startLyricUpdater(appleMusicOrSpotify: spotifyOrAppleMusic) } } Divider() + Button(spotifyOrAppleMusic ? "Switch To Spotify" : "Switch To Apple Music") { + spotifyOrAppleMusic.toggle() + guard let isRunning = spotifyOrAppleMusic ? viewmodel.appleMusicScript?.isRunning : viewmodel.spotifyScript?.isRunning, isRunning else { + viewmodel.isPlaying = false + viewmodel.currentlyPlaying = nil + viewmodel.currentlyPlayingName = nil + return + } + print("Application just started. lets check whats playing") +// if spotifyOrAppleMusic ? viewmodel.appleMusicScript?.playerState == .playing : viewmodel.spotifyScript?.playerState == .playing { +// viewmodel.isPlaying = true +// } else { +// viewmodel.isPlaying = false +// } + viewmodel.isPlaying = spotifyOrAppleMusic ? viewmodel.appleMusicScript?.playerState == .playing : viewmodel.spotifyScript?.playerState == .playing + if spotifyOrAppleMusic { + if let currentTrackName = viewmodel.appleMusicScript?.currentTrack?.name { +// viewmodel.currentlyPlaying = nil + if currentTrackName == "" { + viewmodel.currentlyPlayingName = nil + } else { + viewmodel.currentlyPlayingName = currentTrackName + } + viewmodel.currentlyPlayingAppleMusicPersistentID = viewmodel.appleMusicScript?.currentTrack?.persistentID + } + } else { + viewmodel.currentlyPlayingAppleMusicPersistentID = nil + if let currentTrack = viewmodel.spotifyScript?.currentTrack?.spotifyUrl?.components(separatedBy: ":").last, let currentTrackName = viewmodel.spotifyScript?.currentTrack?.name, currentTrack != "", currentTrackName != "" { + viewmodel.currentlyPlaying = currentTrack + viewmodel.currentlyPlayingName = currentTrackName + print(currentTrack) + } + } + } Button("Help / Install Guide") { NSApplication.shared.activate(ignoringOtherApps: true) openWindow(id: "onboarding") @@ -66,29 +104,72 @@ struct SpotifyLyricsInMenubarApp: App { } , label: { Text(hasOnboarded ? menuBarTitle : "Please Complete Onboarding Process (Click Help)") .onAppear { - if viewmodel.cookie.count != 159 { + if viewmodel.cookie.count == 0 { hasOnboarded = false } guard hasOnboarded else { NSApplication.shared.activate(ignoringOtherApps: true) + // why do i call this? viewmodel.spotifyScript?.name + viewmodel.appleMusicScript?.name openWindow(id: "onboarding") return } - guard let isRunning = viewmodel.spotifyScript?.isRunning, isRunning else { + guard let isRunning = spotifyOrAppleMusic ? viewmodel.appleMusicScript?.isRunning : viewmodel.spotifyScript?.isRunning, isRunning else { return } print("Application just started. lets check whats playing") - if viewmodel.spotifyScript?.playerState == .playing { + if spotifyOrAppleMusic ? viewmodel.appleMusicScript?.playerState == .playing : viewmodel.spotifyScript?.playerState == .playing { viewmodel.isPlaying = true } - if let currentTrack = viewmodel.spotifyScript?.currentTrack?.spotifyUrl?.components(separatedBy: ":").last, let currentTrackName = viewmodel.spotifyScript?.currentTrack?.name, currentTrack != "", currentTrackName != "" { - viewmodel.currentlyPlaying = currentTrack - viewmodel.currentlyPlayingName = currentTrackName - print(currentTrack) + if spotifyOrAppleMusic { + if let currentTrackName = viewmodel.appleMusicScript?.currentTrack?.name { +// viewmodel.currentlyPlaying = nil + if currentTrackName == "" { + viewmodel.currentlyPlayingName = nil + } else { + viewmodel.currentlyPlayingName = currentTrackName + } + print("ON APPEAR HAS UPDATED APPLE MUSIC SONG ID") + viewmodel.currentlyPlayingAppleMusicPersistentID = viewmodel.appleMusicScript?.currentTrack?.persistentID + } + } else { + if let currentTrack = viewmodel.spotifyScript?.currentTrack?.spotifyUrl?.components(separatedBy: ":").last, let currentTrackName = viewmodel.spotifyScript?.currentTrack?.name, currentTrack != "", currentTrackName != "" { + viewmodel.currentlyPlaying = currentTrack + viewmodel.currentlyPlayingName = currentTrackName + print(currentTrack) + } } } + .onReceive(DistributedNotificationCenter.default().publisher(for: Notification.Name(rawValue: "com.apple.Music.playerInfo")), perform: { notification in + guard spotifyOrAppleMusic == true else { + print("#TODO we are still listening to apple music playback state changes even when user selected Spotify") + return + } + print("playback changed in apple music") + if notification.userInfo?["Player State"] as? String == "Playing" { + print("is playing") + viewmodel.isPlaying = true + } else { + print("paused. timer canceled") + viewmodel.isPlaying = false + // manually cancels the lyric-updater task bc media is paused + } + /*let currentlyPlaying = (notification.userInfo?["Track ID"] as? String)?.components(separatedBy: ":").last*/ + let currentlyPlayingName = (notification.userInfo?["Name"] as? String) + //viewmodel.currentlyPlaying = nil + if currentlyPlayingName == "" { + viewmodel.currentlyPlayingName = nil + } else { + viewmodel.currentlyPlayingName = currentlyPlayingName + } + viewmodel.currentlyPlayingAppleMusicPersistentID = viewmodel.appleMusicScript?.currentTrack?.persistentID + }) .onReceive(DistributedNotificationCenter.default().publisher(for: Notification.Name(rawValue: "com.spotify.client.PlaybackStateChanged")), perform: { notification in + guard spotifyOrAppleMusic == false else { + print("#TODO we are still listening to spotify playback state changes even when user selected Apple Music") + return + } print("playback changed in spotify") if notification.userInfo?["Player State"] as? String == "Playing" { print("is playing") @@ -110,24 +191,36 @@ struct SpotifyLyricsInMenubarApp: App { } .onChange(of: viewmodel.isPlaying) { nowPlaying in if nowPlaying, showLyrics { - if !viewmodel.currentlyPlayingLyrics.isEmpty { + if !viewmodel.currentlyPlayingLyrics.isEmpty, spotifyOrAppleMusic ? viewmodel.appleMusicScript?.playerPosition != 0.0 : viewmodel.spotifyScript?.playerPosition != 0.0 { print("timer started for spotify change, lyrics not nil") - viewmodel.startLyricUpdater() + viewmodel.startLyricUpdater(appleMusicOrSpotify: spotifyOrAppleMusic) } } else { viewmodel.stopLyricUpdater() } } + .task(id: viewmodel.currentlyPlayingAppleMusicPersistentID) { + if viewmodel.currentlyPlayingAppleMusicPersistentID != nil { + // reset the store playback id so that the nil check actually works later + viewmodel.appleMusicStorePlaybackID = nil + await viewmodel.appleMusicStarter() + } + } .onChange(of: viewmodel.currentlyPlaying) { nowPlaying in print("song change") + // only set position to 0 when new song selected, user anyways expected song to start at position 0 + // gets rid of spotify's playback position glitch when autoplaying + // see: +// viewmodel.spotifyScript?.playpause?() +// viewmodel.spotifyScript?.playpause?() viewmodel.currentlyPlayingLyricsIndex = nil viewmodel.currentlyPlayingLyrics = [] Task { - if let nowPlaying, let currentlyPlayingName = viewmodel.currentlyPlayingName, let lyrics = await viewmodel.fetch(for: nowPlaying, currentlyPlayingName) { + if let nowPlaying, let currentlyPlayingName = viewmodel.currentlyPlayingName, let lyrics = await viewmodel.fetch(for: nowPlaying, currentlyPlayingName, spotifyOrAppleMusic) { viewmodel.currentlyPlayingLyrics = lyrics if viewmodel.isPlaying, !viewmodel.currentlyPlayingLyrics.isEmpty { print("STARTING UPDATER") - viewmodel.startLyricUpdater() + viewmodel.startLyricUpdater(appleMusicOrSpotify: spotifyOrAppleMusic) } } } @@ -144,7 +237,7 @@ struct SpotifyLyricsInMenubarApp: App { if let currentlyPlayingName = viewmodel.currentlyPlayingName { return "Now \(viewmodel.isPlaying ? "Playing" : "Paused"): \(currentlyPlayingName)" } - return "Open Spotify!" + return "Open \(spotifyOrAppleMusic ? "Apple Music" : "Spotify" )!" } var menuBarTitle: String { @@ -153,7 +246,7 @@ struct SpotifyLyricsInMenubarApp: App { } else if let currentlyPlayingName = viewmodel.currentlyPlayingName { return "Now \(viewmodel.isPlaying ? "Playing" : "Paused"): \(currentlyPlayingName)".trunc(length: truncationLength) } - return "Nothing Playing on Spotify" + return "Nothing Playing on \(spotifyOrAppleMusic ? "Apple Music" : "Spotify" )" } } diff --git a/SpotifyLyricsInMenubar/lyricJsonStruct.swift b/SpotifyLyricsInMenubar/lyricJsonStruct.swift index e73f451..fe0c235 100644 --- a/SpotifyLyricsInMenubar/lyricJsonStruct.swift +++ b/SpotifyLyricsInMenubar/lyricJsonStruct.swift @@ -46,3 +46,16 @@ struct accessTokenJSON: Codable { struct SongObjectParent: Decodable { let lyrics: SongObject } + +struct SpotifyResponse: Codable { + let tracks: Tracks +} + +struct Tracks: Codable { + let items: [Item] +} + +struct Item: Codable { + let type: String + let id: String +} diff --git a/SpotifyLyricsInMenubar/viewModel.swift b/SpotifyLyricsInMenubar/viewModel.swift index 187051f..2baaf29 100644 --- a/SpotifyLyricsInMenubar/viewModel.swift +++ b/SpotifyLyricsInMenubar/viewModel.swift @@ -10,27 +10,56 @@ import ScriptingBridge import CoreData import AmplitudeSwift import Sparkle +import MusicKit import SwiftUI +import MediaPlayer @MainActor class viewModel: ObservableObject { - let decoder = JSONDecoder() + // View Model static let shared = viewModel() + + // + var appleMusicStorePlaybackID: String? = nil @Published var currentlyPlaying: String? var currentlyPlayingName: String? @Published var currentlyPlayingLyrics: [LyricLine] = [] @Published var currentlyPlayingLyricsIndex: Int? + @Published var currentlyPlayingAppleMusicPersistentID: String? = nil @Published var isPlaying: Bool = false var spotifyScript: SpotifyApplication? = SBApplication(bundleIdentifier: "com.spotify.client") + var appleMusicScript: MusicApplication? = SBApplication(bundleIdentifier: "com.apple.Music") + + // CoreData container (for saved lyrics) let coreDataContainer: NSPersistentContainer + + // Logging / Analytics let amplitude = Amplitude(configuration: .init(apiKey: amplitudeKey)) + + // Sparkle / Update Controller let updaterController: SPUStandardUpdaterController @Published var canCheckForUpdates = false + + // Async Tasks (Lyrics fetch, Apple Music -> Spotify ID fetch, Lyrics Updater) private var currentFetchTask: Task<[LyricLine], Error>? private var currentLyricsUpdaterTask: Task? + private var currentAppleMusicFetchTask: Task? + + let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void + var status: MusicAuthorization.Status = .notDetermined + + // Authentication tokens var accessToken: accessTokenJSON? @AppStorage("spDcCookie") var cookie = "" + let decoder = JSONDecoder() init() { + // Load framework + let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")) + + // Get a Swift function for MRMediaRemoteGetNowPlayingInfo + let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString)! + MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self) + updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) coreDataContainer = NSPersistentContainer(name: "Lyrics") coreDataContainer.loadPersistentStores { description, error in @@ -42,17 +71,24 @@ import SwiftUI updaterController.updater.publisher(for: \.canCheckForUpdates) .assign(to: &$canCheckForUpdates) decoder.userInfo[CodingUserInfoKey.managedObjectContext] = coreDataContainer.viewContext + Task { + status = await MusicAuthorization.request() + print(status) + } } + func upcomingIndex(_ currentTime: Double) -> Int? { if let currentlyPlayingLyricsIndex { let newIndex = currentlyPlayingLyricsIndex + 1 if newIndex >= currentlyPlayingLyrics.count { + print("REACHED LAST LYRIC!!!!!!!!") // if current time is before our current index's start time, the user has scrubbed and rewinded // reset into linear search mode if currentTime < currentlyPlayingLyrics[currentlyPlayingLyricsIndex].startTimeMS { return currentlyPlayingLyrics.firstIndex(where: {$0.startTimeMS > currentTime}) } +// spotifyScript?.nextTrack?() // we've reached the end of the song, we're past the last lyric // so we set the timer till the duration of the song, in case the user skips ahead or forward return nil @@ -87,6 +123,8 @@ import SwiftUI print("the difference is \(diff)") try await Task.sleep(nanoseconds: UInt64(1000000*diff)) print("lyrics exist: \(!currentlyPlayingLyrics.isEmpty)") + print("last index: \(lastIndex)") + print("currently playing lryics index: \(currentlyPlayingLyricsIndex)") if currentlyPlayingLyrics.count > lastIndex { currentlyPlayingLyricsIndex = lastIndex } else { @@ -96,14 +134,14 @@ import SwiftUI } while !Task.isCancelled } - func startLyricUpdater() { - if !isPlaying || currentlyPlayingLyrics.isEmpty || spotifyScript?.playerPosition == 0.0 { + func startLyricUpdater(appleMusicOrSpotify: Bool) { + currentLyricsUpdaterTask?.cancel() + if !isPlaying || currentlyPlayingLyrics.isEmpty { return } - currentLyricsUpdaterTask?.cancel() currentLyricsUpdaterTask = Task { do { - try await lyricUpdater() + try await appleMusicOrSpotify ? lyricUpdaterAppleMusic() : lyricUpdater() } catch { print("lyrics were canceled \(error)") } @@ -124,6 +162,7 @@ import SwiftUI if context.hasChanges { do { try context.save() + print("Saved CoreData!") } catch { print("core data error \(error)") // Show some error here @@ -131,10 +170,10 @@ import SwiftUI } } - func fetch(for trackID: String, _ trackName: String) async -> [LyricLine]? { + func fetch(for trackID: String, _ trackName: String, _ spotifyOrAppleMusic: Bool) async -> [LyricLine]? { currentFetchTask?.cancel() let newFetchTask = Task { - try await self.fetchLyrics(for: trackID, trackName) + try await self.fetchLyrics(for: trackID, trackName, spotifyOrAppleMusic) } currentFetchTask = newFetchTask do { @@ -145,7 +184,7 @@ import SwiftUI } } - private func fetchLyrics(for trackID: String, _ trackName: String) async throws -> [LyricLine] { + private func fetchLyrics(for trackID: String, _ trackName: String, _ spotifyOrAppleMusic: Bool) async throws -> [LyricLine] { if let lyrics = fetchFromCoreData(for: trackID) { print("got lyrics from core data :D \(trackID) \(trackName)") try Task.checkCancellation() @@ -153,16 +192,16 @@ import SwiftUI return lyrics } print("no lyrics from core data, going to download from internet \(trackID) \(trackName)") - return try await fetchNetworkLyrics(for: trackID, trackName) + return try await fetchNetworkLyrics(for: trackID, trackName, spotifyOrAppleMusic) } - func fetchNetworkLyrics(for trackID: String, _ trackName: String) async throws -> [LyricLine] { - guard let intDuration = spotifyScript?.currentTrack?.duration else { + func fetchNetworkLyrics(for trackID: String, _ trackName: String, _ spotifyOrAppleMusic: Bool) async throws -> [LyricLine] { + guard let intDuration = spotifyOrAppleMusic ? appleMusicScript?.currentTrack?.duration.map(Int.init) : spotifyScript?.currentTrack?.duration else { throw CancellationError() } decoder.userInfo[CodingUserInfoKey.trackID] = trackID decoder.userInfo[CodingUserInfoKey.trackName] = trackName - decoder.userInfo[CodingUserInfoKey.duration] = TimeInterval(intDuration+10) + decoder.userInfo[CodingUserInfoKey.duration] = spotifyOrAppleMusic ? TimeInterval((intDuration*1000) + 1000) : TimeInterval(intDuration+10) /* check if saved access token is bigger than current time, then continue with lyric fetch else @@ -224,3 +263,156 @@ import SwiftUI return nil } } + +// Apple Music Code +extension viewModel { + // Similar structure to my other Async functions. Only 1 appleMusicFetch() can run at any given moment + func appleMusicStarter() async { + print("apple music test called again, cancelling previous") + currentAppleMusicFetchTask?.cancel() + let newFetchTask = Task { + try await self.appleMusicFetch() + } + currentAppleMusicFetchTask = newFetchTask + do { + return try await newFetchTask.value + } catch { + print("error \(error)") + return + } + } + + func appleMusicFetch() async throws { + // check coredata for apple music persistent id -> spotify id mapping + if let coreDataSpotifyID = fetchSpotifyIDFromPersistentIDCoreData() { + if !Task.isCancelled { + self.currentlyPlaying = coreDataSpotifyID + return + } + } + + try await appleMusicNetworkFetch() + } + + func appleMusicNetworkFetch() async throws { + + // coredata didn't get us anything + + // Get song info + MRMediaRemoteGetNowPlayingInfo(DispatchQueue.global(), { (information) in + self.appleMusicStorePlaybackID = information["kMRMediaRemoteNowPlayingInfoContentItemIdentifier"] as? String + }) + // check for musickit auth + if status != .authorized || appleMusicStorePlaybackID == nil { + print("not authorized (or we dont have playback id yet) , lets wait a bit") + try await Task.sleep(nanoseconds: 100000000) + if status != .authorized { + print("still not authorized i give up") + } + } + print("authorized") + // A little delay to make sure we have musickit auth + storeplayback id by then (most likely) + // Faulty + //try await Task.sleep(nanoseconds: 100000000) + guard let appleMusicStorePlaybackID else { + print("no playback store id, giving up") + return + } + let request = MusicCatalogResourceRequest(matching: \.id, equalTo: .init(appleMusicStorePlaybackID)) + guard let response = try? await request.response(), let song = response.items.first, let isrc = song.isrc else { return } + print("playback ID is \(appleMusicStorePlaybackID) and ISRC is \(isrc)") + if accessToken == nil || (accessToken!.accessTokenExpirationTimestampMs <= Date().timeIntervalSince1970*1000) { + print("creating new access token from apple music, if this appears multiple times thats suspicious") + if let url = URL(string: "https://open.spotify.com/get_access_token?reason=transport&productType=web_player") { + var request = URLRequest(url: url) + request.setValue("sp_dc=\(cookie)", forHTTPHeaderField: "Cookie") + let accessTokenData = try await URLSession.shared.data(for: request) + accessToken = try JSONDecoder().decode(accessTokenJSON.self, from: accessTokenData.0) + print("ACCESS TOKEN IS SAVED") + } + } + if let accessToken, let url = URL(string: "https://api.spotify.com/v1/search?q=isrc:\(isrc)&type=track") { + var request = URLRequest(url: url) + request.addValue("WebPlayer", forHTTPHeaderField: "app-platform") + print("the access token is \(accessToken.accessToken)") + request.addValue("Bearer \(accessToken.accessToken)", forHTTPHeaderField: "authorization") + // Invalidate this request if cancelled (means this song is old, user rapidly skipped) + guard !Task.isCancelled else {return} + let urlResponseAndData = try await URLSession.shared.data(for: request) + if urlResponseAndData.0.isEmpty { + return + } + let response = try decoder.decode(SpotifyResponse.self, from: urlResponseAndData.0) + print("GOT SPOTIFY ID AS \(response.tracks.items.first?.id)") + // Task cancelled means we're working with old song data, so dont update Spotify ID with old song's ID + if !Task.isCancelled { + self.currentlyPlaying = response.tracks.items.first?.id + + if let currentlyPlayingAppleMusicPersistentID, let currentlyPlaying { + print("both persistent ID and spotify ID are non nill, so we attempt to save to coredata") + // save the mapping into coredata persistentIDToSpotify + let newPersistentIDToSpotifyIDMapping = PersistentIDToSpotify(context: coreDataContainer.viewContext) + newPersistentIDToSpotifyIDMapping.persistentID = currentlyPlayingAppleMusicPersistentID + newPersistentIDToSpotifyIDMapping.spotifyID = currentlyPlaying + saveCoreData() + } + } + } + // get equivalent spotify ID + } + + func fetchSpotifyIDFromPersistentIDCoreData() -> String? { + let fetchRequest: NSFetchRequest = PersistentIDToSpotify.fetchRequest() + guard let currentlyPlayingAppleMusicPersistentID else { + print("No persistent ID available. it's nil! should have never happened") + return nil + } + fetchRequest.predicate = NSPredicate(format: "persistentID == %@", currentlyPlayingAppleMusicPersistentID) // Replace persistentID with the desired value + + do { + let results = try coreDataContainer.viewContext.fetch(fetchRequest) + if let persistentIDToSpotify = results.first { + // Found the persistentIDToSpotify object with the matching persistentID + return persistentIDToSpotify.spotifyID + } else { + // No SongObject found with the given trackID + print("No spotifyID found with the provided persistentID. \(currentlyPlayingAppleMusicPersistentID)") + } + } catch { + print("Error fetching persistentIDToSpotify:", error) + } + return nil + } + + func lyricUpdaterAppleMusic() async throws { + repeat { + guard let playerPosition = appleMusicScript?.playerPosition else { + print("no player position hence stopped") + // pauses the timer bc there's no player position + stopLyricUpdater() + return + } + // add a 700 (milisecond?) delay to offset the delta between spotify lyrics and apple music songs (or maybe the way apple music delivers playback position) + let currentTime = playerPosition * 1000 + 400 + guard let lastIndex: Int = upcomingIndex(currentTime) else { + stopLyricUpdater() + return + } + let nextTimestamp = currentlyPlayingLyrics[lastIndex].startTimeMS + let diff = nextTimestamp - currentTime + print("current time: \(currentTime)") + print("next time: \(nextTimestamp)") + print("the difference is \(diff)") + try await Task.sleep(nanoseconds: UInt64(1000000*diff)) + print("lyrics exist: \(!currentlyPlayingLyrics.isEmpty)") + print("last index: \(lastIndex)") + print("currently playing lryics index: \(currentlyPlayingLyricsIndex)") + if currentlyPlayingLyrics.count > lastIndex { + currentlyPlayingLyricsIndex = lastIndex + } else { + currentlyPlayingLyricsIndex = nil + } + print("current lyrics index is now \(currentlyPlayingLyricsIndex?.description ?? "nil")") + } while !Task.isCancelled + } +} diff --git a/appcast.xml b/appcast.xml index baa801c..fa5b288 100644 --- a/appcast.xml +++ b/appcast.xml @@ -8,7 +8,7 @@ 1.6 1.6 13.0 - + \ No newline at end of file diff --git a/newPermissionMac.gif b/newPermissionMac.gif new file mode 100644 index 0000000..cf08714 Binary files /dev/null and b/newPermissionMac.gif differ diff --git a/spotifyPermissionMac.gif b/spotifyPermissionMac.gif deleted file mode 100644 index 488e0c0..0000000 Binary files a/spotifyPermissionMac.gif and /dev/null differ