feat: Android TV support (#503)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-09-28 21:07:49 +02:00 committed by GitHub
parent 7ab8c015b9
commit c299492d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 12019 additions and 3073 deletions

View file

@ -0,0 +1,232 @@
package nl.jknaapen.fladder.player
import PlaybackState
import android.app.ActivityManager
import android.content.Context
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import io.github.peerless2012.ass.media.kt.buildWithAssSupport
import io.github.peerless2012.ass.media.type.AssRenderType
import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.getAudioTracks
import nl.jknaapen.fladder.utility.getSubtitleTracks
import java.io.File
val LocalPlayer = compositionLocalOf<ExoPlayer?> { null }
@OptIn(UnstableApi::class)
@Composable
internal fun ExoPlayer(
controls: @Composable (
player: ExoPlayer,
) -> Unit,
) {
val videoHost = VideoPlayerObject
val context = LocalContext.current
var initialized = false
val extractorsFactory = DefaultExtractorsFactory().apply {
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
setTsExtractorTimestampSearchBytes(
when (isLowRamDevice) {
true -> TsExtractor.TS_PACKET_SIZE * 1800
false -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
}
)
setConstantBitrateSeekingEnabled(true)
setConstantBitrateSeekingAlwaysEnabled(true)
}
val videoCache = remember { VideoCache.buildCacheDataSourceFactory(context) }
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val renderersFactory = DefaultRenderersFactory(context)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true)
val exoPlayer = remember {
ExoPlayer.Builder(context, renderersFactory)
.setTrackSelector(DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().apply {
setAudioOffloadPreferences(
TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply {
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
}.build()
)
setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
setTunnelingEnabled(true)
})
})
.setMediaSourceFactory(
DefaultMediaSourceFactory(
videoCache,
extractorsFactory
),
)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setPauseAtEndOfMediaItems(true)
.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT)
.buildWithAssSupport(context, AssRenderType.LEGACY)
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
videoHost.setPlaybackState(
PlaybackState(
position = exoPlayer.currentPosition,
buffered = exoPlayer.bufferedPosition,
duration = exoPlayer.duration,
playing = exoPlayer.isPlaying,
buffering = playbackState == Player.STATE_BUFFERING,
completed = playbackState == Player.STATE_ENDED,
failed = playbackState == Player.STATE_IDLE
)
)
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
videoHost.setPlaybackState(
PlaybackState(
position = exoPlayer.currentPosition,
buffered = exoPlayer.bufferedPosition,
duration = exoPlayer.duration,
playing = exoPlayer.isPlaying,
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
completed = exoPlayer.playbackState == Player.STATE_ENDED,
failed = exoPlayer.playbackState == Player.STATE_IDLE
)
)
}
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
if (!initialized) {
initialized = true
VideoPlayerObject.implementation.playbackData.value?.let {
exoPlayer.properlySetSubAndAudioTracks(it)
}
VideoPlayerObject.exoSubTracks.value = exoPlayer.getSubtitleTracks()
VideoPlayerObject.exoAudioTracks.value = exoPlayer.getAudioTracks()
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
}
}
DisposableEffect(Unit) {
VideoPlayerObject.implementation.init(exoPlayer)
onDispose {
videoHost.videoPlayerControls?.onStop(callback = {})
VideoPlayerObject.implementation.init(null)
exoPlayer.release()
}
}
Box(
modifier = Modifier
.background(Color.Black)
.fillMaxSize()
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
PlayerView(it).apply {
player = exoPlayer
useController = false
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
subtitleView?.apply {
setStyle(
CaptionStyleCompat(
android.graphics.Color.WHITE,
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
CaptionStyleCompat.EDGE_TYPE_OUTLINE,
android.graphics.Color.BLACK,
null
)
)
}
}
},
)
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
controls(exoPlayer)
}
}
}
@UnstableApi
object VideoCache {
private const val CACHE_SIZE: Long = 150L * 1024L * 1024L // 150 MB
@Volatile
private var cache: SimpleCache? = null
fun getCache(context: Context): SimpleCache {
return cache ?: synchronized(this) {
cache ?: SimpleCache(
File(context.cacheDir, "video_cache"),
LeastRecentlyUsedCacheEvictor(CACHE_SIZE)
).also { cache = it }
}
}
fun buildCacheDataSourceFactory(context: Context): DataSource.Factory {
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
val upstreamFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
return CacheDataSource.Factory()
.setCache(getCache(context))
.setUpstreamDataSourceFactory(upstreamFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}