Start of chromecast support
This commit is contained in:
parent
978aed5498
commit
0a666c49c5
18 changed files with 241 additions and 5 deletions
|
@ -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"))
|
||||
|
|
|
@ -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>
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
10
core/src/main/res/menu/expanded_controller.xml
Normal file
10
core/src/main/res/menu/expanded_controller.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" }
|
||||
|
||||
|
|
|
@ -57,4 +57,5 @@ dependencies {
|
|||
implementation(libs.libmpv)
|
||||
implementation(libs.material)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.playServicesCastFramework)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue