Start of chromecast support

This commit is contained in:
Rhys Davies 2022-08-08 13:09:42 +12:00
parent 978aed5498
commit 0a666c49c5
No known key found for this signature in database
18 changed files with 241 additions and 5 deletions

View file

@ -106,6 +106,8 @@ dependencies {
implementation(libs.jellyfin.core)
compileOnly(libs.libmpv)
implementation(libs.material)
implementation(libs.mediarouter)
implementation(libs.playServicesCastFramework)
implementation(libs.timber)
implementation(rootProject.files("libs/lib-decoder-ffmpeg-release.aar"))

View file

@ -33,6 +33,23 @@
</activity>
<!-- android:theme="@style/Theme.CastVideosDark"-->
<activity
android:name=".chromecast.ExpandedControlsActivity"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:parentActivityName=".PlayerActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<meta-data
android:name=
"com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="dev.jdtech.jellyfin.chromecast.CastOptionsProvider" />
</application>
</manifest>

View file

@ -11,20 +11,31 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI
import androidx.navigation.ui.NavigationUiSaveStateControl
import androidx.navigation.ui.setupActionBarWithNavController
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManager
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.material.navigation.NavigationBarView
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: MainViewModel by viewModels()
private var castSession: CastSession? = null
private lateinit var sessionManager: SessionManager
private val sessionManagerListener: SessionManagerListener<CastSession> =
SessionManagerListenerImpl(this)
@Inject
lateinit var database: ServerDatabaseDao
@ -34,6 +45,9 @@ class MainActivity : AppCompatActivity() {
private lateinit var navController: NavController
@Inject
lateinit var jellyfinApi: JellyfinApi
@OptIn(NavigationUiSaveStateControl::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -42,6 +56,7 @@ class MainActivity : AppCompatActivity() {
setTheme(R.style.Theme_FindroidAMOLED)
}
sessionManager = CastContext.getSharedInstance(this).sessionManager
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
@ -91,6 +106,19 @@ class MainActivity : AppCompatActivity() {
loadDownloadLocation(applicationContext)
}
override fun onResume() {
super.onResume()
castSession = sessionManager.currentCastSession
sessionManager.addSessionManagerListener(sessionManagerListener, CastSession::class.java)
}
override fun onPause() {
super.onPause()
sessionManager.removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
castSession = null
}
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp()
}
@ -118,4 +146,45 @@ class MainActivity : AppCompatActivity() {
}
}
}
companion object {
private class SessionManagerListenerImpl(private val mainActivity: MainActivity) :
SessionManagerListener<CastSession> {
override fun onSessionStarted(session: CastSession, sessionId: String) {
mainActivity.invalidateOptionsMenu()
val thing =
"{\"options\":{},\"command\":\"Identify\",\"userId\":\"${mainActivity.jellyfinApi.userId}\",\"deviceId\":\"${mainActivity.jellyfinApi.api.deviceInfo.id}\",\"accessToken\":\"${mainActivity.jellyfinApi.api.accessToken}\",\"serverAddress\":\"${mainActivity.jellyfinApi.api.baseUrl}\",\"serverId\":\"\",\"serverVersion\":\"\",\"receiverName\":\"\"}"
session.sendMessage("urn:x-cast:com.connectsdk", thing)
session.setMessageReceivedCallbacks(
"urn:x-cast:com.connectsdk"
) { _, _, message -> Timber.i(message) }
}
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
mainActivity.invalidateOptionsMenu()
}
override fun onSessionEnded(session: CastSession, error: Int) {
// finish()
}
override fun onSessionEnding(p0: CastSession) {
}
override fun onSessionResumeFailed(p0: CastSession, p1: Int) {
}
override fun onSessionResuming(p0: CastSession, p1: String) {
}
override fun onSessionStartFailed(p0: CastSession, p1: Int) {
}
override fun onSessionStarting(p0: CastSession) {
}
override fun onSessionSuspended(p0: CastSession, p1: Int) {
}
}
}
}

View file

@ -0,0 +1,40 @@
package dev.jdtech.jellyfin.chromecast
import android.content.Context
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.media.NotificationOptions
class CastOptionsProvider : OptionsProvider {
companion object {
const val CUSTOM_NAMESPACE = "urn:x-cast:com.connectsdk"
}
override fun getCastOptions(context: Context): CastOptions {
val supportedNamespaces: MutableList<String> = ArrayList()
supportedNamespaces.add(CUSTOM_NAMESPACE)
val notificationOptions = NotificationOptions.Builder()
.setTargetActivityClassName(ExpandedControlsActivity::class.java.name)
.build()
val mediaOptions = CastMediaOptions.Builder()
.setNotificationOptions(notificationOptions)
.setExpandedControllerActivityClassName(ExpandedControlsActivity::class.java.name)
.build()
return CastOptions.Builder()
// .setReceiverApplicationId("F007D354")
.setReceiverApplicationId("D991CC1E")
// .setSupportedNamespaces(supportedNamespaces)
.setCastMediaOptions(mediaOptions)
.build()
}
override fun getAdditionalSessionProviders(p0: Context): MutableList<SessionProvider>? {
return null
}
}

View file

@ -0,0 +1,15 @@
package dev.jdtech.jellyfin.chromecast
import android.view.Menu
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity
import dev.jdtech.jellyfin.R
class ExpandedControlsActivity : ExpandedControllerActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.expanded_controller, menu)
CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item)
return true
}
}

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import com.google.android.gms.cast.framework.CastButtonFactory
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
@ -65,6 +66,11 @@ class HomeFragment : Fragment() {
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.home_menu, menu)
CastButtonFactory.setUpMediaRouteButton(
requireContext(),
menu,
R.id.media_route_menu_item
)
val settings = menu.findItem(R.id.action_settings)
val search = menu.findItem(R.id.action_search)

View file

@ -18,6 +18,8 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.paging.LoadState
import androidx.recyclerview.widget.LinearSnapHelper
import com.google.android.gms.cast.framework.CastButtonFactory
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.R
@ -64,6 +66,11 @@ class LibraryFragment : Fragment() {
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.library_menu, menu)
CastButtonFactory.setUpMediaRouteButton(
context!!,
menu,
R.id.media_route_menu_item
)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {

View file

@ -18,6 +18,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import com.google.android.gms.cast.framework.CastButtonFactory
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.CollectionListAdapter
@ -85,6 +86,11 @@ class MediaFragment : Fragment() {
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.media_menu, menu)
CastButtonFactory.setUpMediaRouteButton(
requireContext(),
menu,
R.id.media_route_menu_item
)
val search = menu.findItem(R.id.action_search)
val searchView = search.actionView as SearchView

View file

@ -4,6 +4,19 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/cast_mini_controller"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:visibility="gone"
app:castShowImageThumbnail="true"
app:layout_constraintBottom_toTopOf="@+id/nav_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
class="com.google.android.gms.cast.framework.media.widget.MiniControllerFragment" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="0dp"
@ -21,7 +34,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
app:layout_constraintBottom_toTopOf="@id/cast_mini_controller"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_toolbar_layout"

View file

@ -0,0 +1,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/media_route_menu_item"
android:title="@string/media_route_menu_title"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always"/>
</menu>

View file

@ -10,7 +10,11 @@
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView"
tools:ignore="AlwaysShowAction" />
<item
android:id="@+id/media_route_menu_item"
android:title="@string/media_route_menu_title"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
<item
android:id="@+id/action_settings"
android:icon="@drawable/ic_settings"

View file

@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/media_route_menu_item"
android:title="@string/media_route_menu_title"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
<!--Icon is currently not used but maybe in the future?-->
<item
android:id="@+id/action_sort_by"

View file

@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/media_route_menu_item"
android:title="@string/media_route_menu_title"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_search"

View file

@ -163,4 +163,5 @@
<string name="dolby_logo_desc">Dolby Logo</string>
<string name="extra_info">Display Extra Info</string>
<string name="extra_info_summary">Displays detailed information about Audio, Video and Subtitles</string>
<string name="media_route_menu_title">Cast Thing</string>
</resources>

View file

@ -46,6 +46,18 @@
<item name="dialogCornerRadius">28dp</item>
<item name="preferenceTheme">@style/ThemeOverlay.Findroid.Preference</item>
<item name="dolbyColor">#000</item>
<!-- Cast SDK -->
<item name="castMiniControllerStyle">@style/CustomCastMiniController</item>
<item name="castExpandedControllerStyle">@style/CustomCastExpandedController</item>
</style>
<style name="CustomCastMiniController" parent="CastMiniController">
<item name="castProgressBarColor">?attr/colorPrimary</item>
</style>
<style name="CustomCastExpandedController" parent="CastExpandedController">
<item name="castSeekBarProgressAndThumbColor">?attr/colorPrimary</item>
</style>
<string-array name="themes">
@ -59,4 +71,4 @@
<item>light</item>
<item>dark</item>
</string-array>
</resources>
</resources>

View file

@ -24,6 +24,8 @@ ktlint = "11.1.0"
libmpv = "0.1.1"
material = "1.8.0"
timber = "5.0.1"
androidx-mediarouter = "1.3.1"
play-services-cast-framework = "21.2.0"
[libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" }
@ -57,6 +59,8 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil
jellyfin-core = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jellyfin" }
libmpv = { module = "dev.jdtech.mpv:libmpv", version.ref = "libmpv" }
material = { module = "com.google.android.material:material", version.ref = "material" }
mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "androidx-mediarouter" }
playServicesCastFramework = { module = "com.google.android.gms:play-services-cast-framework", version.ref = "play-services-cast-framework"}
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

View file

@ -57,4 +57,5 @@ dependencies {
implementation(libs.libmpv)
implementation(libs.material)
implementation(libs.timber)
implementation(libs.playServicesCastFramework)
}

View file

@ -1,12 +1,16 @@
package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MimeTypes
import com.google.android.gms.cast.framework.CastContext
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.ExternalSubtitle
import dev.jdtech.jellyfin.models.PlayerItem
@ -18,6 +22,7 @@ import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
@ -30,7 +35,9 @@ import timber.log.Timber
class PlayerViewModel @Inject internal constructor(
private val application: Application,
private val repository: JellyfinRepository,
private val downloadDatabase: DownloadDatabaseDao
private val downloadDatabase: DownloadDatabaseDao,
private val jellyfinApi: JellyfinApi,
@ApplicationContext private val context: Context
) : ViewModel() {
private val playerItems = MutableSharedFlow<PlayerItemState>(
@ -61,6 +68,17 @@ class PlayerViewModel @Inject internal constructor(
}
viewModelScope.launch {
val session = CastContext.getSharedInstance(context).sessionManager.currentCastSession
if (session != null) {
val thing =
"{\"options\":{\"ids\":[\"${item.id}\"],\"startPositionTicks\":${
item.userData?.playbackPositionTicks ?: 0
},\"serverId\":\"\",\"fullscreen\":true,\"items\":[{\"Id\":\"${item.id}\",\"ServerId\":\"\",\"Name\":\"${item.name}\",\"Type\":\"${item.type}\",\"MediaType\":\"${item.mediaType}\",\"IsFolder\":false}]},\"command\":\"PlayNow\",\"userId\":\"${jellyfinApi.userId}\",\"deviceId\":\"${jellyfinApi.api.deviceInfo.id}\",\"accessToken\":\"${jellyfinApi.api.accessToken}\",\"serverAddress\":\"${jellyfinApi.api.baseUrl}\",\"serverId\":\"\",\"serverVersion\":\"\",\"receiverName\":\"Living Room TV\",\"subtitleAppearance\":{\"verticalPosition\":-3},\"subtitleBurnIn\":\"\"}"
session.sendMessage("urn:x-cast:com.connectsdk", thing)
return@launch
}
val playbackPosition = item.userData?.playbackPositionTicks?.div(10000) ?: 0
val items = try {
@ -69,7 +87,6 @@ class PlayerViewModel @Inject internal constructor(
Timber.d(e)
PlayerItemError(e)
}
playerItems.tryEmit(items)
}
}