diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 00000000..c61ea334
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..2eceb106
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,5 @@
+Socialite includes the following third-party software/code:
+
+NanoHTTPD
+Copyright (c) 2012-2013 by Paul S. Hawke, 2001, 2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias
+License: Modified BSD License (see licenses/NANOHTTPD_LICENSE)
diff --git a/README.md b/README.md
index ea20af25..0e2f258c 100644
--- a/README.md
+++ b/README.md
@@ -107,3 +107,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
+
+---
+**Third-Party Licenses**
+
+SociaLite includes the [NanoHTTPD](https://github.com/NanoHttpd/nanohttpd) library, which is licensed under a Modified BSD License. See the [NOTICE](NOTICE) file for more details.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6ec7784a..1ee4f639 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -43,7 +43,7 @@ android {
defaultConfig {
applicationId = "com.google.android.samples.socialite"
- minSdk = 21
+ minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"
@@ -166,6 +166,8 @@ dependencies {
implementation(libs.media3.transformer)
implementation(libs.media3.ui)
implementation(libs.media3.ui.compose)
+ implementation(libs.media3.cast)
+ implementation(libs.nanohttpd)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cc3b47a4..4baeab60 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -49,6 +49,12 @@
android:theme="@style/Theme.Social"
tools:targetApi="34">
+
+
+
+
{
@@ -62,6 +63,7 @@ fun Timeline(
player = player,
mediaItems = mediaItems,
videoRatio = videoRatio,
+ isRemote = isRemote,
onChangePlayerItem = viewModel::changePlayerItem,
onInitializePlayer = viewModel::initializePlayer,
onReleasePlayer = viewModel::releasePlayer,
@@ -77,6 +79,7 @@ fun Timeline(
mediaItems: List,
player: Player?,
videoRatio: Float?,
+ isRemote: Boolean,
modifier: Modifier = Modifier,
format: TimelineFormat = rememberTimelineFormat(),
onChangePlayerItem: (uri: Uri?, page: Int) -> Unit = { uri: Uri?, i: Int -> },
@@ -109,6 +112,7 @@ fun Timeline(
mediaItems = mediaItems,
player = player,
videoRatio = videoRatio,
+ isRemote = isRemote,
onChangePlayerItem = onChangePlayerItem,
modifier = Modifier
.fillMaxSize()
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt
index 658cf228..b051d843 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt
@@ -16,6 +16,7 @@
package com.google.android.samples.socialite.ui.home.timeline
+import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.HandlerThread
@@ -26,7 +27,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.cast.CastPlayer
import androidx.media3.common.C
+import androidx.media3.common.DeviceInfo
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
@@ -36,25 +40,41 @@ import androidx.media3.exoplayer.ExoPlayer
import com.google.android.samples.socialite.model.ChatDetail
import com.google.android.samples.socialite.repository.ChatRepository
import com.google.android.samples.socialite.ui.player.preloadmanager.PreloadManagerWrapper
+import com.google.android.samples.socialite.utils.LocalMediaServer
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.IOException
import javax.inject.Inject
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flattenConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+private const val TAG = "TimelineViewModel"
@UnstableApi
@HiltViewModel
class TimelineViewModel @Inject constructor(
@ApplicationContext private val application: Context,
private val repository: ChatRepository,
+ private val localMediaServer: LocalMediaServer,
) : ViewModel() {
// Single player instance - in the future, we can implement a pool of players to improve
// latency and allow for concurrent playback
- var player by mutableStateOf(null)
+ var player by mutableStateOf(null)
+
+ // Cast player instance
+ var castPlayer by mutableStateOf(null)
+
+ // Local ExoPlayer instance
+ private var localPlayer: ExoPlayer? = null
+
+ // Keeps track if the current playback location is remote or local
+ var isRemote by mutableStateOf(false)
// Width/Height ratio of the current media item, used to properly size the Surface
var videoRatio by mutableStateOf(null)
@@ -68,6 +88,26 @@ class TimelineViewModel @Inject constructor(
var playbackStartTimeMs = C.TIME_UNSET
+ private var currentVideoUri: Uri? = null
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ localMediaServer.start()
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to start local media server", e)
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ viewModelScope.launch(Dispatchers.IO) {
+ localMediaServer.stop()
+ }
+ releasePlayer()
+ }
+
private val videoSizeListener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
videoRatio = if (videoSize.height > 0 && videoSize.width > 0) {
@@ -87,6 +127,53 @@ class TimelineViewModel @Inject constructor(
}
}
+ /**
+ * Listens for MediaRoute connection events.
+ * When a Cast device connects or disconnects, this listener seamlessly transfers
+ * the currently playing video to the new playback destination.
+ */
+ private val castPlayerListener = object : Player.Listener {
+ override fun onDeviceInfoChanged(deviceInfo: DeviceInfo) {
+ isRemote = deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE
+ currentVideoUri?.let { uri ->
+ val mediaItem = getMediaItemForUri(uri)
+ val typeStr = if (isRemote) "REMOTE" else "LOCAL"
+ Log.i(TAG, "Serving content on $typeStr: ${mediaItem.localConfiguration?.uri}")
+ player?.apply {
+ setMediaItem(mediaItem)
+ prepare()
+ play()
+ }
+ }
+ }
+ }
+
+ /**
+ * Determines the appropriate [MediaItem] source structure for a given URI.
+ *
+ * If the session is casting to a remote device AND the URI points to a local file
+ * (content:// or file://), the Cast receiver cannot securely access that file path.
+ * To solve this, we proxy the file stream over an HTTP URL backed by our own [LocalMediaServer].
+ *
+ * @param uri The original location of the file to play.
+ * @return A [MediaItem] that ExoPlayer/CastPlayer can natively decode.
+ */
+ private fun getMediaItemForUri(uri: Uri): MediaItem {
+ val isLocal = uri.scheme in listOf(
+ ContentResolver.SCHEME_FILE,
+ ContentResolver.SCHEME_CONTENT,
+ ) || uri.path.orEmpty().startsWith("/storage/")
+ if (!isRemote || !isLocal) return MediaItem.fromUri(uri)
+
+ val ip = localMediaServer.getLocalIpAddress() ?: return MediaItem.fromUri(uri)
+ val port = localMediaServer.listeningPort.takeIf { it > 0 } ?: return MediaItem.fromUri(uri)
+
+ val proxyPath = if (uri.scheme == ContentResolver.SCHEME_CONTENT) "/$uri" else uri.path
+ localMediaServer.currentSharedUri = proxyPath?.removePrefix("/")
+
+ return MediaItem.fromUri("http://$ip:$port$proxyPath")
+ }
+
@kotlin.OptIn(ExperimentalCoroutinesApi::class)
val media = repository.getChats()
.map { chats ->
@@ -140,7 +227,7 @@ class TimelineViewModel @Inject constructor(
.setPrioritizeTimeOverSizeThresholds(true).build()
val thread = initPlayerThread()
- val newPlayer = ExoPlayer
+ localPlayer = ExoPlayer
.Builder(application.applicationContext)
.setLoadControl(loadControl)
.setPlaybackLooper(thread.looper)
@@ -151,9 +238,12 @@ class TimelineViewModel @Inject constructor(
it.addListener(videoSizeListener)
it.addListener(firstFrameListener)
}
+ castPlayer =
+ CastPlayer.Builder(application.applicationContext).setLocalPlayer(localPlayer!!).build()
+ castPlayer?.addListener(castPlayerListener)
videoRatio = null
- player = newPlayer
+ player = castPlayer
if (enablePreloadManager) {
initPreloadManager(loadControl, thread)
@@ -185,6 +275,7 @@ class TimelineViewModel @Inject constructor(
if (enablePreloadManager) {
preloadManager.release()
}
+ castPlayer?.removeListener(castPlayerListener)
player?.apply {
removeListener(videoSizeListener)
removeListener(firstFrameListener)
@@ -193,6 +284,7 @@ class TimelineViewModel @Inject constructor(
playerThread?.quitSafely()
playerThread = null
videoRatio = null
+ localPlayer = null
player = null
}
@@ -203,18 +295,19 @@ class TimelineViewModel @Inject constructor(
stop()
videoRatio = null
if (uri != null) {
+ currentVideoUri = uri
// Set the right source to play
- val mediaItem = MediaItem.fromUri(uri)
+ val mediaItem = getMediaItemForUri(uri)
+ Log.d(TAG, "Media item changed: ${mediaItem.localConfiguration?.uri}")
if (enablePreloadManager) {
val mediaSource = preloadManager.getMediaSource(mediaItem)
Log.d("PreloadManager", "Mediasource $mediaSource ")
- if (mediaSource == null) {
- setMediaItem(mediaItem)
+ if (!isRemote && mediaSource != null && localPlayer != null) {
+ localPlayer?.setMediaSource(mediaSource)
} else {
- // Use the preloaded media source
- setMediaSource(mediaSource)
+ setMediaItem(mediaItem)
}
preloadManager.setCurrentPlayingIndex(currentPlayingIndex)
} else {
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt
index c67c09fe..5b837b2d 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt
@@ -19,7 +19,9 @@ package com.google.android.samples.socialite.ui.home.timeline.component
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
@@ -42,8 +44,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
-import androidx.compose.ui.zIndex
import androidx.core.net.toUri
+import androidx.media3.cast.MediaRouteButton
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.PlayerSurface
@@ -60,6 +62,7 @@ internal fun TimelineVerticalPager(
mediaItems: List,
player: Player?,
videoRatio: Float?,
+ isRemote: Boolean,
modifier: Modifier = Modifier,
onChangePlayerItem: (uri: Uri?, page: Int) -> Unit = { uri: Uri?, i: Int -> },
onInspectClicked: (uri: String) -> Unit = {},
@@ -80,6 +83,7 @@ internal fun TimelineVerticalPager(
TimelineVerticalPager(
mediaItems = mediaItems,
player = player,
+ isRemote = isRemote,
pagerState = pagerState,
videoRatio = videoRatio,
modifier = modifier,
@@ -92,6 +96,7 @@ internal fun TimelineVerticalPager(
private fun TimelineVerticalPager(
mediaItems: List,
player: Player,
+ isRemote: Boolean,
pagerState: PagerState,
videoRatio: Float?,
modifier: Modifier = Modifier,
@@ -106,6 +111,7 @@ private fun TimelineVerticalPager(
TimelinePage(
mediaItem = mediaItem,
player = player,
+ isRemote = isRemote,
page = page,
pagerState = pagerState,
videoRatio = videoRatio,
@@ -139,6 +145,7 @@ private fun TimelineVerticalPager(
private fun TimelinePage(
mediaItem: TimelineMediaItem,
player: Player,
+ isRemote: Boolean,
page: Int,
pagerState: PagerState,
videoRatio: Float?,
@@ -157,6 +164,7 @@ private fun TimelinePage(
.draggableMediaItem(mediaItem),
media = mediaItem,
player = player,
+ isRemote = isRemote,
page,
pagerState,
videoRatio,
@@ -177,6 +185,7 @@ private fun MediaItem(
modifier: Modifier = Modifier,
media: TimelineMediaItem,
player: Player,
+ isRemote: Boolean,
page: Int,
state: PagerState,
videoRatio: Float?,
@@ -192,28 +201,20 @@ private fun MediaItem(
}
else -> {
- // Display an info button to inspect the video's metadata. This is overlaid on
- // top left of the video preview.
- IconButton(
- onClick = {
- onInspectClicked(media.uri)
- },
- modifier = Modifier
- .padding(8.dp)
- .zIndex(1f),
- // Ensure the button is on top of other content
- ) {
- Icon(
- imageVector = Icons.Filled.Info,
- contentDescription = "Inspect video metadata",
+ // Use the "key" composable to force the recreation of the PlayerSurface
+ // when the playback switch between local and remote
+ androidx.compose.runtime.key(isRemote) {
+ PlayerSurface(
+ player = player,
+ modifier = modifier.resizeWithContentScale(
+ ContentScale.Fit,
+ null,
+ ),
)
}
- PlayerSurface(
- player = player,
- modifier = modifier.resizeWithContentScale(
- ContentScale.Fit,
- null,
- ),
+ PlayerControls(
+ uri = media.uri,
+ onInspectClicked = onInspectClicked,
)
}
}
@@ -230,3 +231,32 @@ private fun MediaItem(
}
}
}
+
+private val MediaRouteButtonIntrinsicPadding = 15.dp
+
+@Composable
+private fun PlayerControls(
+ uri: String,
+ onInspectClicked: (uri: String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(-MediaRouteButtonIntrinsicPadding),
+ ) {
+ // Display an info button to inspect the video's metadata.
+ IconButton(
+ onClick = {
+ onInspectClicked(uri)
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = "Inspect video metadata",
+ )
+ }
+ // Display a Cast button
+ MediaRouteButton()
+ }
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt
index 257a0e18..365fd98e 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt
@@ -555,7 +555,7 @@ private fun VideoMessagePreview(
modifier = Modifier
.width(250.dp)
.height(450.dp),
- )
+ )
LaunchedEffect(previewConfig) {
// Release the previous player instance if it exists
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt
index 5bd7eced..3387ca94 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt
@@ -31,6 +31,8 @@ import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.media3.common.C.TRACK_TYPE_AUDIO
+import androidx.media3.common.C.TRACK_TYPE_VIDEO
import androidx.media3.common.Effect
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
@@ -208,7 +210,9 @@ class VideoEditScreenViewModel @Inject constructor(
val editedMediaItem =
EditedMediaItem.Builder(mediaItem)
.setRemoveAudio(removeAudio).setDurationUs(durationUs).build()
- val videoImageSequence = EditedMediaItemSequence(editedMediaItem)
+ val videoImageSequence =
+ EditedMediaItemSequence.Builder(mutableSetOf(TRACK_TYPE_VIDEO, TRACK_TYPE_AUDIO))
+ .addItem(editedMediaItem).build()
val compositionBuilder = Composition.Builder(videoImageSequence)
// Tone-map to SDR if style transfer is selected since it can only be applied for SDR videos
diff --git a/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt b/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt
new file mode 100644
index 00000000..8bc9ae9f
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt
@@ -0,0 +1,123 @@
+package com.google.android.samples.socialite.utils
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import android.webkit.MimeTypeMap
+import dagger.hilt.android.qualifiers.ApplicationContext
+import fi.iki.elonen.NanoHTTPD
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.net.Inet4Address
+import java.net.NetworkInterface
+import java.net.SocketException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG = "LocalMediaServer"
+
+/**
+ * Serves local media (content:// or file://) to the Google Cast receiver.
+ * The Cast receiver operates as a web app and cannot read Android device paths securely.
+ * This local proxy streams bytes seamlessly over HTTP to the receiver.
+ */
+@Singleton
+class LocalMediaServer @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : NanoHTTPD(0) {
+
+ /**
+ * The single URI that is currently authorized to be streamed.
+ * Prevents arbitrary path traversal and unauthorized data access on the local network.
+ */
+ @Volatile
+ var currentSharedUri: String? = null
+
+ /**
+ * Handles incoming HTTP requests from the Cast receiver.
+ *
+ * Verifies the requested URI matches the authorized [currentSharedUri], then
+ * seamlessly resolves the local file or content stream and chunks it over HTTP.
+ *
+ * @param session The inbound HTTP request session.
+ * @return The HTTP response containing the chunked media stream or an error status.
+ */
+ override fun serve(session: IHTTPSession): Response {
+ val uriString = session.uri.substringAfter("/")
+ Log.d(TAG, "Request: $uriString")
+
+ if (uriString != currentSharedUri) {
+ Log.w(TAG, "Blocked unauthorized request: $uriString")
+ return newFixedLengthResponse(Response.Status.FORBIDDEN, MIME_PLAINTEXT, "Forbidden")
+ }
+
+ return try {
+ val isContentUri = uriString.startsWith("${ContentResolver.SCHEME_CONTENT}://")
+ val androidUri = if (isContentUri) Uri.parse(uriString) else null
+
+ val inputStream = if (isContentUri) {
+ androidUri?.let { context.contentResolver.openInputStream(it) }
+ } else {
+ File("/$uriString").takeIf { it.isFile }?.inputStream()
+ }
+
+ if (inputStream != null) {
+ val mime = if (isContentUri) {
+ androidUri?.let { context.contentResolver.getType(it) } ?: getMimeType(uriString)
+ } else {
+ getMimeType(uriString)
+ }
+ newChunkedResponse(Response.Status.OK, mime, inputStream)
+ } else {
+ Log.w(TAG, "Content not found: $uriString")
+ newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Content not found")
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security violation accessing file", e)
+ newFixedLengthResponse(Response.Status.FORBIDDEN, MIME_PLAINTEXT, "Forbidden")
+ } catch (e: FileNotFoundException) {
+ Log.e(TAG, "File not found", e)
+ newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "File not found")
+ } catch (e: IOException) {
+ Log.e(TAG, "IO error serving file", e)
+ newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Internal Error")
+ } catch (e: Exception) {
+ Log.e(TAG, "Unexpected error serving file", e)
+ newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Internal Error")
+ }
+ }
+
+ /**
+ * Determines the most appropriate local IPv4 address of the device to bind the HTTP server.
+ * Filters out loopbacks, virtual interfaces, and inactive connections.
+ *
+ * @return The local IPv4 address as a string, or null if no valid address is found.
+ */
+ fun getLocalIpAddress(): String? {
+ return try {
+ NetworkInterface.getNetworkInterfaces().asSequence()
+ .filter { !it.isLoopback && it.isUp && !it.isVirtual }
+ .flatMap { it.inetAddresses.asSequence() }
+ .firstOrNull { !it.isLoopbackAddress && it is Inet4Address }
+ ?.hostAddress
+ } catch (ex: SocketException) {
+ Log.e(TAG, "Error getting IP address", ex)
+ null
+ }
+ }
+
+ companion object {
+ /**
+ * Infers the MIME type of a file based on its extension using the native Android [MimeTypeMap].
+ *
+ * @param url The file path or URI string.
+ * @return The inferred MIME type (e.g. "video/mp4"), defaulting to "text/plain".
+ */
+ private fun getMimeType(url: String): String {
+ val extension = MimeTypeMap.getFileExtensionFromUrl(url)
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: MIME_PLAINTEXT
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8dc36b82..62363e0e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -46,7 +46,7 @@ android-material3 = "1.13.0-alpha13"
material3 = "1.4.0-alpha15"
material3-adaptive-navigation-suite = "1.4.0-alpha15"
material3-adaptive-navigation3 = "1.0.0-SNAPSHOT"
-media3 = "1.8.0"
+media3 = "1.9.0"
media3-ui-compose = "1.7.1"
navigation3 = "1.0.0-alpha01"
profileinstaller = "1.4.1"
@@ -126,6 +126,8 @@ media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", versi
media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
media3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose", version.ref = "media3-ui-compose" }
+media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" }
+nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version = "2.3.1" }
navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
diff --git a/licenses/NANOHTTPD_LICENSE b/licenses/NANOHTTPD_LICENSE
new file mode 100644
index 00000000..d8ed9fcb
--- /dev/null
+++ b/licenses/NANOHTTPD_LICENSE
@@ -0,0 +1,11 @@
+Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+Neither the name of the NanoHttpd organization nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.