Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Android Auto #71

Open
hkfuertes opened this issue Nov 27, 2024 · 13 comments
Open

[Feature] Android Auto #71

hkfuertes opened this issue Nov 27, 2024 · 13 comments

Comments

@hkfuertes
Copy link

Is there plans to include Android Auto?

Thank you for the awesome Job!!

@GrakovNe
Copy link
Owner

Yes, Android Auto support is one of the long-term goals for the app.

To be honest, I don’t have much idea how users interact with Android Auto, as I’m just an average European who doesn’t drive at all—even on quiet roads.

If you could share your personal scenarios for using audiobooks and podcasts while driving, that would be the best gift for me.

@hkfuertes
Copy link
Author

hkfuertes commented Nov 27, 2024

Sure thing! You can take Voice (https://github.com/PaulWoitaschek/Voice) as an example... I will try to describe it, and if not, I will fire up the headunit developer emulator and get you some screenshots...

Basically a book selection screen with the books you want can listen, and and a player screen with the cover, time progressbar, and buttons (next chapter or next 10s, previous chapter or previous 10s, play/pause), this would suffice...

Additionally I have been using plappa (for ios) and in carplay they have another screen with chapter selection... can be the same as book selection once you have selected a book, but I have been using voice for a while, and its not that important...

In fact, once you select a book in the phone, the player screen is all that really matters. But I feel that a book selection is required anyways...

Let me search for screenshots for you, if not, during the week I will try and make them myself.

ps: Spaniard here :), but I do drive regularly from my city to my parents city (4h+ commute), ideal for books :)

@hkfuertes
Copy link
Author

@GrakovNe
Copy link
Owner

ok, got it. Thanks, I definitely need to check out how similar apps provide UI/UX

@hkfuertes
Copy link
Author

I played yesterday with adding the Android Auto... but after Java... I went for Flutter, so my multi-routine kotlin is not rusted... its non-existent! This being said... I managed to make the app apear in the headunit emulator... but I have some questions... Mainly, how do you use the ApiResult object?

The thing is, on one hand, there are not much options for creating android auto UI, For what I found (via the wizzard) there are 2 options, map or audio screens. For audio screens, its already predefined and via inheritance and overriding of methods. There is one for populating the list, some for playback controls... etc...

In the end it comes down to convert somehow the Book entity into the MediaItem entity... wich is playable by the android auto activity.

Coming to what I said in the begining... this methods are already overriden, and the parent method does not 'suspend'... So I'm out of luck when trying to ask for libraries, picking the first one, and asking for all the books. Also the ApiResult is a little bit pain to use (if im using it right) as, after creating an auxiliary method that could 'suspend', I was not sure of what method of ApiResult to use... fold? for each subsequent call?

Also, how is your Dependency Injector done? I tried to add the Provider via the constructor, but without any luck. With this solved, I could try and test the previous things with a 'GlobalScope' (I found in the internet) and continue testing and trying... If I make it work, I will propose a PR :)

@GrakovNe
Copy link
Owner

GrakovNe commented Dec 5, 2024

Hi! First of all, thank you for your efforts.

Let me try to answer your questions about the code.

About suspend
Suspend is part of Kotlin coroutines, allowing asynchronous code to run as if it were synchronous. Similar approaches exist in modern programming languages like JavaScript with async/await. Unfortunately, the Android framework was initially written in Java, so it doesn't directly support suspend methods, which in turn cannot be executed outside their coroutine.

To handle asynchronous code, you can look at how it's done in Jetpack Compose UI, where suspend methods are run within CoroutineScope.launch{} or similar wrappers. You might be tempted to use runBlocking{}, but this is not an option, as it would block the thread and cause visible app freezes.

About fold and Applicative Monad Transformations
Fold and similar methods are simply ways to handle errors without relying on Java's expensive and cumbersome exception mechanism.

You can learn more about monadic transformations here: https://typelevel.org/cats/typeclasses/foldable.html. Essentially, ApiResult is an implementation of the "Either" pattern, where a method can return not just a result but a container holding mutually exclusive options: either an error (left) or data (right).

ApiResult is designed to be processed via a chain of map/flatMap, like any functor/applicative (which is a subset of a monad). When you need to handle side effects, you'll use .fold to resolve the container and explicitly process both scenarios (error and success).

For more practical applications of monads, I recommend reading the Red Book by Odersky. While it focuses on Scala, it reflects the modern approach to functional programming in the JVM ecosystem, of which Kotlin is a part.

About Dependency Injection (DI)
I use Hilt for dependency injection. It enables injecting dependencies where needed and operates at compile time. While it complicates build processes, it avoids runtime overhead for memory and performance. Most beans (dependencies) that the application injects into other beans are marked as @singleton, meaning they exist within the application's context. This isn't the best approach but is the simplest, and I suggest sticking to it.

You can find more details about creating such singletons here: https://rommansabbir.com/mastering-hilt-android-kotlin-hilt-dagger-2-part-3. However, you can generally follow the existing approach in the application.

@hkfuertes
Copy link
Author

Wow! Thank you for the info! I will try this out for sure!

@hkfuertes
Copy link
Author

package org.grakovne.lissen

import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import androidx.media.MediaBrowserServiceCompat
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.grakovne.lissen.content.LissenMediaProvider
import org.grakovne.lissen.domain.Book
import org.grakovne.lissen.domain.DetailedItem
import org.grakovne.lissen.domain.Library
import org.grakovne.lissen.widget.MediaRepository

import java.util.ArrayList
import javax.inject.Inject

@AndroidEntryPoint
class AutoBookPlayerService : MediaBrowserServiceCompat() {

    private lateinit var session: MediaSessionCompat
    @Inject
    lateinit var mediaChannel: LissenMediaProvider

    @Inject
    lateinit var mediaRepository: MediaRepository

    private val callback = object : MediaSessionCompat.Callback() {
        override fun onPlay() {
            Log.d(TAG, "onPlay?")
        }

        override fun onSkipToQueueItem(queueId: Long) {}

        override fun onSeekTo(position: Long) {}

        override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
            Log.d(TAG, "onPlayFromMediaId?: $mediaId")
            if(mediaId == null) return
            CoroutineScope(Dispatchers.Main).launch {
                mediaRepository.preparePlayback(mediaId, true)
                mediaRepository.togglePlayPause()
            }
        }

        override fun onPause() {}

        override fun onStop() {}

        override fun onSkipToNext() {}

        override fun onSkipToPrevious() {}

        override fun onCustomAction(action: String?, extras: Bundle?) {}

        override fun onPlayFromSearch(query: String?, extras: Bundle?) {}
    }

    override fun onCreate() {
        super.onCreate()

        session = MediaSessionCompat(this, "AutoBookPlayerService")
        sessionToken = session.sessionToken
        session.setCallback(callback)
        session.setFlags(
            MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
                    MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
        )
    }

    override fun onDestroy() {
        session.release()
    }

    override fun onGetRoot(
        clientPackageName: String,
        clientUid: Int,
        rootHints: Bundle?
    ): BrowserRoot? {
        return BrowserRoot("root", null)
    }

    override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
        CoroutineScope(Dispatchers.Main).launch {
            result.sendResult(getBooksMediaItems())
        }
        result.detach()
    }

    suspend fun getDetailedBook(id: String): DetailedItem? {
        return mediaChannel.fetchBook(id).fold(onFailure = { null }, onSuccess = { it })
    }
    suspend fun getBooksMediaItems(): MutableList<MediaItem>{
        mediaChannel.fetchLibraries().fold( onSuccess = {it.first()}, onFailure = {null})?.let { library ->
            val books = mediaChannel.fetchBooks(library.id,4,1)
            return books
                    .fold(onSuccess = { pagedItems -> pagedItems.items.map {
                        val desc = MediaDescriptionCompat.Builder()
                                .setMediaId(it.id)
                                .setTitle(it.title)
                                .setSubtitle(it.author)
                                .build()
                        MediaItem(desc, MediaItem.FLAG_PLAYABLE)
                    } }, onFailure = { listOf() })
                    .toMutableList()
        }
        return mutableListOf()
    }


    companion object{
        private const val TAG: String = "AutoBookPlayerService"
    }
}
<!--
         Main music service, provides media browsing and media playback services to
         consumers through MediaBrowserService and MediaSession. Consumers connect to it through
         MediaBrowser (for browsing) and MediaController (for playback control)
        -->
        <service
            android:name=".AutoBookPlayerService"
            android:exported="true">
            <intent-filter>
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>

It does something... still figuring out the media session thing, as I believe that your playbackservice can be reuse enterily...
Also, my suspicion is that the player screen its a glorified notification, that I have not figure out...

imagen

@GrakovNe
Copy link
Owner

GrakovNe commented Dec 5, 2024

hat's a great start, but let me show several issues with the code design:

MediaBrowserServiceCompat is a legacy component. The app is trying to work on the Media3 stack: https://developer.android.com/media/implement/playback-app.

Mixing MediaBrowserServiceCompat and Media3 is a bad idea because they do not interoperate with each other, and we can't rely on automatic data transfer between them.

CoroutineScope(Dispatchers.Main) should be avoided when preparing a book. This might be a heavy operation, and it will freeze the UI until it’s complete. Consider using the IO scope to offload the task from the main UI thread.

suspend fun getBooksMediaItems(): MutableList is the main issue here.
The ABS API operates with two independent entities: Chapter and File. They are linked through a Many-to-Many relationship, meaning a single chapter may consist of multiple files, and vice versa. The main challenge is to show chapters to users while playing files. This is generally solved, but the default notification is broken: it shows the file playlist instead of the chapter playlist. The same problem exists here.

I'll approve the changes if you decide to give up and can't handle this properly, but let’s try to fix it first.

By the way, could you open an MR so we can discuss it there?

@hkfuertes
Copy link
Author

Here it is! #76
I will try to work on this over the weekend... but definitely over the Christmas period.

@felmey
Copy link

felmey commented Dec 12, 2024

Just want to say that I would also love this feature to be included! I have over an hour commute and would really like to use this app to listen to my audio books on my server. Thanks for everyone's effort!

@thehijacker
Copy link
Contributor

Another vote for Android Auto support. Thank you all for the work you do.

@hkfuertes
Copy link
Author

@GrakovNe ... You have far more experience than me with Kotlin/Media3... feel free to take the PR over and finish it at any time :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants