From 08f837f31f308170f56bbcb658dbd10d425364b6 Mon Sep 17 00:00:00 2001 From: Aman Choudhary Date: Sun, 8 Feb 2026 16:43:37 +0530 Subject: [PATCH 1/6] Setup: Android manifest, build files, and Media3 version bump --- .idea/markdown.xml | 8 ++++++++ app/build.gradle.kts | 4 +++- app/src/main/AndroidManifest.xml | 6 ++++++ .../socialite/ui/videoedit/VideoEditScreenViewModel.kt | 6 +++++- gradle/libs.versions.toml | 4 +++- 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .idea/markdown.xml 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/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"> + + + + Date: Sun, 8 Feb 2026 16:43:57 +0530 Subject: [PATCH 2/6] Local Media Server: Serve local content to Cast devices --- .../socialite/utils/LocalMediaServer.kt | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt 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..bf1a0b82 --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt @@ -0,0 +1,112 @@ +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.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: Exception) { + Log.e(TAG, "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 + } + } +} From 32c263ae85bebb9d0c03f295891bb3a31744672f Mon Sep 17 00:00:00 2001 From: Aman Choudhary Date: Sun, 8 Feb 2026 16:44:14 +0530 Subject: [PATCH 3/6] Timeline: Cast short-form videos to Google Cast devices --- .../socialite/ui/home/timeline/Timeline.kt | 4 + .../ui/home/timeline/TimelineViewModel.kt | 105 ++++++++++++++++-- .../component/TimelineVerticalPager.kt | 70 ++++++++---- 3 files changed, 151 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/Timeline.kt b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/Timeline.kt index e34f0735..e0f2b879 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/Timeline.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/Timeline.kt @@ -49,6 +49,7 @@ fun Timeline( val player = viewModel.player val videoRatio = viewModel.videoRatio + val isRemote = viewModel.isRemote when { mediaItems.isEmpty() -> { @@ -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..627532ac 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,39 @@ 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) + + // 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 +86,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 +125,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 -> @@ -151,9 +236,12 @@ class TimelineViewModel @Inject constructor( it.addListener(videoSizeListener) it.addListener(firstFrameListener) } + castPlayer = + CastPlayer.Builder(application.applicationContext).setLocalPlayer(newPlayer).build() + castPlayer?.addListener(castPlayerListener) videoRatio = null - player = newPlayer + player = castPlayer if (enablePreloadManager) { initPreloadManager(loadControl, thread) @@ -185,6 +273,8 @@ class TimelineViewModel @Inject constructor( if (enablePreloadManager) { preloadManager.release() } + castPlayer?.removeListener(castPlayerListener) + castPlayer?.release() player?.apply { removeListener(videoSizeListener) removeListener(firstFrameListener) @@ -203,18 +293,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) - } else { - // Use the preloaded media source + if (!isRemote && mediaSource != null && this is ExoPlayer) { setMediaSource(mediaSource) + } else { + 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..a4b20ba6 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,30 @@ private fun MediaItem( } } } + +@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((-15).dp), + ) { + // 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() + } +} From 300b8d5377b79e377740056918201ee46c3c48a3 Mon Sep 17 00:00:00 2001 From: Aman Choudhary Date: Sun, 8 Feb 2026 16:56:55 +0530 Subject: [PATCH 4/6] Style: Apply Spotless formatting to PR --- .../samples/socialite/ui/home/timeline/TimelineViewModel.kt | 1 - .../android/samples/socialite/ui/videoedit/VideoEditScreen.kt | 2 +- .../samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt | 2 +- .../google/android/samples/socialite/utils/LocalMediaServer.kt | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) 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 627532ac..a902ad38 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 @@ -73,7 +73,6 @@ class TimelineViewModel @Inject constructor( // 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) 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 49277b22..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,8 +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_VIDEO 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 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 index bf1a0b82..41c0211a 100644 --- 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 @@ -23,7 +23,7 @@ private const val TAG = "LocalMediaServer" */ @Singleton class LocalMediaServer @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, ) : NanoHTTPD(0) { /** From f3cbabd94924f6a0715a8a4f70aa1883eb9c2e73 Mon Sep 17 00:00:00 2001 From: Aman Choudhary Date: Sun, 8 Feb 2026 17:05:54 +0530 Subject: [PATCH 5/6] Fix: Address final AI review feedback --- .../ui/home/timeline/TimelineViewModel.kt | 13 ++++++++----- .../timeline/component/TimelineVerticalPager.kt | 4 +++- .../samples/socialite/utils/LocalMediaServer.kt | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) 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 a902ad38..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 @@ -70,6 +70,9 @@ class TimelineViewModel @Inject constructor( // 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) @@ -224,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) @@ -236,7 +239,7 @@ class TimelineViewModel @Inject constructor( it.addListener(firstFrameListener) } castPlayer = - CastPlayer.Builder(application.applicationContext).setLocalPlayer(newPlayer).build() + CastPlayer.Builder(application.applicationContext).setLocalPlayer(localPlayer!!).build() castPlayer?.addListener(castPlayerListener) videoRatio = null @@ -273,7 +276,6 @@ class TimelineViewModel @Inject constructor( preloadManager.release() } castPlayer?.removeListener(castPlayerListener) - castPlayer?.release() player?.apply { removeListener(videoSizeListener) removeListener(firstFrameListener) @@ -282,6 +284,7 @@ class TimelineViewModel @Inject constructor( playerThread?.quitSafely() playerThread = null videoRatio = null + localPlayer = null player = null } @@ -301,8 +304,8 @@ class TimelineViewModel @Inject constructor( val mediaSource = preloadManager.getMediaSource(mediaItem) Log.d("PreloadManager", "Mediasource $mediaSource ") - if (!isRemote && mediaSource != null && this is ExoPlayer) { - setMediaSource(mediaSource) + if (!isRemote && mediaSource != null && localPlayer != null) { + localPlayer?.setMediaSource(mediaSource) } else { setMediaItem(mediaItem) } 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 a4b20ba6..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 @@ -232,6 +232,8 @@ private fun MediaItem( } } +private val MediaRouteButtonIntrinsicPadding = 15.dp + @Composable private fun PlayerControls( uri: String, @@ -241,7 +243,7 @@ private fun PlayerControls( Row( modifier = modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((-15).dp), + horizontalArrangement = Arrangement.spacedBy(-MediaRouteButtonIntrinsicPadding), ) { // Display an info button to inspect the video's metadata. IconButton( 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 index 41c0211a..8bc9ae9f 100644 --- 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 @@ -8,6 +8,8 @@ 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 @@ -58,7 +60,7 @@ class LocalMediaServer @Inject constructor( val inputStream = if (isContentUri) { androidUri?.let { context.contentResolver.openInputStream(it) } } else { - File(uriString).takeIf { it.isFile }?.inputStream() + File("/$uriString").takeIf { it.isFile }?.inputStream() } if (inputStream != null) { @@ -72,8 +74,17 @@ class LocalMediaServer @Inject constructor( 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, "Error serving file", e) + Log.e(TAG, "Unexpected error serving file", e) newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Internal Error") } } From 0104713ac00b9da07ec7d15331c94043b4a45b3c Mon Sep 17 00:00:00 2001 From: Aman Choudhary Date: Tue, 3 Mar 2026 09:52:41 +0530 Subject: [PATCH 6/6] docs: Add NanoHTTPD license attribution --- NOTICE | 5 +++++ README.md | 5 +++++ licenses/NANOHTTPD_LICENSE | 11 +++++++++++ 3 files changed, 21 insertions(+) create mode 100644 NOTICE create mode 100644 licenses/NANOHTTPD_LICENSE 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/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.