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) } }