Peter-Schorn / SpotifyAPI

A Swift library for the Spotify web API. Supports all endpoints.
https://peter-schorn.github.io/SpotifyAPI/documentation/spotifywebapi
MIT License
251 stars 32 forks source link

Transfer playback to .smartphone after a pause #34

Closed kimfucious closed 2 years ago

kimfucious commented 2 years ago

HI @Peter-Schorn,

I've got an edge case scenario that you might be able to shed some light on.

In brief:

  1. I've got a view that uses the getAvailableDevices method.
  2. In the scenario that I'm working in, there are two devices returned of types: .smartphone and .computer.
  3. The Spotify user has a premium subscription.
  4. There is a selector that allows the user to selected any of the available devices and stores that in a @State variable
  5. There is a play button in the view
  6. When the play button is tapped, I run getAvailableDevices again (because phones seem to dissapear quickly)
  7. Then I do a .first on the results of getAvailbleDevices for the id of the selected device in state.
  8. There is a pause button that uses the pausePlayback method.
  9. The following only happens when playback has been paused, and a couple/few of seconds have passed.
  10. The app only tries to play/transfer if the device is in available devices; otherwise, it opens the Spotify app. This is intentional and necessary, I believe.

If play is tapped when the selected device is of type, .computer, whether or not that device is active, the song will play using the transferPlayback method using the result offirst from item 7 above. This always works.

Here's the problem If play is tapped when the selected device is of type, .smartphone, even with that device being active, the song will only sometimes play using the transferPlayback method with the id from first from item 7 above.

When this does not play, I can catch the error in a do/catch, and the error message is:

SpotifyError(message: "Not found.", statusCode: 404)

When this occurs, I handle this in the catch block by opening the Spotify app to the track that is paused, using:

await UIApplication.shared.open(url)

Oddly enough, when the Spotify app opens, the track continues where it left off when paused.

Further, the device selector in the View is a Swift UI menu, when the menu is opened, it runs getAvailableDevices and populates menu items for each device, so that the menu is always "fresh".

Tapping on an available device in the menu, will attempt to play the track using the transferPlayback method.

Similar to the above, the .computer always works.

Here's were it gets weird

Selecting .smartphone device in the menu sometimes plays/transfers the track (if not more than a few seconds have passed).

When it doesn't work, one of two things can happen:

  1. If the track is playing, nothing happens until I switch to the Spotify app where it seems to "catch up" and begin to continue the track, playing on the phone
  2. If the track is paused, and I just wait, maybe after 10 or 20 seconds, the track starts playing in the app

For the record, the play method seems to have the same issue in that it throws the aforementioned error in the same circumstances that the transferPlayback method does with .smartPhone devices.

I know that's probably way more than you wanted to read, but it's an interesting edge case that I was wondering if you'd come across before.

I am intentionally not playing on the first available active device, in this scenario, as in some scenarios the user wants to play the track on their phone.

While opening up the app when the error mentioned above is thrown is a workaround, I was hoping to see if there was a solution that provided a better user experience.

As always, I love your library, appreciate your insight, and know that underlying issues may just be because of how the Spotify playback API works.

So thanks for any ideas you have to share.

Peter-Schorn commented 2 years ago

There is a selector that allows the user to selected any of the available devices and stores that in a @State variable

I discourage you from storing any of the devices in another variable precisely because the available devices can change frequently. If the device stored in this variable is no longer in the list of available devices returned by availableDevices, then you can't use it for API calls anymore. Instead, you should use the availableDevices method to display a list/menu of the devices to a user; then, when they select a device from that list/menu, you should immediately transfer playback to that device.

Further, the device selector in the View is a Swift UI menu, when the menu is opened, it runs getAvailableDevices

How do you detect when the menu is opened? Post the code.

Selecting .smartphone device in the menu sometimes plays/transfers the track (if not more than a few seconds have passed).

When it doesn't work, one of two things can happen:

  1. If the track is playing, nothing happens until I switch to the Spotify app where it seems to "catch up" and begin to continue the track, playing on the phone
  2. If the track is paused, and I just wait, maybe after 10 or 20 seconds, the track starts playing in the app

The underlying issue for this behavior is the application lifecycle on iOS. When the user switches from one app to another, the former app is suspended after a short interval, except for certain special cases, such as if the app is playing music. When the Spotify iOS app is not playing music and you switch from it to your app, it will be suspended very shortly after. A consequence of this is that it is no longer an active or available device (although the Spotify web API may incorrectly report it as active/available). When you issue a web API command on your app that targets the Spotify iOS app on the same device while it is suspended, then the command will only take effect after you switch back to the Spotify iOS app because it will no longer be suspended at that point.

kimfucious commented 2 years ago

HI @Peter-Schorn, thanks for your detailed reply.

I discourage you from storing any of the devices in another variable precisely because the available devices can change frequently...

I understand your point, however, the app needs to display what the currently selected device is to the user regardless of it's current state of availability or activeness. This is why, before performing any action on the selected device (e.g. play, transfer, etc.), I perform a getAvailableDevices, check if the selected device is the result, and use that status (if it's there) to transferPlayback or open the Spotify app if the selected device is not there.

After pondering your reply, I did a little test to check how the Spotify app behaves:

  1. Play a track in the Spotify iOS app.
  2. Transfer playback to my laptop running the Spotify macOS app
  3. Note the icon changes to a "computer" symbol.
  4. Quit the Spotify app on the mac
  5. Note that playback stops, the play buttons in the iOS app change from pause to play, and the selected device icon/menu changes from computer to a generic symbol.
  6. Tapping play in the iOS app resumes the track on the iPhone (there are no other available devices).

I wonder how the heck they do that? 🤔 My guess is that the app knows the phone is always there when the Spotify app is running and it's contantly polling the selectedDevice.

Here's my transfer playback scenario in my app once more:

  1. User taps play
  2. App runs getAvailableDevices before attempting to transferPlayback.
  3. App checks if the selectedDevice is in the result of getAvailableDevices (using ID to find via .first)
  4. If the matched device, from step 3 above, is not of type, .smartphone, run the transferPlayback method. This always works when the device is my computer running the macOS Spotify app even when the device is not active.
  5. If the matched device, from step 3 above, is found and is of type, .smartphone && the selectedDevice is active, run the transferPlayback method using the ID from the matched result with the play parameter set to true; otherwise, open the Spotify app.

Again, the problem is that after a few seconds of a track being paused and the user starts at step 1 above, and the device is a smartphone, and it's active, the app transferPlayback method throws the error:

SpotifyError(message: "Not found.", statusCode: 404)

I log this on the line immediately before the transferPlayback is called and the error is thrown.

Device(id: Optional("cbf47231224e87ab30353bdaa645aexxxxxxxxxx"), isActive: true, isPrivateSession: false, isRestricted: false, name: "Kim’s iPhone", type: SpotifyWebAPI.DeviceType.smartphone, volumePercent: Optional(100))

How do you detect when the menu is opened? Post the code.

At first I was using .onAppear on one menu item that is always in the menu, but I opted for using .onTapGesture instead:

SpotifyDetailMenuView(
    availableDevices: $availableDevices,
    selectedDevice: $selectedDevice,
    isPlaying: $isPlaying,
    isPaused: $isPaused,
    nowPlayingTrack: $nowPlayingTrack,
    handlePlayThisTrack: self.handlePlayThisTrack,
    item: item
)
    .onTapGesture(perform: {
        Task.init {
            Logger.log(.info, "Polling Available Devices")
            await pollAvailableDevices()
        }
    })

In the above, pollAvailableDevices() sets up a timer to run getAvailableDevices every second for 5 seconds, after whih the timer ends itself (I haven't figured out how to run an action, like cancelling the timer, when closing a Swift UI menu yet). Results from getAvailable devices are populated into the $availableDevices @State variable (which I understand you discourage, but in this scenario the results are pretty fresh, I believe). This is kinda similar to the sheet that gets opened when you click on the device selector in the Spotify app, I imagine, as there's a spinner next to where it say "Select a Device".

The above is obviously flawed. And I think that there's a reason they are using a sheet here instead of a menu, as menus don't seem to dynamically update. I can only see changes in the menu after closing and re-opening, even though I can see availableDevices changing, using an .onChange. I may need to change out this menu for a sheet, to see for sure.

The underlying issue for this behavior is the application lifecycle on iOS... I understand what you're saying, however, in the scenario that I've attempted to describe, the user remains in my app when the Spotify 404 error occurs.

So in the case where the user attempts to play or transfer playback to a device of type, .smartphone after a track has been paused for a few seconds, even with the "freshest" results from getAvailableDevices and the smartphone being in those results and claiming to be active", the user doesn't leave the app. Only an error is thrown. At which point, I catch that error and then open the track in the Spotify app when the caught error has the message of "Not found."

For the record, although not relevant except that it pertains to the iOS app lifecycle, I'm using the following to "configure spotify" when the user returns to the app. The same thing happens .onAppear.


.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
            Logger.log(.info, "Spotify detail view became active!")
            Task.init {
                await configureSpotify()
            }
        }```
kimfucious commented 2 years ago

I think I'm almost ready to just open the Spotify app whenever there's the result returned from getAvailableDevices does not include a device that is something other than .smartphone.

Peter-Schorn commented 2 years ago

I wonder how the heck they do that? 🤔 My guess is that the app knows the phone is always there when the Spotify app is running and it's contantly polling the selectedDevice.

More likely, each Spotify client (the iOS app, desktop app, etc.) communicates with a backend server when it is running and broadcasts its availability. The client currently playing music sends information about the current playback to the server as well. The clients likely establish two-way connections—meaning the server can send information to a client without the client first requesting it. This eliminates the need for the client to constantly "poll" the server for, say, the available devices, like we have to do with the API.

I understand what you're saying, however, in the scenario that I've attempted to describe, the user remains in my app when the Spotify 404 error occurs.

Again, the problem is that after a few seconds of a track being paused and the user starts at step 1 above, and the device is a smartphone, and it's active, the app transferPlayback method throws the error:

The user is using your app, not the Spotify app. Therefore, if the Spotify app is not playing music, then it's suspended. In other words, it's frozen. It cannot run any code. This is why you cannot play music on the Spotify app (without making it the foreground app first). This also explains why the music sometimes begins playing only after you switch back to the Spotify app. At this point the Spotify app becomes unfrozen and can respond to messages sent to it, such as a request to play music.

even with the "freshest" results from getAvailableDevices and the smartphone being in those results and claiming to be active

As I said before:

When the Spotify iOS app is not playing music and you switch from it to your app, it will be suspended very shortly after. A consequence of this is that it is no longer an active or available device (although the Spotify web API may incorrectly report it as active/available).

The last part is a bug because the system notifies an app when it's about to be suspended, so the Spotify app should change its status at this point to unavailable. But it doesn't do this, so the API reports the device as still active or available, even though it's neither.

Please re-read my previous post.

.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
    Logger.log(.info, "Spotify detail view became active!")
    Task.init {
        await configureSpotify()
    }
}

SwiftUI has its own new API for responding to lifecycle events. See this article.

kimfucious commented 2 years ago

Therefore, if the Spotify app is not playing music, then it's suspended... The last part is a bug because...

Ah, I get you now. That is very clarifying 💡

I was thinking my app centric, not considering that the Spotify app was being suspended when nothing is playing.

Thank you very much for taking the time to undestand and provide insight on this issue!