diff --git a/app/phone/build.gradle.kts b/app/phone/build.gradle.kts
index 42193c50..4bfacfb3 100644
--- a/app/phone/build.gradle.kts
+++ b/app/phone/build.gradle.kts
@@ -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"))
diff --git a/app/phone/src/main/AndroidManifest.xml b/app/phone/src/main/AndroidManifest.xml
index f1d16eb1..f8354581 100644
--- a/app/phone/src/main/AndroidManifest.xml
+++ b/app/phone/src/main/AndroidManifest.xml
@@ -33,6 +33,23 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
index a28f4ceb..b672d857 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
@@ -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 =
+ 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 {
+ 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) {
+ }
+ }
+ }
}
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/chromecast/CastOptionsProvider.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/chromecast/CastOptionsProvider.kt
new file mode 100644
index 00000000..8a1216b9
--- /dev/null
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/chromecast/CastOptionsProvider.kt
@@ -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 = 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? {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/chromecast/ExpandedControlsActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/chromecast/ExpandedControlsActivity.kt
new file mode 100644
index 00000000..e52e480d
--- /dev/null
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/chromecast/ExpandedControlsActivity.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
index 3e2c40a9..5088ef91 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
@@ -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)
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
index 4ec8fae9..394bf94e 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
@@ -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 {
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt
index 0b9e5c07..4f2bc15c 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt
@@ -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
diff --git a/app/phone/src/main/res/layout/activity_main.xml b/app/phone/src/main/res/layout/activity_main.xml
index 9911d987..7ce3123d 100644
--- a/app/phone/src/main/res/layout/activity_main.xml
+++ b/app/phone/src/main/res/layout/activity_main.xml
@@ -4,6 +4,19 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/core/src/main/res/menu/home_menu.xml b/core/src/main/res/menu/home_menu.xml
index afe5b5a0..3616fc5b 100644
--- a/core/src/main/res/menu/home_menu.xml
+++ b/core/src/main/res/menu/home_menu.xml
@@ -10,7 +10,11 @@
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView"
tools:ignore="AlwaysShowAction" />
-
+
-
+
+
-
+
+
- Dolby Logo
Display Extra Info
Displays detailed information about Audio, Video and Subtitles
+ Cast Thing
diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml
index 8a17c4fb..eda7b5d3 100644
--- a/core/src/main/res/values/themes.xml
+++ b/core/src/main/res/values/themes.xml
@@ -46,6 +46,18 @@
- 28dp
- @style/ThemeOverlay.Findroid.Preference
- #000
+
+
+ - @style/CustomCastMiniController
+ - @style/CustomCastExpandedController
+
+
+
+
+
@@ -59,4 +71,4 @@
- light
- dark
-
\ No newline at end of file
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 37031939..12c63d3b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/player/video/build.gradle.kts b/player/video/build.gradle.kts
index a60ecfa8..152d00da 100644
--- a/player/video/build.gradle.kts
+++ b/player/video/build.gradle.kts
@@ -57,4 +57,5 @@ dependencies {
implementation(libs.libmpv)
implementation(libs.material)
implementation(libs.timber)
+ implementation(libs.playServicesCastFramework)
}
diff --git a/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt b/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
index 22b251b1..2077b39e 100644
--- a/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
+++ b/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
@@ -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(
@@ -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)
}
}