Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .idea/markdown.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ android {

defaultConfig {
applicationId = "com.google.android.samples.socialite"
minSdk = 21
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
android:theme="@style/Theme.Social"
tools:targetApi="34">

<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="androidx.media3.cast.DefaultCastOptionsProvider" />

<receiver android:name="androidx.mediarouter.media.MediaTransferReceiver" />

<receiver
android:name=".widget.SociaLiteAppWidgetReceiver"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fun Timeline(

val player = viewModel.player
val videoRatio = viewModel.videoRatio
val isRemote = viewModel.isRemote

when {
mediaItems.isEmpty() -> {
Expand All @@ -62,6 +63,7 @@ fun Timeline(
player = player,
mediaItems = mediaItems,
videoRatio = videoRatio,
isRemote = isRemote,
onChangePlayerItem = viewModel::changePlayerItem,
onInitializePlayer = viewModel::initializePlayer,
onReleasePlayer = viewModel::releasePlayer,
Expand All @@ -77,6 +79,7 @@ fun Timeline(
mediaItems: List<TimelineMediaItem>,
player: Player?,
videoRatio: Float?,
isRemote: Boolean,
modifier: Modifier = Modifier,
format: TimelineFormat = rememberTimelineFormat(),
onChangePlayerItem: (uri: Uri?, page: Int) -> Unit = { uri: Uri?, i: Int -> },
Expand Down Expand Up @@ -109,6 +112,7 @@ fun Timeline(
mediaItems = mediaItems,
player = player,
videoRatio = videoRatio,
isRemote = isRemote,
onChangePlayerItem = onChangePlayerItem,
modifier = Modifier
.fillMaxSize()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<ExoPlayer?>(null)
var player by mutableStateOf<Player?>(null)

// Cast player instance
var castPlayer by mutableStateOf<CastPlayer?>(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<Float?>(null)
Expand All @@ -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) {
Expand All @@ -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 ->
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -185,6 +275,7 @@ class TimelineViewModel @Inject constructor(
if (enablePreloadManager) {
preloadManager.release()
}
castPlayer?.removeListener(castPlayerListener)
player?.apply {
removeListener(videoSizeListener)
removeListener(firstFrameListener)
Expand All @@ -193,6 +284,7 @@ class TimelineViewModel @Inject constructor(
playerThread?.quitSafely()
playerThread = null
videoRatio = null
localPlayer = null
player = null
}

Expand All @@ -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 {
Expand Down
Loading
Loading