y20k / transistor

Transistor - Simple Radio App for Android
http://y20k.org/transistor/
MIT License
433 stars 121 forks source link

Play random station (shuffle function): UI ideas #373

Open heldderarbeit opened 2 years ago

heldderarbeit commented 2 years ago

Preface

Have been using this app for over a year now, I'm all in all very pleased with it but think it could benefit from a small feature addition: The ability to pick a station at random. So far, nothing like this is possible.

This will be very beneficial if you have a lot of channels, but don't have a specific station in mind to listen to. Might even discover some new stuff you like this way, which you are aren't exposed to normally.

I know you want your app to stay simple as it is now, but I think there is an argument to be made in this case. I can think of at least 3 ways of implementing this without changing too much around:

1. Idea - Notification Button

I think this could work as an icon in the notification. No further UI elements in the main window necessary. No visible changes in the app except from one little symbol - it could look something like this:

Screenshot_20211109-132121_Transistor 1

(just a mock up)

The notification area has a lot of unused space right now (especially the right half is often completely empty), so adding another icon should not be a problem in this regard.

Other music players on android are using something like this already: Screenshot_20211109-225000_Auxio_Debug

This suggestion has been brought up the first time back in 2016 (https://github.com/y20k/transistor/issues/89#issuecomment-230033094), but there has been no further comment on it from what I can tell since then.

If notifications don't work for some reason, I can think of 2 possible alternatives:

2. Idea - A small icon in the bottom "settings" part

A button on the left part of the "Add new station" CardView at the bottom Screenshot_20211110-122305_Transistor

I imagine a press on this button would do the following: 1) shuffle the collection of stations around in a random order (not visible on the screen, just internally), and 2) start playing the first element of this queue automatically. Of course, the function of the Next / Previous buttons in the notification area need to be altered in this case: They should cycle between random stations now, not in alphabetical order like they are doing now.

This solution is something I personally would love to see. Should be rather easy to program too, I guess.

3. Idea - A toggleable switch in the settings

This is similar to the previous suggestion, but there are no changes to the main GUI. Instead, in SettingsFragment we can have a toggle for shuffle mode:

Screenshot_20211110-163629_Transistor

When it is switched off (might be the default), nothing changes. When it is switched on, everything functions as it does now, except from: 1) The next button in the notification area picks a station at random, not the next one in alphabetical order like it does now. 2) We have to keep a list of these randomly picked stations, so that the previous button works in the expected behaviour.

I'm curious to hear what you think about all that. In any way, no matter the implementation, a function to play radio stations randomly would be a real benefit in my eyes, even it is just a tiny button.

y20k commented 2 years ago

Hi @heldderarbeit .

Thank you for your very good explanation on how a "Next-Random-Station" feature could be implemented. A new button in the notification is probably the most easy and most intuitive way to implement this.

I am hesitant to go ahead here, since I personally never had the urge to skip to a random station. I need to ask around what friends / colleagues are thinking about this. I currently think this is niche feature. But I am often wrong with those things.

I tend to implement only a minimal set of features to keep the app lean and simple - mostly for me to be able to maintain it. But also because I think there is a niche for a simple radio playback app.

heldderarbeit commented 2 years ago

Hello,

After spending a lot more time and effort than I was expecting beforehand, I am able to present a prototype implementation for the notification area.

Unfortunately, the Class PlayerNotificationManager in ExoPlayer doesn't provide any pre-defined methods for showing the icon like it does for setUseStopAction(boolean) or setUsePlayPauseActions​(boolean). Therefore, we have to do everything on our own. I searched high and low, but came eventually to the conclusion that there exists no easier way to do this than that - even if it's only the addition of one simple icon.

In NotificationHelper.kt, another nested class is needed:

private inner class CustomAction: CustomActionReceiver {
    override fun createCustomActions(context: Context, instanceId: Int): Map < String, NotificationCompat.Action > {

        val intent: Intent = Intent("shuffle").setPackage(context.packageName)

        val pendingIntent = PendingIntent.getBroadcast(
            context,
            instanceId,
            intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT // setting the mutability flag
        )

        val actionShuffle: NotificationCompat.Action = NotificationCompat.Action(
            R.drawable.exo_icon_shuffle_on,
            "shuffleImage",
            pendingIntent
        )

        val actionMap: MutableMap < String,
        NotificationCompat.Action > = HashMap()
        actionMap["shuffle"] = actionShuffle
        return actionMap
    }

    override fun getCustomActions(player: Player): List < String > {
        val customActions: MutableList < String > = ArrayList()
        customActions.add("shuffle")
        Log.d("getCustomActions", "action: " + player.currentWindowIndex)
        return customActions
    }

    override fun onCustomAction(player: Player, action: String, intent: Intent) {
        Log.d("onCustomAction", "action: " + intent.action)
    }
}

Now set our custom ActionReceiver in the builder for the PlayerNotificationManager instance:

val notificationBuilder = ...
    notificationBuilder.apply {
            ...
        setCustomActionReceiver(CustomAction())
    }

Before: Screenshot_20211230-053156_Transistor

After: Screenshot_20211230-054147_Transistor

Alas, we are still not done, since we want an icon that changes on button presses: We need something to visually differentiate between SHUFFLE ON and SHUFFLE OFF mode, just like expected from every other media app. Unfortunately, once again - this is not really simple to achieve in ExoPlayer. You cannot update the icon of an action. Two actions each with a different icon are needed. Then we can swap the icons around, when a press is registered.

So, our custom ActionReceiver needs some updating.

Version 2

private inner class CustomAction: CustomActionReceiver {

    override fun createCustomActions(context: Context, instanceId: Int): Map < String, NotificationCompat.Action > {

        val actionMap: MutableMap < String,
        NotificationCompat.Action > = HashMap()

        val intentShuffleOn = Intent(Keys.CUSTOM_ACTION_NAME_SHUFFLE_ON)
        .setPackage(context.packageName)

        val intentShuffleOff = Intent(Keys.CUSTOM_ACTION_NAME_SHUFFLE_OFF)
        .setPackage(context.packageName)

        val pendingIntentShuffleOn = PendingIntent.getBroadcast(
            context,
            instanceId,
            intentShuffleOn,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)

        val pendingIntentShuffleOff = PendingIntent.getBroadcast(
            context, /* instanceId= */
            instanceId,
            intentShuffleOff,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)

        val shuffleOnAction: NotificationCompat.Action = NotificationCompat.Action(
            R.drawable.exo_icon_shuffle_on,
            Keys.CUSTOM_ACTION_NAME_SHUFFLE_ON,
            pendingIntentShuffleOn)

        val shuffleOffAction: NotificationCompat.Action = NotificationCompat.Action(
            R.drawable.exo_icon_shuffle_off,
            Keys.CUSTOM_ACTION_NAME_SHUFFLE_OFF,
            pendingIntentShuffleOff)

        actionMap[Keys.CUSTOM_ACTION_NAME_SHUFFLE_ON] = shuffleOnAction
        actionMap[Keys.CUSTOM_ACTION_NAME_SHUFFLE_OFF] = shuffleOffAction

        return actionMap
    }

    override fun getCustomActions(player: Player): List < String > {
        Log.d("getCustomActions", "action: " + player.currentWindowIndex)
        return Collections.singletonList(
            if (player.shuffleModeEnabled) Keys.CUSTOM_ACTION_NAME_SHUFFLE_ON
            else Keys.CUSTOM_ACTION_NAME_SHUFFLE_OFF)
    }

    override fun onCustomAction(player: Player, action: String, intent: Intent) {
        player.shuffleModeEnabled = Keys.CUSTOM_ACTION_NAME_SHUFFLE_ON != action
        Log.d("onCustomAction", "action: " + intent.action)
    }
}

In Keys.kt:

...
const val CUSTOM_ACTION_NAME_SHUFFLE_OFF: String = "custom.action.shuffle_off"
const val CUSTOM_ACTION_NAME_SHUFFLE_ON: String = "custom.action.shuffle_on"

We now have a toggable switch:

https://user-images.githubusercontent.com/4840355/147833834-f76b13c6-7d55-49c2-aa76-6fef50189fe9.mp4

The difficult part is done now, but the implementation still missing. I would argue that a new shuffling is needed whenever the icon is set to Activated.

So, our updated onCustomAction could look something like this:

if (Keys.CUSTOM_ACTION_NAME_SHUFFLE_OFF == action) {
    player.shuffleModeEnabled = true;
    mediaController.sendCommand(Keys.CMD_SHUFFLE, null, null)
} else {
    player.shuffleModeEnabled = false;
}

in Keys.kt

...
const val CMD_SHUFFLE: String = "SHUFFLE"
...

in PlayerService.kt

override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
    ...
    Keys.CMD_SHUFFLE -> {
        shuffleStations()
        return true
    }

We can not just pick stations at random as we go. For example, we should care to not select the same station several times in a row. More importantly, we should keep track of our previously generated random stations, so we can cycle through them (The forward and previous buttons should work as expected).

There are of course several ways of achieving this. For a proof-of-work concept, something like this should suffice:

In PlayerService.kt

class PlayerService(): MediaBrowserServiceCompat() {
...
    private var shuffledStations: List<Station> = listOf()
...

    private fun shuffleStations() {
        shuffledStations = collection.stations.shuffled()
    }

Now, all that's left to do is change the behaviour of the forward/previous buttons. If the shuffle button is set to off, get the next station the regular way from the alphabetical sorting. If not, we can iterate through our shuffled Stations by using the index of the current station. The condition (newIndex>=shuffledStations.size) is for starting from the beginning of the array when we reach the end of it. When we get to the last new station, we just repeat all elements in the exact same shuffling. If the user desires a different shuffle order, he can just switch the shuffle mode off and back on again.

private fun skipToNextStation() {
    if (player.isPlaying) player.stop()

    if (player.shuffleModeEnabled) {
        val oldIndex = shuffledStations.indexOf(shuffledStations.find { it.uuid == station.uuid })
        var newIndex = oldIndex+1

        if (newIndex>=shuffledStations.size) { 
            newIndex -= shuffledStations.size
        }
        station = shuffledStations[newIndex]

    } else {
        station = CollectionHelper.getNextStation(collection, station.uuid)
    }

    preparer.onPrepare(true)
}

And the equivalent for skipping backwards.

private fun skipToPreviousStation() {

    if (player.isPlaying) player.stop()

    if (player.shuffleModeEnabled) {
        val oldIndex = shuffledStations.indexOf(shuffledStations.find { it.uuid == station.uuid })
        var newIndex = oldIndex-1

        if (newIndex<0) {
            newIndex += shuffledStations.size
        }
        station = shuffledStations[newIndex]

    } else {
        station = CollectionHelper.getPreviousStation(collection, station.uuid)
    }

    preparer.onPrepare(true)
}

Here is an example of how it all comes together.

https://user-images.githubusercontent.com/4840355/147834562-2573e9e3-bd14-4805-a0da-2c9c47d75bee.mp4

From 00:00 to 00:09 I switch between stations with the regular next function (Shuffle OFF) From 00:10 to 00:29 I turn Shuffle ON. Now the order changes. Note how the sequence of names is B -> L -> F -> B -> F -> # -> S -> U, therefore random and not alphabetically From 00:30 to 00:41 on I test the backwards function. Indeed I now see the same stations as before, only now in a back-to-front manner From 00:42 I turn shuffle mode OFF. Now the ordering switches back to alphabetically. 00:51 I turn the shuffle function ON once again. Now we switch through random stations again, but the shuffling is a different one (N, K, F, B, N, L, Q, E)

y20k commented 2 years ago

Hi @heldderarbeit,

I just wanted to let you know that I have seen your post. I am impressed with amount of work you put into this!! I am currently on vacation. I will write a comment next week when I am at my computer again.

Happy New Year, btw. 🍻