From 1660cd82da995c4a31832aeb01060f14f66e7e47 Mon Sep 17 00:00:00 2001 From: oxy Date: Sat, 18 May 2024 13:33:54 +0800 Subject: [PATCH] build: initial commit for android tv favourite. --- androidApp/src/main/AndroidManifest.xml | 23 ++-- .../m3u/core/architecture/logger/Profiles.kt | 6 +- .../com/m3u/data/database/model/Playlist.kt | 4 +- .../com/m3u/data/worker/SubscriptionWorker.kt | 11 +- .../m3u/features/playlist/PlaylistScreen.kt | 7 +- .../features/playlist/PlaylistViewModel.kt | 104 +++++++++++++++++- .../features/playlist/TvPlaylistActivity.kt | 25 +++++ .../com/m3u/features/stream/StreamMask.kt | 34 +++--- 8 files changed, 177 insertions(+), 37 deletions(-) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 88e31196e..2f0671310 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -62,18 +62,27 @@ android:exported="true" android:launchMode="singleTop" android:supportsPictureInPicture="true" - android:theme="@style/Theme.M3U"> - - - - - + android:theme="@style/Theme.M3U" /> + android:theme="@style/Theme.M3U"> + + + + + + + + + + + + + viewModel.createShortcut(context, id) }, - createTvRecommend = { id -> viewModel.createTvRecommend(context, id) }, + createTvRecommend = { id -> viewModel.createTvRecommend(helper.activityContext, id) }, isVodPlaylist = isVodPlaylist, isSeriesPlaylist = isSeriesPlaylist, getProgrammeCurrently = { channelId -> viewModel.getProgrammeCurrently(channelId) }, diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt b/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt index 891a69da2..3d255407f 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt @@ -1,5 +1,7 @@ package com.m3u.features.playlist +import android.annotation.SuppressLint +import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent @@ -8,6 +10,7 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -15,6 +18,8 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery @@ -35,10 +40,13 @@ import com.m3u.data.database.model.Playlist import com.m3u.data.database.model.Programme import com.m3u.data.database.model.Stream import com.m3u.data.database.model.epgUrlsOrXtreamXmlUrl +import com.m3u.data.database.model.isSeries +import com.m3u.data.database.model.type import com.m3u.data.parser.xtream.XtreamStreamInfo import com.m3u.data.repository.media.MediaRepository import com.m3u.data.repository.playlist.PlaylistRepository import com.m3u.data.repository.stream.StreamRepository +import com.m3u.data.service.MediaCommand import com.m3u.data.service.Messager import com.m3u.data.service.PlayerManager import com.m3u.data.worker.SubscriptionWorker @@ -62,10 +70,13 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.datetime.Clock import javax.inject.Inject +import androidx.tvprovider.media.tv.Channel as TvProviderChannel + +const val REQUEST_CHANNEL_BROWSABLE = 4001 @HiltViewModel class PlaylistViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, + private val savedStateHandle: SavedStateHandle, private val streamRepository: StreamRepository, private val playlistRepository: PlaylistRepository, private val mediaRepository: MediaRepository, @@ -211,7 +222,79 @@ class PlaylistViewModel @Inject constructor( } } - internal fun createTvRecommend(context: Context, id: Int) { + @SuppressLint("RestrictedApi") + internal fun createTvRecommend(activityContext: Context, id: Int) { + val channelInternalProviderId = "M3U" + val programInternalProviderId = "Program_$id" + val contentResolver = activityContext.contentResolver + val existingChannel: TvProviderChannel? = run { + contentResolver.query( + TvContractCompat.Channels.CONTENT_URI, + null, + null, + null, + null + )?.use { cursor -> + while (cursor.moveToNext()) { + val channel = TvProviderChannel.fromCursor(cursor) + if (channel.internalProviderId == channelInternalProviderId) return@run channel + } + } + null + } + viewModelScope.launch { + val stream = streamRepository.get(id) ?: return@launch + val type = when (playlistRepository.get(stream.playlistUrl)?.type) { + in Playlist.VOD_TYPES -> TvContractCompat.PreviewPrograms.TYPE_MOVIE + in Playlist.SERIES_TYPES -> TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE + else -> TvContractCompat.PreviewPrograms.TYPE_CHANNEL + } + val channelBuilder = when (existingChannel) { + null -> TvProviderChannel.Builder() + else -> TvProviderChannel.Builder(existingChannel) + } + val channel = channelBuilder + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName("M3U") + .setInternalProviderId(channelInternalProviderId) + .setAppLinkIntentUri("content://m3u.com/discover".toUri()) + .build() + + val channelId = channel.id + + if (existingChannel == null) { + try { + val intent = Intent(TvContractCompat.ACTION_REQUEST_CHANNEL_BROWSABLE) + intent.putExtra(TvContractCompat.EXTRA_CHANNEL_ID, channelId) + (activityContext as Activity).startActivityForResult( + intent, + REQUEST_CHANNEL_BROWSABLE, + null + ) + } catch (exception: Exception) { + logger.log(exception) + } + contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) + } + + val program = PreviewProgram.Builder() + .setChannelId(channelId) + .setType(type) + .setTitle(stream.title) + .setPreviewVideoUri(stream.url.toUri()) + .setPosterArtUri(stream.cover?.toUri()) + .setIntentUri("content://m3u.com/discover/$id".toUri()) + .setInternalProviderId(programInternalProviderId) + .build() + + contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + program.toContentValues() + ) + } } internal suspend fun getProgrammeCurrently(channelId: String): Programme? { @@ -325,6 +408,23 @@ class PlaylistViewModel @Inject constructor( } } + internal fun setup( + streamId: Int, + onPlayMediaCommand: (MediaCommand) -> Unit + ) { + viewModelScope.launch { + val stream = streamRepository.get(streamId) ?: return@launch + val playlist = playlistRepository.get(stream.playlistUrl) + savedStateHandle[PlaylistNavigation.TYPE_URL] = stream.playlistUrl + + if (playlist?.isSeries == false) { + onPlayMediaCommand(MediaCommand.Common(stream.id)) + } else { + series.value = stream + } + } + } + internal val series = MutableStateFlow(null) internal val seriesReplay = MutableStateFlow(0) diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt b/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt index a792108fd..da7dd5b4e 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt @@ -7,30 +7,55 @@ import android.content.res.Configuration import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.m3u.core.Contracts import com.m3u.ui.Events.enableDPadReaction import com.m3u.ui.Toolkit import com.m3u.ui.helper.Helper import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint class TvPlaylistActivity : AppCompatActivity() { private val helper: Helper = Helper(this) + private val viewModel: PlaylistViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() enableDPadReaction() super.onCreate(savedInstanceState) + handleIntent(intent) setContent { Toolkit(helper) { PlaylistRoute( + viewModel = viewModel, navigateToStream = ::navigateToStream ) } } } + private fun handleIntent(intent: Intent) { + val intentAction = intent.action + if (intentAction == Intent.ACTION_VIEW) { + val intentData = intent.data + val pathSegments = intentData?.pathSegments ?: emptyList() + when (pathSegments.firstOrNull()) { + "discover" -> { + val streamId = pathSegments[1].toIntOrNull() ?: return + viewModel.setup(streamId) { + lifecycleScope.launch { + helper.play(it) + navigateToStream() + } + } + } + } + } + } + private fun navigateToStream() { val options = ActivityOptions.makeCustomAnimation( this, diff --git a/features/stream/src/main/java/com/m3u/features/stream/StreamMask.kt b/features/stream/src/main/java/com/m3u/features/stream/StreamMask.kt index 39af8aa4c..addf005f0 100644 --- a/features/stream/src/main/java/com/m3u/features/stream/StreamMask.kt +++ b/features/stream/src/main/java/com/m3u/features/stream/StreamMask.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -306,26 +305,21 @@ internal fun StreamMask( } }, body = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() + AnimatedVisibility( + visible = (!isPanelExpanded && preferences.alwaysShowReplay) || playerState.playState in arrayOf( + Player.STATE_IDLE, + Player.STATE_ENDED + ) || playerState.playerError != null, + enter = fadeIn() + scaleIn(initialScale = 0.85f), + exit = fadeOut() + scaleOut(targetScale = 0.85f) ) { - androidx.compose.animation.AnimatedVisibility( - visible = (!isPanelExpanded && preferences.alwaysShowReplay) || playerState.playState in arrayOf( - Player.STATE_IDLE, - Player.STATE_ENDED - ) || playerState.playerError != null, - enter = fadeIn() + scaleIn(initialScale = 0.85f), - exit = fadeOut() + scaleOut(targetScale = 0.85f) - ) { - MaskCircleButton( - state = maskState, - icon = Icons.Rounded.Refresh, - onClick = { - coroutineScope.launch { helper.replay() } - } - ) - } + MaskCircleButton( + state = maskState, + icon = Icons.Rounded.Refresh, + onClick = { + coroutineScope.launch { helper.replay() } + } + ) } }, footer = {