diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index f98972f93..2c7c85da4 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -2,14 +2,14 @@ name: Android CI
on:
push:
- branches: [ "master" ]
+ branches:
+ - "master"
+ - "codex/**"
paths-ignore:
- '**.md'
- '**.txt'
- - '.github/**'
- '.idea/**'
- 'fastlane/**'
- - '!.github/workflows/**'
pull_request:
branches: [ "master" ]
workflow_dispatch:
@@ -45,21 +45,79 @@ jobs:
run: ./gradlew cleanManagedDevices --unused-only
- name: Build production app
- run: ./gradlew :app:smartphone:assembleRelease
+ run: ./gradlew :app:smartphone:assembleRelease -Pm3uReleaseAbiSplits=true
- name: Build production TV app
run: ./gradlew :app:tv:assembleRelease
- - name: Upload
+ - name: Generate APK checksums
+ run: |
+ set -euo pipefail
+ find app/smartphone/build/outputs/apk/release \
+ -maxdepth 1 \
+ -type f \
+ -name '*.apk' \
+ -print0 \
+ | sort -z \
+ | xargs -0 sha256sum > SHA256SUMS-smartphone.txt
+ find app/tv/build/outputs/apk/release \
+ -maxdepth 1 \
+ -type f \
+ -name '*.apk' \
+ -print0 \
+ | sort -z \
+ | xargs -0 sha256sum > SHA256SUMS-tv.txt
+ cat SHA256SUMS-smartphone.txt SHA256SUMS-tv.txt > SHA256SUMS.txt
+
+ - name: Upload smartphone APKs
uses: actions/upload-artifact@v4
with:
+ name: smartphone-apks
if-no-files-found: error
path: |
app/smartphone/build/outputs/apk/release/*.apk
+ SHA256SUMS-smartphone.txt
+
+ - name: Upload TV APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: tv-apk
+ if-no-files-found: error
+ path: |
app/tv/build/outputs/apk/release/*.apk
+ SHA256SUMS-tv.txt
- - name: Upload To Telegram
+ - name: Upload all APKs
+ uses: actions/upload-artifact@v4
+ with:
+ name: artifact
+ if-no-files-found: error
+ path: |-
+ app/smartphone/build/outputs/apk/release/*.apk
+ app/tv/build/outputs/apk/release/*.apk
+ SHA256SUMS.txt
+ SHA256SUMS-smartphone.txt
+ SHA256SUMS-tv.txt
+
+ - name: Check Telegram upload configuration
if: github.event_name != 'pull_request'
+ id: telegram-config
+ env:
+ BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
+ CHAT_ID: ${{ secrets.CHAT_ID }}
+ API_ID: ${{ secrets.API_ID }}
+ API_HASH: ${{ secrets.API_HASH }}
+ run: |
+ set -euo pipefail
+ if [ -n "$BOT_TOKEN" ] && [ -n "$CHAT_ID" ] && [ -n "$API_ID" ] && [ -n "$API_HASH" ]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ echo "Telegram upload skipped because one or more Telegram secrets are not configured."
+ fi
+
+ - name: Upload To Telegram
+ if: github.event_name != 'pull_request' && steps.telegram-config.outputs.enabled == 'true'
uses: xireiki/channel-post@v1.0.10
with:
bot_token: ${{ secrets.BOT_TOKEN }}
@@ -68,6 +126,7 @@ jobs:
api_hash: ${{ secrets.API_HASH }}
large_file: true
method: sendFile
- path: |
+ path: |-
app/smartphone/build/outputs/apk/release/*.apk
app/tv/build/outputs/apk/release/*.apk
+ SHA256SUMS*.txt
diff --git a/.github/workflows/native-packs.yml b/.github/workflows/native-packs.yml
index 37c7b3894..32512b3b7 100644
--- a/.github/workflows/native-packs.yml
+++ b/.github/workflows/native-packs.yml
@@ -63,8 +63,7 @@ jobs:
data/build.gradle.kts \
docs \
native-load.yml \
- .github \
- ':!.github/workflows/native-packs.yml'; then
+ .github; then
exit 1
fi
if find native-packs -name '*.json' -print0 \
diff --git a/README.md b/README.md
index edaf63381..aba8a6de4 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[](https://github.com/oxyroid/M3UAndroid/releases)
-[](https://developer.android.com)
+[](https://developer.android.com)
[](https://t.me/m3u_android)
[](LICENSE)
@@ -45,10 +45,17 @@ A modern IPTV streaming player built with Jetpack Compose for Android phones, ta
## Download
[](https://github.com/oxyroid/M3UAndroid/releases/latest)
-[](https://f-droid.org/packages/com.m3u.androidApp)
-[](https://apt.izzysoft.de/fdroid/index/apk/com.m3u.androidApp)
-**Nightly builds** available via [GitHub Actions artifacts](https://nightly.link/oxyroid/M3UAndroid/workflows/android/master/artifact.zip).
+**Nightly builds** are available for [mobile](https://nightly.link/oxyroid/M3UAndroid/workflows/android/master/smartphone-apks.zip), [Android TV](https://nightly.link/oxyroid/M3UAndroid/workflows/android/master/tv-apk.zip), and [all APKs](https://nightly.link/oxyroid/M3UAndroid/workflows/android/master/artifact.zip).
+
+M3UAndroid is not currently listed on F-Droid or IzzyOnDroid. Use the GitHub release or nightly artifacts until those repositories are available again.
+
+For Obtainium or another automatic updater, avoid the combined archive unless the updater also has an APK filter. Use:
+
+- Phones and tablets: the mobile nightly artifact, or GitHub release APK filter `^mobile-.*\.apk$`.
+- Android TV: the Android TV nightly artifact, or GitHub release APK filter `^tv-.*\.apk$`.
+
+Each nightly artifact includes a matching SHA-256 checksum file for verifying downloaded APKs.
## Tech Stack
diff --git a/app/extension/build.gradle.kts b/app/extension/build.gradle.kts
index 108995a30..f1dd5c3fd 100644
--- a/app/extension/build.gradle.kts
+++ b/app/extension/build.gradle.kts
@@ -11,7 +11,7 @@ android {
defaultConfig {
applicationId = "com.m3u.extension"
- minSdk = 26
+ minSdk = 25
targetSdk = 35
versionCode = 1
versionName = "1.0"
diff --git a/app/extension/src/main/AndroidManifest.xml b/app/extension/src/main/AndroidManifest.xml
index 29127b4e4..89cbbe1e0 100644
--- a/app/extension/src/main/AndroidManifest.xml
+++ b/app/extension/src/main/AndroidManifest.xml
@@ -1,5 +1,7 @@
-
+
+
-
+
-
\ No newline at end of file
+
diff --git a/app/smartphone/build.gradle.kts b/app/smartphone/build.gradle.kts
index 3beb262c5..68de01273 100644
--- a/app/smartphone/build.gradle.kts
+++ b/app/smartphone/build.gradle.kts
@@ -15,8 +15,8 @@ android {
namespace = "com.m3u.smartphone"
compileSdk = 36
defaultConfig {
- applicationId = "com.m3u.smartphone"
- minSdk = 26
+ applicationId = "com.m3u.androidApp"
+ minSdk = 25
targetSdk = 33
versionCode = 145
versionName = "1.15.1"
@@ -60,8 +60,12 @@ android {
.startParameter
.taskNames
.find { it.contains("richCodec", ignoreCase = true) } != null
+ val releaseAbiSplits = providers
+ .gradleProperty("m3uReleaseAbiSplits")
+ .map(String::toBoolean)
+ .getOrElse(false)
- isEnable = !benchmark && !snapshotChannel && richCodec
+ isEnable = !benchmark && !snapshotChannel && (richCodec || releaseAbiSplits)
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
@@ -85,9 +89,9 @@ android {
.forEach { output ->
val abi = output.getFilter("ABI")
output.outputFileName = if (abi == null) {
- "$versionName.apk"
+ "mobile-${versionName}.apk"
} else {
- "${versionName}_$abi.apk"
+ "mobile-${versionName}_$abi.apk"
}
}
}
@@ -179,6 +183,7 @@ dependencies {
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose)
implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.common.ktx)
implementation(libs.airbnb.lottie.compose)
implementation(libs.minabox)
@@ -188,6 +193,8 @@ dependencies {
implementation(libs.acra.notification)
implementation(libs.acra.mail)
+ testImplementation(libs.junit)
+
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
diff --git a/app/smartphone/src/main/AndroidManifest.xml b/app/smartphone/src/main/AndroidManifest.xml
index cd70e4fdf..d3e22e434 100644
--- a/app/smartphone/src/main/AndroidManifest.xml
+++ b/app/smartphone/src/main/AndroidManifest.xml
@@ -9,13 +9,18 @@
+
+
+
+ android:launchMode="singleTask"
+ android:resizeableActivity="true">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -69,6 +181,15 @@
android:process=":remote"
tools:ignore="ExportedService" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/BootReceiver.kt b/app/smartphone/src/main/java/com/m3u/smartphone/BootReceiver.kt
new file mode 100644
index 000000000..0e8eaede2
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/BootReceiver.kt
@@ -0,0 +1,39 @@
+package com.m3u.smartphone
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class BootReceiver : BroadcastReceiver() {
+ @Inject
+ lateinit var settings: Settings
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
+ val pendingResult = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ if (
+ settings[PreferencesKeys.LAUNCH_ON_BOOT] &&
+ settings[PreferencesKeys.RESUME_LAST_CHANNEL_ON_STARTUP]
+ ) {
+ context.startActivity(
+ Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/MainActivity.kt b/app/smartphone/src/main/java/com/m3u/smartphone/MainActivity.kt
index 402cec45f..1b8857a63 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/MainActivity.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/MainActivity.kt
@@ -1,17 +1,40 @@
package com.m3u.smartphone
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
import android.content.res.Configuration
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.Uri
import android.os.Bundle
+import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.lifecycleScope
+import androidx.work.WorkManager
+import com.m3u.core.Contracts
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
+import com.m3u.core.util.readFileName
+import com.m3u.data.database.model.isSeries
+import com.m3u.data.repository.channel.ChannelRepository
+import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.worker.SubscriptionWorker
+import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.App
import com.m3u.smartphone.ui.AppViewModel
+import com.m3u.smartphone.ui.business.channel.PlayerActivity
import com.m3u.smartphone.ui.common.helper.Helper
import com.m3u.smartphone.ui.common.internal.Toolkit
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -19,6 +42,18 @@ class MainActivity : AppCompatActivity() {
private val helper: Helper = Helper(this)
+ @Inject
+ lateinit var settings: Settings
+
+ @Inject
+ lateinit var channelRepository: ChannelRepository
+
+ @Inject
+ lateinit var playlistRepository: PlaylistRepository
+
+ @Inject
+ lateinit var workManager: WorkManager
+
override fun onResume() {
super.onResume()
helper.applyConfiguration()
@@ -40,5 +75,100 @@ class MainActivity : AppCompatActivity() {
)
}
}
+ if (savedInstanceState == null) {
+ val playlistEnqueued = maybeEnqueueViewedPlaylist(intent)
+ if (!playlistEnqueued) {
+ maybeResumeLastChannelOnStartup()
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ maybeEnqueueViewedPlaylist(intent)
+ }
+
+ private fun maybeEnqueueViewedPlaylist(intent: Intent?): Boolean {
+ val importIntent = intent?.takeIf { it.action in PLAYLIST_IMPORT_ACTIONS } ?: return false
+ val uri = importIntent.data ?: importIntent.streamUri()
+ uri ?: return false
+
+ takeReadPermission(uri, importIntent.flags)
+ val title = resolveViewedPlaylistTitle(uri)
+
+ SubscriptionWorker.m3u(workManager, title, uri.toString())
+ Toast.makeText(
+ this,
+ getString(string.feat_setting_enqueue_subscribe),
+ Toast.LENGTH_SHORT
+ ).show()
+ return true
+ }
+
+ private fun resolveViewedPlaylistTitle(uri: Uri): String {
+ return runCatching { uri.readFileName(contentResolver) }
+ .getOrNull()
+ ?.substringBeforeLast('.')
+ ?.takeIf { it.isNotBlank() }
+ ?: uri.lastPathSegment
+ ?.substringAfterLast('/')
+ ?.substringBeforeLast('.')
+ ?.takeIf { it.isNotBlank() }
+ ?: getString(R.string.app_name)
+ }
+
+ @Suppress("DEPRECATION")
+ private fun Intent.streamUri(): Uri? = getParcelableExtra(Intent.EXTRA_STREAM)
+
+ private fun takeReadPermission(uri: Uri, flags: Int) {
+ if (flags and Intent.FLAG_GRANT_READ_URI_PERMISSION == 0) return
+ val modeFlags = flags and (
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ runCatching {
+ contentResolver.takePersistableUriPermission(uri, modeFlags)
+ }
+ }
+
+ private fun maybeResumeLastChannelOnStartup() {
+ lifecycleScope.launch {
+ if (!settings[PreferencesKeys.RESUME_LAST_CHANNEL_ON_STARTUP]) {
+ return@launch
+ }
+ val startupDelay = settings[PreferencesKeys.STARTUP_DELAY]
+ if (startupDelay > 0) {
+ delay(startupDelay)
+ }
+ val channel = channelRepository.getPlayedRecently() ?: return@launch
+ if (channel.url.requiresNetwork() && !isNetworkConnected()) {
+ return@launch
+ }
+ val playlist = playlistRepository.get(channel.playlistUrl)
+ if (playlist?.isSeries == true) {
+ return@launch
+ }
+ startActivity(
+ Intent(this@MainActivity, PlayerActivity::class.java)
+ .putExtra(Contracts.PLAYER_SHORTCUT_CHANNEL_RECENTLY, true)
+ )
+ }
+ }
+
+ private fun isNetworkConnected(): Boolean {
+ val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = manager.activeNetwork ?: return false
+ val capabilities = manager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ private fun String.requiresNetwork(): Boolean {
+ val scheme = substringBefore('|').substringBefore(':', missingDelimiterValue = "")
+ return scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true).not() &&
+ scheme.equals(ContentResolver.SCHEME_FILE, ignoreCase = true).not()
+ }
+
+ private companion object {
+ val PLAYLIST_IMPORT_ACTIONS = setOf(Intent.ACTION_VIEW, Intent.ACTION_SEND)
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
index 3ded7bddc..b7145d83d 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
@@ -33,12 +33,15 @@ import androidx.compose.material3.SearchBarValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopSearchBar
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -52,6 +55,7 @@ import androidx.paging.PagingData
import com.m3u.core.architecture.preferences.PreferencesKeys
import com.m3u.core.architecture.preferences.preferenceOf
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.Programme
import com.m3u.data.service.MediaCommand
import com.m3u.data.tv.model.RemoteDirection
import com.m3u.smartphone.ui.business.channel.PlayerActivity
@@ -60,10 +64,14 @@ import com.m3u.smartphone.ui.common.AppNavHost
import com.m3u.smartphone.ui.common.connect.RemoteControlSheet
import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue
import com.m3u.smartphone.ui.common.helper.LocalHelper
+import com.m3u.smartphone.ui.common.helper.useRailNav
+import com.m3u.smartphone.ui.common.internal.Events
import com.m3u.smartphone.ui.material.components.Destination
+import com.m3u.smartphone.ui.material.components.EventHandler
import com.m3u.smartphone.ui.material.components.SnackHost
import com.m3u.smartphone.ui.material.model.LocalSpacing
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable
@@ -76,6 +84,8 @@ fun App(
AppImpl(
navController = navController,
channels = viewModel.channels,
+ searchQuery = viewModel.searchQuery.value,
+ onSearchQueryChange = { viewModel.searchQuery.value = it },
isRemoteControlSheetVisible = viewModel.isConnectSheetVisible,
remoteControlSheetValue = viewModel.remoteControlSheetValue,
openRemoteControlSheet = { viewModel.isConnectSheetVisible = true },
@@ -83,6 +93,7 @@ fun App(
checkTvCodeOnSmartphone = viewModel::checkTvCodeOnSmartphone,
forgetTvCodeOnSmartphone = viewModel::forgetTvCodeOnSmartphone,
onRemoteDirection = viewModel::onRemoteDirection,
+ getProgrammeCurrently = viewModel::getProgrammeCurrently,
onDismissRequest = {
viewModel.code = ""
viewModel.isConnectSheetVisible = false
@@ -95,6 +106,8 @@ fun App(
private fun AppImpl(
navController: NavHostController,
channels: Flow>,
+ searchQuery: String,
+ onSearchQueryChange: (String) -> Unit,
isRemoteControlSheetVisible: Boolean,
remoteControlSheetValue: RemoteControlSheetValue,
openRemoteControlSheet: () -> Unit,
@@ -102,6 +115,7 @@ private fun AppImpl(
checkTvCodeOnSmartphone: () -> Unit,
forgetTvCodeOnSmartphone: () -> Unit,
onRemoteDirection: (RemoteDirection) -> Unit,
+ getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -165,18 +179,31 @@ private fun AppImpl(
)
}
},
+ layoutType = if (helper.useRailNav) {
+ NavigationSuiteType.NavigationRail
+ } else {
+ NavigationSuiteType.NavigationBar
+ },
modifier = modifier
) {
Column {
val coroutineScope = rememberCoroutineScope()
val searchBarState = rememberSearchBarState()
- val textFieldState = rememberTextFieldState()
+ val textFieldState = rememberTextFieldState(searchQuery)
+ LaunchedEffect(textFieldState) {
+ snapshotFlow { textFieldState.text.toString() }
+ .distinctUntilChanged()
+ .collect { onSearchQueryChange(it) }
+ }
+ EventHandler(Events.openSearch) {
+ searchBarState.animateToExpanded()
+ }
val inputField = @Composable {
SearchBarDefaults.InputField(
searchBarState = searchBarState,
textFieldState = textFieldState,
onSearch = { coroutineScope.launch { searchBarState.animateToCollapsed() } },
- placeholder = { Text("Search...") },
+ placeholder = { Text(stringResource(com.m3u.i18n.R.string.feat_playlist_query_placeholder)) },
leadingIcon = {
if (searchBarState.currentValue == SearchBarValue.Expanded) {
IconButton(
@@ -222,7 +249,7 @@ private fun AppImpl(
}
},
onLongClick = {},
- getProgrammeCurrently = { null },
+ getProgrammeCurrently = getProgrammeCurrently,
reloadThumbnail = { null },
syncThumbnail = { null },
contentPadding = WindowInsets.ime.asPaddingValues()
@@ -230,7 +257,7 @@ private fun AppImpl(
}
AppNavHost(
navController = navController,
- navigateToDestination = { navController.navigate(it.name) },
+ navigateToDestination = navigateToDestination,
navigateToChannel = navigateToChannel,
modifier = Modifier
.fillMaxWidth()
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt
index a5efa1636..cd6752d6f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt
@@ -12,8 +12,10 @@ import androidx.paging.PagingData
import androidx.work.WorkManager
import com.m3u.data.api.TvApiDelegate
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.Programme
import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.repository.tv.ConnectionToTvValue
import com.m3u.data.repository.tv.TvRepository
import com.m3u.data.tv.model.RemoteDirection
@@ -23,9 +25,11 @@ import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -38,8 +42,11 @@ class AppViewModel @Inject constructor(
private val workManager: WorkManager,
private val tvRepository: TvRepository,
private val tvApi: TvApiDelegate,
+ private val programmeRepository: ProgrammeRepository,
) : ViewModel() {
val channels: Flow> = snapshotFlow { searchQuery.value }
+ .map { it.trim() }
+ .distinctUntilChanged()
.flatMapLatest { query ->
if (query.isBlank()) {
emptyFlow()
@@ -57,6 +64,11 @@ class AppViewModel @Inject constructor(
}
var searchQuery = mutableStateOf("")
+
+ suspend fun getProgrammeCurrently(channelId: Int): Programme? {
+ return programmeRepository.getProgrammeCurrently(channelId)
+ }
+
private fun refreshProgrammes() {
viewModelScope.launch {
val playlists = playlistRepository.getAllAutoRefresh()
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt
index 010c163fc..b5fef94cd 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt
@@ -36,7 +36,10 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeOff
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Archive
import androidx.compose.material.icons.rounded.Cast
+import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.HighQuality
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PictureInPicture
import androidx.compose.material.icons.rounded.PlayArrow
@@ -52,6 +55,7 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -91,7 +95,10 @@ import com.m3u.core.foundation.ui.composableOf
import com.m3u.core.foundation.ui.thenIf
import com.m3u.core.util.basic.isNotEmpty
import com.m3u.data.database.model.AdjacentChannels
+import com.m3u.data.database.model.Programme
import com.m3u.i18n.R.string
+import com.m3u.smartphone.TimeUtils.formatEOrSh
+import com.m3u.smartphone.TimeUtils.toEOrSh
import com.m3u.smartphone.ui.business.channel.components.Paddings
import com.m3u.smartphone.ui.business.channel.components.MaskTextButton
import com.m3u.smartphone.ui.business.channel.components.PlayerMask
@@ -103,6 +110,8 @@ import com.m3u.smartphone.ui.material.components.mask.MaskPanel
import com.m3u.smartphone.ui.material.components.mask.MaskState
import com.m3u.smartphone.ui.material.effects.currentBackStackEntry
import com.m3u.smartphone.ui.material.model.LocalSpacing
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
@@ -110,6 +119,7 @@ import kotlin.math.roundToInt
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
+import kotlin.time.Instant
import kotlin.time.toDuration
@Composable
@@ -118,6 +128,7 @@ fun ChannelMask(
title: String,
gesture: MaskGesture?,
playlistTitle: String,
+ currentProgramme: Programme?,
playerState: PlayerState,
volume: Float,
brightness: Float,
@@ -125,6 +136,7 @@ fun ChannelMask(
favourite: Boolean,
isSeriesPlaylist: Boolean,
isPanelExpanded: Boolean,
+ controlsLocked: Boolean,
useVertical: Boolean,
hasTrack: Boolean,
cwPosition: Long,
@@ -137,6 +149,8 @@ fun ChannelMask(
openChooseFormat: () -> Unit,
openOrClosePanel: () -> Unit,
onEnterPipMode: () -> Unit,
+ onControlsLockedChange: (Boolean) -> Unit,
+ onRecordVideo: () -> Unit,
onVolume: (Float) -> Unit,
onNextChannelClick: () -> Unit,
onPreviousChannelClick: () -> Unit,
@@ -146,11 +160,26 @@ fun ChannelMask(
val helper = LocalHelper.current
val spacing = LocalSpacing.current
val coroutineScope = rememberCoroutineScope()
+ val controlsLockKey = remember { Any() }
val onBackPressedDispatcher = checkNotNull(
LocalOnBackPressedDispatcherOwner.current
).onBackPressedDispatcher
+ LaunchedEffect(maskState, controlsLocked, controlsLockKey) {
+ if (controlsLocked) {
+ maskState.lock(controlsLockKey)
+ maskState.wake()
+ } else {
+ maskState.unlock(controlsLockKey)
+ }
+ }
+ DisposableEffect(maskState, controlsLockKey) {
+ onDispose {
+ maskState.unlock(controlsLockKey)
+ }
+ }
+
// because they will be updated frequently,
// they must be wrapped with rememberUpdatedState when using them.
val currentVolume by rememberUpdatedState(volume)
@@ -248,7 +277,7 @@ fun ChannelMask(
) {
MaskPanel(
state = maskState,
- isSpeedGestureEnabled = isSpeedable,
+ isSpeedGestureEnabled = isSpeedable && !controlsLocked,
onSpeedUpdated = onSpeedUpdated,
onSpeedStart = onSpeedStart,
onSpeedEnd = onSpeedEnd,
@@ -260,6 +289,21 @@ fun ChannelMask(
val playStateDisplayText = ChannelMaskUtils.playStateDisplayText(playerState.playState)
val exceptionDisplayText =
ChannelMaskUtils.playbackExceptionDisplayText(playerState.playerError)
+ val streamMetadata = playerState.streamMetadata
+ ?.takeIf { it.isNotBlank() }
+ val programmeDisplayText = currentProgramme?.let { programme ->
+ val start = Instant.fromEpochMilliseconds(programme.start)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .toEOrSh()
+ val end = Instant.fromEpochMilliseconds(programme.end)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .toEOrSh()
+ "${start.formatEOrSh(false)} - ${end.formatEOrSh(false)} ${programme.title}"
+ }
+ val programmeDescription = currentProgramme
+ ?.description
+ ?.takeIf { it.isNotBlank() }
+ val hasProgrammeInfo = programmeDisplayText != null || programmeDescription != null
val cwPositionObj = run {
currentCwPosition.takeIf {
@@ -272,256 +316,324 @@ fun ChannelMask(
state = maskState,
color = color,
header = {
- val backStackEntry by currentBackStackEntry()
- MaskButton(
- state = maskState,
- icon = backStackEntry?.navigationIcon ?: Icons.AutoMirrored.Rounded.ArrowBack,
- onClick = { onBackPressedDispatcher.onBackPressed() },
- contentDescription = stringResource(string.feat_channel_tooltip_on_back_pressed)
- )
- Spacer(modifier = Modifier.weight(1f))
-
- MaskTextButton(
- state = maskState,
- icon = when {
- volume == 0f -> Icons.AutoMirrored.Rounded.VolumeOff
- else -> Icons.AutoMirrored.Rounded.VolumeUp
- },
- text = brightnessOrVolumeText,
- tint = if (muted) MaterialTheme.colorScheme.error else Color.Unspecified,
- onClick = {
- onVolume(
- if (volume != 0f) {
- volumeBeforeMuted = volume
- 0f
- } else volumeBeforeMuted
- )
- },
- contentDescription = defaultBrightnessOrVolumeContentDescription
- )
- if (!isSeriesPlaylist) {
+ if (!controlsLocked) {
+ val backStackEntry by currentBackStackEntry()
MaskButton(
state = maskState,
- icon = Icons.Rounded.Star,
- tint = if (favourite) Color(0xffffcd3c) else Color.Unspecified,
- onClick = onFavorite,
- contentDescription = if (favourite) stringResource(string.feat_channel_tooltip_unfavourite)
- else stringResource(string.feat_channel_tooltip_favourite)
+ icon = backStackEntry?.navigationIcon ?: Icons.AutoMirrored.Rounded.ArrowBack,
+ onClick = { onBackPressedDispatcher.onBackPressed() },
+ contentDescription = stringResource(string.feat_channel_tooltip_on_back_pressed)
)
- }
-
- if (hasTrack) {
+ Spacer(modifier = Modifier.weight(1f))
MaskButton(
state = maskState,
- icon = Icons.Rounded.HighQuality,
- onClick = openChooseFormat,
- contentDescription = stringResource(string.feat_channel_tooltip_choose_format)
+ icon = Icons.Rounded.Lock,
+ onClick = { onControlsLockedChange(true) },
+ contentDescription = stringResource(string.feat_channel_tooltip_lock_controls)
)
- }
- if (!currentUseVertical) {
- MaskButton(
+ MaskTextButton(
state = maskState,
- icon = if (currentIsPanelExpanded) Icons.Rounded.Archive
- else Icons.Rounded.Unarchive,
- onClick = openOrClosePanel,
- contentDescription = stringResource(string.feat_channel_tooltip_open_panel)
+ icon = when {
+ volume == 0f -> Icons.AutoMirrored.Rounded.VolumeOff
+ else -> Icons.AutoMirrored.Rounded.VolumeUp
+ },
+ text = brightnessOrVolumeText,
+ tint = if (muted) MaterialTheme.colorScheme.error else Color.Unspecified,
+ onClick = {
+ onVolume(
+ if (volume != 0f) {
+ volumeBeforeMuted = volume
+ 0f
+ } else volumeBeforeMuted
+ )
+ },
+ contentDescription = defaultBrightnessOrVolumeContentDescription
)
- }
+ if (!isSeriesPlaylist) {
+ MaskButton(
+ state = maskState,
+ icon = Icons.Rounded.Star,
+ tint = if (favourite) Color(0xffffcd3c) else Color.Unspecified,
+ onClick = onFavorite,
+ contentDescription = if (favourite) stringResource(string.feat_channel_tooltip_unfavourite)
+ else stringResource(string.feat_channel_tooltip_favourite)
+ )
+ }
- if (screencast) {
- MaskButton(
- state = maskState,
- icon = Icons.Rounded.Cast,
- onClick = openDlnaDevices,
- contentDescription = stringResource(string.feat_channel_tooltip_cast)
- )
- }
- if (playerState.videoSize.isNotEmpty) {
- MaskButton(
- state = maskState,
- icon = Icons.Rounded.PictureInPicture,
- onClick = onEnterPipMode,
- contentDescription = stringResource(string.feat_channel_tooltip_enter_pip_mode),
- wakeWhenClicked = false
- )
- }
- },
- body = {
- val centerRole = MaskCenterRole.of(
- playerState.playState,
- playerState.isPlaying,
- alwaysShowReplay,
- playerState.playerError
- )
- Box(Modifier.size(36.dp)) {
- androidx.compose.animation.AnimatedVisibility(
- visible = !currentIsPanelExpanded && adjacentChannels?.prevId != null,
- enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it / 6 }),
- exit = fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 6 }),
- modifier = Modifier.fillMaxSize()
- ) {
- MaskNavigateButton(
+ if (hasTrack) {
+ MaskButton(
state = maskState,
- navigateRole = MaskNavigateRole.Previous,
- onClick = onPreviousChannelClick,
+ icon = Icons.Rounded.HighQuality,
+ onClick = openChooseFormat,
+ contentDescription = stringResource(string.feat_channel_tooltip_choose_format)
)
}
- }
- Box(Modifier.size(52.dp)) {
- androidx.compose.animation.AnimatedVisibility(
- visible = !currentIsPanelExpanded && centerRole != MaskCenterRole.Loading,
- enter = fadeIn(),
- exit = fadeOut(),
- modifier = Modifier.fillMaxSize()
- ) {
- MaskCenterButton(
+ if (playerState.videoSize.isNotEmpty) {
+ MaskButton(
state = maskState,
- centerRole = centerRole,
- onPlay = { playerState.player?.play() },
- onPause = { playerState.player?.pause() },
- onRetry = { coroutineScope.launch { helper.replay() } },
+ icon = Icons.Rounded.Download,
+ onClick = onRecordVideo,
+ contentDescription = stringResource(string.feat_channel_tooltip_record)
)
}
- }
- Box(Modifier.size(36.dp)) {
- androidx.compose.animation.AnimatedVisibility(
- visible = !currentIsPanelExpanded && adjacentChannels?.nextId != null,
- enter = fadeIn() + slideInHorizontally(initialOffsetX = { it / 6 }),
- exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it / 6 }),
- modifier = Modifier.fillMaxSize()
- ) {
- MaskNavigateButton(
+
+ if (!currentUseVertical) {
+ MaskButton(
+ state = maskState,
+ icon = if (currentIsPanelExpanded) Icons.Rounded.Archive
+ else Icons.Rounded.Unarchive,
+ onClick = openOrClosePanel,
+ contentDescription = stringResource(string.feat_channel_tooltip_open_panel)
+ )
+ }
+
+ if (screencast) {
+ MaskButton(
+ state = maskState,
+ icon = Icons.Rounded.Cast,
+ onClick = openDlnaDevices,
+ contentDescription = stringResource(string.feat_channel_tooltip_cast)
+ )
+ }
+ if (playerState.videoSize.isNotEmpty) {
+ MaskButton(
state = maskState,
- navigateRole = MaskNavigateRole.Next,
- onClick = onNextChannelClick,
+ icon = Icons.Rounded.PictureInPicture,
+ onClick = onEnterPipMode,
+ contentDescription = stringResource(string.feat_channel_tooltip_enter_pip_mode),
+ wakeWhenClicked = false
)
}
}
},
- control = {
- Crossfade(
- targetState = cwPositionObj,
- modifier = Modifier.align { size: IntSize, space: IntSize, _: LayoutDirection ->
- val centerX = (space.width - size.width).toFloat() / 2f
- val centerY = (space.height - size.height).toFloat() / 2f
- val x = centerX
- val y = centerY + playerState.videoSize.height() / 2
- IntOffset(x.fastRoundToInt(), y.fastRoundToInt())
+ body = {
+ if (controlsLocked) {
+ MaskButton(
+ state = maskState,
+ icon = Icons.Rounded.LockOpen,
+ onClick = { onControlsLockedChange(false) },
+ contentDescription = stringResource(string.feat_channel_tooltip_unlock_controls)
+ )
+ } else {
+ val centerRole = MaskCenterRole.of(
+ playerState.playState,
+ playerState.isPlaying,
+ alwaysShowReplay,
+ playerState.playerError
+ )
+ Box(Modifier.size(36.dp)) {
+ androidx.compose.animation.AnimatedVisibility(
+ visible = !currentIsPanelExpanded && adjacentChannels?.prevId != null,
+ enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it / 6 }),
+ exit = fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 6 }),
+ modifier = Modifier.fillMaxSize()
+ ) {
+ MaskNavigateButton(
+ state = maskState,
+ navigateRole = MaskNavigateRole.Previous,
+ onClick = onPreviousChannelClick,
+ )
+ }
}
- ) {
- if (it != null) {
- CwPositionSliderImpl(
- position = it.milliseconds,
- onResetPlayback = onResetPlayback
- )
+
+ Box(Modifier.size(52.dp)) {
+ androidx.compose.animation.AnimatedVisibility(
+ visible = !currentIsPanelExpanded && centerRole != MaskCenterRole.Loading,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier.fillMaxSize()
+ ) {
+ MaskCenterButton(
+ state = maskState,
+ centerRole = centerRole,
+ onPlay = { playerState.player?.play() },
+ onPause = { playerState.player?.pause() },
+ onRetry = { coroutineScope.launch { helper.replay() } },
+ )
+ }
+ }
+
+ Box(Modifier.size(36.dp)) {
+ androidx.compose.animation.AnimatedVisibility(
+ visible = !currentIsPanelExpanded && adjacentChannels?.nextId != null,
+ enter = fadeIn() + slideInHorizontally(initialOffsetX = { it / 6 }),
+ exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it / 6 }),
+ modifier = Modifier.fillMaxSize()
+ ) {
+ MaskNavigateButton(
+ state = maskState,
+ navigateRole = MaskNavigateRole.Next,
+ onClick = onNextChannelClick,
+ )
+ }
}
}
},
- footer = composableOf(
- any {
- suggest { !currentIsPanelExpanded }
- suggest { !currentUseVertical }
- suggest { playStateDisplayText.isNotEmpty() }
- suggest { exceptionDisplayText.isNotEmpty() }
- suggestAll {
- suggest { isStaticAndSeekable }
- suggest { slider }
+ control = {
+ if (!controlsLocked) {
+ Crossfade(
+ targetState = cwPositionObj,
+ modifier = Modifier.align { size: IntSize, space: IntSize, _: LayoutDirection ->
+ val centerX = (space.width - size.width).toFloat() / 2f
+ val centerY = (space.height - size.height).toFloat() / 2f
+ val x = centerX
+ val y = centerY + playerState.videoSize.height() / 2
+ IntOffset(x.fastRoundToInt(), y.fastRoundToInt())
+ }
+ ) {
+ if (it != null) {
+ CwPositionSliderImpl(
+ position = it.milliseconds,
+ onResetPlayback = onResetPlayback
+ )
+ }
}
}
- ) {
- Column(
- verticalArrangement = Arrangement.Bottom,
- modifier = Modifier
- .semantics(mergeDescendants = true) { }
- .weight(1f)
- .padding(bottom = spacing.small)
- ) {
- val alpha by animateFloatAsState(
- if (!currentIsPanelExpanded || !currentUseVertical) 1f else 0f
- )
- Column(Modifier.alpha(alpha)) {
- Text(
- text = playlistTitle.trim().uppercase(),
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- color = LocalContentColor.current.copy(0.54f),
- fontFamily = FontFamilies.LexendExa,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.basicMarquee()
- )
- Text(
- text = title.trim(),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.ExtraBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.basicMarquee()
- )
+ },
+ footer = if (controlsLocked) {
+ null
+ } else {
+ composableOf(
+ any {
+ suggest { !currentIsPanelExpanded }
+ suggest { !currentUseVertical }
+ suggest { playStateDisplayText.isNotEmpty() }
+ suggest { exceptionDisplayText.isNotEmpty() }
+ suggest { hasProgrammeInfo }
+ suggestAll {
+ suggest { isStaticAndSeekable }
+ suggest { slider }
+ }
}
- if (playStateDisplayText.isNotEmpty()
- || exceptionDisplayText.isNotEmpty()
- || (isStaticAndSeekable && slider)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.Bottom,
+ modifier = Modifier
+ .semantics(mergeDescendants = true) { }
+ .weight(1f)
+ .padding(bottom = spacing.small)
) {
- Spacer(
- modifier = Modifier.height(spacing.small)
+ val alpha by animateFloatAsState(
+ if (!currentIsPanelExpanded || !currentUseVertical) 1f else 0f
)
+ Column(Modifier.alpha(alpha)) {
+ Text(
+ text = playlistTitle.trim().uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ color = LocalContentColor.current.copy(0.54f),
+ fontFamily = FontFamilies.LexendExa,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.basicMarquee()
+ )
+ Text(
+ text = title.trim(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.ExtraBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.basicMarquee()
+ )
+ if (streamMetadata != null) {
+ Text(
+ text = streamMetadata,
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalContentColor.current.copy(alpha = 0.85f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.basicMarquee()
+ )
+ }
+ if (programmeDisplayText != null) {
+ Text(
+ text = programmeDisplayText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalContentColor.current.copy(alpha = 0.85f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.basicMarquee()
+ )
+ }
+ if (programmeDescription != null) {
+ Text(
+ text = programmeDescription,
+ style = MaterialTheme.typography.bodySmall,
+ color = LocalContentColor.current.copy(alpha = 0.65f),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ if (hasProgrammeInfo
+ || playStateDisplayText.isNotEmpty()
+ || exceptionDisplayText.isNotEmpty()
+ || (isStaticAndSeekable && slider)
+ ) {
+ Spacer(
+ modifier = Modifier.height(spacing.small)
+ )
+ }
+ if (playStateDisplayText.isNotEmpty()) {
+ Text(
+ text = playStateDisplayText.uppercase(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalContentColor.current.copy(alpha = 0.75f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.basicMarquee()
+ )
+ }
+ if (exceptionDisplayText.isNotBlank()) {
+ Text(
+ text = exceptionDisplayText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.basicMarquee()
+ )
+ }
}
- if (playStateDisplayText.isNotEmpty()) {
- Text(
- text = playStateDisplayText.uppercase(),
- style = MaterialTheme.typography.bodyMedium,
- color = LocalContentColor.current.copy(alpha = 0.75f),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.basicMarquee()
- )
+ val autoRotating by ChannelMaskUtils.IsAutoRotatingEnabled
+ LaunchedEffect(autoRotating) {
+ if (autoRotating) {
+ helper.screenOrientation =
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
}
- if (exceptionDisplayText.isNotBlank()) {
- Text(
- text = exceptionDisplayText,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.error,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.basicMarquee()
+ if (screenRotating && !autoRotating) {
+ MaskButton(
+ state = maskState,
+ icon = Icons.Rounded.ScreenRotationAlt,
+ onClick = {
+ helper.screenOrientation = when (helper.screenOrientation) {
+ ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ },
+ contentDescription = stringResource(string.feat_channel_tooltip_screen_rotating)
)
}
}
- val autoRotating by ChannelMaskUtils.IsAutoRotatingEnabled
- LaunchedEffect(autoRotating) {
- if (autoRotating) {
- helper.screenOrientation =
- ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
- }
- }
- if (screenRotating && !autoRotating) {
- MaskButton(
- state = maskState,
- icon = Icons.Rounded.ScreenRotationAlt,
- onClick = {
- helper.screenOrientation = when (helper.screenOrientation) {
- ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
- else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
- }
- },
- contentDescription = stringResource(string.feat_channel_tooltip_screen_rotating)
+ },
+ slider = if (controlsLocked) {
+ null
+ } else {
+ composableOf(slider && isStaticAndSeekable) {
+ SliderImpl(
+ contentDuration = contentDuration,
+ contentPosition = contentPosition,
+ bufferedPosition = bufferedPosition,
+ isPanelExpanded = currentIsPanelExpanded,
+ onBufferedPositionChanged = {
+ bufferedPosition = it
+ maskState.wake()
+ }
)
}
},
- slider = composableOf(slider && isStaticAndSeekable) {
- SliderImpl(
- contentDuration = contentDuration,
- contentPosition = contentPosition,
- bufferedPosition = bufferedPosition,
- isPanelExpanded = currentIsPanelExpanded,
- onBufferedPositionChanged = {
- bufferedPosition = it
- maskState.wake()
- }
- )
- },
onPaddingsChanged = onPaddingsChanged,
modifier = Modifier.fillMaxSize()
)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt
index 8a1a24827..899879a29 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt
@@ -3,12 +3,18 @@ package com.m3u.smartphone.ui.business.channel
import android.Manifest
import android.content.Intent
import android.graphics.Rect
+import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.VolumeDown
import androidx.compose.material.icons.automirrored.rounded.VolumeOff
@@ -16,6 +22,7 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.DarkMode
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.Speed
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -24,11 +31,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
@@ -46,13 +59,17 @@ import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.permissions.rememberPermissionState
import com.m3u.business.channel.ChannelViewModel
import com.m3u.business.channel.PlayerState
+import com.m3u.core.architecture.preferences.ClipMode
import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.mutablePreferenceOf
import com.m3u.core.architecture.preferences.preferenceOf
import com.m3u.core.util.basic.isNotEmpty
import com.m3u.core.util.basic.title
import com.m3u.data.database.model.AdjacentChannels
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Playlist
+import com.m3u.data.database.model.Programme
+import com.m3u.data.util.StreamUrlOptions
import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.business.channel.components.DlnaDevicesBottomSheet
import com.m3u.smartphone.ui.business.channel.components.FormatsBottomSheet
@@ -73,9 +90,13 @@ import com.m3u.smartphone.ui.material.components.mask.toggle
import com.m3u.smartphone.ui.material.components.rememberPlayerState
import com.m3u.smartphone.ui.material.components.rememberPullPanelLayoutState
import com.m3u.smartphone.ui.material.ktx.checkPermissionOrRationale
+import coil.compose.AsyncImage
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
@@ -105,7 +126,6 @@ fun ChannelRoute(
val searching by viewModel.searching.collectAsStateWithLifecycle()
val tracks by viewModel.tracks.collectAsStateWithLifecycle(emptyMap())
- val selectedFormats by viewModel.currentTracks.collectAsStateWithLifecycle(emptyMap())
val volume by viewModel.volume.collectAsStateWithLifecycle()
val isSeriesPlaylist by viewModel.isSeriesPlaylist.collectAsStateWithLifecycle(false)
@@ -116,6 +136,7 @@ fun ChannelRoute(
val channels = viewModel.pagingChannels.collectAsLazyPagingItems()
val programmes = viewModel.programmes.collectAsLazyPagingItems()
val programmeRange by viewModel.programmeRange.collectAsStateWithLifecycle()
+ val currentProgramme by viewModel.currentProgramme.collectAsStateWithLifecycle()
val programmeReminderIds by viewModel.programmeReminderIds.collectAsStateWithLifecycle()
@@ -125,6 +146,7 @@ fun ChannelRoute(
var isPipMode by remember { mutableStateOf(false) }
var isAutoZappingMode by remember { mutableStateOf(true) }
var choosing by remember { mutableStateOf(false) }
+ var controlsLocked by rememberSaveable { mutableStateOf(false) }
val brightnessGesture by preferenceOf(PreferencesKeys.BRIGHTNESS_GESTURE)
val volumeGesture by preferenceOf(PreferencesKeys.VOLUME_GESTURE)
@@ -137,6 +159,11 @@ fun ChannelRoute(
val maskState = rememberMaskState()
val pullPanelLayoutState = rememberPullPanelLayoutState()
+ BackHandler(enabled = controlsLocked) {
+ controlsLocked = false
+ maskState.unlockAll()
+ }
+
val isPanelExpanded = pullPanelLayoutState.isExpanded
val fraction = pullPanelLayoutState.fraction
@@ -250,7 +277,7 @@ fun ChannelRoute(
PullPanelLayout(
state = pullPanelLayoutState,
- enabled = isPanelEnabled,
+ enabled = isPanelEnabled && !controlsLocked,
aspectRatio = aspectRatio,
useVertical = useVertical,
panel = {
@@ -287,10 +314,12 @@ fun ChannelRoute(
pullPanelLayoutState.collapse()
},
openOrClosePanel = {
- if (isPanelExpanded) {
- pullPanelLayoutState.collapse()
- } else {
- pullPanelLayoutState.expand()
+ if (!controlsLocked) {
+ if (isPanelExpanded) {
+ pullPanelLayoutState.collapse()
+ } else {
+ pullPanelLayoutState.expand()
+ }
}
},
onFavorite = viewModel::onFavorite,
@@ -299,8 +328,10 @@ fun ChannelRoute(
playlist = playlist,
adjacentChannels = adjacentChannels,
channel = channel,
+ currentProgramme = currentProgramme,
hasTrack = tracks.isNotEmpty(),
isPanelExpanded = isPanelExpanded,
+ controlsLocked = controlsLocked,
brightness = brightness,
onBrightness = { brightness = it },
volume = volume,
@@ -318,9 +349,19 @@ fun ChannelRoute(
onNextChannelClick = viewModel::getNextChannel,
onEnterPipMode = {
helper.enterPipMode(playerState.videoSize)
+ controlsLocked = false
maskState.unlockAll()
pullPanelLayoutState.collapse()
},
+ onRecordVideo = {
+ createRecordFileLauncher.launch(channel?.title.orEmpty().toRecordFileName())
+ },
+ onControlsLockedChange = {
+ controlsLocked = it
+ if (it) {
+ pullPanelLayoutState.collapse()
+ }
+ },
onPaddingsChanged = onPaddingsChanged,
onAlignment = onAlignment
)
@@ -335,10 +376,12 @@ fun ChannelRoute(
devices = devices,
connectDlnaDevice = { viewModel.connectDlnaDevice(it) },
openInExternalPlayer = {
- val channelUrl = channel?.url ?: return@DlnaDevicesBottomSheet
+ val channelUrl = channel?.url
+ ?.let(StreamUrlOptions::stripFromUrl)
+ ?: return@DlnaDevicesBottomSheet
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(channelUrl.toUri(), "video/*")
+ setDataAndType(channelUrl.toUri(), externalPlayerMimeType(channelUrl))
}.let { Intent.createChooser(it, openInExternalPlayerString.title()) }
)
},
@@ -348,11 +391,10 @@ fun ChannelRoute(
FormatsBottomSheet(
visible = choosing,
formats = tracks,
- selectedFormats = selectedFormats,
maskState = maskState,
onDismiss = { choosing = false },
- onChooseTrack = { type, format ->
- viewModel.chooseTrack(type, format)
+ onChooseTrack = { option ->
+ viewModel.chooseTrack(option)
},
onClearTrack = { type ->
viewModel.clearTrack(type)
@@ -366,10 +408,12 @@ private fun ChannelPlayer(
playerState: PlayerState,
playlist: Playlist?,
channel: Channel?,
+ currentProgramme: Programme?,
adjacentChannels: AdjacentChannels?,
isSeriesPlaylist: Boolean,
hasTrack: Boolean,
isPanelExpanded: Boolean,
+ controlsLocked: Boolean,
brightness: Float,
volume: Float,
brightnessGestureEnabled: Boolean,
@@ -386,6 +430,8 @@ private fun ChannelPlayer(
onPreviousChannelClick: () -> Unit,
onNextChannelClick: () -> Unit,
onEnterPipMode: () -> Unit,
+ onRecordVideo: () -> Unit,
+ onControlsLockedChange: (Boolean) -> Unit,
onSpeedUpdated: (Float) -> Unit,
onPaddingsChanged: (Paddings) -> Unit,
onAlignment: (size: IntSize, space: IntSize) -> IntOffset,
@@ -403,7 +449,7 @@ private fun ChannelPlayer(
val currentVolume by rememberUpdatedState(volume)
val currentSpeed by rememberUpdatedState(speed)
- val clipMode by preferenceOf(PreferencesKeys.CLIP_MODE)
+ var clipMode by mutablePreferenceOf(PreferencesKeys.CLIP_MODE)
val useVertical = with(windowInfo.containerSize) { width < height }
@@ -412,7 +458,24 @@ private fun ChannelPlayer(
maskState.wake(6.seconds)
}
}
+ LaunchedEffect(controlsLocked) {
+ if (controlsLocked) {
+ gesture = null
+ }
+ }
Box(modifier) {
+ if (cover.isNotBlank() && playerState.videoSize.isNotEmpty && clipMode == ClipMode.ADAPTIVE) {
+ AsyncImage(
+ model = cover,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .fillMaxSize()
+ .blur(36.dp)
+ .alpha(0.42f)
+ .background(MaterialTheme.colorScheme.scrim)
+ )
+ }
val state = rememberPlayerState(
player = playerState.player,
clipMode = clipMode
@@ -421,10 +484,39 @@ private fun ChannelPlayer(
state = state,
modifier = Modifier
.fillMaxWidth()
+ .pointerInput(controlsLocked) {
+ if (!controlsLocked) {
+ detectTransformGestures { _, _, zoom, _ ->
+ clipMode = when {
+ zoom > 1.08f -> ClipMode.CLIP
+ zoom < 0.92f -> ClipMode.ADAPTIVE
+ else -> clipMode
+ }
+ }
+ }
+ }
.align { size: IntSize, space: IntSize, _ ->
onAlignment(size, space)
}
)
+ if (cover.isNotBlank() && !playerState.videoSize.isNotEmpty) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
+ ) {
+ AsyncImage(
+ model = cover,
+ contentDescription = title,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth(0.42f)
+ .sizeIn(maxHeight = 220.dp)
+ .clip(RoundedCornerShape(16.dp))
+ )
+ }
+ }
VerticalGestureArea(
percent = currentBrightness,
time = 0.65f,
@@ -439,7 +531,7 @@ private fun ChannelPlayer(
.fillMaxHeight(0.7f)
.fillMaxWidth(0.18f)
.align(Alignment.CenterStart),
- enabled = brightnessGestureEnabled
+ enabled = brightnessGestureEnabled && !controlsLocked
)
VerticalGestureArea(
@@ -456,13 +548,14 @@ private fun ChannelPlayer(
.align(Alignment.CenterEnd)
.fillMaxHeight(0.7f)
.fillMaxWidth(0.18f),
- enabled = volumeGestureEnabled
+ enabled = volumeGestureEnabled && !controlsLocked
)
ChannelMask(
adjacentChannels = adjacentChannels,
title = title,
playlistTitle = playlistTitle,
+ currentProgramme = currentProgramme,
playerState = playerState,
volume = volume,
brightness = brightness,
@@ -474,17 +567,20 @@ private fun ChannelPlayer(
cwPosition = cwPosition,
onResetPlayback = onResetPlayback,
isPanelExpanded = isPanelExpanded,
+ controlsLocked = controlsLocked,
onFavorite = onFavorite,
openDlnaDevices = openDlnaDevices,
openChooseFormat = openChooseFormat,
openOrClosePanel = openOrClosePanel,
onVolume = onVolume,
onEnterPipMode = onEnterPipMode,
+ onRecordVideo = onRecordVideo,
onPreviousChannelClick = onPreviousChannelClick,
onNextChannelClick = onNextChannelClick,
onSpeedUpdated = onSpeedUpdated,
onSpeedStart = { gesture = MaskGesture.SPEED },
onSpeedEnd = { gesture = null },
+ onControlsLockedChange = onControlsLockedChange,
gesture = gesture,
onPaddingsChanged = onPaddingsChanged
)
@@ -521,4 +617,12 @@ private fun ChannelPlayer(
}
}
}
-}
\ No newline at end of file
+}
+
+private fun String.toRecordFileName(): String {
+ val name = replace(Regex("""[\\/:*?"<>|]"""), "_")
+ .trim()
+ .ifEmpty { "record" }
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ return "${name}_$timestamp.mp4"
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ExternalPlayerMime.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ExternalPlayerMime.kt
new file mode 100644
index 000000000..9af50f70c
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ExternalPlayerMime.kt
@@ -0,0 +1,25 @@
+package com.m3u.smartphone.ui.business.channel
+
+internal fun externalPlayerMimeType(url: String): String {
+ return when (url.streamExtension()) {
+ "aac" -> "audio/aac"
+ "flac" -> "audio/flac"
+ "m4a" -> "audio/mp4"
+ "mp3" -> "audio/mpeg"
+ "oga",
+ "ogg",
+ "opus" -> "audio/ogg"
+ "wav" -> "audio/wav"
+ "weba" -> "audio/webm"
+ else -> "video/*"
+ }
+}
+
+private fun String.streamExtension(): String {
+ val path = substringBefore('#')
+ .substringBefore('?')
+ .trimEnd('/')
+ return path.substringAfterLast('/', missingDelimiterValue = path)
+ .substringAfterLast('.', missingDelimiterValue = "")
+ .lowercase()
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlaybackService.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlaybackService.kt
new file mode 100644
index 000000000..156945907
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlaybackService.kt
@@ -0,0 +1,78 @@
+package com.m3u.smartphone.ui.business.channel
+
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.media3.common.Player
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import com.m3u.core.Contracts
+import com.m3u.data.service.PlayerManager
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class PlaybackService : MediaSessionService() {
+ @Inject
+ lateinit var playerManager: PlayerManager
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var mediaSession: MediaSession? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ serviceScope.launch {
+ playerManager.player.collectLatest { player ->
+ if (player == null) {
+ releaseSession()
+ } else {
+ updateSession(player)
+ }
+ }
+ }
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+ return mediaSession
+ }
+
+ override fun onDestroy() {
+ releaseSession()
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ private fun updateSession(player: Player) {
+ val session = mediaSession
+ if (session == null) {
+ mediaSession = MediaSession.Builder(this, player)
+ .setSessionActivity(createSessionActivity())
+ .build()
+ .also(::addSession)
+ } else if (session.player !== player) {
+ session.setPlayer(player)
+ }
+ }
+
+ private fun releaseSession() {
+ mediaSession?.release()
+ mediaSession = null
+ }
+
+ private fun createSessionActivity(): PendingIntent {
+ val intent = Intent(this, PlayerActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .putExtra(Contracts.PLAYER_SHORTCUT_CHANNEL_RECENTLY, true)
+ return PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlayerActivity.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlayerActivity.kt
index 7464e9b27..2e25f9609 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlayerActivity.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/PlayerActivity.kt
@@ -2,7 +2,9 @@ package com.m3u.smartphone.ui.business.channel
import android.content.Intent
import android.content.res.Configuration
+import android.graphics.Rect
import android.os.Bundle
+import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -10,10 +12,14 @@ import androidx.activity.viewModels
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
+import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.m3u.business.channel.ChannelViewModel
import com.m3u.core.Contracts
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.flowOf
import com.m3u.data.database.model.isSeries
import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.repository.playlist.PlaylistRepository
@@ -32,6 +38,8 @@ class PlayerActivity : ComponentActivity() {
private val viewModel: ChannelViewModel by viewModels()
private val helper: Helper = Helper(this)
+ private var backgroundPlayback = true
+ private var autoPipOnHome = false
companion object {
// FIXME: the property is worked only when activity has one instance at most.
@@ -45,9 +53,22 @@ class PlayerActivity : ComponentActivity() {
@Inject
lateinit var playlistRepository: PlaylistRepository
+ @Inject
+ lateinit var settings: Settings
+
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
+ lifecycleScope.launch {
+ settings.flowOf(PreferencesKeys.BACKGROUND_PLAYBACK).collect { enabled ->
+ backgroundPlayback = enabled
+ }
+ }
+ lifecycleScope.launch {
+ settings.flowOf(PreferencesKeys.AUTO_PIP_ON_HOME).collect { enabled ->
+ autoPipOnHome = enabled
+ }
+ }
handleIntent(intent)
setContent {
Toolkit(
@@ -118,20 +139,78 @@ class PlayerActivity : ComponentActivity() {
helper.applyConfiguration()
}
- override fun onResume() {
- super.onResume()
- viewModel.pauseOrContinue(true)
- }
-
override fun onPause() {
super.onPause()
- if (!isInPictureInPictureMode) {
+ val player = viewModel.playerState.value.player
+ if (isInPictureInPictureMode || player == null) {
+ return
+ }
+ if (backgroundPlayback) {
+ val intent = Intent(this, PlaybackService::class.java)
+ if (player.playWhenReady) {
+ ContextCompat.startForegroundService(this, intent)
+ } else {
+ startService(intent)
+ }
+ } else {
viewModel.pauseOrContinue(false)
}
}
+ override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
+ if (!autoPipOnHome || isInPictureInPictureMode) return
+
+ val playerState = viewModel.playerState.value
+ if (playerState.player == null) return
+
+ val videoSize = playerState.videoSize
+ val pipSize = if (videoSize.width() > 0 && videoSize.height() > 0) {
+ videoSize
+ } else {
+ Rect(0, 0, 1920, 1080)
+ }
+ runCatching {
+ helper.enterPipMode(pipSize)
+ }
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ return when (event.keyCode) {
+ KeyEvent.KEYCODE_CHANNEL_UP,
+ KeyEvent.KEYCODE_PAGE_UP,
+ KeyEvent.KEYCODE_MEDIA_NEXT -> {
+ if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
+ viewModel.getNextChannel()
+ }
+ true
+ }
+
+ KeyEvent.KEYCODE_CHANNEL_DOWN,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
+ if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
+ viewModel.getPreviousChannel()
+ }
+ true
+ }
+
+ else -> super.dispatchKeyEvent(event)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ stopService(Intent(this, PlaybackService::class.java))
+ if (!backgroundPlayback) {
+ viewModel.pauseOrContinue(true)
+ }
+ }
+
override fun onDestroy() {
super.onDestroy()
- viewModel.destroy()
+ if (isFinishing && !isChangingConfigurations) {
+ viewModel.destroy()
+ }
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatItem.kt
index d91a8f8f0..6176e6fdf 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatItem.kt
@@ -36,14 +36,26 @@ internal fun FormatItem(
}
private fun Format.displayText(type: @C.TrackType Int): String = when (type) {
- C.TRACK_TYPE_AUDIO -> "$sampleRate $sampleMimeType"
- C.TRACK_TYPE_VIDEO -> "$width×$height $sampleMimeType"
+ C.TRACK_TYPE_AUDIO -> buildList {
+ label?.takeIf { it.isNotBlank() }?.let { add(it) }
+ language?.takeIf { it.isNotBlank() }?.let { add(it) }
+ if (channelCount > 0) add("${channelCount}ch")
+ if (sampleRate > 0) add("${sampleRate}Hz")
+ sampleMimeType?.takeIf { it.isNotBlank() }?.let { add(it) }
+ }.joinToDisplayText()
+
+ C.TRACK_TYPE_VIDEO -> buildList {
+ if (width > 0 && height > 0) add("${width}x$height")
+ sampleMimeType?.takeIf { it.isNotBlank() }?.let { add(it) }
+ }.joinToDisplayText()
+
C.TRACK_TYPE_TEXT -> buildList {
- label?.let { add(it) }
- language?.let { add(it) }
- sampleMimeType?.let { add(it) }
- }
- .joinToString(separator = "-")
+ label?.takeIf { it.isNotBlank() }?.let { add(it) }
+ language?.takeIf { it.isNotBlank() }?.let { add(it) }
+ sampleMimeType?.takeIf { it.isNotBlank() }?.let { add(it) }
+ }.joinToDisplayText()
else -> sampleMimeType.orEmpty()
-}
\ No newline at end of file
+}
+
+private fun List.joinToDisplayText(): String = joinToString(separator = " / ")
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt
index c9cca0af8..e207b19c8 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/FormatsBottomSheet.kt
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Audiotrack
import androidx.compose.material.icons.rounded.ClosedCaption
import androidx.compose.material.icons.rounded.DeviceUnknown
import androidx.compose.material.icons.rounded.VideoLibrary
+import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
@@ -33,9 +34,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.media3.common.C
-import androidx.media3.common.Format
+import com.m3u.data.service.TrackOption
import com.m3u.i18n.R.string
-import androidx.compose.material3.Icon
import com.m3u.smartphone.ui.material.components.mask.MaskState
import com.m3u.smartphone.ui.material.model.LocalSpacing
import kotlinx.coroutines.launch
@@ -43,11 +43,10 @@ import kotlinx.coroutines.launch
@Composable
internal fun FormatsBottomSheet(
visible: Boolean,
- formats: Map>,
- selectedFormats: Map,
+ formats: Map>,
maskState: MaskState,
onDismiss: () -> Unit,
- onChooseTrack: (@C.TrackType Int, Format) -> Unit,
+ onChooseTrack: (TrackOption) -> Unit,
onClearTrack: (@C.TrackType Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -83,9 +82,6 @@ internal fun FormatsBottomSheet(
val formatsIndexed = remember(formats) {
formats.map { it.value }
}
- val selectedFormatsIndexed = remember(selectedFormats) {
- selectedFormats.map { it.value }
- }
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
@@ -94,21 +90,19 @@ internal fun FormatsBottomSheet(
) { page ->
val type = typesIndexed[page]
val currentFormats = formatsIndexed[page]
- val selectedFormat = selectedFormatsIndexed[page]
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
- items(currentFormats) { format ->
- val selected = format.id == selectedFormat?.id
+ items(currentFormats) { option ->
FormatItem(
- format = format,
+ format = option.format,
type = type,
- selected = selected,
+ selected = option.selected,
onClick = {
- if (selected) {
+ if (option.selected && type == C.TRACK_TYPE_TEXT) {
onClearTrack(type)
- } else {
- onChooseTrack(type, format)
+ } else if (!option.selected) {
+ onChooseTrack(option)
}
}
)
@@ -129,10 +123,10 @@ internal fun FormatsBottomSheet(
else -> Icons.Rounded.DeviceUnknown
}
val text = when (type) {
- C.TRACK_TYPE_AUDIO -> "AUDIO"
- C.TRACK_TYPE_VIDEO -> "VIDEO"
- C.TRACK_TYPE_TEXT -> "TEXT"
- else -> "OTHER"
+ C.TRACK_TYPE_AUDIO -> stringResource(string.feat_channel_track_type_audio)
+ C.TRACK_TYPE_VIDEO -> stringResource(string.feat_channel_track_type_video)
+ C.TRACK_TYPE_TEXT -> stringResource(string.feat_channel_track_type_subtitle)
+ else -> stringResource(string.feat_channel_track_type_other)
}
SegmentedButton(
selected = index == pagerState.currentPage,
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt
index 7537323ac..79ae6913c 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt
@@ -2,10 +2,17 @@ package com.m3u.smartphone.ui.business.favourite
import android.content.res.Configuration
import android.view.KeyEvent
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Sort
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -20,6 +27,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.ImeAction
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -33,6 +41,7 @@ import com.m3u.core.foundation.ui.thenIf
import com.m3u.core.util.basic.title
import com.m3u.core.wrapper.Sort
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.Programme
import com.m3u.data.database.model.isSeries
import com.m3u.data.service.MediaCommand
import com.m3u.i18n.R
@@ -44,8 +53,12 @@ import com.m3u.smartphone.ui.material.components.EpisodesBottomSheet
import com.m3u.smartphone.ui.material.components.MediaSheet
import com.m3u.smartphone.ui.material.components.MediaSheetValue
import com.m3u.smartphone.ui.material.components.SortBottomSheet
+import com.m3u.smartphone.ui.material.components.TextField
import com.m3u.smartphone.ui.material.ktx.interceptVolumeEvent
+import com.m3u.smartphone.ui.material.ktx.minus
+import com.m3u.smartphone.ui.material.ktx.only
import com.m3u.smartphone.ui.material.model.LocalHazeState
+import com.m3u.smartphone.ui.material.model.LocalSpacing
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.launch
@@ -71,17 +84,26 @@ fun FavoriteRoute(
val zapping by viewModel.zapping.collectAsStateWithLifecycle()
val sorts = viewModel.sorts
val sort by viewModel.sort.collectAsStateWithLifecycle()
+ val query by viewModel.query.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState()
var isSortSheetVisible by rememberSaveable { mutableStateOf(false) }
+ var isSearchVisible by rememberSaveable { mutableStateOf(false) }
var mediaSheetValue: MediaSheetValue.FavoriteScreen by remember {
mutableStateOf(MediaSheetValue.FavoriteScreen())
}
val series: Channel? by viewModel.series.collectAsStateWithLifecycle()
- LifecycleResumeEffect(title) {
+ BackHandler(query.isNotEmpty() || isSearchVisible) {
+ when {
+ query.isNotEmpty() -> viewModel.query.value = ""
+ else -> isSearchVisible = false
+ }
+ }
+
+ LifecycleResumeEffect(title, isSearchVisible) {
Metadata.title = AnnotatedString(title.title())
Metadata.color = Color.Unspecified
Metadata.contentColor = Color.Unspecified
@@ -90,6 +112,14 @@ fun FavoriteRoute(
icon = Icons.AutoMirrored.Rounded.Sort,
contentDescription = "sort",
onClick = { isSortSheetVisible = true }
+ ),
+ Action(
+ icon = if (isSearchVisible) Icons.Rounded.Close else Icons.Rounded.Search,
+ contentDescription = if (isSearchVisible) "close search" else "search",
+ onClick = {
+ isSearchVisible = !isSearchVisible
+ if (!isSearchVisible) viewModel.query.value = ""
+ }
)
)
onPauseOrDispose {
@@ -99,10 +129,14 @@ fun FavoriteRoute(
FavoriteScreen(
contentPadding = contentPadding,
+ query = query,
+ searchVisible = isSearchVisible,
rowCount = rowCount,
channels = channels,
zapping = zapping,
recently = sort == Sort.RECENTLY,
+ getProgrammeCurrently = { channelId -> viewModel.getProgrammeCurrently(channelId) },
+ onQuery = { viewModel.query.value = it },
onClickChannel = { channel ->
coroutineScope.launch {
val playlist = viewModel.getPlaylist(channel.playlistUrl)
@@ -183,28 +217,57 @@ fun FavoriteRoute(
@Composable
private fun FavoriteScreen(
contentPadding: PaddingValues,
+ query: String,
+ searchVisible: Boolean,
rowCount: Int,
channels: LazyPagingItems,
zapping: Channel?,
recently: Boolean,
+ getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
+ onQuery: (String) -> Unit,
onClickChannel: (Channel) -> Unit,
onLongClickChannel: (Channel) -> Unit,
modifier: Modifier = Modifier
) {
val configuration = LocalConfiguration.current
+ val spacing = LocalSpacing.current
val actualRowCount = when (configuration.orientation) {
Configuration.ORIENTATION_PORTRAIT -> rowCount
Configuration.ORIENTATION_LANDSCAPE -> rowCount + 2
else -> rowCount + 2
}
- FavoriteGallery(
- contentPadding = contentPadding,
- channels = channels,
- zapping = zapping,
- recently = recently,
- rowCount = actualRowCount,
- onClick = onClickChannel,
- onLongClick = onLongClickChannel,
- modifier = modifier.hazeSource(LocalHazeState.current)
- )
-}
\ No newline at end of file
+ val showSearch = searchVisible || query.isNotEmpty()
+ Column(modifier = modifier) {
+ AnimatedVisibility(visible = showSearch) {
+ TextField(
+ text = query,
+ placeholder = stringResource(R.string.feat_favorite_query_placeholder),
+ imeAction = ImeAction.Search,
+ onValueChange = onQuery,
+ modifier = Modifier
+ .padding(contentPadding only WindowInsetsSides.Top)
+ .padding(
+ horizontal = spacing.medium,
+ vertical = spacing.small
+ )
+ )
+ }
+ FavoriteGallery(
+ contentPadding = if (showSearch) {
+ contentPadding - (contentPadding only WindowInsetsSides.Top)
+ } else {
+ contentPadding
+ },
+ channels = channels,
+ zapping = zapping,
+ recently = recently,
+ getProgrammeCurrently = getProgrammeCurrently,
+ rowCount = actualRowCount,
+ onClick = onClickChannel,
+ onLongClick = onLongClickChannel,
+ modifier = Modifier
+ .weight(1f)
+ .hazeSource(LocalHazeState.current)
+ )
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt
index bbd367550..9eadc1d9f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteGallery.kt
@@ -11,11 +11,15 @@ import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import com.m3u.core.foundation.components.CircularProgressIndicator
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.Programme
import com.m3u.smartphone.ui.material.ktx.plus
import com.m3u.smartphone.ui.material.model.LocalSpacing
@@ -26,11 +30,13 @@ internal fun FavoriteGallery(
zapping: Channel?,
recently: Boolean,
rowCount: Int,
+ getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
onClick: (Channel) -> Unit,
onLongClick: (Channel) -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
+ val currentGetProgrammeCurrently by rememberUpdatedState(getProgrammeCurrently)
Row(
modifier = modifier
.fillMaxSize()
@@ -57,8 +63,17 @@ internal fun FavoriteGallery(
if (channel == null) {
CircularProgressIndicator()
} else {
+ val programme: Programme? by produceState(
+ initialValue = null,
+ key1 = channel.id,
+ key2 = recently
+ ) {
+ if (recently) return@produceState
+ value = currentGetProgrammeCurrently(channel.id)
+ }
FavoriteItem(
channel = channel,
+ programme = programme,
zapping = zapping == channel,
onClick = { onClick(channel) },
onLongClick = { onLongClick(channel) },
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteItem.kt
index 75a6ea37c..8ade64705 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/components/FavoriteItem.kt
@@ -1,7 +1,12 @@
package com.m3u.smartphone.ui.business.favourite.components
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.BrokenImage
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
@@ -9,27 +14,39 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.SubcomposeAsyncImage
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.preferenceOf
+import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
+import com.m3u.core.foundation.components.CircularProgressIndicator
+import com.m3u.core.foundation.ui.composableOf
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.Programme
import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.business.playlist.components.readText
import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
-import kotlin.time.Clock
-import kotlin.time.Instant
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Clock
+import kotlin.time.Instant
@Composable
internal fun FavoriteItem(
channel: Channel,
+ programme: Programme?,
recently: Boolean,
zapping: Boolean,
onClick: () -> Unit,
@@ -41,6 +58,8 @@ internal fun FavoriteItem(
val recentlyString = stringResource(string.ui_sort_recently)
val neverPlayedString = stringResource(string.ui_sort_never_played)
+ val noPictureMode by preferenceOf(PreferencesKeys.NO_PICTURE_MODE)
+
OutlinedCard(
modifier = Modifier.semantics(mergeDescendants = true) { },
border = CardDefaults.outlinedCardBorder(zapping),
@@ -57,27 +76,67 @@ internal fun FavoriteItem(
fontWeight = FontWeight.Bold,
)
},
+ leadingContent = composableOf(!noPictureMode) {
+ SubcomposeAsyncImage(
+ model = channel.cover,
+ contentDescription = channel.title,
+ contentScale = ContentScale.Fit,
+ loading = {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.size(56.dp)
+ ) {
+ CircularProgressIndicator()
+ }
+ },
+ error = {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.size(56.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.BrokenImage,
+ contentDescription = null,
+ tint = LocalContentColor.current.copy(0.56f)
+ )
+ }
+ },
+ modifier = Modifier.size(56.dp)
+ )
+ },
supportingContent = {
- if (recently) {
- Text(
- text = remember(channel.seen) {
- val now = Clock.System.now()
- val instant = Instant.fromEpochMilliseconds(channel.seen)
- val duration = now - instant
- duration.toComponents { days, hours, minutes, seconds, _ ->
- when {
- channel.seen == 0L -> neverPlayedString
- days > 0 -> days.days.toString()
- hours > 0 -> hours.hours.toString()
- minutes > 0 -> minutes.minutes.toString()
- seconds > 0 -> seconds.seconds.toString()
- else -> recentlyString
+ when {
+ recently -> {
+ Text(
+ text = remember(channel.seen) {
+ val now = Clock.System.now()
+ val instant = Instant.fromEpochMilliseconds(channel.seen)
+ val duration = now - instant
+ duration.toComponents { days, hours, minutes, seconds, _ ->
+ when {
+ channel.seen == 0L -> neverPlayedString
+ days > 0 -> days.days.toString()
+ hours > 0 -> hours.hours.toString()
+ minutes > 0 -> minutes.minutes.toString()
+ seconds > 0 -> seconds.seconds.toString()
+ else -> recentlyString
+ }
}
- }
- },
- style = MaterialTheme.typography.bodySmall,
- color = LocalContentColor.current.copy(0.56f)
- )
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = LocalContentColor.current.copy(0.56f)
+ )
+ }
+
+ programme != null -> {
+ Text(
+ text = programme.readText(),
+ style = MaterialTheme.typography.bodySmall,
+ color = LocalContentColor.current.copy(0.56f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
}
},
colors = ListItemDefaults.colors(Color.Transparent),
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt
index 09705dc5a..283531d29 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -40,6 +41,7 @@ import com.m3u.core.foundation.ui.composableOf
import com.m3u.core.foundation.ui.thenIf
import com.m3u.core.util.basic.title
import com.m3u.core.wrapper.Resource
+import com.m3u.core.wrapper.eventOf
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Playlist
import com.m3u.data.database.model.PlaylistWithCount
@@ -52,6 +54,7 @@ import com.m3u.smartphone.ui.business.foryou.components.recommend.RecommendGalle
import com.m3u.smartphone.ui.common.helper.Action
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.common.helper.Metadata
+import com.m3u.smartphone.ui.common.internal.Events
import com.m3u.smartphone.ui.material.components.EpisodesBottomSheet
import com.m3u.smartphone.ui.material.components.MediaSheet
import com.m3u.smartphone.ui.material.components.MediaSheetValue
@@ -92,6 +95,11 @@ fun ForyouRoute(
Metadata.color = Color.Unspecified
Metadata.contentColor = Color.Unspecified
Metadata.actions = listOf(
+ Action(
+ icon = Icons.Rounded.Search,
+ contentDescription = "search",
+ onClick = { Events.openSearch = eventOf(Unit) }
+ ),
Action(
icon = Icons.Rounded.Add,
contentDescription = "add",
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt
index ef2d127d7..d42ef81fa 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt
@@ -225,20 +225,23 @@ private fun DiscoverContent(spec: Recommend.DiscoverSpec) {
val playlist = spec.playlist
val category = spec.category
Text(
- text = stringResource(string.feat_foryou_recommend_unseen_label).uppercase(),
+ text = stringResource(string.feat_foryou_recommend_discover_label).uppercase(),
style = MaterialTheme.typography.labelLarge,
maxLines = 1
)
Text(
- text = "",
+ text = stringResource(string.feat_foryou_recommend_discover_from, playlist.title),
style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(spacing.extraSmall))
Text(
- text = "",
+ text = category,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Black,
- maxLines = 1
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
}
@@ -351,4 +354,4 @@ private fun NewReleaseContent(spec: Recommend.NewRelease) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt
index e8bff4b69..58c470678 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt
@@ -26,13 +26,16 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Sort
+import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp
import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
@@ -53,7 +56,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.ImeAction
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -81,12 +86,14 @@ import com.m3u.smartphone.ui.common.helper.Action
import com.m3u.smartphone.ui.common.helper.Fob
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.common.helper.Metadata
+import com.m3u.smartphone.ui.common.internal.Events
import com.m3u.smartphone.ui.material.components.Destination
import com.m3u.smartphone.ui.material.components.EpisodesBottomSheet
import com.m3u.smartphone.ui.material.components.EventHandler
import com.m3u.smartphone.ui.material.components.MediaSheet
import com.m3u.smartphone.ui.material.components.MediaSheetValue
import com.m3u.smartphone.ui.material.components.SortBottomSheet
+import com.m3u.smartphone.ui.material.components.TextField
import com.m3u.smartphone.ui.material.ktx.checkPermissionOrRationale
import com.m3u.smartphone.ui.material.ktx.interceptVolumeEvent
import com.m3u.smartphone.ui.material.ktx.isAtTop
@@ -164,10 +171,6 @@ internal fun PlaylistRoute(
}
}
- BackHandler(query.isNotEmpty()) {
- viewModel.query.value = ""
- }
-
PlaylistScreen(
title = playlist?.title.orEmpty(),
query = query,
@@ -177,6 +180,7 @@ internal fun PlaylistRoute(
channels = channels,
pinnedCategories = pinnedCategories,
onPinOrUnpinCategory = { viewModel.onPinOrUnpinCategory(it) },
+ onReorderCategories = { viewModel.onReorderCategories(it) },
onHideCategory = { viewModel.onHideCategory(it) },
scrollUp = scrollUp,
sorts = sorts,
@@ -285,6 +289,7 @@ private fun PlaylistScreen(
channels: Map>>,
pinnedCategories: List,
onPinOrUnpinCategory: (String) -> Unit,
+ onReorderCategories: (List) -> Unit,
onHideCategory: (String) -> Unit,
sorts: List,
sort: Sort,
@@ -337,14 +342,23 @@ private fun PlaylistScreen(
var mediaSheetValue: MediaSheetValue.PlaylistScreen by remember { mutableStateOf(MediaSheetValue.PlaylistScreen()) }
var isSortSheetVisible by rememberSaveable { mutableStateOf(false) }
+ var isSearchVisible by rememberSaveable { mutableStateOf(false) }
- LifecycleResumeEffect(refreshing) {
+ LifecycleResumeEffect(refreshing, isSearchVisible) {
Metadata.actions = buildList {
Action(
icon = Icons.AutoMirrored.Rounded.Sort,
contentDescription = "sort",
onClick = { isSortSheetVisible = true }
).also { add(it) }
+ Action(
+ icon = if (isSearchVisible) Icons.Rounded.Close else Icons.Rounded.Search,
+ contentDescription = if (isSearchVisible) "close search" else "search",
+ onClick = {
+ isSearchVisible = !isSearchVisible
+ if (!isSearchVisible) onQuery("")
+ }
+ ).also { add(it) }
Action(
icon = Icons.Rounded.Refresh,
enabled = !refreshing,
@@ -360,15 +374,6 @@ private fun PlaylistScreen(
val categories = remember(channels) { channels.map { it.key } }
var category by remember(categories) { mutableStateOf(categories.firstOrNull().orEmpty()) }
- val state = rememberLazyStaggeredGridState()
- LaunchedEffect(Unit) {
- snapshotFlow { state.isAtTop }
- .onEach { isAtTopState.value = it }
- .launchIn(this)
- }
- EventHandler(scrollUp) {
- state.scrollToItem(0)
- }
val orientation = configuration.orientation
val actualRowCount = remember(orientation, rowCount) {
when (orientation) {
@@ -380,9 +385,38 @@ private fun PlaylistScreen(
var isExpanded by remember(sort == Sort.MIXED) {
mutableStateOf(false)
}
+ var currentGridState: LazyStaggeredGridState? by remember { mutableStateOf(null) }
BackHandler(isExpanded) { isExpanded = false }
+ BackHandler(query.isNotEmpty() || isSearchVisible) {
+ when {
+ query.isNotEmpty() -> onQuery("")
+ else -> isSearchVisible = false
+ }
+ }
+ EventHandler(scrollUp) {
+ currentGridState?.scrollToItem(0)
+ }
var targetPageIndex: Event by remember { mutableStateOf(Event.Handled()) }
+ var pendingDiscoverCategory by remember { mutableStateOf(null) }
+ fun selectCategory(target: String) {
+ category = target
+ targetPageIndex = categories.indexOf(target)
+ .takeIf { it != -1 }
+ ?.let { eventOf(it) }
+ ?: Event.Handled()
+ }
+
+ EventHandler(Events.discoverCategory) { target ->
+ pendingDiscoverCategory = target
+ }
+ LaunchedEffect(categories, pendingDiscoverCategory) {
+ val target = pendingDiscoverCategory ?: return@LaunchedEffect
+ if (target in categories) {
+ selectCategory(target)
+ pendingDiscoverCategory = null
+ }
+ }
val tabs = @Composable {
PlaylistTabRow(
@@ -391,38 +425,61 @@ private fun PlaylistScreen(
isExpanded = isExpanded,
bottomContentPadding = contentPadding only WindowInsetsSides.Bottom,
onExpanded = { isExpanded = !isExpanded },
- onCategoryChanged = {
- category = it
- targetPageIndex = categories.indexOf(it)
- .takeIf { it != -1 }
- ?.let { eventOf(it) }
- ?: Event.Handled()
- },
+ onCategoryChanged = ::selectCategory,
pinnedCategories = pinnedCategories,
onPinOrUnpinCategory = onPinOrUnpinCategory,
+ onReorderCategories = onReorderCategories,
onHideCategory = onHideCategory
)
}
val gallery = @Composable {
- val pagerState = rememberPagerState { channels.size }
val entries = channels.entries.toList()
+ val pagerState = rememberPagerState { entries.size }
LaunchedEffect(entries) {
+ if (entries.isEmpty()) return@LaunchedEffect
+ val selectedIndex = entries
+ .indexOfFirst { it.key == category }
+ .takeIf { it != -1 }
+ ?: 0
+ val selectedCategory = entries[selectedIndex].key
+ if (category != selectedCategory) {
+ category = selectedCategory
+ }
+ if (pagerState.currentPage != selectedIndex) {
+ pagerState.scrollToPage(selectedIndex)
+ }
+ }
+ LaunchedEffect(entries, pagerState) {
snapshotFlow { pagerState.settledPage }
+ .distinctUntilChanged()
.collectLatest { index ->
category = entries.getOrNull(index)?.key.orEmpty()
}
}
EventHandler(targetPageIndex) {
- pagerState.scrollToPage(it)
+ pagerState.animateScrollToPage(it)
}
HorizontalPager(
state = pagerState,
+ key = { index -> entries[index].key },
modifier = Modifier
.hazeSource(LocalHazeState.current)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
) { index ->
val (_, channels) = entries[index]
+ val state = rememberLazyStaggeredGridState()
+
+ LaunchedEffect(index, pagerState, state) {
+ snapshotFlow { (pagerState.currentPage == index) to state.isAtTop }
+ .distinctUntilChanged()
+ .collectLatest { (isCurrentPage, isAtTop) ->
+ if (isCurrentPage) {
+ currentGridState = state
+ isAtTopState.value = isAtTop
+ }
+ }
+ }
ChannelGallery(
state = state,
@@ -447,6 +504,19 @@ private fun PlaylistScreen(
.padding(contentPadding.minus(contentPadding.only(WindowInsetsSides.Bottom)))
.then(modifier)
) {
+ val spacing = LocalSpacing.current
+ AnimatedVisibility(visible = isSearchVisible || query.isNotEmpty()) {
+ TextField(
+ text = query,
+ placeholder = stringResource(string.feat_playlist_query_placeholder),
+ imeAction = ImeAction.Search,
+ onValueChange = onQuery,
+ modifier = Modifier.padding(
+ horizontal = spacing.medium,
+ vertical = spacing.small
+ )
+ )
+ }
if (!isExpanded) {
AnimatedVisibility(
visible = categories.size > 1,
@@ -525,7 +595,7 @@ private fun UnsupportedUIModeContent(
),
modifier = modifier.fillMaxSize()
) {
- Text("Unsupported UI Mode: $device")
+ Text(stringResource(string.feat_playlist_unsupported_ui_mode, device))
if (description != null) {
Text(description)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt
index 3a45ddfd2..5a027ec71 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt
@@ -3,6 +3,7 @@ package com.m3u.smartphone.ui.business.playlist.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -45,13 +46,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
import com.m3u.core.foundation.ui.thenIf
import com.m3u.smartphone.ui.material.effects.BackStackEntry
import com.m3u.smartphone.ui.material.effects.BackStackHandler
@@ -59,7 +64,6 @@ import com.m3u.smartphone.ui.material.ktx.Edge
import com.m3u.smartphone.ui.material.ktx.blurEdge
import com.m3u.smartphone.ui.material.model.LocalHazeState
import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
import dev.chrisbanes.haze.hazeSource
@Composable
@@ -71,6 +75,7 @@ internal fun PlaylistTabRow(
onCategoryChanged: (String) -> Unit,
pinnedCategories: List,
onPinOrUnpinCategory: (String) -> Unit,
+ onReorderCategories: (List) -> Unit,
onHideCategory: (String) -> Unit,
onExpanded: () -> Unit,
modifier: Modifier = Modifier
@@ -78,6 +83,32 @@ internal fun PlaylistTabRow(
val spacing = LocalSpacing.current
val hapticFeedback = LocalHapticFeedback.current
val state = rememberLazyListState()
+ var orderedCategories by remember(categories) { mutableStateOf(categories) }
+ var draggedCategory: String? by remember { mutableStateOf(null) }
+ var dragOffset by remember { mutableStateOf(Offset.Zero) }
+ var pendingOrder: List? by remember { mutableStateOf(null) }
+
+ fun moveCategory(category: String, targetCategory: String) {
+ val fromIndex = orderedCategories.indexOf(category)
+ val toIndex = orderedCategories.indexOf(targetCategory)
+ if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return
+ orderedCategories = orderedCategories.toMutableList().apply {
+ val moved = removeAt(fromIndex)
+ add(toIndex, moved)
+ }
+ pendingOrder = orderedCategories
+ }
+
+ fun cancelDragging() {
+ pendingOrder = null
+ draggedCategory = null
+ dragOffset = Offset.Zero
+ }
+
+ fun finishDragging() {
+ pendingOrder?.let(onReorderCategories)
+ cancelDragging()
+ }
Box(modifier) {
var focusCategory: String? by rememberSaveable { mutableStateOf(null) }
@@ -135,14 +166,18 @@ internal fun PlaylistTabRow(
}
}
LaunchedEffect(selectedCategory) {
- val index = categories.indexOf(selectedCategory)
+ val index = orderedCategories.indexOf(selectedCategory)
if (index != -1) {
state.animateScrollToItem(index)
}
}
val categoriesContent: LazyListScope.() -> Unit = {
stickyHeader { header() }
- items(categories) { category ->
+ items(
+ items = orderedCategories,
+ key = { it }
+ ) { category ->
+ val dragging = draggedCategory == category
PlaylistTabRowItem(
name = category,
selected = category == selectedCategory,
@@ -159,7 +194,50 @@ internal fun PlaylistTabRow(
focusCategory = category
onCategoryChanged(category)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
+ },
+ modifier = Modifier
+ .thenIf(dragging) {
+ Modifier.graphicsLayer {
+ translationX = dragOffset.x
+ translationY = dragOffset.y
+ }
+ }
+ .pointerInput(category, isExpanded, orderedCategories) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = {
+ focusCategory = category
+ draggedCategory = category
+ dragOffset = Offset.Zero
+ onCategoryChanged(category)
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ },
+ onDragCancel = ::cancelDragging,
+ onDragEnd = ::finishDragging,
+ onDrag = { change, amount ->
+ change.consume()
+ dragOffset += amount
+ val draggedItem = state.layoutInfo.visibleItemsInfo
+ .firstOrNull { it.key == category }
+ ?: return@detectDragGesturesAfterLongPress
+ val targetCenter = if (isExpanded) {
+ draggedItem.offset + draggedItem.size / 2 + dragOffset.y
+ } else {
+ draggedItem.offset + draggedItem.size / 2 + dragOffset.x
+ }
+ val target = state.layoutInfo.visibleItemsInfo
+ .firstOrNull { item ->
+ item.key is String &&
+ item.key != category &&
+ targetCenter >= item.offset &&
+ targetCenter <= item.offset + item.size
+ }
+ ?.key as? String
+ ?: return@detectDragGesturesAfterLongPress
+ moveCategory(category, target)
+ dragOffset = Offset.Zero
+ }
+ )
+ }
)
}
}
@@ -333,4 +411,4 @@ private fun PlaylistTabRowItem(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt
index 44dad7d4b..73c9a14dc 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt
@@ -9,13 +9,15 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChangeCircle
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -51,7 +53,6 @@ import com.m3u.smartphone.ui.material.components.EventHandler
import com.m3u.smartphone.ui.material.components.SettingDestination
import com.m3u.smartphone.ui.material.model.LocalHazeState
import dev.chrisbanes.haze.hazeSource
-import kotlinx.coroutines.launch
@Composable
fun SettingRoute(
@@ -109,6 +110,7 @@ fun SettingRoute(
controller?.hide()
viewModel.subscribe()
},
+ onRestoreToTv = viewModel::restoreToTv,
onUnhideChannel = { viewModel.onUnhideChannel(it) },
onUnhidePlaylistCategory = { playlistUrl, group ->
viewModel.onUnhidePlaylistCategory(playlistUrl, group)
@@ -142,6 +144,7 @@ private fun SettingScreen(
backingUpOrRestoring: BackingUpAndRestoringState,
codecPackState: CodecPackState,
onSubscribe: () -> Unit,
+ onRestoreToTv: () -> Unit,
hiddenChannels: List,
hiddenCategoriesWithPlaylists: List>,
onUnhideChannel: (Int) -> Unit,
@@ -160,8 +163,6 @@ private fun SettingScreen(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues()
) {
- val coroutineScope = rememberCoroutineScope()
-
val defaultTitle = stringResource(string.ui_title_setting)
val playlistTitle = stringResource(string.feat_setting_playlist_management)
val appearanceTitle = stringResource(string.feat_setting_appearance)
@@ -170,11 +171,38 @@ private fun SettingScreen(
val colorArgb by preferenceOf(PreferencesKeys.COLOR_ARGB)
- val navigator = rememberListDetailPaneScaffoldNavigator()
- val destination = navigator.currentDestination?.contentKey ?: SettingDestination.Default
+ var destination by rememberSaveable {
+ mutableStateOf(SettingDestination.Default)
+ }
+ val initialDestinationHistory = remember(destination) {
+ listOfNotNull(
+ ThreePaneScaffoldDestinationItem(
+ pane = ListDetailPaneScaffoldRole.List
+ ),
+ destination.takeIf { it != SettingDestination.Default }?.let {
+ ThreePaneScaffoldDestinationItem(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = it
+ )
+ }
+ )
+ }
+ val navigator = key(destination) {
+ rememberListDetailPaneScaffoldNavigator(
+ initialDestinationHistory = initialDestinationHistory
+ )
+ }
+
+ fun navigateToDetail(target: SettingDestination) {
+ destination = target
+ }
+
+ fun navigateBackToList() {
+ destination = SettingDestination.Default
+ }
EventHandler(Events.settingDestination) {
- navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it)
+ navigateToDetail(it)
}
LifecycleResumeEffect(destination, defaultTitle, playlistTitle, appearanceTitle, optionalTitle, codecPackTitle) {
@@ -195,9 +223,7 @@ private fun SettingScreen(
icon = Icons.Rounded.ChangeCircle,
iconTextId = string.feat_setting_back_home
) {
- coroutineScope.launch {
- navigator.navigateBack()
- }
+ navigateBackToList()
}
}
onPauseOrDispose {
@@ -216,36 +242,16 @@ private fun SettingScreen(
versionCode = versionCode,
codecPackEnabled = codecPackState.enabled,
navigateToPlaylistManagement = {
- coroutineScope.launch {
- navigator.navigateTo(
- pane = ListDetailPaneScaffoldRole.Detail,
- contentKey = SettingDestination.Playlists
- )
- }
+ navigateToDetail(SettingDestination.Playlists)
},
navigateToThemeSelector = {
- coroutineScope.launch {
- navigator.navigateTo(
- pane = ListDetailPaneScaffoldRole.Detail,
- contentKey = SettingDestination.Appearance
- )
- }
+ navigateToDetail(SettingDestination.Appearance)
},
navigateToOptional = {
- coroutineScope.launch {
- navigator.navigateTo(
- pane = ListDetailPaneScaffoldRole.Detail,
- contentKey = SettingDestination.Optional
- )
- }
+ navigateToDetail(SettingDestination.Optional)
},
navigateToCodecPack = {
- coroutineScope.launch {
- navigator.navigateTo(
- pane = ListDetailPaneScaffoldRole.Detail,
- contentKey = SettingDestination.CodecPack
- )
- }
+ navigateToDetail(SettingDestination.CodecPack)
},
modifier = Modifier.fillMaxSize()
)
@@ -261,6 +267,7 @@ private fun SettingScreen(
onUnhidePlaylistCategory = onUnhidePlaylistCategory,
onClipboard = onClipboard,
onSubscribe = onSubscribe,
+ onRestoreToTv = onRestoreToTv,
backup = backup,
restore = restore,
epgs = epgs,
@@ -307,9 +314,7 @@ private fun SettingScreen(
.hazeSource(LocalHazeState.current)
.testTag("feature:setting")
)
- BackHandler(navigator.canNavigateBack()) {
- coroutineScope.launch {
- navigator.navigateBack()
- }
+ BackHandler(destination != SettingDestination.Default) {
+ navigateBackToList()
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt
index 11ca8edb0..13382eb80 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt
@@ -11,11 +11,14 @@ import androidx.compose.material.icons.rounded.BrightnessMedium
import androidx.compose.material.icons.rounded.Cast
import androidx.compose.material.icons.rounded.Details
import androidx.compose.material.icons.rounded.FlashOn
+import androidx.compose.material.icons.rounded.Headphones
import androidx.compose.material.icons.rounded.Loop
import androidx.compose.material.icons.rounded.PictureInPicture
+import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.Recommend
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.ReplayCircleFilled
+import androidx.compose.material.icons.rounded.SurroundSound
import androidx.compose.material.icons.rounded.ScreenRotation
import androidx.compose.material.icons.rounded.SettingsEthernet
import androidx.compose.material.icons.rounded.SettingsRemote
@@ -23,6 +26,7 @@ import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material.icons.rounded.Unarchive
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -30,9 +34,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.m3u.core.architecture.preferences.ConnectTimeout
+import com.m3u.core.architecture.preferences.EpgOffset
import com.m3u.core.architecture.preferences.PlaylistStrategy
import com.m3u.core.architecture.preferences.PreferencesKeys
import com.m3u.core.architecture.preferences.ReconnectMode
+import com.m3u.core.architecture.preferences.StartupDelay
import com.m3u.core.architecture.preferences.UnseensMilliseconds
import com.m3u.core.architecture.preferences.mutablePreferenceOf
import com.m3u.core.util.basic.title
@@ -41,6 +47,7 @@ import com.m3u.smartphone.ui.business.setting.components.SwitchSharedPreference
import com.m3u.smartphone.ui.material.components.TextPreference
import com.m3u.smartphone.ui.material.ktx.plus
import com.m3u.smartphone.ui.material.model.LocalSpacing
+import kotlin.math.absoluteValue
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@@ -50,6 +57,13 @@ internal fun OptionalFragment(
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
+ var resumeLastChannel by mutablePreferenceOf(PreferencesKeys.RESUME_LAST_CHANNEL_ON_STARTUP)
+ var launchOnBoot by mutablePreferenceOf(PreferencesKeys.LAUNCH_ON_BOOT)
+ LaunchedEffect(resumeLastChannel, launchOnBoot) {
+ if (!resumeLastChannel && launchOnBoot) {
+ launchOnBoot = false
+ }
+ }
LazyColumn(
verticalArrangement = Arrangement.spacedBy(spacing.small),
contentPadding = contentPadding + PaddingValues(spacing.medium),
@@ -95,6 +109,69 @@ internal fun OptionalFragment(
onChanged = { zappingMode = !zappingMode }
)
}
+ item {
+ var backgroundPlayback by mutablePreferenceOf(PreferencesKeys.BACKGROUND_PLAYBACK)
+ SwitchSharedPreference(
+ title = string.feat_setting_background_playback,
+ content = string.feat_setting_background_playback_description,
+ icon = Icons.Rounded.Headphones,
+ checked = backgroundPlayback,
+ onChanged = { backgroundPlayback = !backgroundPlayback }
+ )
+ }
+ item {
+ var autoPipOnHome by mutablePreferenceOf(PreferencesKeys.AUTO_PIP_ON_HOME)
+ SwitchSharedPreference(
+ title = string.feat_setting_auto_pip_on_home,
+ content = string.feat_setting_auto_pip_on_home_description,
+ icon = Icons.Rounded.PictureInPicture,
+ checked = autoPipOnHome,
+ onChanged = { autoPipOnHome = !autoPipOnHome }
+ )
+ }
+ item {
+ SwitchSharedPreference(
+ title = string.feat_setting_resume_last_channel_on_startup,
+ content = string.feat_setting_resume_last_channel_on_startup_description,
+ icon = Icons.Rounded.ReplayCircleFilled,
+ checked = resumeLastChannel,
+ onChanged = {
+ resumeLastChannel = !resumeLastChannel
+ if (!resumeLastChannel) {
+ launchOnBoot = false
+ }
+ }
+ )
+ }
+ item {
+ SwitchSharedPreference(
+ title = string.feat_setting_launch_on_boot,
+ content = string.feat_setting_launch_on_boot_description,
+ icon = Icons.Rounded.PowerSettingsNew,
+ enabled = resumeLastChannel,
+ checked = launchOnBoot && resumeLastChannel,
+ onChanged = { launchOnBoot = !launchOnBoot }
+ )
+ }
+ item {
+ var startupDelay by mutablePreferenceOf(PreferencesKeys.STARTUP_DELAY)
+ TextPreference(
+ title = stringResource(string.feat_setting_startup_delay).title(),
+ icon = Icons.Rounded.Timer,
+ trailing = when (startupDelay) {
+ StartupDelay.SECONDS_2 -> stringResource(string.feat_setting_startup_delay_2s)
+ StartupDelay.SECONDS_5 -> stringResource(string.feat_setting_startup_delay_5s)
+ else -> stringResource(string.feat_setting_startup_delay_none)
+ },
+ onClick = {
+ startupDelay = when (startupDelay) {
+ StartupDelay.NONE -> StartupDelay.SECONDS_2
+ StartupDelay.SECONDS_2 -> StartupDelay.SECONDS_5
+ else -> StartupDelay.NONE
+ }
+ }
+ )
+ }
item {
var brightnessGesture by mutablePreferenceOf(PreferencesKeys.BRIGHTNESS_GESTURE)
SwitchSharedPreference(
@@ -113,6 +190,16 @@ internal fun OptionalFragment(
onChanged = { volumeGesture = !volumeGesture }
)
}
+ item {
+ var nightAudioMode by mutablePreferenceOf(PreferencesKeys.NIGHT_AUDIO_MODE)
+ SwitchSharedPreference(
+ title = string.feat_setting_night_audio_mode,
+ content = string.feat_setting_night_audio_mode_description,
+ icon = Icons.Rounded.SurroundSound,
+ checked = nightAudioMode,
+ onChanged = { nightAudioMode = !nightAudioMode }
+ )
+ }
item {
var alwaysShowReplay by mutablePreferenceOf(PreferencesKeys.ALWAYS_SHOW_REPLAY)
SwitchSharedPreference(
@@ -240,6 +327,18 @@ internal fun OptionalFragment(
)
}
+ item {
+ var epgOffset by mutablePreferenceOf(PreferencesKeys.EPG_TIME_OFFSET)
+ TextPreference(
+ title = stringResource(string.feat_setting_epg_time_offset).title(),
+ content = stringResource(string.feat_setting_epg_time_offset_description),
+ icon = Icons.Rounded.AccessTime,
+ trailing = epgOffset.formatEpgOffset(
+ none = stringResource(string.feat_setting_epg_time_offset_none)
+ ),
+ onClick = { epgOffset = epgOffset.nextEpgOffset() }
+ )
+ }
item {
var twelveHourClock by mutablePreferenceOf(PreferencesKeys.CLOCK_MODE)
SwitchSharedPreference(
@@ -260,4 +359,19 @@ internal fun OptionalFragment(
)
}
}
-}
\ No newline at end of file
+}
+
+private fun Long.nextEpgOffset(): Long {
+ val currentIndex = EpgOffset.VALUES.indexOf(this)
+ val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % EpgOffset.VALUES.size
+ return EpgOffset.VALUES[nextIndex]
+}
+
+private fun Long.formatEpgOffset(none: String): String {
+ if (this == EpgOffset.NONE) return none
+ val duration = absoluteValue
+ .toDuration(DurationUnit.MILLISECONDS)
+ .toString()
+ .title()
+ return if (this > 0) "+$duration" else "-$duration"
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt
index 70ff146e0..787a657cf 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt
@@ -3,7 +3,6 @@ package com.m3u.smartphone.ui.business.setting.fragments
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
-import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
@@ -86,6 +85,7 @@ internal fun SubscriptionsFragment(
onUnhidePlaylistCategory: (playlistUrl: String, category: String) -> Unit,
onClipboard: (String) -> Unit,
onSubscribe: () -> Unit,
+ onRestoreToTv: () -> Unit,
backup: () -> Unit,
restore: () -> Unit,
epgs: List,
@@ -112,6 +112,7 @@ internal fun SubscriptionsFragment(
backingUpOrRestoring = backingUpOrRestoring,
onClipboard = onClipboard,
onSubscribe = onSubscribe,
+ onRestoreToTv = onRestoreToTv,
backup = backup,
restore = restore,
modifier = Modifier.fillMaxSize()
@@ -159,6 +160,7 @@ private fun MainContentImpl(
backingUpOrRestoring: BackingUpAndRestoringState,
onClipboard: (String) -> Unit,
onSubscribe: () -> Unit,
+ onRestoreToTv: () -> Unit,
backup: () -> Unit,
restore: () -> Unit,
modifier: Modifier = Modifier
@@ -283,6 +285,17 @@ private fun MainContentImpl(
text = restoreText
)
}
+ if (remoteControl) {
+ TextButton(
+ onClick = onRestoreToTv,
+ enabled = backingUpOrRestoring == BackingUpAndRestoringState.NONE,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ ) {
+ Text(
+ text = stringResource(string.feat_setting_restore_to_tv).uppercase()
+ )
+ }
+ }
}
}
@@ -382,7 +395,7 @@ private fun M3UInputContent(
PlaceholderField(
text = properties.titleState.value,
placeholder = stringResource(string.feat_setting_placeholder_title).uppercase(),
- onValueChange = { properties.titleState.value = Uri.decode(it) },
+ onValueChange = { properties.titleState.value = it },
imeAction = ImeAction.Next,
modifier = Modifier.fillMaxWidth()
)
@@ -394,14 +407,24 @@ private fun M3UInputContent(
PlaceholderField(
text = properties.urlState.value,
placeholder = stringResource(string.feat_setting_placeholder_url).uppercase(),
- onValueChange = { properties.urlState.value = Uri.decode(it) },
+ onValueChange = { properties.urlState.value = it },
modifier = Modifier.fillMaxWidth()
)
} else {
- LocalStorageButton(
- titleState = properties.titleState,
- uriState = properties.uriState,
- )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ LocalStorageButton(
+ titleState = properties.titleState,
+ uriState = properties.uriState,
+ )
+ PlaceholderField(
+ text = properties.urlState.value,
+ placeholder = stringResource(string.feat_setting_placeholder_local_path).uppercase(),
+ onValueChange = { properties.urlState.value = it },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
}
@@ -427,13 +450,13 @@ private fun EPGInputContent(
PlaceholderField(
text = properties.titleState.value,
placeholder = stringResource(string.feat_setting_placeholder_epg_title).uppercase(),
- onValueChange = { properties.titleState.value = Uri.decode(it) },
+ onValueChange = { properties.titleState.value = it },
modifier = Modifier.fillMaxWidth()
)
PlaceholderField(
text = properties.epgState.value,
placeholder = stringResource(string.feat_setting_placeholder_epg).uppercase(),
- onValueChange = { properties.epgState.value = Uri.decode(it) },
+ onValueChange = { properties.epgState.value = it },
modifier = Modifier.fillMaxWidth()
)
}
@@ -451,7 +474,7 @@ private fun XtreamInputContent(modifier: Modifier = Modifier) {
PlaceholderField(
text = properties.titleState.value,
placeholder = stringResource(string.feat_setting_placeholder_title).uppercase(),
- onValueChange = { properties.titleState.value = Uri.decode(it) },
+ onValueChange = { properties.titleState.value = it },
modifier = Modifier.fillMaxWidth()
)
PlaceholderField(
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/SmartphoneViewModel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/SmartphoneViewModel.kt
index 9c7c9d8de..f3dd9ec44 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/SmartphoneViewModel.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/SmartphoneViewModel.kt
@@ -11,19 +11,24 @@ import com.m3u.data.repository.channel.ChannelRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
class SmartphoneViewModel @Inject constructor(
private val channelRepository: ChannelRepository
-): ViewModel() {
+) : ViewModel() {
val query = MutableStateFlow("")
- val channels: Flow> = query.flatMapLatest {
- Pager(PagingConfig(15)) {
- channelRepository.pagingAll(it)
+ val channels: Flow> = query
+ .map { it.trim() }
+ .distinctUntilChanged()
+ .flatMapLatest {
+ Pager(PagingConfig(15)) {
+ channelRepository.pagingAll(it)
+ }
+ .flow
+ .cachedIn(viewModelScope)
}
- .flow
- .cachedIn(viewModelScope)
- }
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt
index 7246e66f2..bd087253a 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt
@@ -1,7 +1,9 @@
package com.m3u.smartphone.ui.common.helper
import android.app.PictureInPictureParams
+import android.app.UiModeManager
import android.content.Context
+import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Rect
import android.provider.Settings
@@ -101,6 +103,12 @@ class Helper(private val activity: ComponentActivity) {
activity.requestedOrientation = value
}
val activityContext: Context get() = activity
+ val isDesktopMode: Boolean
+ get() {
+ val uiModeManager = activity.getSystemService(UiModeManager::class.java)
+ return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_DESK ||
+ activity.resources.configuration.isSamsungDesktopMode
+ }
val windowSizeClass: WindowSizeClass
@Composable get() = calculateWindowSizeClass(activity)
@@ -146,7 +154,7 @@ class Helper(private val activity: ComponentActivity) {
val atBottom = ViewConfiguration
.get(activity)
.hasPermanentMenuKey()
- if (configuration.isPortraitMode || !atBottom) {
+ if (isDesktopMode || configuration.isPortraitMode || !atBottom) {
show(WindowInsetsCompat.Type.navigationBars())
} else {
hide(WindowInsetsCompat.Type.navigationBars())
@@ -163,6 +171,18 @@ class Helper(private val activity: ComponentActivity) {
}
val Helper.useRailNav: Boolean
- @Composable get() = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
+ @Composable get() = isDesktopMode || windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
val LocalHelper = staticCompositionLocalOf { error("Please provide helper.") }
+
+private val Configuration.isSamsungDesktopMode: Boolean
+ get() = runCatching {
+ val configurationClass = javaClass
+ val enabledValue = configurationClass
+ .getField("SEM_DESKTOP_MODE_ENABLED")
+ .getInt(configurationClass)
+ val currentValue = configurationClass
+ .getField("semDesktopModeEnabled")
+ .getInt(this)
+ enabledValue == currentValue
+ }.getOrDefault(false)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Events.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Events.kt
index 700f65a3f..d5cbf407f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Events.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Events.kt
@@ -10,4 +10,5 @@ import com.m3u.smartphone.ui.material.components.SettingDestination
object Events {
var settingDestination: Event by mutableStateOf(handledEvent())
var discoverCategory: Event by mutableStateOf(handledEvent())
-}
\ No newline at end of file
+ var openSearch: Event by mutableStateOf(handledEvent())
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Player.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Player.kt
index 269444a93..ea52060c7 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Player.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Player.kt
@@ -1,5 +1,6 @@
package com.m3u.smartphone.ui.material.components
+import android.graphics.Color
import android.view.Surface
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
@@ -59,6 +60,8 @@ fun Player(
factory = { context ->
PlayerView(context).apply {
useController = false
+ setBackgroundColor(Color.TRANSPARENT)
+ setShutterBackgroundColor(Color.TRANSPARENT)
videoSurfaceView
}
},
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Permissions.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Permissions.kt
index a26d5f80b..75a72da85 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Permissions.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Permissions.kt
@@ -14,10 +14,8 @@ inline fun PermissionState.checkPermissionOrRationale(
val skip = when (permission) {
Manifest.permission.POST_NOTIFICATIONS -> sdk < Build.VERSION_CODES.TIRAMISU
- // If you try to check or request the WRITE_EXTERNAL_STORAGE on Android 13+,
- // it will always return false.
- // So you'll have to skip the permission check/request completely on Android 13+.
- Manifest.permission.WRITE_EXTERNAL_STORAGE -> sdk >= Build.VERSION_CODES.TIRAMISU
+ // Android 10+ writes shared images through MediaStore without this legacy permission.
+ Manifest.permission.WRITE_EXTERNAL_STORAGE -> sdk >= Build.VERSION_CODES.Q
else -> false
}
when {
@@ -34,4 +32,4 @@ inline fun PermissionState.checkPermissionOrRationale(
else -> {}
}
block()
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/channel/ExternalPlayerMimeTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/channel/ExternalPlayerMimeTest.kt
new file mode 100644
index 000000000..a6d8d3373
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/channel/ExternalPlayerMimeTest.kt
@@ -0,0 +1,42 @@
+package com.m3u.smartphone.ui.business.channel
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ExternalPlayerMimeTest {
+ @Test
+ fun returnsAudioMimeTypeForAudioStreams() {
+ assertEquals(
+ "audio/mpeg",
+ externalPlayerMimeType("https://example.com/live/station.mp3")
+ )
+ assertEquals(
+ "audio/aac",
+ externalPlayerMimeType("https://example.com/radio/stream.aac")
+ )
+ assertEquals(
+ "audio/ogg",
+ externalPlayerMimeType("https://example.com/radio/stream.opus")
+ )
+ }
+
+ @Test
+ fun ignoresQueryFragmentAndExtensionCase() {
+ assertEquals(
+ "audio/flac",
+ externalPlayerMimeType("https://example.com/radio/STREAM.FLAC?token=abc#live")
+ )
+ }
+
+ @Test
+ fun keepsVideoFallbackForUnknownStreams() {
+ assertEquals(
+ "video/*",
+ externalPlayerMimeType("https://example.com/live/channel.ts?token=abc")
+ )
+ assertEquals(
+ "video/*",
+ externalPlayerMimeType("https://example.com/live/channel")
+ )
+ }
+}
diff --git a/app/tv/build.gradle.kts b/app/tv/build.gradle.kts
index a0038c08f..6c74984d9 100644
--- a/app/tv/build.gradle.kts
+++ b/app/tv/build.gradle.kts
@@ -15,7 +15,7 @@ android {
compileSdk = 36
defaultConfig {
applicationId = "com.m3u.tv"
- minSdk = 26
+ minSdk = 25
targetSdk = 33
versionCode = 2
versionName = "1.0.1"
@@ -134,6 +134,8 @@ dependencies {
implementation(libs.haze)
implementation(libs.haze.materials)
+ testImplementation(libs.junit)
+
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
diff --git a/app/tv/src/main/AndroidManifest.xml b/app/tv/src/main/AndroidManifest.xml
index e42ef5dc9..ba27e9b87 100644
--- a/app/tv/src/main/AndroidManifest.xml
+++ b/app/tv/src/main/AndroidManifest.xml
@@ -9,8 +9,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/tv/src/main/java/com/m3u/tv/App.kt b/app/tv/src/main/java/com/m3u/tv/App.kt
index a158f2d3b..f6a3c5ee5 100644
--- a/app/tv/src/main/java/com/m3u/tv/App.kt
+++ b/app/tv/src/main/java/com/m3u/tv/App.kt
@@ -7,6 +7,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -23,6 +24,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -30,6 +32,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import com.m3u.data.tv.model.keyCode
+import com.m3u.i18n.R.string
@Composable
fun App(
@@ -39,12 +42,19 @@ fun App(
val state by viewModel.state.collectAsStateWithLifecycle()
val player by viewModel.player.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
+ val currentFavorite by viewModel.currentFavorite.collectAsStateWithLifecycle()
val isPlaying by viewModel.isPlaying.collectAsStateWithLifecycle()
val playbackState by viewModel.playbackState.collectAsStateWithLifecycle()
+ val currentProgramme by viewModel.currentProgramme.collectAsStateWithLifecycle()
val remoteControlCode by viewModel.remoteControlCode.collectAsStateWithLifecycle()
+ val subscribingXtream by viewModel.subscribingXtream.collectAsStateWithLifecycle()
+ val subscribingM3u by viewModel.subscribingM3u.collectAsStateWithLifecycle()
+ val xtreamSubscriptionMessage by viewModel.xtreamSubscriptionMessage.collectAsStateWithLifecycle()
+ val m3uSubscriptionMessage by viewModel.m3uSubscriptionMessage.collectAsStateWithLifecycle()
val view = LocalView.current
var destination by remember { mutableStateOf(TvDestination.Home) }
var surface by remember { mutableStateOf(TvSurface.Browse) }
+ var focusChannelsOnLibraryOpen by remember { mutableStateOf(false) }
val closePlayer = {
viewModel.releasePlayer()
surface = TvSurface.Browse
@@ -65,6 +75,12 @@ fun App(
}
}
+ LaunchedEffect(Unit) {
+ viewModel.startupPlaybackRequests.collect {
+ surface = TvSurface.Player
+ }
+ }
+
Box(
modifier = Modifier
.fillMaxSize()
@@ -75,17 +91,34 @@ fun App(
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
selected = destination,
- onSelect = { destination = it }
+ onSelect = {
+ focusChannelsOnLibraryOpen = it == TvDestination.Library
+ destination = it
+ }
)
TvBrowsePane(
destination = destination,
state = state,
- onOpenLibrary = { destination = TvDestination.Library },
+ subscribingXtream = subscribingXtream,
+ subscribingM3u = subscribingM3u,
+ xtreamSubscriptionMessage = xtreamSubscriptionMessage,
+ m3uSubscriptionMessage = m3uSubscriptionMessage,
+ focusChannelsOnLibraryOpen = focusChannelsOnLibraryOpen,
+ onOpenLibrary = {
+ focusChannelsOnLibraryOpen = false
+ destination = TvDestination.Library
+ },
onPlaylist = {
viewModel.selectPlaylist(it)
+ focusChannelsOnLibraryOpen = true
destination = TvDestination.Library
},
+ onLibraryChannelFocusHandled = { focusChannelsOnLibraryOpen = false },
onRefresh = viewModel::refreshSelectedPlaylist,
+ onAddXtreamPlaylist = viewModel::addXtreamPlaylist,
+ onClearXtreamSubscriptionMessage = viewModel::clearXtreamSubscriptionMessage,
+ onAddM3uPlaylist = viewModel::addM3uPlaylist,
+ onClearM3uSubscriptionMessage = viewModel::clearM3uSubscriptionMessage,
onPlay = {
viewModel.play(it)
surface = TvSurface.Player
@@ -105,27 +138,42 @@ fun App(
TvPlayerScreen(
player = player,
channel = currentChannel,
+ isFavorite = currentFavorite,
isPlaying = isPlaying,
playbackState = playbackState,
+ programme = currentProgramme,
onPlayPause = { viewModel.pauseOrContinue(!isPlaying) },
+ onFavorite = viewModel::toggleCurrentFavorite,
+ onPreviousChannel = viewModel::playPreviousChannel,
+ onNextChannel = viewModel::playNextChannel,
onBack = closePlayer,
onClose = closePlayer
)
}
remoteControlCode?.let { code ->
- Text(
- text = code.toString().padStart(6, '0'),
- color = TvColors.TextPrimary,
- fontFamily = TvFonts.Body,
- fontSize = 28.sp,
- fontWeight = FontWeight.Bold,
+ Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(24.dp)
.background(TvColors.Surface.copy(alpha = 0.86f), RoundedCornerShape(8.dp))
.padding(horizontal = 18.dp, vertical = 10.dp)
- )
+ ) {
+ Text(
+ text = stringResource(string.tv_remote_pairing_code),
+ color = TvColors.TextSecondary,
+ fontFamily = TvFonts.Body,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = code.toString().padStart(6, '0'),
+ color = TvColors.TextPrimary,
+ fontFamily = TvFonts.Body,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/BootReceiver.kt b/app/tv/src/main/java/com/m3u/tv/BootReceiver.kt
new file mode 100644
index 000000000..504dab2c8
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/BootReceiver.kt
@@ -0,0 +1,39 @@
+package com.m3u.tv
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class BootReceiver : BroadcastReceiver() {
+ @Inject
+ lateinit var settings: Settings
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
+ val pendingResult = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ if (
+ settings[PreferencesKeys.LAUNCH_ON_BOOT] &&
+ settings[PreferencesKeys.RESUME_LAST_CHANNEL_ON_STARTUP]
+ ) {
+ context.startActivity(
+ Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/MainActivity.kt b/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
index f8ff37546..b13f5d8f6 100644
--- a/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
+++ b/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
@@ -1,7 +1,10 @@
package com.m3u.tv
+import android.content.Intent
import android.graphics.Color as AndroidColor
+import android.net.Uri
import android.os.Bundle
+import android.widget.Toast
import androidx.activity.SystemBarStyle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
@@ -11,10 +14,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
+import androidx.work.WorkManager
+import com.m3u.core.util.readFileName
+import com.m3u.data.worker.SubscriptionWorker
+import com.m3u.i18n.R.string
import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ @Inject
+ lateinit var workManager: WorkManager
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
@@ -40,5 +51,60 @@ class MainActivity : ComponentActivity() {
}
}
}
+ if (savedInstanceState == null) {
+ maybeEnqueueViewedPlaylist(intent)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ maybeEnqueueViewedPlaylist(intent)
+ }
+
+ private fun maybeEnqueueViewedPlaylist(intent: Intent?): Boolean {
+ val importIntent = intent?.takeIf { it.action in PLAYLIST_IMPORT_ACTIONS } ?: return false
+ val uri = importIntent.data ?: importIntent.streamUri()
+ uri ?: return false
+
+ takeReadPermission(uri, importIntent.flags)
+ val title = resolveViewedPlaylistTitle(uri)
+
+ SubscriptionWorker.m3u(workManager, title, uri.toString())
+ Toast.makeText(
+ this,
+ getString(string.feat_setting_enqueue_subscribe),
+ Toast.LENGTH_SHORT
+ ).show()
+ return true
+ }
+
+ private fun resolveViewedPlaylistTitle(uri: Uri): String {
+ return runCatching { uri.readFileName(contentResolver) }
+ .getOrNull()
+ ?.substringBeforeLast('.')
+ ?.takeIf { it.isNotBlank() }
+ ?: uri.lastPathSegment
+ ?.substringAfterLast('/')
+ ?.substringBeforeLast('.')
+ ?.takeIf { it.isNotBlank() }
+ ?: getString(R.string.app_name)
+ }
+
+ @Suppress("DEPRECATION")
+ private fun Intent.streamUri(): Uri? = getParcelableExtra(Intent.EXTRA_STREAM)
+
+ private fun takeReadPermission(uri: Uri, flags: Int) {
+ if (flags and Intent.FLAG_GRANT_READ_URI_PERMISSION == 0) return
+ val modeFlags = flags and (
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ runCatching {
+ contentResolver.takePersistableUriPermission(uri, modeFlags)
+ }
+ }
+
+ companion object {
+ private val PLAYLIST_IMPORT_ACTIONS = setOf(Intent.ACTION_VIEW, Intent.ACTION_SEND)
}
}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvComponents.kt b/app/tv/src/main/java/com/m3u/tv/TvComponents.kt
index 2ff920c02..786ce1a85 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvComponents.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvComponents.kt
@@ -338,7 +338,8 @@ fun ChannelCard(
modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null,
onFocused: () -> Unit = {},
- compact: Boolean = false
+ compact: Boolean = false,
+ badgeText: String? = null
) {
FocusFrame(
onClick = onPlay,
@@ -365,6 +366,29 @@ fun ChannelCard(
.fillMaxSize()
.clip(RoundedCornerShape(8.dp))
)
+ if (!badgeText.isNullOrBlank()) {
+ Text(
+ text = badgeText,
+ color = if (focused) TvColors.OnFocus else TvColors.TextPrimary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(6.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(
+ if (focused) {
+ TvColors.Focus.copy(alpha = 0.92f)
+ } else {
+ Color.Black.copy(alpha = 0.76f)
+ }
+ )
+ .padding(horizontal = 8.dp, vertical = 3.dp)
+ )
+ }
}
Box(
contentAlignment = Alignment.CenterStart,
@@ -591,4 +615,4 @@ fun playlistLabel(playlist: Playlist, count: Int): String {
else -> playlist.source.value.uppercase()
}
return stringResource(string.tv_playlist_label, type, count)
-}
\ No newline at end of file
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt b/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt
index 3993e5758..2474fee74 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt
@@ -1,28 +1,57 @@
package com.m3u.tv
+import android.content.ContentResolver
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkQuery
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.DataSource
import com.m3u.data.database.model.Playlist
+import com.m3u.data.database.model.Programme
+import com.m3u.data.database.model.isSeries
import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.repository.tv.TvRepository
import com.m3u.data.service.DPadReactionService
import com.m3u.data.service.MediaCommand
import com.m3u.data.service.PlayerManager
+import com.m3u.data.parser.xtream.XtreamInput
+import com.m3u.data.worker.SubscriptionWorker
+import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import java.io.File
+import kotlin.time.Duration.Companion.minutes
@Immutable
data class TvUiState(
@@ -38,34 +67,119 @@ data class TvUiState(
val heroChannel: Channel? get() = recent ?: channels.firstOrNull()
}
+enum class TvXtreamSubscriptionMessage {
+ MissingFields,
+ InvalidUrl,
+ Enqueued
+}
+
+enum class TvM3uSubscriptionMessage {
+ MissingFields,
+ InvalidInput,
+ Enqueued
+}
+
@HiltViewModel
class TvHomeViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
private val playlistRepository: PlaylistRepository,
private val channelRepository: ChannelRepository,
+ private val programmeRepository: ProgrammeRepository,
private val playerManager: PlayerManager,
+ private val settings: Settings,
+ private val workManager: WorkManager,
tvRepository: TvRepository,
dPadReactionService: DPadReactionService
) : ViewModel() {
private val _state = MutableStateFlow(TvUiState())
val state: StateFlow
= _state.asStateFlow()
+ private val _xtreamSubscriptionMessage = MutableStateFlow(null)
+ val xtreamSubscriptionMessage: StateFlow =
+ _xtreamSubscriptionMessage.asStateFlow()
+ private val _m3uSubscriptionMessage = MutableStateFlow(null)
+ val m3uSubscriptionMessage: StateFlow =
+ _m3uSubscriptionMessage.asStateFlow()
val player: StateFlow = playerManager.player
val currentChannel: StateFlow = playerManager.channel
+ val currentFavorite: StateFlow = combine(currentChannel, state) { channel, state ->
+ channel != null && state.favorites.any { it.id == channel.id }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = false,
+ started = SharingStarted.WhileSubscribed(5_000)
+ )
val isPlaying: StateFlow = playerManager.isPlaying
val playbackState: StateFlow = playerManager.playbackState
+ val currentProgramme: StateFlow = currentChannel.flatMapLatest { channel ->
+ channel ?: return@flatMapLatest flowOf(null)
+ flow {
+ while (true) {
+ emit(programmeRepository.getProgrammeCurrently(channel.id))
+ delay(1.minutes)
+ }
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = null,
+ started = SharingStarted.WhileSubscribed(5_000)
+ )
val remoteControlCode: StateFlow = tvRepository.broadcastCodeOnTv
val remoteDirections = dPadReactionService.incoming
+ val subscribingXtream: StateFlow = workManager
+ .getWorkInfosFlow(
+ WorkQuery.fromStates(
+ WorkInfo.State.RUNNING,
+ WorkInfo.State.ENQUEUED
+ )
+ )
+ .mapLatest { infos ->
+ infos.any { info -> DataSource.Xtream.value in info.tags }
+ }
+ .flowOn(Dispatchers.Default)
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = false,
+ started = SharingStarted.WhileSubscribed(5_000)
+ )
+ val subscribingM3u: StateFlow = workManager
+ .getWorkInfosFlow(
+ WorkQuery.fromStates(
+ WorkInfo.State.RUNNING,
+ WorkInfo.State.ENQUEUED
+ )
+ )
+ .mapLatest { infos ->
+ infos.any { info -> DataSource.M3U.value in info.tags }
+ }
+ .flowOn(Dispatchers.Default)
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = false,
+ started = SharingStarted.WhileSubscribed(5_000)
+ )
+ private val _startupPlaybackRequests = MutableSharedFlow()
+ val startupPlaybackRequests: SharedFlow = _startupPlaybackRequests
private var loadChannelsJob: Job? = null
init {
observePlaylists()
observeFavorites()
observeRecent()
+ resumeLastChannelOnStartup()
}
fun selectPlaylist(playlist: Playlist) {
if (_state.value.selectedPlaylist?.url == playlist.url) return
- _state.update { it.copy(selectedPlaylist = playlist) }
+ _state.update {
+ it.copy(
+ selectedPlaylist = playlist,
+ channels = emptyList(),
+ loadingChannels = true
+ )
+ }
loadChannels(playlist.url)
}
@@ -77,6 +191,87 @@ class TvHomeViewModel @Inject constructor(
}
}
+ fun addXtreamPlaylist(
+ title: String,
+ basicUrl: String,
+ username: String,
+ password: String,
+ type: String?
+ ) {
+ val normalizedTitle = title.trim()
+ val normalizedBasicUrl = basicUrl.trim().let { input ->
+ if (input.startsWith("http://", ignoreCase = true) ||
+ input.startsWith("https://", ignoreCase = true)
+ ) {
+ input
+ } else {
+ "http://$input"
+ }
+ }
+ val normalizedUsername = username.trim()
+ val normalizedPassword = password.trim()
+ if (
+ normalizedTitle.isBlank() ||
+ normalizedBasicUrl.removePrefix("http://").removePrefix("https://").isBlank() ||
+ normalizedUsername.isBlank() ||
+ normalizedPassword.isBlank()
+ ) {
+ _xtreamSubscriptionMessage.value = TvXtreamSubscriptionMessage.MissingFields
+ return
+ }
+
+ val url = runCatching {
+ XtreamInput.encodeToPlaylistUrl(
+ XtreamInput(
+ basicUrl = normalizedBasicUrl,
+ username = normalizedUsername,
+ password = normalizedPassword,
+ type = type
+ )
+ )
+ }.getOrElse {
+ _xtreamSubscriptionMessage.value = TvXtreamSubscriptionMessage.InvalidUrl
+ return
+ }
+
+ SubscriptionWorker.xtream(
+ workManager = workManager,
+ title = normalizedTitle,
+ url = url,
+ basicUrl = normalizedBasicUrl,
+ username = normalizedUsername,
+ password = normalizedPassword
+ )
+ _xtreamSubscriptionMessage.value = TvXtreamSubscriptionMessage.Enqueued
+ }
+
+ fun clearXtreamSubscriptionMessage() {
+ _xtreamSubscriptionMessage.value = null
+ }
+
+ fun addM3uPlaylist(title: String, urlOrPath: String) {
+ val normalizedTitle = title.trim()
+ val input = urlOrPath.trim()
+ if (normalizedTitle.isBlank() || input.isBlank()) {
+ _m3uSubscriptionMessage.value = TvM3uSubscriptionMessage.MissingFields
+ return
+ }
+ val normalizedUrlOrPath = input.toM3uUrlOrPath() ?: run {
+ _m3uSubscriptionMessage.value = TvM3uSubscriptionMessage.InvalidInput
+ return
+ }
+ SubscriptionWorker.m3u(
+ workManager = workManager,
+ title = normalizedTitle,
+ url = normalizedUrlOrPath
+ )
+ _m3uSubscriptionMessage.value = TvM3uSubscriptionMessage.Enqueued
+ }
+
+ fun clearM3uSubscriptionMessage() {
+ _m3uSubscriptionMessage.value = null
+ }
+
fun play(channel: Channel) {
viewModelScope.launch {
playerManager.play(MediaCommand.Common(channel.id))
@@ -94,14 +289,44 @@ class TvHomeViewModel @Inject constructor(
}
}
+ fun toggleCurrentFavorite() {
+ currentChannel.value?.let(::toggleFavorite)
+ }
+
fun pauseOrContinue(continuePlayback: Boolean) {
playerManager.pauseOrContinue(continuePlayback)
}
+ fun playPreviousChannel() {
+ playAdjacentChannel(step = -1)
+ }
+
+ fun playNextChannel() {
+ playAdjacentChannel(step = 1)
+ }
+
fun releasePlayer() {
playerManager.release()
}
+ private fun playAdjacentChannel(step: Int) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val current = currentChannel.value ?: return@launch
+ val channels = channelsForPlayback(current.playlistUrl)
+ val currentIndex = channels.indexOfFirst { it.id == current.id }
+ if (currentIndex == -1 || channels.size < 2) {
+ return@launch
+ }
+ val targetIndex = when (val adjacentIndex = currentIndex + step) {
+ -1 -> channels.lastIndex
+ channels.size -> 0
+ else -> adjacentIndex
+ }
+ val target = channels[targetIndex]
+ play(target)
+ }
+ }
+
private fun observePlaylists() {
viewModelScope.launch {
playlistRepository
@@ -150,17 +375,46 @@ class TvHomeViewModel @Inject constructor(
}
}
+ private fun resumeLastChannelOnStartup() {
+ viewModelScope.launch {
+ if (!settings[PreferencesKeys.RESUME_LAST_CHANNEL_ON_STARTUP]) {
+ return@launch
+ }
+ val startupDelay = settings[PreferencesKeys.STARTUP_DELAY]
+ if (startupDelay > 0) {
+ delay(startupDelay)
+ }
+ val channel = channelRepository.getPlayedRecently() ?: return@launch
+ if (channel.url.requiresNetwork() && !isNetworkConnected()) {
+ return@launch
+ }
+ val playlist = playlistRepository.get(channel.playlistUrl)
+ if (playlist?.isSeries == true) {
+ return@launch
+ }
+ play(channel)
+ _startupPlaybackRequests.emit(Unit)
+ }
+ }
+
+ private fun isNetworkConnected(): Boolean {
+ val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = manager.activeNetwork ?: return false
+ val capabilities = manager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ private fun String.requiresNetwork(): Boolean {
+ val scheme = substringBefore('|').substringBefore(':', missingDelimiterValue = "")
+ return scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true).not() &&
+ scheme.equals(ContentResolver.SCHEME_FILE, ignoreCase = true).not()
+ }
+
private fun loadChannels(url: String) {
loadChannelsJob?.cancel()
loadChannelsJob = viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(loadingChannels = true) }
- val channels = channelRepository
- .getByPlaylistUrl(url)
- .filterNot { it.hidden }
- .sortedWith(
- compareBy { it.category.lowercase() }
- .thenBy { it.title.lowercase() }
- )
+ val channels = channelsForPlayback(url)
_state.update { state ->
if (state.selectedPlaylist?.url == url) {
state.copy(
@@ -174,6 +428,28 @@ class TvHomeViewModel @Inject constructor(
}
}
+ private suspend fun channelsForPlayback(playlistUrl: String): List =
+ channelRepository
+ .getByPlaylistUrl(playlistUrl)
+ .filterNot { it.hidden }
+ .sortedWith(
+ compareBy { it.category.lowercase() }
+ .thenBy { it.title.lowercase() }
+ )
+
private fun Map.countFor(url: String): Int? =
entries.firstOrNull { it.key.url == url }?.value
-}
\ No newline at end of file
+}
+
+private fun String.toM3uUrlOrPath(): String? {
+ val input = trim()
+ return when {
+ input.startsWith("/") -> Uri.fromFile(File(input)).toString()
+ input.startsWith("http://", ignoreCase = true) ||
+ input.startsWith("https://", ignoreCase = true) ||
+ input.startsWith("${ContentResolver.SCHEME_FILE}:", ignoreCase = true) ||
+ input.startsWith("${ContentResolver.SCHEME_CONTENT}:", ignoreCase = true) -> input
+ input.substringBefore(":", missingDelimiterValue = "").isNotBlank() -> null
+ else -> input
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvPlayerScreen.kt b/app/tv/src/main/java/com/m3u/tv/TvPlayerScreen.kt
index de245e70b..46d6679e0 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvPlayerScreen.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvPlayerScreen.kt
@@ -1,5 +1,6 @@
package com.m3u.tv
+import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -10,13 +11,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.FavoriteBorder
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.SkipNext
+import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.tv.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
@@ -26,6 +32,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -36,16 +45,24 @@ import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import com.m3u.core.util.basic.title
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.Programme
import com.m3u.i18n.R.string
+import java.text.DateFormat
+import java.util.Date
import kotlinx.coroutines.yield
@Composable
fun TvPlayerScreen(
player: Player?,
channel: Channel?,
+ isFavorite: Boolean,
isPlaying: Boolean,
playbackState: Int,
+ programme: Programme?,
onPlayPause: () -> Unit,
+ onFavorite: () -> Unit,
+ onPreviousChannel: () -> Unit,
+ onNextChannel: () -> Unit,
onBack: () -> Unit,
onClose: () -> Unit
) {
@@ -60,6 +77,27 @@ fun TvPlayerScreen(
Box(
modifier = Modifier
.fillMaxSize()
+ .onPreviewKeyEvent { event ->
+ when (
+ tvPlayerChannelNavigationAction(
+ keyCode = event.nativeKeyEvent.keyCode,
+ isKeyDown = event.type == KeyEventType.KeyDown,
+ repeatCount = event.nativeKeyEvent.repeatCount
+ )
+ ) {
+ TvPlayerChannelNavigationAction.Previous -> {
+ onPreviousChannel()
+ true
+ }
+
+ TvPlayerChannelNavigationAction.Next -> {
+ onNextChannel()
+ true
+ }
+
+ null -> false
+ }
+ }
.background(Color.Black)
) {
if (player != null) {
@@ -94,6 +132,25 @@ fun TvPlayerScreen(
onClick = onPlayPause,
focusRequester = playPauseFocusRequester
)
+ TvIconActionButton(
+ icon = Icons.Rounded.SkipPrevious,
+ contentDescription = stringResource(string.tv_action_previous_channel),
+ onClick = onPreviousChannel
+ )
+ TvIconActionButton(
+ icon = Icons.Rounded.SkipNext,
+ contentDescription = stringResource(string.tv_action_next_channel),
+ onClick = onNextChannel
+ )
+ TvIconActionButton(
+ icon = if (isFavorite) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder,
+ contentDescription = if (isFavorite) {
+ stringResource(string.feat_channel_tooltip_unfavourite)
+ } else {
+ stringResource(string.feat_channel_tooltip_favourite)
+ },
+ onClick = onFavorite
+ )
TvIconActionButton(
icon = Icons.Rounded.Close,
contentDescription = stringResource(string.tv_action_close_player),
@@ -103,7 +160,7 @@ fun TvPlayerScreen(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
- .widthIn(max = 420.dp)
+ .widthIn(max = 520.dp)
) {
Text(
text = channel?.title?.title().orEmpty(),
@@ -114,6 +171,29 @@ fun TvPlayerScreen(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
+ programme?.let {
+ Text(
+ text = it.programmeLine(),
+ color = TvColors.TextPrimary.copy(alpha = 0.9f),
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (it.description.isNotBlank()) {
+ Text(
+ text = it.description,
+ color = TvColors.TextSecondary,
+ fontSize = 13.sp,
+ lineHeight = 18.sp,
+ fontFamily = TvFonts.Body,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
Text(
text = playerStateText(playbackState),
color = TvColors.TextSecondary,
@@ -132,4 +212,39 @@ private fun playerStateText(playbackState: Int): String = when (playbackState) {
Player.STATE_READY -> stringResource(string.feat_channel_playback_state_ready)
Player.STATE_ENDED -> stringResource(string.feat_channel_playback_state_ended)
else -> stringResource(string.feat_channel_playback_state_idle)
-}
\ No newline at end of file
+}
+
+private fun Programme.programmeLine(): String {
+ val formatter = DateFormat.getTimeInstance(DateFormat.SHORT)
+ val startText = formatter.format(Date(start))
+ val endText = formatter.format(Date(end))
+ return "$startText-$endText ${title.title()}"
+}
+
+internal enum class TvPlayerChannelNavigationAction {
+ Previous,
+ Next
+}
+
+internal fun tvPlayerChannelNavigationAction(
+ keyCode: Int,
+ isKeyDown: Boolean,
+ repeatCount: Int
+): TvPlayerChannelNavigationAction? {
+ if (!isKeyDown || repeatCount != 0) {
+ return null
+ }
+ return when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_CHANNEL_DOWN,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ KeyEvent.KEYCODE_MEDIA_PREVIOUS -> TvPlayerChannelNavigationAction.Previous
+
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_CHANNEL_UP,
+ KeyEvent.KEYCODE_PAGE_UP,
+ KeyEvent.KEYCODE_MEDIA_NEXT -> TvPlayerChannelNavigationAction.Next
+
+ else -> null
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvScreens.kt b/app/tv/src/main/java/com/m3u/tv/TvScreens.kt
index f87655ba0..899eedca7 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvScreens.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvScreens.kt
@@ -1,6 +1,8 @@
package com.m3u.tv
import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusGroup
@@ -27,6 +29,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
+import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Refresh
@@ -38,26 +41,35 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m3u.core.util.basic.title
import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.DataSource
import com.m3u.data.database.model.Playlist
import com.m3u.i18n.R.string
import kotlinx.coroutines.yield
@@ -66,9 +78,19 @@ import kotlinx.coroutines.yield
fun TvBrowsePane(
destination: TvDestination,
state: TvUiState,
+ subscribingXtream: Boolean,
+ subscribingM3u: Boolean,
+ xtreamSubscriptionMessage: TvXtreamSubscriptionMessage?,
+ m3uSubscriptionMessage: TvM3uSubscriptionMessage?,
+ focusChannelsOnLibraryOpen: Boolean,
onOpenLibrary: () -> Unit,
onPlaylist: (Playlist) -> Unit,
+ onLibraryChannelFocusHandled: () -> Unit,
onRefresh: () -> Unit,
+ onAddXtreamPlaylist: (String, String, String, String, String?) -> Unit,
+ onClearXtreamSubscriptionMessage: () -> Unit,
+ onAddM3uPlaylist: (String, String) -> Unit,
+ onClearM3uSubscriptionMessage: () -> Unit,
onPlay: (Channel) -> Unit,
onPlayRecent: () -> Unit
) {
@@ -78,7 +100,16 @@ fun TvBrowsePane(
contentAlignment = Alignment.Center
) {
if (state.playlists.isEmpty()) {
- EmptyLibraryScreen()
+ EmptyLibraryScreen(
+ subscribingXtream = subscribingXtream,
+ subscribingM3u = subscribingM3u,
+ xtreamSubscriptionMessage = xtreamSubscriptionMessage,
+ m3uSubscriptionMessage = m3uSubscriptionMessage,
+ onAddXtreamPlaylist = onAddXtreamPlaylist,
+ onClearXtreamSubscriptionMessage = onClearXtreamSubscriptionMessage,
+ onAddM3uPlaylist = onAddM3uPlaylist,
+ onClearM3uSubscriptionMessage = onClearM3uSubscriptionMessage
+ )
} else {
when (destination) {
TvDestination.Home -> HomeScreen(
@@ -91,7 +122,9 @@ fun TvBrowsePane(
TvDestination.Library -> LibraryScreen(
state = state,
+ focusChannelsOnOpen = focusChannelsOnLibraryOpen,
onPlaylist = onPlaylist,
+ onChannelFocusRequestHandled = onLibraryChannelFocusHandled,
onRefresh = onRefresh,
onPlay = onPlay
)
@@ -103,7 +136,17 @@ fun TvBrowsePane(
onPlay = onPlay
)
- TvDestination.Status -> StatusScreen(state)
+ TvDestination.Status -> StatusScreen(
+ state = state,
+ subscribingXtream = subscribingXtream,
+ subscribingM3u = subscribingM3u,
+ xtreamSubscriptionMessage = xtreamSubscriptionMessage,
+ m3uSubscriptionMessage = m3uSubscriptionMessage,
+ onAddXtreamPlaylist = onAddXtreamPlaylist,
+ onClearXtreamSubscriptionMessage = onClearXtreamSubscriptionMessage,
+ onAddM3uPlaylist = onAddM3uPlaylist,
+ onClearM3uSubscriptionMessage = onClearM3uSubscriptionMessage
+ )
}
}
}
@@ -166,7 +209,9 @@ private fun HomeScreen(
channels = featuredChannels,
onPlay = onPlay,
onFocused = { highlightedChannel = it },
- firstItemFocusRequester = firstFeaturedFocusRequester
+ firstItemFocusRequester = firstFeaturedFocusRequester,
+ recentChannelId = state.recent?.id,
+ recentBadgeText = stringResource(string.tv_action_resume)
)
}
}
@@ -395,22 +440,30 @@ private fun HeroActionChip(
@Composable
private fun LibraryScreen(
state: TvUiState,
+ focusChannelsOnOpen: Boolean,
onPlaylist: (Playlist) -> Unit,
+ onChannelFocusRequestHandled: () -> Unit,
onRefresh: () -> Unit,
onPlay: (Channel) -> Unit
) {
val playlistFocusRequester = remember { FocusRequester() }
+ val channelGridFocusRequester = remember { FocusRequester() }
val focusTarget = state.selectedPlaylist ?: state.playlists.firstOrNull()
var initialFocusRequested by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
+ LaunchedEffect(focusTarget?.url) {
if (focusTarget != null && !initialFocusRequested) {
yield()
playlistFocusRequester.requestFocus()
initialFocusRequested = true
}
}
-
+ LaunchedEffect(focusChannelsOnOpen, state.loadingChannels, state.channels.size) {
+ if (!focusChannelsOnOpen || state.loadingChannels) return@LaunchedEffect
+ yield()
+ channelGridFocusRequester.requestFocus()
+ onChannelFocusRequestHandled()
+ }
LazyColumn(
verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = PaddingValues(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp),
@@ -438,6 +491,7 @@ private fun LibraryScreen(
modifier = Modifier
.widthIn(min = 256.dp, max = 336.dp)
.height(144.dp)
+ .focusProperties { down = channelGridFocusRequester }
)
}
}
@@ -469,17 +523,34 @@ private fun LibraryScreen(
TvActionButton(
text = stringResource(string.feat_setting_label_subscribe),
icon = Icons.Rounded.Refresh,
- onClick = onRefresh
+ onClick = onRefresh,
+ modifier = Modifier.focusProperties {
+ up = playlistFocusRequester
+ down = channelGridFocusRequester
+ }
)
}
}
item {
- ChannelGrid(
- channels = state.channels,
- onPlay = onPlay,
- modifier = Modifier.height(620.dp)
- )
+ if (state.channels.isEmpty()) {
+ EmptyChannelGrid(
+ title = stringResource(string.tv_empty_channels_title),
+ subtitle = stringResource(string.tv_empty_channels_subtitle),
+ icon = Icons.Rounded.VideoLibrary,
+ focusRequester = channelGridFocusRequester,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp)
+ )
+ } else {
+ ChannelGrid(
+ channels = state.channels,
+ onPlay = onPlay,
+ firstItemFocusRequester = channelGridFocusRequester,
+ modifier = Modifier.height(620.dp)
+ )
+ }
}
}
}
@@ -492,11 +563,14 @@ private fun ChannelGridScreen(
onPlay: (Channel) -> Unit
) {
val firstChannelFocusRequester = remember { FocusRequester() }
+ val emptyStateFocusRequester = remember { FocusRequester() }
LaunchedEffect(channels.size) {
+ yield()
if (channels.isNotEmpty()) {
- yield()
firstChannelFocusRequester.requestFocus()
+ } else {
+ emptyStateFocusRequester.requestFocus()
}
}
@@ -508,55 +582,157 @@ private fun ChannelGridScreen(
.focusGroup()
) {
SectionTitle(title = title, subtitle = subtitle)
- ChannelGrid(
- channels = channels,
- onPlay = onPlay,
- firstItemFocusRequester = firstChannelFocusRequester
- )
+ if (channels.isEmpty()) {
+ EmptyChannelGrid(
+ title = title,
+ subtitle = subtitle,
+ focusRequester = emptyStateFocusRequester
+ )
+ } else {
+ ChannelGrid(
+ channels = channels,
+ onPlay = onPlay,
+ firstItemFocusRequester = firstChannelFocusRequester
+ )
+ }
}
}
@Composable
-private fun StatusScreen(state: TvUiState) {
- Column(
+private fun EmptyChannelGrid(
+ title: String,
+ subtitle: String,
+ focusRequester: FocusRequester,
+ modifier: Modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp),
+ icon: ImageVector = Icons.Rounded.Favorite
+) {
+ FocusFrame(
+ onClick = {},
+ focusRequester = focusRequester,
+ focusedScale = 1f,
+ shape = RoundedCornerShape(16.dp),
+ modifier = modifier
+ ) { focused ->
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 28.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (focused) TvColors.OnFocus else TvColors.TextPrimary,
+ modifier = Modifier.size(48.dp)
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ color = if (focused) TvColors.OnFocus else TvColors.TextPrimary,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = subtitle,
+ color = if (focused) TvColors.OnFocus.copy(alpha = 0.78f) else TvColors.TextSecondary,
+ fontSize = 16.sp,
+ lineHeight = 22.sp,
+ fontFamily = TvFonts.Body,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusScreen(
+ state: TvUiState,
+ subscribingXtream: Boolean,
+ subscribingM3u: Boolean,
+ xtreamSubscriptionMessage: TvXtreamSubscriptionMessage?,
+ m3uSubscriptionMessage: TvM3uSubscriptionMessage?,
+ onAddXtreamPlaylist: (String, String, String, String, String?) -> Unit,
+ onClearXtreamSubscriptionMessage: () -> Unit,
+ onAddM3uPlaylist: (String, String) -> Unit,
+ onClearM3uSubscriptionMessage: () -> Unit
+) {
+ LazyColumn(
verticalArrangement = Arrangement.spacedBy(24.dp),
+ contentPadding = PaddingValues(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp),
modifier = Modifier
.fillMaxSize()
- .padding(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp)
+ .focusGroup()
) {
- SectionTitle(
- title = stringResource(string.tv_settings_title),
- subtitle = stringResource(string.tv_settings_subtitle)
- )
- Row(
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- MetricTile(
- title = stringResource(string.tv_metric_playlists),
- value = state.playlists.size.toString(),
- icon = Icons.Rounded.VideoLibrary,
- modifier = Modifier
- .weight(1f)
- .height(136.dp)
- )
- MetricTile(
- title = stringResource(string.tv_metric_channels),
- value = state.channelCount.toString(),
- icon = Icons.AutoMirrored.Rounded.PlaylistPlay,
- modifier = Modifier
- .weight(1f)
- .height(136.dp)
- )
- MetricTile(
- title = stringResource(string.tv_metric_favorites),
- value = state.favorites.size.toString(),
- icon = Icons.Rounded.Favorite,
- modifier = Modifier
- .weight(1f)
- .height(136.dp)
+ item {
+ SectionTitle(
+ title = stringResource(string.tv_settings_title),
+ subtitle = stringResource(string.tv_settings_subtitle)
)
}
+ item {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ MetricTile(
+ title = stringResource(string.tv_metric_playlists),
+ value = state.playlists.size.toString(),
+ icon = Icons.Rounded.VideoLibrary,
+ modifier = Modifier
+ .weight(1f)
+ .height(136.dp)
+ )
+ MetricTile(
+ title = stringResource(string.tv_metric_channels),
+ value = state.channelCount.toString(),
+ icon = Icons.AutoMirrored.Rounded.PlaylistPlay,
+ modifier = Modifier
+ .weight(1f)
+ .height(136.dp)
+ )
+ MetricTile(
+ title = stringResource(string.tv_metric_favorites),
+ value = state.favorites.size.toString(),
+ icon = Icons.Rounded.Favorite,
+ modifier = Modifier
+ .weight(1f)
+ .height(136.dp)
+ )
+ }
+ }
+ item {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ M3uSubscribePanel(
+ subscribing = subscribingM3u,
+ message = m3uSubscriptionMessage,
+ onSubmit = onAddM3uPlaylist,
+ onInputChange = onClearM3uSubscriptionMessage,
+ modifier = Modifier
+ .weight(0.92f)
+ .height(360.dp)
+ )
+ XtreamSubscribePanel(
+ subscribing = subscribingXtream,
+ message = xtreamSubscriptionMessage,
+ onSubmit = onAddXtreamPlaylist,
+ onInputChange = onClearXtreamSubscriptionMessage,
+ modifier = Modifier
+ .weight(1.08f)
+ .height(460.dp)
+ )
+ }
+ }
}
}
@@ -565,7 +741,9 @@ private fun ContentRow(
channels: List,
onPlay: (Channel) -> Unit,
onFocused: (Channel) -> Unit = {},
- firstItemFocusRequester: FocusRequester? = null
+ firstItemFocusRequester: FocusRequester? = null,
+ recentChannelId: Int? = null,
+ recentBadgeText: String? = null
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
@@ -579,6 +757,7 @@ private fun ContentRow(
onFocused = { onFocused(channel) },
focusRequester = firstItemFocusRequester.takeIf { index == 0 },
compact = true,
+ badgeText = recentBadgeText.takeIf { channel.id == recentChannelId },
modifier = Modifier
.widthIn(min = 104.dp, max = 120.dp)
.aspectRatio(2f / 3f)
@@ -612,13 +791,34 @@ private fun ChannelGrid(
}
@Composable
-private fun EmptyLibraryScreen() {
+private fun EmptyLibraryScreen(
+ subscribingXtream: Boolean,
+ subscribingM3u: Boolean,
+ xtreamSubscriptionMessage: TvXtreamSubscriptionMessage?,
+ m3uSubscriptionMessage: TvM3uSubscriptionMessage?,
+ onAddXtreamPlaylist: (String, String, String, String, String?) -> Unit,
+ onClearXtreamSubscriptionMessage: () -> Unit,
+ onAddM3uPlaylist: (String, String) -> Unit,
+ onClearM3uSubscriptionMessage: () -> Unit
+) {
+ val initialFocusRequester = remember { FocusRequester() }
+ var initialFocusRequested by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ yield()
+ if (!initialFocusRequested) {
+ initialFocusRequester.requestFocus()
+ initialFocusRequested = true
+ }
+ }
+
Row(
- horizontalArrangement = Arrangement.spacedBy(48.dp),
+ horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
- .height(360.dp)
+ .height(460.dp)
+ .focusGroup()
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -658,18 +858,367 @@ private fun EmptyLibraryScreen() {
.fillMaxWidth(0.72f)
.widthIn(max = 420.dp)
) {
- InfoPill(text = stringResource(string.tv_empty_library_phone_hint), modifier = Modifier.fillMaxWidth())
+ InfoPill(text = stringResource(string.tv_empty_library_xtream_hint), modifier = Modifier.fillMaxWidth())
InfoPill(text = stringResource(string.tv_empty_library_restore_hint), modifier = Modifier.fillMaxWidth())
}
}
- SetupPanel(
+ M3uSubscribePanel(
+ subscribing = subscribingM3u,
+ message = m3uSubscriptionMessage,
+ onSubmit = onAddM3uPlaylist,
+ onInputChange = onClearM3uSubscriptionMessage,
+ firstInputFocusRequester = initialFocusRequester,
+ modifier = Modifier
+ .weight(0.72f)
+ .widthIn(max = 360.dp)
+ .height(320.dp)
+ )
+ XtreamSubscribePanel(
+ subscribing = subscribingXtream,
+ message = xtreamSubscriptionMessage,
+ onSubmit = onAddXtreamPlaylist,
+ onInputChange = onClearXtreamSubscriptionMessage,
modifier = Modifier
.weight(0.88f)
.widthIn(max = 420.dp)
+ .fillMaxHeight()
)
}
}
+private enum class TvXtreamContentType(val type: String?) {
+ All(null),
+ Live(DataSource.Xtream.TYPE_LIVE),
+ Vod(DataSource.Xtream.TYPE_VOD),
+ Series(DataSource.Xtream.TYPE_SERIES)
+}
+
+@Composable
+private fun M3uSubscribePanel(
+ subscribing: Boolean,
+ message: TvM3uSubscriptionMessage?,
+ onSubmit: (String, String) -> Unit,
+ onInputChange: () -> Unit,
+ firstInputFocusRequester: FocusRequester? = null,
+ modifier: Modifier = Modifier
+) {
+ var title by rememberSaveable { mutableStateOf("") }
+ var urlOrPath by rememberSaveable { mutableStateOf("") }
+ val canSubmit = title.isNotBlank() && urlOrPath.isNotBlank()
+
+ FocusFrame(
+ onClick = {},
+ enabled = false,
+ modifier = modifier,
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.linearGradient(
+ 0f to TvColors.SurfaceRaised,
+ 1f to TvColors.BackgroundSoft
+ )
+ )
+ .padding(20.dp)
+ ) {
+ SectionTitle(
+ title = stringResource(string.tv_m3u_panel_title),
+ subtitle = stringResource(string.tv_m3u_panel_subtitle)
+ )
+ TvInputField(
+ value = title,
+ onValueChange = {
+ title = it
+ onInputChange()
+ },
+ placeholder = stringResource(string.tv_m3u_title_placeholder),
+ focusRequester = firstInputFocusRequester
+ )
+ TvInputField(
+ value = urlOrPath,
+ onValueChange = {
+ urlOrPath = it
+ onInputChange()
+ },
+ placeholder = stringResource(string.tv_m3u_url_placeholder),
+ keyboardType = KeyboardType.Uri
+ )
+ Text(
+ text = message?.let { m3uSubscriptionMessageText(it) }.orEmpty(),
+ color = when (message) {
+ TvM3uSubscriptionMessage.Enqueued -> TvColors.Focus
+ TvM3uSubscriptionMessage.InvalidInput,
+ TvM3uSubscriptionMessage.MissingFields -> TvColors.Accent
+ null -> Color.Transparent
+ },
+ fontSize = 13.sp,
+ lineHeight = 18.sp,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.height(22.dp)
+ )
+ TvActionButton(
+ text = if (subscribing) {
+ stringResource(string.tv_xtream_subscribing)
+ } else {
+ stringResource(string.feat_setting_label_subscribe)
+ },
+ icon = Icons.Rounded.Add,
+ onClick = { onSubmit(title, urlOrPath) },
+ enabled = canSubmit && !subscribing,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+@Composable
+private fun XtreamSubscribePanel(
+ subscribing: Boolean,
+ message: TvXtreamSubscriptionMessage?,
+ onSubmit: (String, String, String, String, String?) -> Unit,
+ onInputChange: () -> Unit,
+ initialTypeFocusRequester: FocusRequester? = null,
+ modifier: Modifier = Modifier
+) {
+ var title by rememberSaveable { mutableStateOf("") }
+ var basicUrl by rememberSaveable { mutableStateOf("") }
+ var username by rememberSaveable { mutableStateOf("") }
+ var password by rememberSaveable { mutableStateOf("") }
+ var selectedType by rememberSaveable { mutableStateOf(TvXtreamContentType.All) }
+ val canSubmit = title.isNotBlank() &&
+ basicUrl.isNotBlank() &&
+ username.isNotBlank() &&
+ password.isNotBlank()
+
+ FocusFrame(
+ onClick = {},
+ enabled = false,
+ modifier = modifier,
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.linearGradient(
+ 0f to TvColors.SurfaceRaised,
+ 1f to TvColors.BackgroundSoft
+ )
+ )
+ .padding(24.dp)
+ ) {
+ SectionTitle(
+ title = stringResource(string.tv_xtream_panel_title),
+ subtitle = stringResource(string.tv_xtream_panel_subtitle)
+ )
+ TvInputField(
+ value = title,
+ onValueChange = {
+ title = it
+ onInputChange()
+ },
+ placeholder = stringResource(string.tv_xtream_title_placeholder)
+ )
+ TvInputField(
+ value = basicUrl,
+ onValueChange = {
+ basicUrl = it
+ onInputChange()
+ },
+ placeholder = stringResource(string.tv_xtream_address_placeholder),
+ keyboardType = KeyboardType.Uri
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ TvInputField(
+ value = username,
+ onValueChange = {
+ username = it
+ onInputChange()
+ },
+ placeholder = stringResource(string.tv_xtream_username_placeholder),
+ modifier = Modifier.weight(1f)
+ )
+ TvInputField(
+ value = password,
+ onValueChange = {
+ password = it
+ onInputChange()
+ },
+ placeholder = stringResource(string.tv_xtream_password_placeholder),
+ keyboardType = KeyboardType.Password,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.weight(1f)
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TvXtreamContentType.entries.forEach { type ->
+ XtreamTypeChip(
+ type = type,
+ selected = type == selectedType,
+ onSelect = {
+ selectedType = type
+ onInputChange()
+ },
+ focusRequester = initialTypeFocusRequester.takeIf {
+ type == TvXtreamContentType.All
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(44.dp)
+ )
+ }
+ }
+ val messageText = message?.let { xtreamSubscriptionMessageText(it) }
+ Text(
+ text = messageText.orEmpty(),
+ color = when (message) {
+ TvXtreamSubscriptionMessage.Enqueued -> TvColors.Focus
+ TvXtreamSubscriptionMessage.InvalidUrl,
+ TvXtreamSubscriptionMessage.MissingFields -> TvColors.Accent
+ null -> Color.Transparent
+ },
+ fontSize = 13.sp,
+ lineHeight = 18.sp,
+ fontFamily = TvFonts.Body,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.height(30.dp)
+ )
+ TvActionButton(
+ text = if (subscribing) {
+ stringResource(string.tv_xtream_subscribing)
+ } else {
+ stringResource(string.tv_xtream_subscribe)
+ },
+ icon = Icons.Rounded.Add,
+ onClick = {
+ onSubmit(title, basicUrl, username, password, selectedType.type)
+ },
+ enabled = canSubmit && !subscribing,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+@Composable
+private fun TvInputField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+ focusRequester: FocusRequester? = null,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ visualTransformation: VisualTransformation = VisualTransformation.None
+) {
+ var focused by remember { mutableStateOf(false) }
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle = TextStyle(
+ color = TvColors.TextPrimary,
+ fontSize = 15.sp,
+ fontFamily = TvFonts.Body
+ ),
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ visualTransformation = visualTransformation,
+ cursorBrush = SolidColor(TvColors.Focus),
+ decorationBox = { innerTextField ->
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (value.isEmpty()) {
+ Text(
+ text = placeholder,
+ color = TvColors.TextMuted,
+ fontSize = 15.sp,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ innerTextField()
+ }
+ },
+ modifier = modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(TvColors.Surface.copy(alpha = 0.92f))
+ .border(
+ BorderStroke(
+ width = if (focused) 3.dp else 1.dp,
+ color = if (focused) TvColors.Focus else Color.White.copy(alpha = 0.12f)
+ ),
+ RoundedCornerShape(8.dp)
+ )
+ .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
+ .onFocusChanged { focused = it.isFocused }
+ .padding(horizontal = 14.dp, vertical = 13.dp)
+ )
+}
+
+@Composable
+private fun XtreamTypeChip(
+ type: TvXtreamContentType,
+ selected: Boolean,
+ onSelect: () -> Unit,
+ focusRequester: FocusRequester? = null,
+ modifier: Modifier = Modifier
+) {
+ FocusFrame(
+ onClick = onSelect,
+ selected = selected,
+ focusedScale = 1f,
+ shape = RoundedCornerShape(22.dp),
+ focusRequester = focusRequester,
+ modifier = modifier
+ ) { focused ->
+ Text(
+ text = when (type) {
+ TvXtreamContentType.All -> stringResource(string.tv_xtream_type_all)
+ TvXtreamContentType.Live -> stringResource(string.tv_xtream_type_live)
+ TvXtreamContentType.Vod -> stringResource(string.tv_xtream_type_vod)
+ TvXtreamContentType.Series -> stringResource(string.tv_xtream_type_series)
+ },
+ color = if (focused || selected) TvColors.OnFocus else TvColors.TextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+}
+
+@Composable
+private fun xtreamSubscriptionMessageText(message: TvXtreamSubscriptionMessage): String =
+ when (message) {
+ TvXtreamSubscriptionMessage.MissingFields ->
+ stringResource(string.tv_xtream_message_missing_fields)
+ TvXtreamSubscriptionMessage.InvalidUrl ->
+ stringResource(string.tv_xtream_message_invalid_url)
+ TvXtreamSubscriptionMessage.Enqueued ->
+ stringResource(string.tv_xtream_message_enqueued)
+ }
+
+@Composable
+private fun m3uSubscriptionMessageText(message: TvM3uSubscriptionMessage): String =
+ when (message) {
+ TvM3uSubscriptionMessage.MissingFields ->
+ stringResource(string.tv_m3u_message_missing_fields)
+ TvM3uSubscriptionMessage.InvalidInput ->
+ stringResource(string.tv_m3u_message_invalid_input)
+ TvM3uSubscriptionMessage.Enqueued ->
+ stringResource(string.tv_m3u_message_enqueued)
+ }
+
@Composable
private fun SetupPanel(modifier: Modifier = Modifier) {
FocusFrame(
@@ -727,4 +1276,4 @@ private fun SetupStep(text: String) {
overflow = TextOverflow.Ellipsis
)
}
-}
\ No newline at end of file
+}
diff --git a/app/tv/src/test/java/com/m3u/tv/TvPlayerScreenKeyTest.kt b/app/tv/src/test/java/com/m3u/tv/TvPlayerScreenKeyTest.kt
new file mode 100644
index 000000000..f95858711
--- /dev/null
+++ b/app/tv/src/test/java/com/m3u/tv/TvPlayerScreenKeyTest.kt
@@ -0,0 +1,68 @@
+package com.m3u.tv
+
+import android.view.KeyEvent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TvPlayerScreenKeyTest {
+ @Test
+ fun channelNavigationKeysMapToPreviousAndNextChannels() {
+ listOf(
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_CHANNEL_DOWN,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ KeyEvent.KEYCODE_MEDIA_PREVIOUS
+ ).forEach { keyCode ->
+ assertEquals(
+ TvPlayerChannelNavigationAction.Previous,
+ tvPlayerChannelNavigationAction(
+ keyCode = keyCode,
+ isKeyDown = true,
+ repeatCount = 0
+ )
+ )
+ }
+
+ listOf(
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_CHANNEL_UP,
+ KeyEvent.KEYCODE_PAGE_UP,
+ KeyEvent.KEYCODE_MEDIA_NEXT
+ ).forEach { keyCode ->
+ assertEquals(
+ TvPlayerChannelNavigationAction.Next,
+ tvPlayerChannelNavigationAction(
+ keyCode = keyCode,
+ isKeyDown = true,
+ repeatCount = 0
+ )
+ )
+ }
+ }
+
+ @Test
+ fun channelNavigationIgnoresKeyUpRepeatAndUnmappedKeys() {
+ assertNull(
+ tvPlayerChannelNavigationAction(
+ keyCode = KeyEvent.KEYCODE_CHANNEL_UP,
+ isKeyDown = false,
+ repeatCount = 0
+ )
+ )
+ assertNull(
+ tvPlayerChannelNavigationAction(
+ keyCode = KeyEvent.KEYCODE_CHANNEL_UP,
+ isKeyDown = true,
+ repeatCount = 1
+ )
+ )
+ assertNull(
+ tvPlayerChannelNavigationAction(
+ keyCode = KeyEvent.KEYCODE_DPAD_CENTER,
+ isKeyDown = true,
+ repeatCount = 0
+ )
+ )
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index f51a64569..59129d1a2 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.android.build.api.dsl.ApplicationExtension
import com.android.build.gradle.LibraryExtension
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
@@ -49,7 +50,10 @@ subprojects {
}
plugins.withId("org.jetbrains.kotlin.plugin.compose") {
configure {
- includeSourceInformation = true
+ includeSourceInformation = providers
+ .gradleProperty("m3uComposeSourceInformation")
+ .map(String::toBoolean)
+ .getOrElse(false)
val file = rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
if (file.asFile.exists()) {
stabilityConfigurationFiles.add(file)
@@ -58,11 +62,25 @@ subprojects {
reportsDestination = layout.buildDirectory.dir("compose_metrics")
}
}
+ plugins.withId("com.android.application") {
+ configure {
+ dependenciesInfo {
+ includeInApk = false
+ includeInBundle = false
+ }
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ }
+ dependencies.add("coreLibraryDesugaring", libs.desugar.jdk.libs)
+ }
plugins.withId("com.android.library") {
configure {
compileSdk = 36
defaultConfig {
- minSdk = 26
+ minSdk = 25
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
@@ -76,9 +94,11 @@ subprojects {
}
}
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
+ dependencies.add("coreLibraryDesugaring", libs.desugar.jdk.libs)
}
}
diff --git a/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt b/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt
index 549e8532d..cef82e346 100644
--- a/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt
+++ b/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt
@@ -3,6 +3,7 @@ package com.m3u.business.channel
import android.annotation.SuppressLint
import android.media.AudioManager
import android.net.Uri
+import android.net.wifi.WifiManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
@@ -10,7 +11,6 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
-import androidx.media3.common.Format
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
@@ -33,11 +33,12 @@ import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.service.MediaCommand
import com.m3u.data.service.PlayerManager
-import com.m3u.data.service.currentTracks
-import com.m3u.data.service.tracks
+import com.m3u.data.service.TrackOption
+import com.m3u.data.service.trackOptions
import com.m3u.data.worker.ProgrammeReminder
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,6 +47,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -54,6 +56,7 @@ import kotlinx.coroutines.launch
import net.mm2d.upnp.ControlPoint
import net.mm2d.upnp.ControlPointFactory
import net.mm2d.upnp.Device
+import java.net.URLDecoder
import javax.inject.Inject
import kotlin.math.roundToInt
import kotlin.time.Clock
@@ -67,6 +70,7 @@ class ChannelViewModel @Inject constructor(
private val channelRepository: ChannelRepository,
private val playerManager: PlayerManager,
private val audioManager: AudioManager,
+ private val wifiManager: WifiManager,
private val programmeRepository: ProgrammeRepository,
private val workManager: WorkManager,
) : ViewModel(), ControlPoint.DiscoveryListener {
@@ -144,28 +148,16 @@ class ChannelViewModel @Inject constructor(
}
}
- val tracks: Flow