├── automotive
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── values
│ │ │ ├── dimens.xml
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── styles.xml
│ │ │ ├── colors.xml
│ │ │ ├── themes.xml
│ │ │ ├── arrays.xml
│ │ │ └── strings.xml
│ │ ├── xml
│ │ │ ├── automotive_app_desc.xml
│ │ │ ├── authenticator.xml
│ │ │ └── preferences.xml
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.webp
│ │ │ ├── ic_launcher_round.webp
│ │ │ └── ic_launcher_foreground.webp
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.webp
│ │ │ ├── ic_launcher_round.webp
│ │ │ └── ic_launcher_foreground.webp
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ ├── ic_launcher_round.webp
│ │ │ └── ic_launcher_foreground.webp
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ ├── ic_launcher_round.webp
│ │ │ └── ic_launcher_foreground.webp
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ ├── ic_launcher_round.webp
│ │ │ └── ic_launcher_foreground.webp
│ │ ├── layout
│ │ │ ├── activity_sign_in.xml
│ │ │ ├── activity_settings.xml
│ │ │ ├── server_sign_in.xml
│ │ │ └── username_password_sign_in.xml
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── drawable
│ │ │ ├── playlists.xml
│ │ │ ├── star_filled.xml
│ │ │ ├── schedule.xml
│ │ │ ├── app_logo.xml
│ │ │ └── casino.xml
│ │ ├── app_logo-playstore.png
│ │ ├── ic_launcher-playstore.png
│ │ ├── java
│ │ └── be
│ │ │ └── bendardenne
│ │ │ └── jellyfin
│ │ │ └── aaos
│ │ │ ├── Constants.kt
│ │ │ ├── SharkMarmaladeApplication.kt
│ │ │ ├── auth
│ │ │ ├── AuthenticatorService.kt
│ │ │ └── Authenticator.kt
│ │ │ ├── settings
│ │ │ ├── SettingsFragmentViewModel.kt
│ │ │ ├── SettingsFragment.kt
│ │ │ └── SettingsActivity.kt
│ │ │ ├── JellyfinHiltModule.kt
│ │ │ ├── JellyfinAccountManager.kt
│ │ │ ├── signin
│ │ │ ├── SignInActivity.kt
│ │ │ ├── ServerSignInFragment.kt
│ │ │ ├── UsernamePasswordSignInFragment.kt
│ │ │ └── SignInActivityViewModel.kt
│ │ │ ├── CommandButtons.kt
│ │ │ ├── AlbumArtContentProvider.kt
│ │ │ ├── JellyfinMusicService.kt
│ │ │ ├── JellyfinMediaTree.kt
│ │ │ ├── MediaItemFactory.kt
│ │ │ └── JellyfinMediaLibrarySessionCallback.kt
│ │ └── AndroidManifest.xml
├── proguard-rules.pro
└── build.gradle.kts
├── assets
├── playstore
│ ├── banner.png
│ ├── tablet.png
│ ├── portrait.png
│ ├── portrait2.png
│ ├── landscape1.png
│ ├── landscape2.png
│ └── landscape3_honda.png
└── play_store_512x512.png
├── settings.gradle.kts
├── .gitignore
├── README.md
├── gradle.properties
└── LICENSE
/automotive/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/automotive/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/assets/playstore/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/banner.png
--------------------------------------------------------------------------------
/assets/playstore/tablet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/tablet.png
--------------------------------------------------------------------------------
/assets/play_store_512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/play_store_512x512.png
--------------------------------------------------------------------------------
/assets/playstore/portrait.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/portrait.png
--------------------------------------------------------------------------------
/assets/playstore/portrait2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/portrait2.png
--------------------------------------------------------------------------------
/assets/playstore/landscape1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/landscape1.png
--------------------------------------------------------------------------------
/assets/playstore/landscape2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/landscape2.png
--------------------------------------------------------------------------------
/assets/playstore/landscape3_honda.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/assets/playstore/landscape3_honda.png
--------------------------------------------------------------------------------
/automotive/src/main/app_logo-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/app_logo-playstore.png
--------------------------------------------------------------------------------
/automotive/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/automotive/src/main/res/xml/automotive_app_desc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/Constants.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | object Constants {
4 | const val LOG_MARKER = "SharkMarmalade"
5 | }
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #93A3AC
4 |
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bendardenne/sharkmarmalade/HEAD/automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/automotive/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/SharkMarmaladeApplication.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class SharkMarmaladeApplication : Application() {
8 | }
--------------------------------------------------------------------------------
/automotive/src/main/res/layout/activity_sign_in.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/automotive/src/main/res/xml/authenticator.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/automotive/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 |
16 | rootProject.name = "Shark Marmalade"
17 | include(":automotive")
18 |
--------------------------------------------------------------------------------
/automotive/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #93A3AC
4 | #667278
5 | #daeaea
6 | #0e1011
7 |
8 | #DADADA
9 |
10 | #fff1f3f4
11 | #ffdadce0
12 |
--------------------------------------------------------------------------------
/automotive/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/auth/AuthenticatorService.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.auth
2 |
3 | import android.app.Service
4 | import android.content.Intent
5 | import android.os.IBinder
6 |
7 | class AuthenticatorService : Service() {
8 | private lateinit var authenticator: Authenticator
9 |
10 | override fun onCreate() {
11 | super.onCreate()
12 | authenticator = Authenticator(this)
13 | }
14 |
15 | override fun onBind(intent: Intent): IBinder {
16 | return authenticator.iBinder
17 | }
18 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle files
2 | .gradle/
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | local.properties
7 |
8 | # Log/OS Files
9 | *.log
10 |
11 | # Android Studio generated files and folders
12 | captures/
13 | .externalNativeBuild/
14 | .cxx/
15 | *.apk
16 | output.json
17 |
18 | # IntelliJ
19 | *.iml
20 | .idea/
21 | misc.xml
22 | deploymentTargetDropDown.xml
23 | render.experimental.xml
24 |
25 | # Keystore files
26 | *.jks
27 | *.keystore
28 |
29 | # Google Services (e.g. APIs or Firebase)
30 | google-services.json
31 |
32 | # Android Profiling
33 | *.hprof
34 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/settings/SettingsFragmentViewModel.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.settings
2 |
3 | import androidx.lifecycle.ViewModel
4 | import dagger.hilt.android.lifecycle.HiltViewModel
5 | import org.jellyfin.sdk.Jellyfin
6 | import javax.inject.Inject
7 |
8 | @HiltViewModel
9 | class SettingsFragmentViewModel @Inject constructor() : ViewModel() {
10 |
11 | @Inject
12 | lateinit var jellyfin: Jellyfin
13 |
14 | fun versionString(): CharSequence =
15 | "SharkMarmalade: ${jellyfin.clientInfo?.version}, Jellyfin API: ${Jellyfin.apiVersion}"
16 | }
--------------------------------------------------------------------------------
/automotive/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - Direct stream
5 | - 320 kbps
6 | - 256 kbps
7 | - 192 kbps
8 | - 160 kbps
9 | - 128 kbps
10 |
11 |
12 |
13 | - Direct stream
14 | - 320000
15 | - 256000
16 | - 192000
17 | - 160000
18 | - 128000
19 |
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Shark Marmalade
2 |
3 | 
4 |
5 | Shark Marmalade is a third-party music client for Jellyfin servers, targetting Android Automotive (AAOS).
6 |
7 |
8 |
9 |
10 |
11 | ## Known compatible cars
12 |
13 | Please feel free to add to this list if you manage to use the app in your car!
14 |
15 | - Polestar 2
16 | - Polestar 3
17 | - Volvo XC90
18 | - Volvo XC60
19 | - Volvo XC40
20 | - Volvo EX30
21 | - Chevy Equinox EV
22 | - Renault / Nissan ?? OpenR Link
23 |
--------------------------------------------------------------------------------
/automotive/src/main/res/drawable/playlists.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
12 |
--------------------------------------------------------------------------------
/automotive/src/main/res/xml/preferences.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
13 |
14 |
15 |
16 |
19 |
20 |
--------------------------------------------------------------------------------
/automotive/src/main/res/drawable/star_filled.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
--------------------------------------------------------------------------------
/automotive/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/settings/SettingsFragment.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.settings
2 |
3 | import android.os.Bundle
4 | import androidx.lifecycle.ViewModelProvider
5 | import androidx.preference.Preference
6 | import androidx.preference.PreferenceFragmentCompat
7 | import be.bendardenne.jellyfin.aaos.R
8 | import dagger.hilt.android.AndroidEntryPoint
9 |
10 | @AndroidEntryPoint
11 | class SettingsFragment : PreferenceFragmentCompat() {
12 |
13 | private lateinit var viewModel: SettingsFragmentViewModel
14 |
15 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
16 | setPreferencesFromResource(R.xml.preferences, rootKey)
17 |
18 | viewModel = ViewModelProvider(this)[SettingsFragmentViewModel::class.java]
19 | findPreference("version")?.summary = viewModel.versionString()
20 | }
21 | }
22 |
23 |
--------------------------------------------------------------------------------
/automotive/src/main/res/drawable/schedule.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
--------------------------------------------------------------------------------
/automotive/src/main/res/layout/activity_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
13 |
14 |
20 |
21 |
22 |
23 |
28 |
29 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/JellyfinHiltModule.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.accounts.AccountManager
4 | import android.content.Context
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.android.qualifiers.ApplicationContext
9 | import dagger.hilt.components.SingletonComponent
10 | import org.jellyfin.sdk.Jellyfin
11 | import org.jellyfin.sdk.android.androidDevice
12 | import org.jellyfin.sdk.createJellyfin
13 | import org.jellyfin.sdk.model.ClientInfo
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | class JellyfinHiltModule {
18 |
19 | @Provides
20 | fun provideJellyfin(@ApplicationContext appContext: Context): Jellyfin {
21 | val version =
22 | appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName
23 |
24 | return createJellyfin {
25 | clientInfo = ClientInfo(appContext.getString(R.string.app_name), version ?: "unknown")
26 | deviceInfo = androidDevice(appContext)
27 | context = appContext
28 | }
29 | }
30 |
31 | @Provides
32 | fun provideAccountManager(@ApplicationContext appContext: Context): JellyfinAccountManager {
33 | return JellyfinAccountManager(AccountManager.get(appContext))
34 | }
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/settings/SettingsActivity.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.settings
2 |
3 | import android.os.Bundle
4 | import androidx.activity.OnBackPressedCallback
5 | import androidx.appcompat.app.AppCompatActivity
6 | import be.bendardenne.jellyfin.aaos.R
7 | import be.bendardenne.jellyfin.aaos.databinding.ActivitySettingsBinding
8 | import dagger.hilt.android.AndroidEntryPoint
9 |
10 |
11 | @AndroidEntryPoint
12 | class SettingsActivity : AppCompatActivity() {
13 |
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | super.onCreate(savedInstanceState)
16 | val binding = ActivitySettingsBinding.inflate(layoutInflater)
17 | setContentView(binding.root)
18 |
19 | setSupportActionBar(binding.toolbar)
20 | supportActionBar?.setHomeButtonEnabled(true)
21 | supportActionBar?.setDisplayHomeAsUpEnabled(true)
22 | supportActionBar?.setTitle(R.string.settings)
23 |
24 | supportFragmentManager
25 | .beginTransaction()
26 | .replace(R.id.settings_container, SettingsFragment())
27 | .commit()
28 |
29 | onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
30 | override fun handleOnBackPressed() = finish()
31 | })
32 | }
33 |
34 | override fun onSupportNavigateUp(): Boolean {
35 | onBackPressedDispatcher.onBackPressed()
36 | return true
37 | }
38 |
39 | }
--------------------------------------------------------------------------------
/automotive/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Shark Marmalade
3 | Sign in to your Jellyfin server
4 | https://myjellyfin.server.com:8096
5 | Connect to server
6 | Server URL cannot be empty
7 | QuickConnect
8 | QuickConnect succeeded
9 | Open Jellyfin on your phone,\n select QuickConnect,\n and enter this code.
10 | Error
11 | Unavailable
12 | username
13 | password
14 | Log In
15 | Username cannot be empty
16 | Could not reach server
17 | Failed to login
18 | Artists
19 | Tracks
20 | Albums
21 | Playlists
22 | Settings
23 | Maximum bitrate
24 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/JellyfinAccountManager.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.accounts.Account
4 | import android.accounts.AccountManager
5 | import android.os.Bundle
6 | import be.bendardenne.jellyfin.aaos.auth.Authenticator
7 |
8 | class JellyfinAccountManager(private val accountManager: AccountManager) {
9 |
10 | companion object {
11 | const val ACCOUNT_TYPE = Authenticator.ACCOUNT_TYPE
12 | const val TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
13 | const val USERDATA_SERVER_KEY = "$ACCOUNT_TYPE.server"
14 | }
15 |
16 | private val account: Account?
17 | get() = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull()
18 |
19 | val server: String?
20 | get() = account?.let { accountManager.getUserData(it, USERDATA_SERVER_KEY) }
21 |
22 | val token: String?
23 | get() = account?.let { accountManager.peekAuthToken(it, TOKEN_TYPE) }
24 |
25 | val isAuthenticated: Boolean
26 | get() = token != null
27 |
28 | fun storeAccount(server: String, username: String, token: String): Account {
29 | // Find existing account, if any
30 | var account = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull {
31 | accountManager.getUserData(it, USERDATA_SERVER_KEY).equals(server) &&
32 | it.name.equals(username)
33 | }
34 |
35 | if (account == null) {
36 | account = Account(username, ACCOUNT_TYPE)
37 | accountManager.addAccountExplicitly(
38 | account,
39 | "", // We don't keep the password, just the auth token.
40 | Bundle().also { it.putString(USERDATA_SERVER_KEY, server) }
41 | )
42 | }
43 |
44 | accountManager.setAuthToken(account, TOKEN_TYPE, token)
45 |
46 | return account
47 | }
48 | }
--------------------------------------------------------------------------------
/automotive/src/main/res/drawable/app_logo.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/signin/SignInActivity.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.signin
2 |
3 | import android.content.ComponentName
4 | import android.os.Bundle
5 | import androidx.appcompat.app.AppCompatActivity
6 | import androidx.concurrent.futures.await
7 | import androidx.lifecycle.ViewModelProvider
8 | import androidx.lifecycle.lifecycleScope
9 | import androidx.media3.session.MediaController
10 | import androidx.media3.session.SessionCommand
11 | import androidx.media3.session.SessionToken
12 | import be.bendardenne.jellyfin.aaos.JellyfinMediaLibrarySessionCallback.Companion.LOGIN_COMMAND
13 | import be.bendardenne.jellyfin.aaos.JellyfinMusicService
14 | import be.bendardenne.jellyfin.aaos.R
15 | import dagger.hilt.android.AndroidEntryPoint
16 | import kotlinx.coroutines.launch
17 |
18 |
19 | @AndroidEntryPoint
20 | class SignInActivity : AppCompatActivity() {
21 |
22 | private lateinit var viewModel: SignInActivityViewModel
23 |
24 | override fun onCreate(savedInstanceState: Bundle?) {
25 | super.onCreate(savedInstanceState)
26 | setContentView(R.layout.activity_sign_in)
27 |
28 | viewModel = ViewModelProvider(this)[SignInActivityViewModel::class.java]
29 |
30 | viewModel.loggedIn.observe(this) { loggedIn ->
31 | if (loggedIn == true) {
32 | val service = ComponentName(applicationContext, JellyfinMusicService::class.java)
33 | val future = MediaController.Builder(
34 | applicationContext,
35 | SessionToken(applicationContext, service)
36 | ).buildAsync()
37 |
38 | lifecycleScope.launch {
39 | val controller = future.await()
40 | controller.sendCustomCommand(SessionCommand(LOGIN_COMMAND, Bundle()), Bundle())
41 | finish()
42 | }
43 |
44 | }
45 | }
46 |
47 | supportFragmentManager.beginTransaction()
48 | .add(R.id.sign_in_container, ServerSignInFragment())
49 | .commit()
50 | }
51 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/CommandButtons.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.os.Bundle
4 | import androidx.annotation.OptIn
5 | import androidx.media3.common.Player
6 | import androidx.media3.common.Player.REPEAT_MODE_ALL
7 | import androidx.media3.common.Player.REPEAT_MODE_OFF
8 | import androidx.media3.common.Player.REPEAT_MODE_ONE
9 | import androidx.media3.common.util.UnstableApi
10 | import androidx.media3.session.CommandButton
11 | import androidx.media3.session.SessionCommand
12 | import be.bendardenne.jellyfin.aaos.JellyfinMediaLibrarySessionCallback.Companion.REPEAT_COMMAND
13 | import be.bendardenne.jellyfin.aaos.JellyfinMediaLibrarySessionCallback.Companion.SHUFFLE_COMMAND
14 | import com.google.common.collect.ImmutableList
15 |
16 | /**
17 | * Helper for creating the custom command buttons we need for a player.
18 | */
19 | object CommandButtons {
20 |
21 | @OptIn(UnstableApi::class)
22 | fun createButtons(player: Player): List {
23 | // The UI should show the icon for the active mode
24 | val repeatIcon = when (player.repeatMode) {
25 | REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL
26 | REPEAT_MODE_OFF -> CommandButton.ICON_REPEAT_OFF
27 | REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE
28 | else -> throw IllegalStateException("Unexpected change to Repeat mode")
29 | }
30 |
31 | val repeat =
32 | CommandButton.Builder(repeatIcon)
33 | .setDisplayName("Toggle repeat")
34 | .setSessionCommand(SessionCommand(REPEAT_COMMAND, Bundle.EMPTY))
35 | .setSlots(CommandButton.SLOT_OVERFLOW)
36 | .build()
37 |
38 | val shuffleIcon =
39 | if (player.shuffleModeEnabled)
40 | CommandButton.ICON_SHUFFLE_ON
41 | else
42 | CommandButton.ICON_SHUFFLE_OFF
43 |
44 | val shuffle = CommandButton.Builder(shuffleIcon)
45 | .setDisplayName("Toggle Shuffle")
46 | .setSessionCommand(SessionCommand(SHUFFLE_COMMAND, Bundle.EMPTY))
47 | .setSlots(CommandButton.SLOT_OVERFLOW)
48 | .build()
49 |
50 | return ImmutableList.of(shuffle, repeat)
51 | }
52 |
53 | }
--------------------------------------------------------------------------------
/automotive/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("org.jetbrains.kotlin.android")
4 | id("kotlin-kapt")
5 | id("com.google.dagger.hilt.android")
6 | }
7 |
8 | android {
9 | namespace = "be.bendardenne.jellyfin.aaos"
10 | compileSdk = 35
11 |
12 | defaultConfig {
13 | applicationId = "be.bendardenne.jellyfin.aaos"
14 | minSdk = 29
15 | targetSdk = 34
16 | versionCode = 26
17 | versionName = "1.1"
18 |
19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20 | }
21 |
22 | buildFeatures {
23 | dataBinding = true
24 | viewBinding = true
25 | }
26 |
27 | buildTypes {
28 | release {
29 | isMinifyEnabled = false
30 | proguardFiles(
31 | getDefaultProguardFile("proguard-android-optimize.txt"),
32 | "proguard-rules.pro"
33 | )
34 | }
35 | }
36 | compileOptions {
37 | sourceCompatibility = JavaVersion.VERSION_1_8
38 | targetCompatibility = JavaVersion.VERSION_1_8
39 | }
40 | kotlinOptions {
41 | jvmTarget = "1.8"
42 | }
43 | }
44 |
45 | dependencies {
46 | implementation("androidx.car:car:1.0.0-alpha7")
47 | implementation("androidx.core:core-ktx:1.16.0")
48 | implementation("androidx.constraintlayout:constraintlayout:2.2.1")
49 | implementation("androidx.preference:preference-ktx:1.2.1")
50 | implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
51 | implementation("androidx.credentials:credentials:1.5.0")
52 | implementation("androidx.databinding:databinding-runtime:8.10.0")
53 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0")
54 | implementation("androidx.media3:media3-exoplayer:1.7.1")
55 | implementation("androidx.media3:media3-session:1.7.1")
56 | implementation("androidx.media3:media3-ui:1.7.1")
57 | implementation("com.squareup.okhttp3:okhttp:4.12.0")
58 | implementation("org.jellyfin.sdk:jellyfin-core:1.6.1")
59 | implementation("com.google.dagger:hilt-android:2.51.1")
60 | kapt("com.google.dagger:hilt-compiler:2.51.1")
61 |
62 | testImplementation("junit:junit:4.13.2")
63 | androidTestImplementation("androidx.test.ext:junit:1.2.1")
64 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
65 | }
--------------------------------------------------------------------------------
/automotive/src/main/res/drawable/casino.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
--------------------------------------------------------------------------------
/automotive/src/main/res/layout/server_sign_in.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
19 |
20 |
34 |
35 |
43 |
44 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/signin/ServerSignInFragment.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.signin
2 |
3 | import android.os.Bundle
4 | import android.text.Editable
5 | import android.text.TextUtils
6 | import android.view.LayoutInflater
7 | import android.view.View
8 | import android.view.ViewGroup
9 | import android.widget.Button
10 | import android.widget.EditText
11 | import android.widget.ProgressBar
12 | import android.widget.Toast
13 | import androidx.fragment.app.Fragment
14 | import androidx.lifecycle.ViewModelProvider
15 | import androidx.lifecycle.lifecycleScope
16 | import be.bendardenne.jellyfin.aaos.R
17 | import be.bendardenne.jellyfin.aaos.signin.SignInActivityViewModel.Companion.JELLYFIN_SERVER_URL
18 | import kotlinx.coroutines.launch
19 |
20 |
21 | class ServerSignInFragment : Fragment() {
22 |
23 | private lateinit var serverInput: EditText
24 | private lateinit var submitServer: Button
25 | private lateinit var progressBar: ProgressBar
26 | private lateinit var viewModel: SignInActivityViewModel
27 |
28 | override fun onCreateView(
29 | inflater: LayoutInflater, container: ViewGroup?,
30 | savedInstanceState: Bundle?
31 | ): View? {
32 | return inflater.inflate(R.layout.server_sign_in, container, false)
33 | }
34 |
35 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
36 | super.onViewCreated(view, savedInstanceState)
37 |
38 | viewModel = ViewModelProvider(requireActivity())[SignInActivityViewModel::class.java]
39 |
40 | serverInput = view.findViewById(R.id.server_uri)
41 | submitServer = view.findViewById(R.id.submit_server_button)
42 | progressBar = view.findViewById(R.id.progress_bar)
43 |
44 | submitServer.setOnClickListener {
45 | val serverUrl = serverInput.text
46 | if (!TextUtils.isEmpty(serverUrl)) {
47 | progressBar.visibility = View.VISIBLE
48 |
49 | viewLifecycleOwner.lifecycleScope.launch {
50 | val pingServer = viewModel.pingServer(serverUrl.toString())
51 |
52 | if (pingServer) {
53 | signInToServer(serverUrl)
54 | } else {
55 | progressBar.visibility = View.INVISIBLE
56 | Toast.makeText(context, R.string.server_unreachable, Toast.LENGTH_SHORT)
57 | .show()
58 | }
59 | }
60 | }
61 | }
62 | }
63 |
64 | private fun signInToServer(serverUrl: Editable) {
65 | val args = Bundle()
66 | args.putString(JELLYFIN_SERVER_URL, serverUrl.toString())
67 | val fragment = UsernamePasswordSignInFragment()
68 | fragment.arguments = args
69 |
70 | requireActivity().supportFragmentManager.beginTransaction()
71 | .replace(R.id.sign_in_container, fragment)
72 | .addToBackStack("landingPage")
73 | .commit()
74 | }
75 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/auth/Authenticator.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.auth
2 |
3 | import android.accounts.AbstractAccountAuthenticator
4 | import android.accounts.Account
5 | import android.accounts.AccountAuthenticatorResponse
6 | import android.accounts.AccountManager
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.os.Bundle
10 | import be.bendardenne.jellyfin.aaos.signin.SignInActivity
11 |
12 | class Authenticator(val context: Context) : AbstractAccountAuthenticator(context) {
13 | companion object {
14 | const val ACCOUNT_TYPE = "be.bendardenne.jellyfin.aaos"
15 | const val AUTHTOKEN_TYPE = "be.bendardenne.jellyfin.aaos"
16 | }
17 |
18 | override fun editProperties(p0: AccountAuthenticatorResponse?, p1: String?): Bundle =
19 | throw UnsupportedOperationException()
20 |
21 | override fun addAccount(
22 | response: AccountAuthenticatorResponse?,
23 | accountType: String?,
24 | authTokenType: String?,
25 | requiredFeatures: Array?,
26 | options: Bundle?
27 | ): Bundle {
28 | return Bundle()
29 | }
30 |
31 | override fun confirmCredentials(
32 | response: AccountAuthenticatorResponse?,
33 | account: Account?,
34 | options: Bundle?
35 | ): Bundle? = null
36 |
37 | override fun getAuthToken(
38 | response: AccountAuthenticatorResponse?,
39 | account: Account?,
40 | authTokenType: String?,
41 | loginOptions: Bundle?
42 | ): Bundle {
43 | if (authTokenType != AUTHTOKEN_TYPE) {
44 | val res = Bundle()
45 | res.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid auth token type")
46 | return res
47 | }
48 |
49 | if (account == null) {
50 | val res = Bundle()
51 | res.putString(AccountManager.KEY_ERROR_MESSAGE, "account must not be null")
52 | return res
53 | }
54 |
55 | // password is the auth token
56 | val accountManager = AccountManager.get(context)
57 | val password = accountManager.getPassword(account)
58 | if (password != null) {
59 | val res = Bundle()
60 | res.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
61 | res.putString(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE)
62 | res.putString(AccountManager.KEY_AUTHTOKEN, password)
63 | return res
64 | }
65 |
66 | // invalid password, ask for sign-in
67 | val intent = Intent(context, SignInActivity::class.java)
68 | intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
69 | val bundle = Bundle()
70 | bundle.putParcelable(AccountManager.KEY_INTENT, intent)
71 | return bundle
72 | }
73 |
74 | override fun getAuthTokenLabel(p0: String?): String? = null
75 |
76 | override fun updateCredentials(
77 | p0: AccountAuthenticatorResponse?,
78 | p1: Account?,
79 | p2: String?,
80 | p3: Bundle?
81 | ): Bundle? = null
82 |
83 | override fun hasFeatures(
84 | p0: AccountAuthenticatorResponse?,
85 | p1: Account?,
86 | p2: Array?
87 | ): Bundle {
88 | val result = Bundle()
89 | result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false)
90 | return result
91 | }
92 | }
--------------------------------------------------------------------------------
/automotive/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
11 |
14 |
17 |
18 |
19 |
20 |
21 |
24 |
25 |
28 |
29 |
39 |
40 |
43 |
44 |
47 |
48 |
51 |
52 |
53 |
54 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
64 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
79 |
80 |
81 |
82 |
83 |
86 |
87 |
88 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/signin/UsernamePasswordSignInFragment.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.signin
2 |
3 | import android.os.Bundle
4 | import android.text.TextUtils
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.view.inputmethod.InputMethodManager
9 | import android.widget.Button
10 | import android.widget.EditText
11 | import android.widget.ProgressBar
12 | import android.widget.TextView
13 | import android.widget.Toast
14 | import androidx.appcompat.app.AppCompatActivity
15 | import androidx.fragment.app.Fragment
16 | import androidx.lifecycle.Observer
17 | import androidx.lifecycle.ViewModelProvider
18 | import androidx.lifecycle.lifecycleScope
19 | import be.bendardenne.jellyfin.aaos.R
20 | import be.bendardenne.jellyfin.aaos.signin.SignInActivityViewModel.Companion.JELLYFIN_SERVER_URL
21 | import kotlinx.coroutines.launch
22 |
23 |
24 | class UsernamePasswordSignInFragment : Fragment() {
25 |
26 | private lateinit var viewModel: SignInActivityViewModel
27 | private lateinit var usernameInput: EditText
28 | private lateinit var passwordInput: EditText
29 | private lateinit var loginButton: Button
30 |
31 | private lateinit var quickConnectCode: TextView
32 | private lateinit var quickConnectProgressBar: ProgressBar
33 |
34 | override fun onCreateView(
35 | inflater: LayoutInflater, container: ViewGroup?,
36 | savedInstanceState: Bundle?
37 | ): View? {
38 | return inflater.inflate(R.layout.username_password_sign_in, container, false)
39 | }
40 |
41 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
42 | super.onViewCreated(view, savedInstanceState)
43 |
44 | viewModel = ViewModelProvider(requireActivity())[SignInActivityViewModel::class.java]
45 | val server = arguments?.getString(JELLYFIN_SERVER_URL)!!
46 |
47 | usernameInput = view.findViewById(R.id.username)
48 | passwordInput = view.findViewById(R.id.password)
49 | loginButton = view.findViewById(R.id.login_button)
50 | quickConnectCode = view.findViewById(R.id.quickconnect_code)
51 | quickConnectProgressBar = view.findViewById(R.id.quickconnect_progressbar)
52 |
53 | viewModel.startQuickConnect(server)
54 |
55 | viewModel.quickConnectCode.observe(viewLifecycleOwner, object : Observer {
56 | override fun onChanged(value: Int) {
57 | quickConnectProgressBar.visibility = View.GONE
58 | quickConnectCode.visibility = View.VISIBLE
59 |
60 | if( value == -1 ){
61 | quickConnectCode.text = context?.getText(R.string.unavailable)
62 | } else {
63 | val code = value.toString()
64 | val formattedCode = code.substring(0, 3) + " " + code.substring(3)
65 | quickConnectCode.text = formattedCode
66 | }
67 | }
68 | })
69 |
70 | loginButton.setOnClickListener {
71 | val username = usernameInput.text
72 | val password = passwordInput.text
73 |
74 | if (TextUtils.isEmpty(username)) {
75 | toast(R.string.username_textfield_error)
76 | } else {
77 | viewLifecycleOwner.lifecycleScope.launch {
78 | val result = viewModel.login(server, username.toString(), password.toString())
79 |
80 | if (!result) {
81 | toast(R.string.login_unsuccessful)
82 | }
83 |
84 | // If successful, the Activity will finish. Apparently we need to manually hide the keyboard.
85 | val inputManager =
86 | activity?.getSystemService(AppCompatActivity.INPUT_METHOD_SERVICE) as InputMethodManager
87 | inputManager.hideSoftInputFromWindow(
88 | activity?.currentFocus?.windowToken,
89 | InputMethodManager.HIDE_NOT_ALWAYS
90 | )
91 | }
92 | }
93 |
94 | }
95 | }
96 |
97 | private fun toast(message: Int) {
98 | Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
99 | }
100 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/AlbumArtContentProvider.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.content.ContentProvider
4 | import android.content.ContentResolver
5 | import android.content.ContentValues
6 | import android.database.Cursor
7 | import android.net.Uri
8 | import android.os.ParcelFileDescriptor
9 | import android.util.Log
10 | import be.bendardenne.jellyfin.aaos.Constants.LOG_MARKER
11 | import okhttp3.OkHttpClient
12 | import okhttp3.Request
13 | import okio.buffer
14 | import okio.sink
15 | import java.io.File
16 | import java.io.FileNotFoundException
17 | import java.util.concurrent.CountDownLatch
18 | import java.util.concurrent.TimeUnit
19 |
20 |
21 | /**
22 | * ContentProvider for album arts.
23 | */
24 | class AlbumArtContentProvider : ContentProvider() {
25 |
26 | private val client = OkHttpClient()
27 |
28 | companion object {
29 | private val uriMap = mutableMapOf()
30 | private val inProgress = HashMap()
31 |
32 | fun mapUri(uri: Uri): Uri {
33 | val path = uri.encodedPath?.substring(1)?.replace('/', ':') ?: return Uri.EMPTY
34 | val contentUri = Uri.Builder()
35 | .scheme(ContentResolver.SCHEME_CONTENT)
36 | .authority("be.bendardenne.jellyfin.aaos")
37 | .path(path)
38 | .build()
39 | uriMap[contentUri] = uri
40 | return contentUri
41 | }
42 | }
43 |
44 | override fun onCreate() = true
45 |
46 | override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
47 | val context = this.context ?: return null
48 | val remoteUri = uriMap[uri] ?: throw FileNotFoundException(uri.path)
49 | val file = File(context.cacheDir, uri.path)
50 |
51 | if (file.exists()) {
52 | Log.d(LOG_MARKER, "Returning existing file for $remoteUri: $file")
53 | return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
54 | }
55 |
56 | // Several threads may request the same image (typical when listing an album).
57 | // To avoid firing multiple downloads, the first thread makes the request, others will wait.
58 | synchronized(inProgress) {
59 | if (inProgress.contains(remoteUri)) {
60 | Log.d(LOG_MARKER, "Waiting for image download in separate thread... $remoteUri")
61 | inProgress.get(remoteUri)?.await(15, TimeUnit.SECONDS)
62 | Log.d(LOG_MARKER, "... Available!")
63 | return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
64 | }
65 |
66 | // Any other thread will now see a countdownlatch and block.
67 | // This thread will continue and download.
68 | inProgress.put(remoteUri, CountDownLatch(1))
69 | }
70 |
71 | val tmpFile = File.createTempFile("sharkmarmalade-albumart", ".png", context.cacheDir)
72 | val request: Request = Request.Builder()
73 | .url(remoteUri.toString())
74 | .build()
75 |
76 | Log.d(LOG_MARKER, "Downloading $remoteUri ...")
77 | client.newCall(request).execute().use {
78 | if (it.body != null && it.code == 200) {
79 | Log.d(LOG_MARKER, "Downloaded $remoteUri")
80 | val source = it.body!!.source()
81 | source.request(Long.MAX_VALUE)
82 |
83 | val sink = tmpFile.sink().buffer()
84 | sink.writeAll(source)
85 | sink.flush()
86 | sink.close()
87 |
88 | tmpFile.renameTo(file)
89 | } else {
90 | Log.w(LOG_MARKER, "Failed to download $remoteUri: \n ${it.code} - ${it.body}")
91 | }
92 |
93 | inProgress.get(remoteUri)?.countDown()
94 | inProgress.remove(remoteUri)
95 | }
96 |
97 | return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
98 | }
99 |
100 | override fun insert(uri: Uri, values: ContentValues?): Uri? = null
101 |
102 | override fun query(
103 | uri: Uri,
104 | projection: Array?,
105 | selection: String?,
106 | selectionArgs: Array?,
107 | sortOrder: String?
108 | ): Cursor? = null
109 |
110 | override fun update(
111 | uri: Uri,
112 | values: ContentValues?,
113 | selection: String?,
114 | selectionArgs: Array?
115 | ) = 0
116 |
117 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
118 |
119 | override fun getType(uri: Uri): String? = null
120 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/JellyfinMusicService.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.accounts.AccountManager
4 | import android.util.Log
5 | import androidx.annotation.OptIn
6 | import androidx.concurrent.futures.SuspendToFutureAdapter
7 | import androidx.media3.common.AudioAttributes
8 | import androidx.media3.common.Player
9 | import androidx.media3.common.util.UnstableApi
10 | import androidx.media3.datasource.DefaultHttpDataSource
11 | import androidx.media3.exoplayer.ExoPlayer
12 | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
13 | import androidx.media3.session.MediaLibraryService
14 | import androidx.media3.session.MediaSession
15 | import be.bendardenne.jellyfin.aaos.Constants.LOG_MARKER
16 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.ROOT_ID
17 | import dagger.hilt.android.AndroidEntryPoint
18 | import org.jellyfin.sdk.Jellyfin
19 | import org.jellyfin.sdk.api.client.ApiClient
20 | import org.jellyfin.sdk.api.client.extensions.playStateApi
21 | import org.jellyfin.sdk.model.serializer.toUUID
22 | import javax.inject.Inject
23 |
24 | @AndroidEntryPoint
25 | @OptIn(UnstableApi::class)
26 | class JellyfinMusicService : MediaLibraryService() {
27 |
28 | @Inject
29 | lateinit var jellyfin: Jellyfin
30 |
31 | private lateinit var accountManager: JellyfinAccountManager
32 | private lateinit var jellyfinApi: ApiClient
33 | private lateinit var mediaSourceFactory: DefaultMediaSourceFactory
34 | private lateinit var mediaLibrarySession: MediaLibrarySession
35 |
36 | override fun onCreate() {
37 | super.onCreate()
38 |
39 | accountManager = JellyfinAccountManager(AccountManager.get(applicationContext))
40 | jellyfinApi = jellyfin.createApi()
41 | mediaSourceFactory = DefaultMediaSourceFactory(this)
42 |
43 | val player = ExoPlayer.Builder(this)
44 | .setAudioAttributes(AudioAttributes.DEFAULT, true)
45 | .setMediaSourceFactory(mediaSourceFactory)
46 | .build()
47 |
48 | player.addListener(object : Player.Listener {
49 | override fun onEvents(player: Player, events: Player.Events) {
50 | if (events.containsAny(
51 | Player.EVENT_PLAYBACK_STATE_CHANGED,
52 | Player.EVENT_MEDIA_ITEM_TRANSITION
53 | )
54 | ) {
55 | SuspendToFutureAdapter.launchFuture { reportPlayback(player) }
56 | }
57 | }
58 | })
59 |
60 | val callback = JellyfinMediaLibrarySessionCallback(this, accountManager, jellyfinApi)
61 |
62 | // Start in Repeat all & no shuffle by default
63 | player.repeatMode = Player.REPEAT_MODE_ALL
64 | player.shuffleModeEnabled = false
65 |
66 | mediaLibrarySession = MediaLibrarySession.Builder(this, player, callback)
67 | .setMediaButtonPreferences(CommandButtons.createButtons(player))
68 | .build()
69 |
70 | if (accountManager.isAuthenticated) {
71 | onLogin()
72 | }
73 | }
74 |
75 | private suspend fun reportPlayback(player: Player) {
76 | if (player.isPlaying) {
77 | val exoPlayer = player as ExoPlayer
78 | val format = exoPlayer.audioFormat
79 | val formatString = "${format?.containerMimeType} at ${format?.averageBitrate} bps"
80 |
81 | Log.i(LOG_MARKER, "Playing: $formatString")
82 | Log.i(LOG_MARKER, "Playing: ${exoPlayer.currentMediaItem?.localConfiguration?.uri}")
83 | jellyfinApi.playStateApi.onPlaybackStart(
84 | player.currentMediaItem!!.mediaId.toUUID()
85 | )
86 | } else {
87 | jellyfinApi.playStateApi.onPlaybackStopped(
88 | player.currentMediaItem!!.mediaId.toUUID()
89 | )
90 | }
91 | }
92 |
93 | override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
94 | return mediaLibrarySession
95 | }
96 |
97 | override fun onDestroy() {
98 | mediaLibrarySession.release()
99 | mediaLibrarySession.player.release()
100 | super.onDestroy()
101 | }
102 |
103 | fun onLogin() {
104 | jellyfinApi.update(
105 | baseUrl = accountManager.server,
106 | accessToken = accountManager.token
107 | )
108 |
109 | val headers = mapOf(
110 | "Authorization" to "MediaBrowser Client=\"${jellyfinApi.clientInfo.name}\", " +
111 | "Device=\"${jellyfinApi.deviceInfo.name}\", " +
112 | "DeviceId=\"${jellyfinApi.deviceInfo.id}\", " +
113 | "Version=\"${jellyfinApi.clientInfo.version}\", " +
114 | "Token=\"${jellyfinApi.accessToken}\""
115 | )
116 |
117 | val authedFactory =
118 | DefaultHttpDataSource.Factory().setDefaultRequestProperties(headers)
119 | mediaSourceFactory.setDataSourceFactory(authedFactory)
120 |
121 | // Trigger a refresh upon login.
122 | mediaLibrarySession.notifyChildrenChanged(ROOT_ID, 4, null)
123 | }
124 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/signin/SignInActivityViewModel.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos.signin
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.LiveData
5 | import androidx.lifecycle.MutableLiveData
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import be.bendardenne.jellyfin.aaos.Constants.LOG_MARKER
9 | import be.bendardenne.jellyfin.aaos.JellyfinAccountManager
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.Dispatchers
12 | import kotlinx.coroutines.delay
13 | import kotlinx.coroutines.isActive
14 | import kotlinx.coroutines.launch
15 | import kotlinx.coroutines.withContext
16 | import org.jellyfin.sdk.Jellyfin
17 | import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
18 | import org.jellyfin.sdk.api.client.extensions.quickConnectApi
19 | import org.jellyfin.sdk.api.client.extensions.systemApi
20 | import org.jellyfin.sdk.api.client.extensions.userApi
21 | import org.jellyfin.sdk.model.api.QuickConnectDto
22 | import javax.inject.Inject
23 | import kotlin.time.Duration.Companion.seconds
24 |
25 | @HiltViewModel
26 | class SignInActivityViewModel @Inject constructor() : ViewModel() {
27 |
28 | @Inject
29 | lateinit var jellyfin: Jellyfin
30 |
31 | @Inject
32 | lateinit var accountManager: JellyfinAccountManager
33 |
34 | private var quickConnectSecret: String = ""
35 |
36 | private val _loggedIn = MutableLiveData()
37 | val loggedIn: LiveData = _loggedIn
38 |
39 | private val _quickConnectCode = MutableLiveData()
40 | val quickConnectCode: LiveData = _quickConnectCode
41 |
42 | suspend fun pingServer(serverUrl: String): Boolean {
43 | return try {
44 | Log.i(LOG_MARKER, "Pinging $serverUrl")
45 |
46 | val response = withContext(Dispatchers.IO) {
47 | jellyfin.createApi(serverUrl).systemApi.getPingSystem()
48 | }
49 |
50 | response.status == 200
51 | } catch (e: Exception) {
52 | Log.w(LOG_MARKER, "Error", e)
53 | false
54 | }
55 | }
56 |
57 | fun startQuickConnect(serverUrl: String) {
58 | Log.i(LOG_MARKER, "Initiate QuickConnect")
59 | val api = jellyfin.createApi(serverUrl)
60 |
61 | viewModelScope.launch {
62 | val isEnabled = withContext(Dispatchers.IO) {
63 | api.quickConnectApi.getQuickConnectEnabled()
64 | }
65 |
66 | if (isEnabled.status != 200 || !isEnabled.content) {
67 | _quickConnectCode.value = -1
68 | return@launch
69 | }
70 |
71 | val response = withContext(Dispatchers.IO) {
72 | api.quickConnectApi.initiateQuickConnect()
73 | }
74 |
75 | if (response.status == 200) {
76 | quickConnectSecret = response.content.secret
77 | Log.d(LOG_MARKER, "QuickConnect initiated")
78 | _quickConnectCode.value = Integer.valueOf(response.content.code)
79 |
80 | do {
81 | delay(1.seconds)
82 | checkQuickConnect(serverUrl)
83 | } while (isActive)
84 | }
85 | }
86 | }
87 |
88 | private suspend fun checkQuickConnect(server: String) {
89 | val api = jellyfin.createApi(server)
90 | val response = withContext(Dispatchers.IO) {
91 | api.quickConnectApi.getQuickConnectState(quickConnectSecret)
92 | }
93 |
94 | Log.d(LOG_MARKER, "Checking QuickConnect")
95 |
96 | if (response.status == 200) {
97 | if (!response.content.authenticated) {
98 | return
99 | }
100 |
101 | val loginResponse = withContext(Dispatchers.IO) {
102 | api.userApi.authenticateWithQuickConnect(QuickConnectDto(response.content.secret))
103 | }
104 |
105 | if (loginResponse.status == 200) {
106 | loginSuccess(
107 | server,
108 | loginResponse.content.user?.name!!,
109 | loginResponse.content.accessToken!!
110 | )
111 | }
112 | }
113 | }
114 |
115 | suspend fun login(server: String, username: String, password: String): Boolean {
116 | return try {
117 | val response = withContext(Dispatchers.IO) {
118 | jellyfin.createApi(server).userApi.authenticateUserByName(username, password)
119 | }
120 |
121 | if (response.status == 200) {
122 | loginSuccess(server, username, response.content.accessToken!!)
123 | }
124 |
125 | response.status == 200
126 | } catch (e: Exception) {
127 | Log.e(LOG_MARKER, "Error", e)
128 | false
129 | }
130 | }
131 |
132 | private fun loginSuccess(
133 | server: String,
134 | username: String,
135 | token: String
136 | ) {
137 | Log.i(LOG_MARKER, "$username successfully authenticated")
138 | accountManager.storeAccount(server, username, token)
139 | _loggedIn.postValue(true)
140 | }
141 |
142 | companion object {
143 | internal const val JELLYFIN_SERVER_URL = "jellyfinServer"
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/automotive/src/main/res/layout/username_password_sign_in.xml:
--------------------------------------------------------------------------------
1 |
16 |
20 |
21 |
30 |
31 |
39 |
40 |
41 |
46 |
47 |
53 |
54 |
60 |
61 |
68 |
69 |
77 |
78 |
79 |
86 |
87 |
91 |
92 |
102 |
103 |
114 |
115 |
126 |
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/JellyfinMediaTree.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.content.Context
4 | import androidx.media3.common.MediaItem
5 | import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ARTIST
6 | import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST
7 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.FAVOURITES
8 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.LATEST_ALBUMS
9 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.PLAYLISTS
10 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.RANDOM_ALBUMS
11 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.ROOT_ID
12 | import com.google.common.cache.Cache
13 | import com.google.common.cache.CacheBuilder
14 | import org.jellyfin.sdk.api.client.ApiClient
15 | import org.jellyfin.sdk.api.client.extensions.artistsApi
16 | import org.jellyfin.sdk.api.client.extensions.itemsApi
17 | import org.jellyfin.sdk.api.client.extensions.userLibraryApi
18 | import org.jellyfin.sdk.model.api.BaseItemKind
19 | import org.jellyfin.sdk.model.api.ItemFilter
20 | import org.jellyfin.sdk.model.api.ItemSortBy
21 | import org.jellyfin.sdk.model.api.SortOrder
22 | import org.jellyfin.sdk.model.serializer.toUUID
23 |
24 | class JellyfinMediaTree(
25 | private val context: Context,
26 | private val api: ApiClient,
27 | private val itemFactory: MediaItemFactory
28 | ) {
29 |
30 | private val mediaItems: Cache = CacheBuilder.newBuilder()
31 | .maximumSize(1000)
32 | .build()
33 |
34 | suspend fun getItem(id: String): MediaItem {
35 | if (mediaItems.getIfPresent(id) == null) {
36 | // Cache miss
37 | val newItem = when (id) {
38 | ROOT_ID -> itemFactory.rootNode()
39 | LATEST_ALBUMS -> itemFactory.latestAlbums()
40 | RANDOM_ALBUMS -> itemFactory.randomAlbums()
41 | FAVOURITES -> itemFactory.favourites()
42 | PLAYLISTS -> itemFactory.playlists()
43 | else -> {
44 | val response = api.userLibraryApi.getItem(id.toUUID())
45 | itemFactory.create(response.content)
46 | }
47 | }
48 |
49 | mediaItems.put(id, newItem)
50 | }
51 |
52 | return mediaItems.getIfPresent(id)!!
53 | }
54 |
55 | suspend fun getChildren(id: String): List {
56 | return when (id) {
57 | ROOT_ID -> listOf(
58 | getItem(LATEST_ALBUMS),
59 | getItem(RANDOM_ALBUMS),
60 | getItem(FAVOURITES),
61 | getItem(PLAYLISTS)
62 | )
63 |
64 | LATEST_ALBUMS -> getLatestAlbums()
65 | RANDOM_ALBUMS -> getRandomAlbums()
66 | FAVOURITES -> getFavouriteTracks()
67 | PLAYLISTS -> getPlaylists()
68 | else -> getItemChildren(id)
69 | }
70 | }
71 |
72 | private suspend fun getLatestAlbums(): List {
73 | val response = api.userLibraryApi.getLatestMedia(
74 | includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
75 | limit = 24
76 | )
77 |
78 | return response.content.map {
79 | val item = itemFactory.create(it)
80 | mediaItems.put(item.mediaId, item)
81 | item
82 | }
83 | }
84 |
85 | private suspend fun getRandomAlbums(): List {
86 | val response = api.itemsApi.getItems(
87 | includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
88 | recursive = true,
89 | sortBy = listOf(ItemSortBy.RANDOM),
90 | limit = 24
91 | )
92 |
93 | return response.content.items.map {
94 | val item = itemFactory.create(it)
95 | mediaItems.put(item.mediaId, item)
96 | item
97 | }
98 | }
99 |
100 | private suspend fun getPlaylists(): List {
101 | val response = api.itemsApi.getItems(
102 | includeItemTypes = listOf(BaseItemKind.PLAYLIST),
103 | recursive = true,
104 | sortOrder = listOf(SortOrder.DESCENDING),
105 | sortBy = listOf(ItemSortBy.DATE_CREATED),
106 | limit = 24
107 | )
108 |
109 | return response.content.items.map {
110 | val item = itemFactory.create(it)
111 | mediaItems.put(item.mediaId, item)
112 | item
113 | }
114 | }
115 |
116 | private suspend fun getItemChildren(id: String): List {
117 | if (getItem(id).mediaMetadata.mediaType == MEDIA_TYPE_ARTIST) {
118 | return getArtistAlbums(id)
119 | }
120 |
121 | var sortBy = listOf(
122 | ItemSortBy.PARENT_INDEX_NUMBER,
123 | ItemSortBy.INDEX_NUMBER,
124 | ItemSortBy.SORT_NAME
125 | );
126 |
127 | // For playlists, we should respect the default order (user's track order)
128 | if (getItem(id).mediaMetadata.mediaType == MEDIA_TYPE_PLAYLIST) {
129 | sortBy = listOf(ItemSortBy.DEFAULT)
130 | }
131 |
132 | val response = api.itemsApi.getItems(
133 | sortBy = sortBy,
134 | parentId = id.toUUID()
135 | )
136 |
137 | return response.content.items.map {
138 | val item = itemFactory.create(it, parent = id)
139 | mediaItems.put(item.mediaId, item)
140 | item
141 | }
142 | }
143 |
144 | private suspend fun getArtistAlbums(id: String): List {
145 | val response = api.itemsApi.getItems(
146 | sortBy = listOf(
147 | ItemSortBy.PARENT_INDEX_NUMBER,
148 | ItemSortBy.INDEX_NUMBER,
149 | ItemSortBy.SORT_NAME
150 | ),
151 | recursive = true,
152 | includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
153 | albumArtistIds = listOf(id.toUUID()),
154 | )
155 |
156 | return response.content.items.map {
157 | val item = itemFactory.create(it)
158 | mediaItems.put(item.mediaId, item)
159 | item
160 | }
161 | }
162 |
163 | private suspend fun getFavouriteTracks(): List {
164 | val response = api.itemsApi.getItems(
165 | recursive = true,
166 | filters = listOf(ItemFilter.IS_FAVORITE),
167 | includeItemTypes = listOf(BaseItemKind.AUDIO)
168 | )
169 |
170 | return response.content.items.map {
171 | val item = itemFactory.create(it, parent = FAVOURITES)
172 | mediaItems.put(item.mediaId, item)
173 | item
174 | }
175 | }
176 |
177 | suspend fun search(query: String): List {
178 | val items = mutableListOf()
179 |
180 | var response = api.artistsApi.getAlbumArtists(
181 | searchTerm = query,
182 | limit = 10,
183 | )
184 |
185 | items.addAll(response.content.items.map {
186 | val item = itemFactory.create(it, context.getString(R.string.artists))
187 | mediaItems.put(item.mediaId, item)
188 | item
189 | })
190 |
191 | response = api.itemsApi.getItems(
192 | recursive = true,
193 | searchTerm = query,
194 | includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
195 | limit = 10
196 | )
197 |
198 | items.addAll(response.content.items.map {
199 | val item = itemFactory.create(it, context.getString(R.string.albums))
200 | mediaItems.put(item.mediaId, item)
201 | item
202 | })
203 |
204 | response = api.itemsApi.getItems(
205 | recursive = true,
206 | searchTerm = query,
207 | includeItemTypes = listOf(BaseItemKind.PLAYLIST),
208 | limit = 10
209 | )
210 |
211 | items.addAll(response.content.items.map {
212 | val item = itemFactory.create(it, context.getString(R.string.playlists))
213 | mediaItems.put(item.mediaId, item)
214 | item
215 | })
216 |
217 |
218 | response = api.itemsApi.getItems(
219 | recursive = true,
220 | searchTerm = query,
221 | includeItemTypes = listOf(BaseItemKind.AUDIO),
222 | limit = 20
223 | )
224 |
225 | items.addAll(response.content.items.map {
226 | val item = itemFactory.create(it, context.getString(R.string.tracks))
227 | mediaItems.put(item.mediaId, item)
228 | item
229 | })
230 |
231 | return items
232 | }
233 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/MediaItemFactory.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.content.Context
4 | import android.net.Uri
5 | import android.os.Bundle
6 | import androidx.annotation.OptIn
7 | import androidx.media3.common.HeartRating
8 | import androidx.media3.common.MediaItem
9 | import androidx.media3.common.MediaMetadata
10 | import androidx.media3.common.util.UnstableApi
11 | import androidx.media3.session.MediaConstants
12 | import androidx.preference.PreferenceManager
13 | import org.jellyfin.sdk.api.client.ApiClient
14 | import org.jellyfin.sdk.api.client.extensions.universalAudioApi
15 | import org.jellyfin.sdk.api.operations.ImageApi
16 | import org.jellyfin.sdk.model.UUID
17 | import org.jellyfin.sdk.model.api.BaseItemDto
18 | import org.jellyfin.sdk.model.api.BaseItemKind
19 | import org.jellyfin.sdk.model.api.ImageType
20 |
21 | @OptIn(UnstableApi::class)
22 | class MediaItemFactory(
23 | private val context: Context,
24 | private val jellyfinApi: ApiClient,
25 | private val artSize: Int
26 | ) {
27 |
28 | companion object {
29 | const val ROOT_ID = "ROOT_ID"
30 | const val LATEST_ALBUMS = "LATEST_ALBUMS_ID"
31 | const val RANDOM_ALBUMS = "RANDOM_ALBUMS_ID"
32 | const val FAVOURITES = "FAVOURITES_ID"
33 | const val PLAYLISTS = "PLAYLISTS_ID"
34 | const val PARENT_KEY = "PARENT_KEY"
35 | }
36 |
37 | fun rootNode(): MediaItem {
38 | val metadata = MediaMetadata.Builder()
39 | .setTitle("Root")
40 | .setIsBrowsable(true)
41 | .setIsPlayable(false)
42 | .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
43 | .build()
44 |
45 | return MediaItem.Builder()
46 | .setMediaId(ROOT_ID)
47 | .setMediaMetadata(metadata)
48 | .build()
49 | }
50 |
51 | fun latestAlbums(): MediaItem {
52 | return albumCategory(LATEST_ALBUMS, "Latest", "schedule")
53 | }
54 |
55 | fun randomAlbums(): MediaItem {
56 | return albumCategory(RANDOM_ALBUMS, "Random", "casino")
57 | }
58 |
59 |
60 | private fun albumCategory(id: String, label: String, icon: String): MediaItem {
61 | val extras = Bundle()
62 | extras.putInt(
63 | MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
64 | MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
65 | )
66 | extras.putInt(
67 | MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
68 | MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
69 | )
70 |
71 | val metadata = MediaMetadata.Builder()
72 | .setTitle(label)
73 | .setIsBrowsable(true)
74 | .setIsPlayable(false)
75 | .setArtworkUri(Uri.parse("android.resource://be.bendardenne.jellyfin.aaos/drawable/$icon"))
76 | .setExtras(extras)
77 | .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS)
78 | .build()
79 |
80 | return MediaItem.Builder()
81 | .setMediaId(id)
82 | .setMediaMetadata(metadata)
83 | .build()
84 | }
85 |
86 | fun favourites(): MediaItem {
87 | val extras = Bundle()
88 | extras.putInt(
89 | MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
90 | MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
91 | )
92 |
93 | val metadata = MediaMetadata.Builder()
94 | .setTitle("Favourites")
95 | .setIsBrowsable(true)
96 | .setIsPlayable(false)
97 | .setArtworkUri(Uri.parse("android.resource://be.bendardenne.jellyfin.aaos/drawable/star_filled"))
98 | .setExtras(extras)
99 | .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
100 | .build()
101 |
102 | return MediaItem.Builder()
103 | .setMediaId(FAVOURITES)
104 | .setMediaMetadata(metadata)
105 | .build()
106 | }
107 |
108 | fun playlists(): MediaItem {
109 | val extras = Bundle()
110 | extras.putInt(
111 | MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
112 | MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
113 | )
114 |
115 | val metadata = MediaMetadata.Builder()
116 | .setTitle("Playlists")
117 | .setIsBrowsable(true)
118 | .setIsPlayable(false)
119 | .setArtworkUri(Uri.parse("android.resource://be.bendardenne.jellyfin.aaos/drawable/playlists"))
120 | .setExtras(extras)
121 | .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS)
122 | .build()
123 |
124 | return MediaItem.Builder()
125 | .setMediaId(PLAYLISTS)
126 | .setMediaMetadata(metadata)
127 | .build()
128 | }
129 |
130 | private fun forArtist(item: BaseItemDto, group: String? = null): MediaItem {
131 | val extras = Bundle()
132 | if (group != null) {
133 | extras.putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, group)
134 | }
135 |
136 | extras.putInt(
137 | MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
138 | MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
139 | )
140 | extras.putInt(
141 | MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
142 | MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
143 | )
144 |
145 | val metadata = MediaMetadata.Builder()
146 | .setTitle(item.name)
147 | .setAlbumArtist(item.albumArtist)
148 | .setIsBrowsable(true)
149 | .setIsPlayable(false)
150 | .setArtworkUri(artUri(item.id))
151 | .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
152 | .setExtras(extras)
153 | .build()
154 |
155 | return MediaItem.Builder()
156 | .setMediaId(item.id.toString())
157 | .setMediaMetadata(metadata)
158 | .build()
159 | }
160 |
161 | private fun forAlbum(item: BaseItemDto, group: String? = null): MediaItem {
162 | val extras = Bundle()
163 | if (group != null) {
164 | extras.putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, group)
165 | }
166 |
167 | val metadata = MediaMetadata.Builder()
168 | .setTitle(item.name)
169 | .setAlbumArtist(item.albumArtist)
170 | .setIsBrowsable(false)
171 | .setIsPlayable(true)
172 | .setArtworkUri(artUri(item.id))
173 | .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
174 | .setExtras(extras)
175 | .build()
176 |
177 | return MediaItem.Builder()
178 | .setMediaId(item.id.toString())
179 | .setMediaMetadata(metadata)
180 | .build()
181 | }
182 |
183 | private fun forPlaylist(item: BaseItemDto, group: String? = null): MediaItem {
184 | val extras = Bundle()
185 | if (group != null) {
186 | extras.putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, group)
187 | }
188 |
189 | val metadata = MediaMetadata.Builder()
190 | .setTitle(item.name)
191 | .setIsBrowsable(false)
192 | .setIsPlayable(true)
193 | .setArtworkUri(artUri(item.id))
194 | .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
195 | .setExtras(extras)
196 | .build()
197 |
198 | return MediaItem.Builder()
199 | .setMediaId(item.id.toString())
200 | .setMediaMetadata(metadata)
201 | .build()
202 | }
203 |
204 | private fun forTrack(
205 | item: BaseItemDto,
206 | group: String? = null,
207 | parent: String? = null
208 | ): MediaItem {
209 | // Use the album ID for album art, if present.
210 | // This way, all tracks in an album have the same URI, which saves some downloads.
211 | // It probably makes sense most of the time, unless someone uses different images for
212 | // tracks within the same album, which seems weird.
213 | val artUrl = artUri(item.albumId ?: item.id)
214 |
215 | val preferenceBitrate = PreferenceManager
216 | .getDefaultSharedPreferences(context)
217 | .getString("bitrate", "Direct stream")!!
218 |
219 | val bitrate = if (preferenceBitrate == "Direct stream") null else preferenceBitrate.toInt()
220 |
221 | // Nice-to-have: it would be nice to force transcoding when the codec is not supported
222 | // (eg ALAC).
223 | // This would require that we know the codec upfront, but we currently don't have access to
224 | // it because our BaseItemDtos are mostly fecthed via getItemChildren, which queries the
225 | // /Items endpoint, which does not include the codec (mediaSources/mediaStreams) in its
226 | // response.
227 |
228 | // When a file is not in this list of containers, it will always be transcoded.
229 | // This list are containers that I tested.
230 | val allowedContainers = listOf("flac", "mp3", "m4a", "aac", "ogg")
231 | val audioStream =
232 | jellyfinApi.universalAudioApi.getUniversalAudioStreamUrl(
233 | item.id,
234 | container = allowedContainers,
235 | audioBitRate = bitrate,
236 | maxStreamingBitrate = bitrate,
237 | transcodingContainer = "mp3",
238 | audioCodec = "mp3",
239 | )
240 |
241 | val extras = Bundle()
242 | if (group != null) {
243 | extras.putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, group)
244 | }
245 |
246 | if (parent != null) {
247 | extras.putString(PARENT_KEY, parent)
248 | }
249 |
250 | val metadata = MediaMetadata.Builder()
251 | .setTitle(item.name)
252 | .setAlbumArtist(item.albumArtist)
253 | .setIsBrowsable(false)
254 | .setIsPlayable(true)
255 | .setArtworkUri(artUrl)
256 | .setUserRating(HeartRating(item.userData?.isFavorite ?: false))
257 | .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
258 | .setDurationMs(item.runTimeTicks?.div(10_000))
259 | .setExtras(extras)
260 | .build()
261 |
262 | return MediaItem.Builder()
263 | .setMediaId(item.id.toString())
264 | .setMediaMetadata(metadata)
265 | .setUri(audioStream)
266 | .build()
267 | }
268 |
269 | private fun artUri(id: UUID): Uri {
270 | val artUrl = ImageApi(jellyfinApi).getItemImageUrl(
271 | id,
272 | ImageType.PRIMARY,
273 | quality = 90,
274 | maxWidth = artSize,
275 | maxHeight = artSize,
276 | )
277 | val localUrl = AlbumArtContentProvider.mapUri(Uri.parse(artUrl))
278 | return localUrl
279 | }
280 |
281 |
282 | fun create(
283 | baseItemDto: BaseItemDto,
284 | group: String? = null,
285 | parent: String? = null
286 | ): MediaItem {
287 | return when (baseItemDto.type) {
288 | BaseItemKind.MUSIC_ARTIST -> forArtist(baseItemDto, group)
289 | BaseItemKind.MUSIC_ALBUM -> forAlbum(baseItemDto, group)
290 | BaseItemKind.PLAYLIST -> forPlaylist(baseItemDto, group)
291 | BaseItemKind.AUDIO -> forTrack(baseItemDto, group, parent)
292 | else -> throw UnsupportedOperationException("Can't create mediaItem for ${baseItemDto.type}")
293 | }
294 | }
295 | }
--------------------------------------------------------------------------------
/automotive/src/main/java/be/bendardenne/jellyfin/aaos/JellyfinMediaLibrarySessionCallback.kt:
--------------------------------------------------------------------------------
1 | package be.bendardenne.jellyfin.aaos
2 |
3 | import android.app.PendingIntent
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.util.Log
7 | import androidx.annotation.OptIn
8 | import androidx.concurrent.futures.SuspendToFutureAdapter
9 | import androidx.core.content.edit
10 | import androidx.media3.common.HeartRating
11 | import androidx.media3.common.MediaItem
12 | import androidx.media3.common.MediaMetadata
13 | import androidx.media3.common.Player
14 | import androidx.media3.common.Rating
15 | import androidx.media3.common.util.UnstableApi
16 | import androidx.media3.common.util.Util
17 | import androidx.media3.session.LibraryResult
18 | import androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT
19 | import androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT
20 | import androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_USING_CAR_APP_LIBRARY_INTENT_COMPAT
21 | import androidx.media3.session.MediaConstants.EXTRAS_KEY_MEDIA_ART_SIZE_PIXELS
22 | import androidx.media3.session.MediaLibraryService
23 | import androidx.media3.session.MediaSession
24 | import androidx.media3.session.MediaSession.ConnectionResult
25 | import androidx.media3.session.SessionCommand
26 | import androidx.media3.session.SessionError
27 | import androidx.media3.session.SessionResult
28 | import androidx.preference.PreferenceManager
29 | import be.bendardenne.jellyfin.aaos.Constants.LOG_MARKER
30 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.PARENT_KEY
31 | import be.bendardenne.jellyfin.aaos.MediaItemFactory.Companion.ROOT_ID
32 | import be.bendardenne.jellyfin.aaos.signin.SignInActivity
33 | import com.google.common.collect.ImmutableList
34 | import com.google.common.util.concurrent.Futures
35 | import com.google.common.util.concurrent.ListenableFuture
36 | import kotlinx.coroutines.async
37 | import kotlinx.coroutines.awaitAll
38 | import org.jellyfin.sdk.api.client.ApiClient
39 | import org.jellyfin.sdk.api.client.extensions.userLibraryApi
40 | import org.jellyfin.sdk.model.serializer.toUUID
41 |
42 |
43 | @OptIn(UnstableApi::class)
44 | class JellyfinMediaLibrarySessionCallback(
45 | private val service: JellyfinMusicService,
46 | private val accountManager: JellyfinAccountManager,
47 | private val jellyfinApi: ApiClient
48 | ) : MediaLibraryService.MediaLibrarySession.Callback {
49 |
50 | companion object {
51 | const val LOGIN_COMMAND = "be.bendardenne.jellyfin.aaos.COMMAND.LOGIN"
52 | const val REPEAT_COMMAND = "be.bendardenne.jellyfin.aaos.COMMAND.REPEAT"
53 | const val SHUFFLE_COMMAND = "be.bendardenne.jellyfin.aaos.COMMAND.SHUFFLE"
54 | const val PLAYLIST_IDS_PREF = "playlistIds"
55 | const val PLAYLIST_INDEX_PREF = "playlistIndex"
56 | }
57 |
58 | private lateinit var tree: JellyfinMediaTree;
59 | private val playlistSaveListener = object : Player.Listener {
60 | override fun onEvents(player: Player, events: Player.Events) {
61 | if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) {
62 | // Persist the current index of the queue in the preferences.
63 | // This is restore in onPlaybackResumption
64 | PreferenceManager.getDefaultSharedPreferences(service).edit {
65 | putInt(PLAYLIST_INDEX_PREF, player.currentMediaItemIndex)
66 | }
67 | }
68 | }
69 | }
70 |
71 | override fun onConnect(
72 | session: MediaSession,
73 | controller: MediaSession.ControllerInfo
74 | ): ConnectionResult {
75 | val connectionResult = super.onConnect(session, controller)
76 |
77 | session.player.addListener(playlistSaveListener)
78 |
79 | val sessionCommands = connectionResult.availableSessionCommands
80 | .buildUpon()
81 | .add(SessionCommand(LOGIN_COMMAND, Bundle()))
82 | .add(SessionCommand(REPEAT_COMMAND, Bundle()))
83 | .add(SessionCommand(SHUFFLE_COMMAND, Bundle()))
84 | .build()
85 |
86 | return ConnectionResult.accept(
87 | sessionCommands,
88 | connectionResult.availablePlayerCommands
89 | )
90 | }
91 |
92 | override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) {
93 | session.player.removeListener(playlistSaveListener)
94 | super.onDisconnected(session, controller)
95 | }
96 |
97 | override fun onGetLibraryRoot(
98 | session: MediaLibraryService.MediaLibrarySession,
99 | browser: MediaSession.ControllerInfo,
100 | params: MediaLibraryService.LibraryParams?
101 | ): ListenableFuture> {
102 | Log.i(LOG_MARKER, "onGetRoot")
103 |
104 | if (!::tree.isInitialized) {
105 | val artSize = params?.extras?.getInt(EXTRAS_KEY_MEDIA_ART_SIZE_PIXELS) ?: 512
106 | Log.d(LOG_MARKER, "Art size hint from system: $artSize")
107 |
108 | val itemFactory = MediaItemFactory(service, jellyfinApi, artSize)
109 | tree = JellyfinMediaTree(service, jellyfinApi, itemFactory)
110 | }
111 |
112 | return SuspendToFutureAdapter.launchFuture {
113 | LibraryResult.ofItem(
114 | tree.getItem(ROOT_ID),
115 | params
116 | )
117 | }
118 | }
119 |
120 | override fun onGetChildren(
121 | session: MediaLibraryService.MediaLibrarySession,
122 | browser: MediaSession.ControllerInfo,
123 | parentId: String,
124 | page: Int,
125 | pageSize: Int,
126 | params: MediaLibraryService.LibraryParams?
127 | ): ListenableFuture>> {
128 | Log.i(LOG_MARKER, "onGetChildren $parentId")
129 | if (!accountManager.isAuthenticated) {
130 | return Futures.immediateFuture(
131 | LibraryResult.ofError(
132 | SessionError(
133 | SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
134 | service.getString(R.string.sign_in_to_your_jellyfin_server)
135 | ),
136 | MediaLibraryService.LibraryParams.Builder()
137 | .setExtras(authenticationExtras()).build()
138 | )
139 | )
140 | }
141 |
142 | return SuspendToFutureAdapter.launchFuture {
143 | LibraryResult.ofItemList(tree.getChildren(parentId), params)
144 | }
145 | }
146 |
147 | private fun authenticationExtras(): Bundle {
148 | return Bundle().also {
149 | it.putString(
150 | EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT,
151 | service.getString(R.string.sign_in_to_your_jellyfin_server)
152 | )
153 |
154 | val signInIntent = Intent(service, SignInActivity::class.java)
155 |
156 | val flags = if (Util.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
157 | it.putParcelable(
158 | EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT,
159 | PendingIntent.getActivity(service, 0, signInIntent, flags)
160 | )
161 |
162 | it.putParcelable(
163 | EXTRAS_KEY_ERROR_RESOLUTION_USING_CAR_APP_LIBRARY_INTENT_COMPAT,
164 | PendingIntent.getActivity(service, 0, signInIntent, flags)
165 | )
166 | }
167 | }
168 |
169 | override fun onGetItem(
170 | session: MediaLibraryService.MediaLibrarySession,
171 | browser: MediaSession.ControllerInfo,
172 | mediaId: String,
173 | ): ListenableFuture> {
174 | Log.i(LOG_MARKER, "onGetItem $mediaId")
175 | return SuspendToFutureAdapter.launchFuture {
176 | LibraryResult.ofItem(
177 | tree.getItem(mediaId),
178 | null
179 | )
180 | }
181 | }
182 |
183 | override fun onAddMediaItems(
184 | mediaSession: MediaSession,
185 | controller: MediaSession.ControllerInfo,
186 | mediaItems: List,
187 | ): ListenableFuture> {
188 | Log.i(LOG_MARKER, "onAddMediaItems $mediaItems")
189 | return SuspendToFutureAdapter.launchFuture { resolveMediaItems(mediaItems) }
190 | }
191 |
192 | override fun onSetMediaItems(
193 | mediaSession: MediaSession,
194 | browser: MediaSession.ControllerInfo,
195 | mediaItems: List,
196 | startIndex: Int,
197 | startPositionMs: Long,
198 | ): ListenableFuture {
199 | Log.i(LOG_MARKER, "onSetMediaItems $mediaItems")
200 | return SuspendToFutureAdapter.launchFuture {
201 | if (isSingleItemWithParent(mediaItems)) {
202 | val singleItem = mediaItems[0]
203 | val resolvedItems = expandSingleItem(singleItem)
204 |
205 | val mediaItemsWithStartPosition = MediaSession.MediaItemsWithStartPosition(
206 | resolvedItems,
207 | resolvedItems.indexOfFirst { it.mediaId == singleItem.mediaId },
208 | startPositionMs
209 | )
210 | savePlaylist(resolvedItems)
211 | return@launchFuture mediaItemsWithStartPosition
212 | }
213 |
214 | val resolvedItems = resolveMediaItems(mediaItems)
215 | val mediaItemsWithStartPosition = MediaSession.MediaItemsWithStartPosition(
216 | resolvedItems,
217 | startIndex,
218 | startPositionMs
219 | )
220 | savePlaylist(resolvedItems)
221 | mediaItemsWithStartPosition
222 | }
223 | }
224 |
225 | /**
226 | * Saves the playlist to shared preferences, so it can be restored in onPlaybackResumption.
227 | */
228 | private fun savePlaylist(resolvedItems: List) {
229 | val playlistIDs = resolvedItems.map { it.mediaId }.joinToString(",")
230 | Log.d(LOG_MARKER, "Saving playlist $playlistIDs")
231 |
232 | PreferenceManager.getDefaultSharedPreferences(service).edit {
233 | putString(PLAYLIST_IDS_PREF, playlistIDs)
234 | }
235 | }
236 |
237 | private suspend fun isSingleItemWithParent(mediaItems: List): Boolean {
238 | return mediaItems.size == 1 &&
239 | tree.getItem(mediaItems[0].mediaId).mediaMetadata.extras?.containsKey(PARENT_KEY) == true
240 | }
241 |
242 | private suspend fun expandSingleItem(item: MediaItem): List {
243 | // This could load a lot of tracks if the parent has many children.
244 | val parentId = tree.getItem(item.mediaId).mediaMetadata.extras?.getString(PARENT_KEY)!!
245 | return resolveMediaItems(tree.getChildren(parentId))
246 | }
247 |
248 | /**
249 | * Expands items to a list of playable items: collections are expanded to get to the playable
250 | * nodes.
251 | */
252 | private suspend fun resolveMediaItems(mediaItems: List): List {
253 | val playlist = mutableListOf()
254 |
255 | mediaItems.forEach {
256 | // We need to call getItem to resolve the full item: the provided MediaItem only has an ID.
257 | val item = tree.getItem(it.mediaId)
258 | // If the item is an album or playlist, get its children and add them to the playlist.
259 | // Albums and playlists are "immediately playable" items, that actually load their
260 | // children (tracks).
261 | if (item.mediaMetadata.mediaType == MediaMetadata.MEDIA_TYPE_ALBUM ||
262 | item.mediaMetadata.mediaType == MediaMetadata.MEDIA_TYPE_PLAYLIST
263 | ) {
264 | resolveMediaItems(tree.getChildren(item.mediaId)).forEach(playlist::add)
265 | } else if (item.mediaMetadata.isPlayable == true) {
266 | playlist.add(item)
267 | } else {
268 | Log.e(LOG_MARKER, "Cannot add media ${item.mediaMetadata.title}")
269 | }
270 | }
271 |
272 | return playlist
273 | }
274 |
275 | override fun onSearch(
276 | session: MediaLibraryService.MediaLibrarySession,
277 | browser: MediaSession.ControllerInfo,
278 | query: String,
279 | params: MediaLibraryService.LibraryParams?
280 | ): ListenableFuture> {
281 | return SuspendToFutureAdapter.launchFuture {
282 | val results = tree.search(query).size
283 | session.notifySearchResultChanged(browser, query, results, params)
284 | LibraryResult.ofVoid(params)
285 | }
286 | }
287 |
288 | override fun onGetSearchResult(
289 | session: MediaLibraryService.MediaLibrarySession,
290 | browser: MediaSession.ControllerInfo,
291 | query: String,
292 | page: Int,
293 | pageSize: Int,
294 | params: MediaLibraryService.LibraryParams?
295 | ): ListenableFuture>> {
296 | return SuspendToFutureAdapter.launchFuture {
297 | val results = tree.search(query)
298 | LibraryResult.ofItemList(results, params)
299 | }
300 | }
301 |
302 | override fun onPlaybackResumption(
303 | mediaSession: MediaSession,
304 | controller: MediaSession.ControllerInfo
305 | ): ListenableFuture {
306 | return SuspendToFutureAdapter.launchFuture {
307 | val prefs = PreferenceManager.getDefaultSharedPreferences(service)
308 |
309 | val mediaItemsToRestore = prefs
310 | .getString(PLAYLIST_IDS_PREF, "")
311 | ?.split(",")
312 | ?.map { async { tree.getItem(it) } }
313 | ?.awaitAll() ?: listOf()
314 |
315 | Log.d(LOG_MARKER, "Resuming playback with $mediaItemsToRestore")
316 |
317 | // TODO save positionMs. Is there a convenient way of saving this without polling from
318 | // a background thread?
319 | MediaSession.MediaItemsWithStartPosition(
320 | mediaItemsToRestore,
321 | prefs.getInt(PLAYLIST_INDEX_PREF, 0),
322 | 0
323 | )
324 | }
325 | }
326 |
327 | override fun onCustomCommand(
328 | session: MediaSession,
329 | controller: MediaSession.ControllerInfo,
330 | customCommand: SessionCommand,
331 | args: Bundle
332 | ): ListenableFuture {
333 | Log.i(LOG_MARKER, "CustomCommand: ${customCommand.customAction}")
334 | when (customCommand.customAction) {
335 | LOGIN_COMMAND -> {
336 | service.onLogin()
337 | return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
338 | }
339 |
340 | REPEAT_COMMAND -> {
341 | val currentMode = session.player.repeatMode
342 | session.player.repeatMode = (currentMode + 1) % 3 // There are 3 repeat modes
343 | session.setMediaButtonPreferences(CommandButtons.createButtons(session.player))
344 | return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
345 | }
346 |
347 | SHUFFLE_COMMAND -> {
348 | session.player.shuffleModeEnabled = !session.player.shuffleModeEnabled
349 | session.setMediaButtonPreferences(CommandButtons.createButtons(session.player))
350 | return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
351 | }
352 | }
353 |
354 | return super.onCustomCommand(session, controller, customCommand, args)
355 | }
356 |
357 | override fun onSetRating(
358 | session: MediaSession,
359 | controller: MediaSession.ControllerInfo,
360 | mediaId: String,
361 | rating: Rating
362 | ): ListenableFuture {
363 | Log.i(LOG_MARKER, "onSetRating ${(rating as HeartRating).isHeart}")
364 |
365 | val item = session.player.currentMediaItem
366 | item?.let {
367 | val metadata = it.mediaMetadata.buildUpon().setUserRating(rating).build()
368 | val mediaItem = it.buildUpon().setMediaMetadata(metadata).build()
369 | session.player.replaceMediaItem(session.player.currentMediaItemIndex, mediaItem)
370 | }
371 |
372 | return SuspendToFutureAdapter.launchFuture {
373 | applyRating(mediaId, rating)
374 | SessionResult(SessionResult.RESULT_SUCCESS)
375 | }
376 | }
377 |
378 | private suspend fun applyRating(currentMediaItem: String, newRating: Rating) {
379 | val id = currentMediaItem.toUUID()
380 |
381 | if (newRating == HeartRating(true)) {
382 | Log.i(LOG_MARKER, "Marking as favorite")
383 | jellyfinApi.userLibraryApi.markFavoriteItem(id).content.isFavorite.toString()
384 | } else {
385 | Log.i(LOG_MARKER, "Unmarking as favorite")
386 | jellyfinApi.userLibraryApi.unmarkFavoriteItem(id).content.isFavorite.toString()
387 | }
388 | }
389 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 2, June 1991
3 |
4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
6 | Everyone is permitted to copy and distribute verbatim copies
7 | of this license document, but changing it is not allowed.
8 |
9 | Preamble
10 |
11 | The licenses for most software are designed to take away your
12 | freedom to share and change it. By contrast, the GNU General Public
13 | License is intended to guarantee your freedom to share and change free
14 | software--to make sure the software is free for all its users. This
15 | General Public License applies to most of the Free Software
16 | Foundation's software and to any other program whose authors commit to
17 | using it. (Some other Free Software Foundation software is covered by
18 | the GNU Lesser General Public License instead.) You can apply it to
19 | your programs, too.
20 |
21 | When we speak of free software, we are referring to freedom, not
22 | price. Our General Public Licenses are designed to make sure that you
23 | have the freedom to distribute copies of free software (and charge for
24 | this service if you wish), that you receive source code or can get it
25 | if you want it, that you can change the software or use pieces of it
26 | in new free programs; and that you know you can do these things.
27 |
28 | To protect your rights, we need to make restrictions that forbid
29 | anyone to deny you these rights or to ask you to surrender the rights.
30 | These restrictions translate to certain responsibilities for you if you
31 | distribute copies of the software, or if you modify it.
32 |
33 | For example, if you distribute copies of such a program, whether
34 | gratis or for a fee, you must give the recipients all the rights that
35 | you have. You must make sure that they, too, receive or can get the
36 | source code. And you must show them these terms so they know their
37 | rights.
38 |
39 | We protect your rights with two steps: (1) copyright the software, and
40 | (2) offer you this license which gives you legal permission to copy,
41 | distribute and/or modify the software.
42 |
43 | Also, for each author's protection and ours, we want to make certain
44 | that everyone understands that there is no warranty for this free
45 | software. If the software is modified by someone else and passed on, we
46 | want its recipients to know that what they have is not the original, so
47 | that any problems introduced by others will not reflect on the original
48 | authors' reputations.
49 |
50 | Finally, any free program is threatened constantly by software
51 | patents. We wish to avoid the danger that redistributors of a free
52 | program will individually obtain patent licenses, in effect making the
53 | program proprietary. To prevent this, we have made it clear that any
54 | patent must be licensed for everyone's free use or not licensed at all.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | GNU GENERAL PUBLIC LICENSE
60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
61 |
62 | 0. This License applies to any program or other work which contains
63 | a notice placed by the copyright holder saying it may be distributed
64 | under the terms of this General Public License. The "Program", below,
65 | refers to any such program or work, and a "work based on the Program"
66 | means either the Program or any derivative work under copyright law:
67 | that is to say, a work containing the Program or a portion of it,
68 | either verbatim or with modifications and/or translated into another
69 | language. (Hereinafter, translation is included without limitation in
70 | the term "modification".) Each licensee is addressed as "you".
71 |
72 | Activities other than copying, distribution and modification are not
73 | covered by this License; they are outside its scope. The act of
74 | running the Program is not restricted, and the output from the Program
75 | is covered only if its contents constitute a work based on the
76 | Program (independent of having been made by running the Program).
77 | Whether that is true depends on what the Program does.
78 |
79 | 1. You may copy and distribute verbatim copies of the Program's
80 | source code as you receive it, in any medium, provided that you
81 | conspicuously and appropriately publish on each copy an appropriate
82 | copyright notice and disclaimer of warranty; keep intact all the
83 | notices that refer to this License and to the absence of any warranty;
84 | and give any other recipients of the Program a copy of this License
85 | along with the Program.
86 |
87 | You may charge a fee for the physical act of transferring a copy, and
88 | you may at your option offer warranty protection in exchange for a fee.
89 |
90 | 2. You may modify your copy or copies of the Program or any portion
91 | of it, thus forming a work based on the Program, and copy and
92 | distribute such modifications or work under the terms of Section 1
93 | above, provided that you also meet all of these conditions:
94 |
95 | a) You must cause the modified files to carry prominent notices
96 | stating that you changed the files and the date of any change.
97 |
98 | b) You must cause any work that you distribute or publish, that in
99 | whole or in part contains or is derived from the Program or any
100 | part thereof, to be licensed as a whole at no charge to all third
101 | parties under the terms of this License.
102 |
103 | c) If the modified program normally reads commands interactively
104 | when run, you must cause it, when started running for such
105 | interactive use in the most ordinary way, to print or display an
106 | announcement including an appropriate copyright notice and a
107 | notice that there is no warranty (or else, saying that you provide
108 | a warranty) and that users may redistribute the program under
109 | these conditions, and telling the user how to view a copy of this
110 | License. (Exception: if the Program itself is interactive but
111 | does not normally print such an announcement, your work based on
112 | the Program is not required to print an announcement.)
113 |
114 | These requirements apply to the modified work as a whole. If
115 | identifiable sections of that work are not derived from the Program,
116 | and can be reasonably considered independent and separate works in
117 | themselves, then this License, and its terms, do not apply to those
118 | sections when you distribute them as separate works. But when you
119 | distribute the same sections as part of a whole which is a work based
120 | on the Program, the distribution of the whole must be on the terms of
121 | this License, whose permissions for other licensees extend to the
122 | entire whole, and thus to each and every part regardless of who wrote it.
123 |
124 | Thus, it is not the intent of this section to claim rights or contest
125 | your rights to work written entirely by you; rather, the intent is to
126 | exercise the right to control the distribution of derivative or
127 | collective works based on the Program.
128 |
129 | In addition, mere aggregation of another work not based on the Program
130 | with the Program (or with a work based on the Program) on a volume of
131 | a storage or distribution medium does not bring the other work under
132 | the scope of this License.
133 |
134 | 3. You may copy and distribute the Program (or a work based on it,
135 | under Section 2) in object code or executable form under the terms of
136 | Sections 1 and 2 above provided that you also do one of the following:
137 |
138 | a) Accompany it with the complete corresponding machine-readable
139 | source code, which must be distributed under the terms of Sections
140 | 1 and 2 above on a medium customarily used for software interchange; or,
141 |
142 | b) Accompany it with a written offer, valid for at least three
143 | years, to give any third party, for a charge no more than your
144 | cost of physically performing source distribution, a complete
145 | machine-readable copy of the corresponding source code, to be
146 | distributed under the terms of Sections 1 and 2 above on a medium
147 | customarily used for software interchange; or,
148 |
149 | c) Accompany it with the information you received as to the offer
150 | to distribute corresponding source code. (This alternative is
151 | allowed only for noncommercial distribution and only if you
152 | received the program in object code or executable form with such
153 | an offer, in accord with Subsection b above.)
154 |
155 | The source code for a work means the preferred form of the work for
156 | making modifications to it. For an executable work, complete source
157 | code means all the source code for all modules it contains, plus any
158 | associated interface definition files, plus the scripts used to
159 | control compilation and installation of the executable. However, as a
160 | special exception, the source code distributed need not include
161 | anything that is normally distributed (in either source or binary
162 | form) with the major components (compiler, kernel, and so on) of the
163 | operating system on which the executable runs, unless that component
164 | itself accompanies the executable.
165 |
166 | If distribution of executable or object code is made by offering
167 | access to copy from a designated place, then offering equivalent
168 | access to copy the source code from the same place counts as
169 | distribution of the source code, even though third parties are not
170 | compelled to copy the source along with the object code.
171 |
172 | 4. You may not copy, modify, sublicense, or distribute the Program
173 | except as expressly provided under this License. Any attempt
174 | otherwise to copy, modify, sublicense or distribute the Program is
175 | void, and will automatically terminate your rights under this License.
176 | However, parties who have received copies, or rights, from you under
177 | this License will not have their licenses terminated so long as such
178 | parties remain in full compliance.
179 |
180 | 5. You are not required to accept this License, since you have not
181 | signed it. However, nothing else grants you permission to modify or
182 | distribute the Program or its derivative works. These actions are
183 | prohibited by law if you do not accept this License. Therefore, by
184 | modifying or distributing the Program (or any work based on the
185 | Program), you indicate your acceptance of this License to do so, and
186 | all its terms and conditions for copying, distributing or modifying
187 | the Program or works based on it.
188 |
189 | 6. Each time you redistribute the Program (or any work based on the
190 | Program), the recipient automatically receives a license from the
191 | original licensor to copy, distribute or modify the Program subject to
192 | these terms and conditions. You may not impose any further
193 | restrictions on the recipients' exercise of the rights granted herein.
194 | You are not responsible for enforcing compliance by third parties to
195 | this License.
196 |
197 | 7. If, as a consequence of a court judgment or allegation of patent
198 | infringement or for any other reason (not limited to patent issues),
199 | conditions are imposed on you (whether by court order, agreement or
200 | otherwise) that contradict the conditions of this License, they do not
201 | excuse you from the conditions of this License. If you cannot
202 | distribute so as to satisfy simultaneously your obligations under this
203 | License and any other pertinent obligations, then as a consequence you
204 | may not distribute the Program at all. For example, if a patent
205 | license would not permit royalty-free redistribution of the Program by
206 | all those who receive copies directly or indirectly through you, then
207 | the only way you could satisfy both it and this License would be to
208 | refrain entirely from distribution of the Program.
209 |
210 | If any portion of this section is held invalid or unenforceable under
211 | any particular circumstance, the balance of the section is intended to
212 | apply and the section as a whole is intended to apply in other
213 | circumstances.
214 |
215 | It is not the purpose of this section to induce you to infringe any
216 | patents or other property right claims or to contest validity of any
217 | such claims; this section has the sole purpose of protecting the
218 | integrity of the free software distribution system, which is
219 | implemented by public license practices. Many people have made
220 | generous contributions to the wide range of software distributed
221 | through that system in reliance on consistent application of that
222 | system; it is up to the author/donor to decide if he or she is willing
223 | to distribute software through any other system and a licensee cannot
224 | impose that choice.
225 |
226 | This section is intended to make thoroughly clear what is believed to
227 | be a consequence of the rest of this License.
228 |
229 | 8. If the distribution and/or use of the Program is restricted in
230 | certain countries either by patents or by copyrighted interfaces, the
231 | original copyright holder who places the Program under this License
232 | may add an explicit geographical distribution limitation excluding
233 | those countries, so that distribution is permitted only in or among
234 | countries not thus excluded. In such case, this License incorporates
235 | the limitation as if written in the body of this License.
236 |
237 | 9. The Free Software Foundation may publish revised and/or new versions
238 | of the General Public License from time to time. Such new versions will
239 | be similar in spirit to the present version, but may differ in detail to
240 | address new problems or concerns.
241 |
242 | Each version is given a distinguishing version number. If the Program
243 | specifies a version number of this License which applies to it and "any
244 | later version", you have the option of following the terms and conditions
245 | either of that version or of any later version published by the Free
246 | Software Foundation. If the Program does not specify a version number of
247 | this License, you may choose any version ever published by the Free Software
248 | Foundation.
249 |
250 | 10. If you wish to incorporate parts of the Program into other free
251 | programs whose distribution conditions are different, write to the author
252 | to ask for permission. For software which is copyrighted by the Free
253 | Software Foundation, write to the Free Software Foundation; we sometimes
254 | make exceptions for this. Our decision will be guided by the two goals
255 | of preserving the free status of all derivatives of our free software and
256 | of promoting the sharing and reuse of software generally.
257 |
258 | NO WARRANTY
259 |
260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
268 | REPAIR OR CORRECTION.
269 |
270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
278 | POSSIBILITY OF SUCH DAMAGES.
279 |
280 | END OF TERMS AND CONDITIONS
281 |
282 | How to Apply These Terms to Your New Programs
283 |
284 | If you develop a new program, and you want it to be of the greatest
285 | possible use to the public, the best way to achieve this is to make it
286 | free software which everyone can redistribute and change under these terms.
287 |
288 | To do so, attach the following notices to the program. It is safest
289 | to attach them to the start of each source file to most effectively
290 | convey the exclusion of warranty; and each file should have at least
291 | the "copyright" line and a pointer to where the full notice is found.
292 |
293 | {{description}}
294 | Copyright (C) {{year}} {{fullname}}
295 |
296 | This program is free software; you can redistribute it and/or modify
297 | it under the terms of the GNU General Public License as published by
298 | the Free Software Foundation; either version 2 of the License, or
299 | (at your option) any later version.
300 |
301 | This program is distributed in the hope that it will be useful,
302 | but WITHOUT ANY WARRANTY; without even the implied warranty of
303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
304 | GNU General Public License for more details.
305 |
306 | You should have received a copy of the GNU General Public License along
307 | with this program; if not, write to the Free Software Foundation, Inc.,
308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
309 |
310 | Also add information on how to contact you by electronic and paper mail.
311 |
312 | If the program is interactive, make it output a short notice like this
313 | when it starts in an interactive mode:
314 |
315 | Gnomovision version 69, Copyright (C) year name of author
316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
317 | This is free software, and you are welcome to redistribute it
318 | under certain conditions; type `show c' for details.
319 |
320 | The hypothetical commands `show w' and `show c' should show the appropriate
321 | parts of the General Public License. Of course, the commands you use may
322 | be called something other than `show w' and `show c'; they could even be
323 | mouse-clicks or menu items--whatever suits your program.
324 |
325 | You should also get your employer (if you work as a programmer) or your
326 | school, if any, to sign a "copyright disclaimer" for the program, if
327 | necessary. Here is a sample; alter the names:
328 |
329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program
330 | `Gnomovision' (which makes passes at compilers) written by James Hacker.
331 |
332 | {signature of Ty Coon}, 1 April 1989
333 | Ty Coon, President of Vice
334 |
335 | This General Public License does not permit incorporating your program into
336 | proprietary programs. If your program is a subroutine library, you may
337 | consider it more useful to permit linking proprietary applications with the
338 | library. If this is what you want to do, use the GNU Lesser General
339 | Public License instead of this License.
--------------------------------------------------------------------------------