feat: listen connection to television.

This commit is contained in:
oxy
2024-02-14 17:46:50 +08:00
parent bca2848210
commit 2a48268f8b
78 changed files with 865 additions and 521 deletions

View File

@ -15,4 +15,9 @@
9. Never use view-based XML, you can use view in AndroidView composable only.
10. Never use Painter to inflate drawable resources, use `ImageVector.vectorResource` instead.
11. If you wanna to add some libraries, please make sure they are located in MavenCentral, google or
jitpack repository. And jar library is not allowed as well.
jitpack repository. And jar library is not allowed as well.
12. Due to compatibility needs, for data table `playlists` and `streams`,
please do not change the existing column names(referring to the real field names mapped to the
database, that is, the name field defined in ColumnInfo),
and remember the new fields must have default value (it needs to be defined in the
defaultValue field in both the data class and ColumnInfo).

View File

@ -2,6 +2,7 @@ package com.m3u.androidApp
import android.app.Application
import android.os.Build
import com.m3u.core.architecture.Abi
import com.m3u.core.architecture.Publisher
import com.m3u.material.ktx.isTelevision
import javax.inject.Inject
@ -13,6 +14,7 @@ class AppPublisher @Inject constructor(private val application: Application) : P
override val debug: Boolean = BuildConfig.DEBUG
override val snapshot: Boolean = "snapshot" in BuildConfig.FLAVOR
override val model: String = Build.MODEL
override val abi: Abi = Abi.of(Build.SUPPORTED_ABIS[0])
override val isTelevision: Boolean
get() = application.resources.configuration.isTelevision()
}

View File

@ -20,7 +20,7 @@ class M3UApplication : Application(), Configuration.Provider {
lateinit var handler: CrashHandler
@Inject
@Logger.Message
@Logger.MessageImpl
lateinit var messager: Logger
@Inject

View File

@ -45,8 +45,8 @@ import com.m3u.core.util.context.isPortraitMode
import com.m3u.core.util.coroutine.getValue
import com.m3u.core.util.coroutine.setValue
import com.m3u.core.wrapper.Message
import com.m3u.data.local.service.MessageManager
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.MessageManager
import com.m3u.data.service.PlayerManager
import com.m3u.ui.Toolkit
import com.m3u.ui.helper.Action
import com.m3u.ui.helper.Fob
@ -84,7 +84,7 @@ class MainActivity : AppCompatActivity() {
lateinit var pref: Pref
@Inject
@Logger.Message
@Logger.MessageImpl
lateinit var logger: Logger
@Inject

View File

@ -5,7 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.m3u.core.architecture.pref.Pref
import com.m3u.data.local.service.MessageManager
import com.m3u.data.service.MessageManager
import com.m3u.ui.Destination
import com.m3u.ui.helper.Action
import com.m3u.ui.helper.Fob

View File

@ -24,7 +24,8 @@ subprojects {
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.material3.adaptive.navigation.suite.ExperimentalMaterial3AdaptiveNavigationSuiteApi",
"-opt-in=androidx.tv.material3.ExperimentalTvMaterial3Api"
"-opt-in=androidx.tv.material3.ExperimentalTvMaterial3Api",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
val path = project.layout.buildDirectory.dir("compose_metrics").get().asFile.path

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.com.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.org.jetbrains.kotlin.serialization)
alias(libs.plugins.com.google.devtools.ksp)
id("kotlin-parcelize")
}
@ -44,6 +45,8 @@ dependencies {
implementation(libs.androidx.media3.media3.session)
implementation(libs.com.google.dagger.hilt.android)
ksp(libs.com.google.dagger.hilt.compiler)
implementation(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
}

View File

@ -1,5 +1,7 @@
package com.m3u.core.architecture
import kotlinx.serialization.Serializable
interface Publisher {
val author: String get() = "realOxy"
val repository: String get() = "M3UAndroid"
@ -9,5 +11,39 @@ interface Publisher {
val debug: Boolean
val snapshot: Boolean
val model: String
val abi: Abi
val isTelevision: Boolean
}
@JvmInline
@Serializable
value class Abi private constructor(
val value: String
) {
fun accept(self: Abi, target: Abi): Boolean {
check(self != unsupported) { "self abi cannot be unsupported!" }
if (target == unsupported) return false
if (self == target) return true
return self == universal
}
companion object {
val universal = Abi("universal")
val x86 = Abi("x86")
val x86_64 = Abi("x86_64")
val arm64_v8a = Abi("arm64-v8a")
val arm64_v7a = Abi("arm64-v7a")
val unsupported = Abi("unsupported")
fun of(value: String): Abi {
return when (value) {
x86.value -> x86
x86_64.value -> x86_64
arm64_v7a.value -> arm64_v7a
arm64_v8a.value -> arm64_v8a
universal.value -> universal
else -> unsupported
}
}
}
}

View File

@ -1,6 +1,6 @@
package com.m3u.core.architecture.logger
import com.m3u.core.wrapper.Message.Companion.LEVEL_ERROR
import com.m3u.core.wrapper.Message
import javax.inject.Qualifier
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@ -8,10 +8,10 @@ import kotlin.time.Duration.Companion.seconds
interface Logger {
fun log(
text: String,
level: Int = LEVEL_ERROR,
level: Int = Message.LEVEL_ERROR,
tag: String = "LOGGER",
duration: Duration = 3.seconds,
type: Int = com.m3u.core.wrapper.Message.TYPE_SNACK
duration: Duration = 5.seconds,
type: Int = Message.TYPE_SNACK
)
fun log(
@ -21,7 +21,7 @@ interface Logger {
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Message
annotation class MessageImpl
}
fun Logger.prefix(text: String): PrefixLogger = PrefixLogger(this, text)

View File

@ -79,7 +79,7 @@ dependencies {
implementation(libs.nextlib.media3ext)
implementation(libs.ktor.server.jetty)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.content.negotiation)

View File

@ -0,0 +1,164 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "525922b9d8fae525560b9f1e503fffe6",
"entities": [
{
"tableName": "streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `group` TEXT NOT NULL, `title` TEXT NOT NULL, `cover` TEXT, `playlistUrl` TEXT NOT NULL, `license_type` TEXT DEFAULT NULL, `license_key` TEXT DEFAULT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `favourite` INTEGER NOT NULL, `hidden` INTEGER NOT NULL DEFAULT 0, `seen` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "group",
"columnName": "group",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cover",
"columnName": "cover",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "playlistUrl",
"columnName": "playlistUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "licenseType",
"columnName": "license_type",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "licenseKey",
"columnName": "license_key",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourite",
"columnName": "favourite",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "seen",
"columnName": "seen",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `url` TEXT NOT NULL, `pinned_groups` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`url`))",
"fields": [
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pinnedGroups",
"columnName": "pinned_groups",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"url"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "color_pack",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`argb` INTEGER NOT NULL, `dark` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`argb`, `dark`))",
"fields": [
{
"fieldPath": "argb",
"columnName": "argb",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDark",
"columnName": "dark",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"argb",
"dark"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '525922b9d8fae525560b9f1e503fffe6')"
]
}
}

View File

@ -1,10 +1,21 @@
package com.m3u.data.api
import com.m3u.core.architecture.Publisher
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.architecture.logger.execute
import com.m3u.data.local.http.endpoint.Playlists
import com.m3u.data.local.http.endpoint.SayHello
import com.m3u.data.television.http.endpoint.Playlists
import com.m3u.data.television.http.endpoint.SayHello
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import retrofit2.Retrofit
import retrofit2.create
import retrofit2.http.GET
@ -27,12 +38,15 @@ interface LocalService {
@Singleton
class LocalPreparedService @Inject constructor(
private val builder: Retrofit.Builder,
@Logger.Message private val logger: Logger
private val okHttpClient: OkHttpClient,
@Logger.MessageImpl private val logger: Logger,
private val publisher: Publisher,
) : LocalService {
override suspend fun sayHello(): SayHello.Rep? = logger.execute {
val api = checkNotNull(api) { "You haven't connected television" }
api.sayHello()
}
override suspend fun subscribe(
title: String,
url: String
@ -43,16 +57,45 @@ class LocalPreparedService @Inject constructor(
private var api: LocalService? = null
fun prepare(host: String, port: Int) {
fun prepare(host: String, port: Int): Flow<SayHello.Rep> = callbackFlow {
val json = Json {
ignoreUnknownKeys = true
}
val baseUrl = HttpUrl.Builder()
.scheme("http")
.host(host)
.port(port)
.build()
api = builder
.baseUrl(baseUrl)
.build()
.create()
val request = Request.Builder()
.url(
baseUrl
.newBuilder("say_hello")!!
.addQueryParameter("model", publisher.model)
.build()
)
.build()
val listener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
val rep = json.decodeFromString<SayHello.Rep>(text)
trySendBlocking(rep)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.cancel()
cancel(reason)
}
}
val webSocket = okHttpClient.newWebSocket(request, listener)
awaitClose {
webSocket.cancel()
}
}
fun close() {

View File

@ -32,4 +32,10 @@ internal object DatabaseMigrations {
db.execSQL("ALTER TABLE playlists ADD COLUMN pinned_groups TEXT NOT NULL DEFAULT '[]'")
}
}
@RenameColumn(
tableName = "streams",
fromColumnName = "banned",
toColumnName = "hidden"
)
class AutoMigration8To9: AutoMigrationSpec
}

View File

@ -13,7 +13,7 @@ import com.m3u.data.database.model.Stream
@Database(
entities = [Stream::class, Playlist::class, ColorPack::class],
version = 8,
version = 9,
exportSchema = true,
autoMigrations = [
AutoMigration(
@ -23,7 +23,12 @@ import com.m3u.data.database.model.Stream
),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7)
AutoMigration(from = 6, to = 7),
AutoMigration(
from = 8,
to = 9,
spec = DatabaseMigrations.AutoMigration8To9::class
)
]
)
@TypeConverters(Converters::class)

View File

@ -52,8 +52,8 @@ interface StreamDao {
@Query("UPDATE streams SET favourite = :target WHERE id = :id")
suspend fun setFavourite(id: Int, target: Boolean)
@Query("UPDATE streams SET banned = :target WHERE id = :id")
suspend fun ban(id: Int, target: Boolean)
@Query("UPDATE streams SET hidden = :target WHERE id = :id")
suspend fun hide(id: Int, target: Boolean)
@Query("UPDATE streams SET seen = :target WHERE id = :id")
suspend fun updateSeen(id: Int, target: Long)

View File

@ -33,8 +33,8 @@ data class Stream(
// extra fields
@ColumnInfo(name = "favourite")
val favourite: Boolean = false,
@ColumnInfo(name = "banned")
val banned: Boolean = false,
@ColumnInfo(name = "hidden", defaultValue = "0")
val hidden: Boolean = false,
@ColumnInfo(name = "seen", defaultValue = "0")
val seen: Long = 0L,
) : Likable<Stream> {

View File

@ -1,43 +0,0 @@
package com.m3u.data.local.http.endpoint
import androidx.annotation.Keep
import com.m3u.core.architecture.Publisher
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.websocket.webSocket
import kotlinx.serialization.Serializable
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
data class SayHello @Inject constructor(
private val publisher: Publisher
) : Endpoint {
override fun apply(route: Route) {
route.route("/say_hello") {
get {
call.respond(publisher.model)
}
webSocket {
for (frame in incoming) {
val rep = Rep(
model = publisher.model,
version = publisher.versionCode,
snapshot = publisher.snapshot
)
call.respond(rep)
}
}
}
}
@Keep
@Serializable
data class Rep(
val model: String,
val version: Int,
val snapshot: Boolean
)
}

View File

@ -37,12 +37,8 @@ fun PlaylistRepository.refresh(
@PlaylistStrategy strategy: Int
): Flow<Resource<Unit>> = channelFlow {
try {
val playlist = get(url) ?: error("Cannot find playlist: $url")
if (playlist.fromLocal) {
// refreshing is not needed for local storage playlist.
send(Resource.Success(Unit))
return@channelFlow
}
val playlist = checkNotNull(get(url)) { "Cannot find playlist: $url" }
check(!playlist.fromLocal) { "refreshing is not needed for local storage playlist." }
subscribe(playlist.title, url, strategy)
.onEach(::send)
.launchIn(this)

View File

@ -13,7 +13,7 @@ interface StreamRepository {
suspend fun getByUrl(url: String): Stream?
suspend fun getByPlaylistUrl(playlistUrl: String): List<Stream>
suspend fun setFavourite(id: Int, target: Boolean)
suspend fun ban(id: Int, target: Boolean)
suspend fun hide(id: Int, target: Boolean)
suspend fun reportPlayed(id: Int)
suspend fun getPlayedRecently(): Stream?
fun observeAllUnseenFavourites(limit: Duration): Flow<List<Stream>>

View File

@ -0,0 +1,26 @@
package com.m3u.data.repository
import com.m3u.data.television.http.endpoint.SayHello
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
abstract class TelevisionRepository {
abstract val broadcastCodeOnTelevision: StateFlow<Int?>
protected abstract fun broadcastOnTelevision()
protected abstract fun closeBroadcastOnTelevision()
abstract val connectedTelevision: StateFlow<SayHello.Rep?>
abstract fun connectToTelevision(code: Int, timeout: Duration = 8.seconds): Flow<ConnectionToTelevision>
abstract suspend fun disconnectToTelevision()
}
sealed interface ConnectionToTelevision {
data class Idle(val reason: String? = null) : ConnectionToTelevision
data object Timeout : ConnectionToTelevision
data object Searching: ConnectionToTelevision
data class Completed(val host: String, val port: Int) : ConnectionToTelevision
}

View File

@ -1,21 +0,0 @@
package com.m3u.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
abstract class TvRepository {
abstract val pinCodeForServer: StateFlow<Int?>
protected abstract fun startForServer()
protected abstract fun stopForServer()
abstract fun pairForClient(pin: Int, timeout: Duration = 8.seconds): Flow<PairState>
}
sealed interface PairState {
data object Idle : PairState
data object Timeout : PairState
data object Connecting : PairState
data class Connected(val host: String, val port: Int) : PairState
}

View File

@ -5,11 +5,11 @@ package com.m3u.data.repository.di
import com.m3u.data.repository.MediaRepository
import com.m3u.data.repository.PlaylistRepository
import com.m3u.data.repository.StreamRepository
import com.m3u.data.repository.TvRepository
import com.m3u.data.repository.TelevisionRepository
import com.m3u.data.repository.internal.MediaRepositoryImpl
import com.m3u.data.repository.internal.PlaylistRepositoryImpl
import com.m3u.data.repository.internal.StreamRepositoryImpl
import com.m3u.data.repository.internal.TvRepositoryImpl
import com.m3u.data.repository.internal.TelevisionRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -39,7 +39,7 @@ interface RepositoryModule {
@Binds
@Singleton
fun bindTvRepository(
repository: TvRepositoryImpl
): TvRepository
fun bindTelevisionRepository(
repository: TelevisionRepositoryImpl
): TelevisionRepository
}

View File

@ -1,12 +1,13 @@
package com.m3u.data.work
package com.m3u.data.repository.internal
object BackupContracts {
object BackupOrRestoreContracts {
fun wrapPlaylist(encoded: String): String = "P,$encoded"
fun wrapStream(encoded: String): String = "S,$encoded"
fun unwrapPlaylist(wrapped: String): String? {
if (!wrapped.startsWith("P,")) return null
return wrapped.drop(2)
}
fun unwrapStream(wrapped: String): String? {
if (!wrapped.startsWith("S,")) return null
return wrapped.drop(2)

View File

@ -27,7 +27,6 @@ import com.m3u.data.database.model.Stream
import com.m3u.data.repository.PlaylistRepository
import com.m3u.data.repository.parser.M3UPlaylistParser
import com.m3u.data.repository.parser.model.toStream
import com.m3u.data.work.BackupContracts
import com.m3u.i18n.R.string
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
@ -48,7 +47,7 @@ import javax.inject.Inject
class PlaylistRepositoryImpl @Inject constructor(
private val playlistDao: PlaylistDao,
private val streamDao: StreamDao,
@Logger.Message private val logger: Logger,
@Logger.MessageImpl private val logger: Logger,
private val client: OkHttpClient,
@M3UPlaylistParser.Default private val parser: M3UPlaylistParser,
@ApplicationContext private val context: Context,
@ -105,11 +104,11 @@ class PlaylistRepositoryImpl @Inject constructor(
all.forEach { (playlist, streams) ->
if (playlist.fromLocal) return@forEach
val encodedPlaylist = json.encodeToString(playlist)
val wrappedPlaylist = BackupContracts.wrapPlaylist(encodedPlaylist)
val wrappedPlaylist = BackupOrRestoreContracts.wrapPlaylist(encodedPlaylist)
writer.appendLine(wrappedPlaylist)
streams.forEach { stream ->
val encodedStream = json.encodeToString(stream)
val wrappedStream = BackupContracts.wrapStream(encodedStream)
val wrappedStream = BackupOrRestoreContracts.wrapStream(encodedStream)
writer.appendLine(wrappedStream)
}
}
@ -125,8 +124,8 @@ class PlaylistRepositoryImpl @Inject constructor(
reader.forEachLine { line ->
if (line.isBlank()) return@forEachLine
val encodedPlaylist = BackupContracts.unwrapPlaylist(line)
val encodedStream = BackupContracts.unwrapStream(line)
val encodedPlaylist = BackupOrRestoreContracts.unwrapPlaylist(line)
val encodedStream = BackupOrRestoreContracts.unwrapStream(line)
when {
encodedPlaylist != null -> {
val playlist = json.decodeFromString<Playlist>(encodedPlaylist)

View File

@ -40,8 +40,8 @@ class StreamRepositoryImpl @Inject constructor(
streamDao.setFavourite(id, target)
}
override suspend fun ban(id: Int, target: Boolean) = logger.sandBox {
streamDao.ban(id, target)
override suspend fun hide(id: Int, target: Boolean) = logger.sandBox {
streamDao.hide(id, target)
}
override suspend fun reportPlayed(id: Int) = logger.sandBox {

View File

@ -9,24 +9,26 @@ import com.m3u.core.architecture.logger.prefix
import com.m3u.core.architecture.pref.Pref
import com.m3u.core.architecture.pref.observeAsFlow
import com.m3u.core.util.coroutine.onTimeout
import com.m3u.data.local.http.internal.Utils
import com.m3u.data.api.LocalPreparedService
import com.m3u.data.local.http.HttpServer
import com.m3u.data.local.nsd.NsdDeviceManager
import com.m3u.data.repository.PairState
import com.m3u.data.repository.TvRepository
import com.m3u.data.television.http.HttpServer
import com.m3u.data.television.http.endpoint.SayHello
import com.m3u.data.television.http.internal.Utils
import com.m3u.data.television.nsd.NsdDeviceManager
import com.m3u.data.repository.ConnectionToTelevision
import com.m3u.data.repository.TelevisionRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@ -35,7 +37,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration
class TvRepositoryImpl @Inject constructor(
class TelevisionRepositoryImpl @Inject constructor(
private val nsdDeviceManager: NsdDeviceManager,
private val server: HttpServer,
private val localService: LocalPreparedService,
@ -43,30 +45,33 @@ class TvRepositoryImpl @Inject constructor(
pref: Pref,
publisher: Publisher,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher
) : TvRepository() {
) : TelevisionRepository() {
private val logger = logger.prefix("tv-repos")
private val isTelevision = publisher.isTelevision
private val coroutineScope = CoroutineScope(ioDispatcher)
init {
pref
.observeAsFlow { it.remoteControl }
.onEach { remoteControl ->
when {
remoteControl && (isTelevision || pref.alwaysTv) -> startForServer()
else -> stopForServer()
}
combine(
pref.observeAsFlow { it.remoteControl },
pref.observeAsFlow { it.alwaysTv }
) { remoteControl, alwaysTv ->
when {
!remoteControl -> closeBroadcastOnTelevision()
alwaysTv || isTelevision -> broadcastOnTelevision()
else -> closeBroadcastOnTelevision()
}
}
.launchIn(coroutineScope)
}
private val _pinCodeForServer = MutableStateFlow<Int?>(null)
override val pinCodeForServer: StateFlow<Int?> = _pinCodeForServer.asStateFlow()
override val broadcastCodeOnTelevision = _pinCodeForServer.asStateFlow()
private var serverJob: Job? = null
override fun startForServer() {
override fun broadcastOnTelevision() {
val serverPort = Utils.findPort()
stopForServer()
closeBroadcastOnTelevision()
server.start(serverPort)
serverJob = coroutineScope.launch {
while (isActive) {
@ -101,41 +106,60 @@ class TvRepositoryImpl @Inject constructor(
}
}
override fun stopForServer() {
override fun closeBroadcastOnTelevision() {
server.stop()
serverJob?.cancel()
serverJob = null
}
override fun pairForClient(pin: Int, timeout: Duration): Flow<PairState> = channelFlow {
trySendBlocking(PairState.Idle)
nsdDeviceManager
private val _connectedToTelevision = MutableStateFlow<SayHello.Rep?>(null)
override val connectedTelevision = _connectedToTelevision.asStateFlow()
private var connectionToTelevisionJob: Job? = null
override fun connectToTelevision(
code: Int,
timeout: Duration
): Flow<ConnectionToTelevision> = channelFlow {
val completed = nsdDeviceManager
.search()
.onStart {
logger.log("pair: start")
trySendBlocking(PairState.Connecting)
}
.onStart { trySendBlocking(ConnectionToTelevision.Searching) }
.onTimeout(timeout) {
logger.log("pair: timeout")
trySendBlocking(PairState.Timeout)
trySendBlocking(ConnectionToTelevision.Timeout)
}
.onEach { all ->
.mapNotNull { all ->
logger.log("pair: all devices: $all")
val info = all.find {
it.getAttribute(NsdDeviceManager.META_DATA_PIN) == pin.toString()
} ?: return@onEach
it.getAttribute(NsdDeviceManager.META_DATA_PIN) == code.toString()
} ?: return@mapNotNull null
val port = info.getAttribute(NsdDeviceManager.META_DATA_PORT)
?.toIntOrNull()
?: return@onEach
?: return@mapNotNull null
val host =
info.getAttribute(NsdDeviceManager.META_DATA_HOST) ?: return@onEach
info.getAttribute(NsdDeviceManager.META_DATA_HOST) ?: return@mapNotNull null
logger.log("pair: connected")
trySendBlocking(PairState.Connected(host, port))
localService.prepare(host, port)
cancel()
logger.log("pair: connecting")
ConnectionToTelevision.Completed(host, port)
}
.launchIn(this)
.firstOrNull()
if (completed != null) {
trySendBlocking(completed)
connectionToTelevisionJob?.cancel()
connectionToTelevisionJob = localService
.prepare(completed.host, completed.port)
.onEach { rep ->
logger.log("pair: connected")
_connectedToTelevision.value = rep
}
.launchIn(coroutineScope)
}
}
override suspend fun disconnectToTelevision() {
connectionToTelevisionJob?.cancel()
connectionToTelevisionJob = null
_connectedToTelevision.value = null
}
private fun NsdServiceInfo.getAttribute(key: String): String? =

View File

@ -2,7 +2,7 @@ package com.m3u.data.repository.logger
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.wrapper.Message
import com.m3u.data.local.service.MessageManager
import com.m3u.data.service.MessageManager
import java.util.Locale
import javax.inject.Inject
import kotlin.time.Duration

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.service
package com.m3u.data.service
import com.m3u.core.wrapper.Message
import kotlinx.coroutines.flow.StateFlow

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.service
package com.m3u.data.service
import android.graphics.Rect
import androidx.media3.common.C

View File

@ -1,23 +1,21 @@
@file:Suppress("unused")
package com.m3u.data.local.service
package com.m3u.data.service
import android.app.NotificationManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.wifi.WifiManager
import androidx.core.app.NotificationManagerCompat
import androidx.work.WorkManager
import com.m3u.core.architecture.TraceFileProvider
import com.m3u.core.architecture.logger.Logger
import com.m3u.data.local.http.HttpServer
import com.m3u.data.local.http.internal.HttpServerImpl
import com.m3u.data.local.nsd.NsdDeviceManager
import com.m3u.data.local.nsd.internal.NsdDeviceManagerImpl
import com.m3u.data.local.service.internal.MessageManagerImpl
import com.m3u.data.local.service.internal.PlayerManagerImpl
import com.m3u.data.local.service.internal.TraceFileProviderImpl
import com.m3u.data.television.http.HttpServer
import com.m3u.data.television.http.internal.HttpServerImpl
import com.m3u.data.television.nsd.NsdDeviceManager
import com.m3u.data.television.nsd.internal.NsdDeviceManagerImpl
import com.m3u.data.service.internal.MessageManagerImpl
import com.m3u.data.service.internal.PlayerManagerImpl
import com.m3u.data.service.internal.TraceFileProviderImpl
import com.m3u.data.repository.logger.CommonLogger
import com.m3u.data.repository.logger.MessageLogger
import dagger.Binds
@ -49,7 +47,7 @@ internal interface BindServicesModule {
@Binds
@Singleton
@Logger.Message
@Logger.MessageImpl
fun bindMessageLogger(logger: MessageLogger): Logger
@Binds
@ -82,18 +80,6 @@ object ProvidedServicesModule {
return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
@Provides
@Singleton
fun provideWifiManager(@ApplicationContext context: Context): WifiManager {
return context.getSystemService(Context.WIFI_SERVICE) as WifiManager
}
@Provides
@Singleton
fun provideConnectivityManager(@ApplicationContext context: Context): ConnectivityManager {
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
@Provides
@Singleton
fun provideNsdManager(@ApplicationContext context: Context): NsdManager {

View File

@ -1,9 +1,9 @@
package com.m3u.data.local.service.internal
package com.m3u.data.service.internal
import com.m3u.core.architecture.dispatcher.Dispatcher
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
import com.m3u.core.wrapper.Message
import com.m3u.data.local.service.MessageManager
import com.m3u.data.service.MessageManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.service.internal
package com.m3u.data.service.internal
import android.content.Context
import android.graphics.Rect
@ -33,7 +33,7 @@ import com.m3u.core.architecture.dispatcher.M3uDispatchers.Main
import com.m3u.core.architecture.pref.Pref
import com.m3u.core.architecture.pref.annotation.ReconnectMode
import com.m3u.core.architecture.pref.observeAsFlow
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.PlayerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
import kotlinx.coroutines.CoroutineDispatcher

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.service.internal
package com.m3u.data.service.internal
import android.content.Context
import android.content.pm.PackageInfo

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.http
package com.m3u.data.television.http
interface HttpServer {
fun start(port: Int)

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.http.endpoint
package com.m3u.data.television.http.endpoint
import io.ktor.server.routing.Route

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.http.endpoint
package com.m3u.data.television.http.endpoint
import android.content.Context
import androidx.annotation.Keep
@ -7,7 +7,7 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.m3u.core.architecture.pref.Pref
import com.m3u.data.work.SubscriptionWorker
import com.m3u.data.worker.SubscriptionWorker
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.server.response.respond
import io.ktor.server.routing.Route

View File

@ -0,0 +1,75 @@
package com.m3u.data.television.http.endpoint
import androidx.annotation.Keep
import androidx.compose.runtime.Immutable
import com.m3u.core.architecture.Abi
import com.m3u.core.architecture.Publisher
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.wrapper.Message
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.websocket.sendSerialized
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readReason
import io.ktor.websocket.readText
import kotlinx.serialization.Serializable
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
data class SayHello @Inject constructor(
private val publisher: Publisher,
@Logger.MessageImpl private val messager: Logger
) : Endpoint {
override fun apply(route: Route) {
route.route("/say_hello") {
get {
val rep = Rep(
model = publisher.model,
version = publisher.versionCode,
snapshot = publisher.snapshot,
abi = publisher.abi
)
call.respond(rep)
}
webSocket {
val model = call.request.queryParameters["model"] ?: "?"
val rep = with(publisher) { Rep(model, versionCode, snapshot, abi) }
messager.log("Connection from [$model]", Message.LEVEL_INFO)
sendSerialized(rep)
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
messager.log("[$model] " + frame.readText(), Message.LEVEL_WARN)
sendSerialized(rep)
}
is Frame.Binary -> {
}
is Frame.Close -> {
messager.log("Connection lost from [$model], reason: ${frame.readReason()}")
}
else -> {}
}
}
}
}
}
@Keep
@Serializable
@Immutable
data class Rep(
val model: String,
val version: Int,
val snapshot: Boolean,
val abi: Abi
)
}

View File

@ -1,14 +1,15 @@
package com.m3u.data.local.http.internal
package com.m3u.data.television.http.internal
import com.m3u.data.local.http.HttpServer
import com.m3u.data.local.http.endpoint.Playlists
import com.m3u.data.local.http.endpoint.SayHello
import com.m3u.data.television.http.HttpServer
import com.m3u.data.television.http.endpoint.Playlists
import com.m3u.data.television.http.endpoint.SayHello
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.jetty.Jetty
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.routing.routing
@ -26,7 +27,7 @@ internal class HttpServerImpl @Inject constructor(
private var server: EmbeddedServer<*, *>? = null
override fun start(port: Int) {
server = embeddedServer(Jetty, port) {
server = embeddedServer(Netty, port) {
configureSerialization()
configureSockets()
configureCors()
@ -57,6 +58,11 @@ internal class HttpServerImpl @Inject constructor(
private fun Application.configureSockets() {
install(WebSockets) {
val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
contentConverter = KotlinxWebsocketSerializationConverter(json)
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
}
@ -65,6 +71,7 @@ internal class HttpServerImpl @Inject constructor(
private fun Application.configureCors() {
install(CORS) {
anyHost()
allowSameOrigin = true
}
}
}

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.http.internal
package com.m3u.data.television.http.internal
import java.net.InetAddress
import java.net.NetworkInterface

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.nsd
package com.m3u.data.television.nsd
import android.net.nsd.NsdServiceInfo
import kotlinx.coroutines.flow.Flow

View File

@ -1,4 +1,4 @@
package com.m3u.data.local.nsd.internal
package com.m3u.data.television.nsd.internal
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
@ -6,9 +6,9 @@ import com.m3u.core.architecture.dispatcher.Dispatcher
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.architecture.logger.prefix
import com.m3u.data.local.nsd.NsdDeviceManager
import com.m3u.data.local.nsd.NsdDeviceManager.Companion.META_DATA_PIN
import com.m3u.data.local.nsd.NsdDeviceManager.Companion.SERVICE_TYPE
import com.m3u.data.television.nsd.NsdDeviceManager
import com.m3u.data.television.nsd.NsdDeviceManager.Companion.META_DATA_PIN
import com.m3u.data.television.nsd.NsdDeviceManager.Companion.SERVICE_TYPE
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking

View File

@ -1,4 +1,4 @@
package com.m3u.data.work
package com.m3u.data.worker
import android.app.Notification
import android.content.Context

View File

@ -1,4 +1,4 @@
package com.m3u.data.work
package com.m3u.data.worker
import android.app.Notification
import android.content.Context

View File

@ -1,4 +1,4 @@
package com.m3u.data.work
package com.m3u.data.worker
import android.app.Notification
import android.app.NotificationChannel

View File

@ -13,7 +13,7 @@ import com.m3u.core.Contracts
import com.m3u.core.architecture.pref.Pref
import com.m3u.core.architecture.pref.observeAsFlow
import com.m3u.data.database.model.Stream
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.PlayerManager
import com.m3u.data.repository.MediaRepository
import com.m3u.data.repository.StreamRepository
import com.m3u.data.repository.observeAll

View File

@ -13,11 +13,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
@ -43,7 +41,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.m3u.core.architecture.pref.LocalPref
import com.m3u.core.util.basic.title
import com.m3u.data.database.model.Playlist
import com.m3u.data.repository.PairState
import com.m3u.data.repository.ConnectionToTelevision
import com.m3u.features.foryou.components.ConnectBottomSheet
import com.m3u.features.foryou.components.ForyouDialog
import com.m3u.features.foryou.components.OnRename
import com.m3u.features.foryou.components.OnUnsubscribe
@ -57,12 +56,10 @@ import com.m3u.material.components.Background
import com.m3u.material.components.Button
import com.m3u.material.ktx.interceptVolumeEvent
import com.m3u.material.ktx.isTelevision
import com.m3u.material.ktx.minus
import com.m3u.material.ktx.only
import com.m3u.material.ktx.split
import com.m3u.material.ktx.thenIf
import com.m3u.material.model.LocalHazeState
import com.m3u.material.model.LocalSpacing
import com.m3u.ui.ConnectBottomSheet
import com.m3u.ui.EventHandler
import com.m3u.ui.FontFamilies
import com.m3u.ui.ResumeEvent
@ -100,19 +97,21 @@ fun ForyouRoute(
var isConnectSheetVisible by remember { mutableStateOf(false) }
val pinCodeForServer by viewModel.pinCodeForServer.collectAsStateWithLifecycle()
val pairStateForClient by viewModel.pairStateForClient.collectAsStateWithLifecycle()
// for televisions
val broadcastCodeOnTelevision by viewModel.broadcastCodeOnTelevision.collectAsStateWithLifecycle()
val connecting by remember {
derivedStateOf { pairStateForClient == PairState.Connecting }
// for smartphones
val connectionToTelevision by viewModel.connectionToTelevision.collectAsStateWithLifecycle()
val connectedTelevision by viewModel.connectedTelevision.collectAsStateWithLifecycle()
val searchingToTelevision by remember {
derivedStateOf { connectionToTelevision is ConnectionToTelevision.Searching }
}
val connected by remember {
derivedStateOf { pairStateForClient is PairState.Connected }
val connectedToTelevision by remember {
derivedStateOf { connectedTelevision != null }
}
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { !connecting }
confirmValueChange = { !searchingToTelevision }
)
EventHandler(resume) {
@ -128,8 +127,8 @@ fun ForyouRoute(
}
LaunchedEffect(Unit) {
snapshotFlow { connected }.collectLatest { visible ->
if (visible) {
snapshotFlow { connectedToTelevision }.collectLatest { connected ->
if (connected) {
isConnectSheetVisible = false
code = ""
}
@ -165,8 +164,9 @@ fun ForyouRoute(
ConnectBottomSheet(
sheetState = sheetState,
visible = isConnectSheetVisible,
connectedTelevision = connectedTelevision,
code = code,
connecting = connecting,
connecting = searchingToTelevision,
onCode = {
code = it
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
@ -174,13 +174,12 @@ fun ForyouRoute(
onDismissRequest = {
isConnectSheetVisible = false
},
onConnect = {
viewModel.pair(code.toInt())
}
onConnect = { viewModel.openTelevisionCodeOnSmartphone(code.toInt()) },
onDisconnect = { viewModel.closeTelevisionCodeOnSmartphone() }
)
Crossfade(
targetState = pinCodeForServer,
label = "pin-code",
targetState = broadcastCodeOnTelevision,
label = "broadcast-code-on-television",
modifier = Modifier
.padding(spacing.medium)
.align(Alignment.BottomEnd)
@ -230,13 +229,10 @@ private fun ForyouScreen(
) {
val showRecommend = recommend.isNotEmpty()
val showPlaylist = details.isNotEmpty()
val (topContentPadding, otherContentPadding) =
contentPadding split if (showRecommend) WindowInsetsSides.Top else null
if (showRecommend) {
Column {
Spacer(
Modifier
.fillMaxWidth()
.height(contentPadding.calculateTopPadding())
)
Box(Modifier.padding(topContentPadding)) {
RecommendGallery(
recommend = recommend,
navigateToStream = navigateToStream,
@ -247,14 +243,12 @@ private fun ForyouScreen(
}
if (showPlaylist) {
val actualContentPadding = if (!showRecommend) contentPadding
else contentPadding - contentPadding.only(WindowInsetsSides.Top)
PlaylistGallery(
rowCount = actualRowCount,
details = details,
navigateToPlaylist = navigateToPlaylist,
onMenu = { dialog = ForyouDialog.Selections(it) },
contentPadding = actualContentPadding,
contentPadding = otherContentPadding,
modifier = Modifier
.fillMaxSize()
.haze(

View File

@ -7,10 +7,10 @@ import com.m3u.core.architecture.dispatcher.M3uDispatchers.Default
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
import com.m3u.core.architecture.pref.Pref
import com.m3u.core.architecture.pref.observeAsFlow
import com.m3u.data.repository.PairState
import com.m3u.data.repository.ConnectionToTelevision
import com.m3u.data.repository.PlaylistRepository
import com.m3u.data.repository.StreamRepository
import com.m3u.data.repository.TvRepository
import com.m3u.data.repository.TelevisionRepository
import com.m3u.features.foryou.components.recommend.Recommend
import com.m3u.features.foryou.model.PlaylistDetail
import com.m3u.features.foryou.model.toDetail
@ -19,14 +19,13 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@ -41,16 +40,14 @@ import kotlin.time.toDuration
class ForyouViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
streamRepository: StreamRepository,
private val tvRepository: TvRepository,
private val televisionRepository: TelevisionRepository,
pref: Pref,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
@Dispatcher(Default) defaultDispatcher: CoroutineDispatcher
) : ViewModel() {
internal val pinCodeForServer: StateFlow<String?> = tvRepository
.pinCodeForServer
.map { code ->
code?.let { convertToPaddedString(it) }
}
internal val broadcastCodeOnTelevision: StateFlow<String?> = televisionRepository
.broadcastCodeOnTelevision
.map { code -> code?.let { convertToPaddedString(it) } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
@ -60,6 +57,7 @@ class ForyouViewModel @Inject constructor(
private val counts: StateFlow<Map<String, Int>> = streamRepository
.observeAll()
.map { streams ->
// map playlistUrl to count
streams
.groupBy { it.playlistUrl }
.mapValues { it.value.size }
@ -73,10 +71,11 @@ class ForyouViewModel @Inject constructor(
internal val details: StateFlow<ImmutableList<PlaylistDetail>> = playlistRepository
.observeAll()
.distinctUntilChanged()
.combine(counts) { fs, cs ->
.combine(counts) { playlists, counts ->
withContext(defaultDispatcher) {
fs.map { f ->
f.toDetail(cs[f.url] ?: PlaylistDetail.DEFAULT_COUNT)
playlists.map { playlist ->
val count = counts[playlist.url] ?: PlaylistDetail.DEFAULT_COUNT
playlist.toDetail(count)
}
}
.toPersistentList()
@ -96,7 +95,6 @@ class ForyouViewModel @Inject constructor(
initialValue = Duration.INFINITE
)
@OptIn(ExperimentalCoroutinesApi::class)
internal val recommend: StateFlow<Recommend> = unseensDuration
.flatMapLatest { streamRepository.observeAllUnseenFavourites(it) }
.map { prev -> Recommend(prev.map { Recommend.UnseenSpec(it) }) }
@ -118,24 +116,34 @@ class ForyouViewModel @Inject constructor(
}
}
private val pinCodeForClient = MutableSharedFlow<Int?>()
private val televisionCodeOnSmartphone = MutableSharedFlow<Int?>()
@OptIn(ExperimentalCoroutinesApi::class)
internal val pairStateForClient: StateFlow<PairState> =
pinCodeForClient.flatMapLatest { pinCode ->
if (pinCode != null) tvRepository.pairForClient(pinCode)
else flow { }
internal val connectionToTelevision: StateFlow<ConnectionToTelevision> =
televisionCodeOnSmartphone.flatMapLatest { code ->
if (code != null) televisionRepository.connectToTelevision(code)
else {
televisionRepository.disconnectToTelevision()
flowOf(ConnectionToTelevision.Idle())
}
}
.flowOn(ioDispatcher)
.stateIn(
scope = viewModelScope,
initialValue = PairState.Idle,
initialValue = ConnectionToTelevision.Idle(),
started = SharingStarted.WhileSubscribed(5_000)
)
internal fun pair(pin: Int) {
internal val connectedTelevision = televisionRepository.connectedTelevision
internal fun openTelevisionCodeOnSmartphone(code: Int) {
viewModelScope.launch {
pinCodeForClient.emit(pin)
televisionCodeOnSmartphone.emit(code)
}
}
internal fun closeTelevisionCodeOnSmartphone() {
viewModelScope.launch {
televisionCodeOnSmartphone.emit(null)
}
}
}

View File

@ -0,0 +1,93 @@
package com.m3u.features.foryou.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m3u.material.model.LocalSpacing
@Composable
internal fun CodeRow(
code: String,
length: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
val element = remember(code) { code.toCharArray().map { it.toString() } }
Row(
Modifier
.fillMaxWidth()
.padding(
horizontal = spacing.extraLarge,
vertical = spacing.medium
)
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.then(modifier),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(length) { i ->
CodeField(
text = element.getOrNull(i).orEmpty()
)
}
}
}
@Composable
private fun CodeField(text: String) {
Box(
Modifier
.padding(start = 4.dp, end = 4.dp)
.size(40.dp, 45.dp)
.background(
color = MaterialTheme.colorScheme.onSurface.copy(.05f),
shape = RoundedCornerShape(6.dp)
)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(.1f)),
RoundedCornerShape(6.dp)
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = text,
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
if (text.isBlank()) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(start = 12.dp, end = 12.dp, bottom = 13.dp)
.height(1.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.onSurface.copy(.15f))
)
}
}
}

View File

@ -0,0 +1,125 @@
package com.m3u.features.foryou.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetDefaults
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m3u.core.util.basic.title
import com.m3u.data.television.http.endpoint.SayHello
import com.m3u.i18n.R
@Composable
internal fun ConnectBottomSheet(
visible: Boolean,
connecting: Boolean,
connectedTelevision: SayHello.Rep?,
code: String,
sheetState: SheetState,
onCode: (String) -> Unit,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier
) {
if (visible) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
if (!connecting) onDismissRequest()
},
windowInsets = WindowInsets(0),
properties = ModalBottomSheetDefaults.properties(
shouldDismissOnBackPress = false
)
) {
Column(
modifier.padding(WindowInsets.navigationBarsIgnoringVisibility.asPaddingValues())
) {
val title = when {
connectedTelevision != null -> connectedTelevision.model
else -> stringResource(R.string.feat_foryou_connect_title).title()
}
val subtitle = stringResource(R.string.feat_foryou_connect_subtitle)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(
text = title,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
if (connectedTelevision == null) {
Text(
text = subtitle,
modifier = Modifier.padding(16.dp, 8.dp, 16.dp, 0.dp),
textAlign = TextAlign.Center,
fontSize = 14.sp,
lineHeight = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.78f)
)
CodeRow(
code = code,
length = 6,
onClick = {}
)
}
TextButton(
enabled = connectedTelevision != null || (!connecting && code.length == 6),
onClick = when {
connectedTelevision != null -> onDisconnect
else -> onConnect
},
modifier = Modifier.padding(top = 4.dp)
) {
Text(
when {
connectedTelevision != null -> "DISCONNECT"
connecting -> "CONNECTING"
else -> "CONNECT"
}
)
}
}
AnimatedVisibility(
visible = connectedTelevision == null,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut()
) {
VirtualNumberKeyboard(
code = code,
onCode = onCode,
modifier = Modifier.padding(top = 16.dp)
)
}
}
}
}
}

View File

@ -1,26 +1,14 @@
package com.m3u.material.components
package com.m3u.features.foryou.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -36,111 +24,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m3u.material.model.LocalSpacing
@Composable
fun CodeSkeleton(
title: String,
subtitle: String,
code: String,
onCode: (String) -> Unit,
modifier: Modifier = Modifier,
loading: Boolean = false,
keyboard: Boolean = true,
onKeyboard: () -> Unit = {},
onSubmit: () -> Unit
) {
Column(modifier) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(
text = title,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = subtitle,
modifier = Modifier.padding(16.dp, 8.dp, 16.dp, 0.dp),
textAlign = TextAlign.Center,
fontSize = 14.sp,
lineHeight = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.78f)
)
CodeRow(
code = code,
length = 6,
onClick = onKeyboard
)
TextButton(
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
enabled = !loading && code.length == 6,
onClick = {
onSubmit()
},
) {
Text(if (loading) "CONNECTING" else "CONNECT")
}
}
AnimatedVisibility(
visible = keyboard,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut()
) {
VirtualNumberKeyboard(
code = code,
onCode = onCode,
)
}
}
}
@Composable
fun CodeRow(
code: String,
length: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
val element = remember(code) { code.toCharArray().map { it.toString() } }
Row(
Modifier
.fillMaxWidth()
.padding(
horizontal = spacing.extraLarge,
vertical = spacing.medium
)
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.then(modifier),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(length) { i ->
CodeField(
text = element.getOrNull(i).orEmpty()
)
}
}
}
@Composable
fun VirtualNumberKeyboard(
internal fun VirtualNumberKeyboard(
modifier: Modifier = Modifier,
code: String,
onCode: (String) -> Unit,
@ -150,7 +38,6 @@ fun VirtualNumberKeyboard(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.onSurface.copy(.1f))
.padding(WindowInsets.navigationBarsIgnoringVisibility.asPaddingValues())
) {
Row(
Modifier.fillMaxWidth()
@ -288,38 +175,3 @@ private fun KeyboardKey(
)
}
}
@Composable
private fun CodeField(text: String) {
Box(
Modifier
.padding(start = 4.dp, end = 4.dp)
.size(40.dp, 45.dp)
.background(
color = MaterialTheme.colorScheme.onSurface.copy(.05f),
shape = RoundedCornerShape(6.dp)
)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(.1f)),
RoundedCornerShape(6.dp)
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = text,
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
if (text.isBlank()) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(start = 12.dp, end = 12.dp, bottom = 13.dp)
.height(1.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.onSurface.copy(.15f))
)
}
}
}

View File

@ -6,7 +6,7 @@ import android.content.Context
sealed interface PlaylistEvent {
data object Refresh : PlaylistEvent
data class Favourite(val id: Int, val target: Boolean) : PlaylistEvent
data class Ban(val id: Int) : PlaylistEvent
data class Hide(val id: Int) : PlaylistEvent
data class SavePicture(val id: Int) : PlaylistEvent
data object ScrollUp : PlaylistEvent
data class Query(val text: String) : PlaylistEvent

View File

@ -132,7 +132,7 @@ internal fun PlaylistRoute(
onRefresh = { viewModel.onEvent(PlaylistEvent.Refresh) },
contentPadding = contentPadding,
onFavorite = { id, target -> viewModel.onEvent(PlaylistEvent.Favourite(id, target)) },
ban = { id -> viewModel.onEvent(PlaylistEvent.Ban(id)) },
hide = { id -> viewModel.onEvent(PlaylistEvent.Hide(id)) },
savePicture = {
if (writeExternalPermissionRequired && writeExternalPermissionState.status is PermissionStatus.Denied) {
writeExternalPermissionState.launchPermissionRequest()
@ -175,7 +175,7 @@ private fun PlaylistScreen(
navigateToStream: () -> Unit,
onScrollUp: () -> Unit,
onFavorite: (streamId: Int, target: Boolean) -> Unit,
ban: (streamId: Int) -> Unit,
hide: (streamId: Int) -> Unit,
savePicture: (streamId: Int) -> Unit,
createShortcut: (streamId: Int) -> Unit,
contentPadding: PaddingValues,
@ -226,7 +226,7 @@ private fun PlaylistScreen(
sort = sort,
onSort = onSort,
onFavorite = onFavorite,
ban = ban,
hide = hide,
onSavePicture = savePicture,
createShortcut = createShortcut,
modifier = modifier
@ -243,7 +243,7 @@ private fun PlaylistScreen(
sorts = sorts,
sort = sort,
onFavorite = onFavorite,
ban = ban,
hide = hide,
savePicture = savePicture,
createShortcut = createShortcut,
modifier = modifier

View File

@ -22,8 +22,8 @@ import com.m3u.core.wrapper.Resource
import com.m3u.core.wrapper.eventOf
import com.m3u.data.database.model.Playlist
import com.m3u.data.database.model.Stream
import com.m3u.data.local.service.MessageManager
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.MessageManager
import com.m3u.data.service.PlayerManager
import com.m3u.data.repository.MediaRepository
import com.m3u.data.repository.PlaylistRepository
import com.m3u.data.repository.StreamRepository
@ -35,7 +35,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -59,7 +58,7 @@ class PlaylistViewModel @Inject constructor(
private val mediaRepository: MediaRepository,
playerManager: PlayerManager,
private val pref: Pref,
@Logger.Message private val logger: Logger,
@Logger.MessageImpl private val logger: Logger,
private val messageManager: MessageManager
) : BaseViewModel<PlaylistState, PlaylistEvent>(
emptyState = PlaylistState()
@ -72,7 +71,7 @@ class PlaylistViewModel @Inject constructor(
PlaylistEvent.Refresh -> refresh()
is PlaylistEvent.Favourite -> favourite(event)
PlaylistEvent.ScrollUp -> scrollUp()
is PlaylistEvent.Ban -> ban(event)
is PlaylistEvent.Hide -> hide(event)
is PlaylistEvent.SavePicture -> savePicture(event)
is PlaylistEvent.Query -> query(event)
is PlaylistEvent.CreateShortcut -> createShortcut(event.context, event.id)
@ -165,14 +164,14 @@ class PlaylistViewModel @Inject constructor(
}
}
private fun ban(event: PlaylistEvent.Ban) {
private fun hide(event: PlaylistEvent.Hide) {
viewModelScope.launch {
val id = event.id
val stream = streamRepository.get(id)
if (stream == null) {
messageManager.emit(PlaylistMessage.StreamNotFound)
} else {
streamRepository.ban(stream.id, true)
streamRepository.hide(stream.id, true)
}
}
}
@ -245,14 +244,13 @@ class PlaylistViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L)
)
@OptIn(ExperimentalCoroutinesApi::class)
private val unsorted: StateFlow<List<Stream>> = combine(
playlistUrl.flatMapLatest { url ->
playlistRepository.observeWithStreams(url)
},
query
) { current, query ->
current?.streams?.filter { !it.banned && it.title.contains(query, true) } ?: emptyList()
current?.streams?.filter { !it.hidden && it.title.contains(query, true) } ?: emptyList()
}
.stateIn(
scope = viewModelScope,

View File

@ -33,8 +33,8 @@ import com.m3u.core.unspecified.unspecifiable
import com.m3u.core.util.context.isDarkMode
import com.m3u.core.util.context.isPortraitMode
import com.m3u.core.wrapper.Message
import com.m3u.data.local.service.MessageManager
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.MessageManager
import com.m3u.data.service.PlayerManager
import com.m3u.ui.Toolkit
import com.m3u.ui.helper.Action
import com.m3u.ui.helper.Fob
@ -62,7 +62,7 @@ class TvPlaylistActivity : AppCompatActivity() {
lateinit var pref: Pref
@Inject
@Logger.Message
@Logger.MessageImpl
lateinit var logger: Logger
@Inject

View File

@ -15,7 +15,7 @@ internal fun PlaylistDialog(
status: DialogStatus,
onUpdate: (DialogStatus) -> Unit,
onFavorite: (streamId: Int, target: Boolean) -> Unit,
ban: (streamId: Int) -> Unit,
hide: (streamId: Int) -> Unit,
onSavePicture: (streamId: Int) -> Unit,
createShortcut: (streamId: Int) -> Unit,
modifier: Modifier = Modifier
@ -40,9 +40,9 @@ internal fun PlaylistDialog(
onUpdate(DialogStatus.Idle)
onFavorite(status.stream.id, !favourite)
}
DialogItem(string.feat_playlist_dialog_mute_title) {
DialogItem(string.feat_playlist_dialog_hide_title) {
onUpdate(DialogStatus.Idle)
ban(status.stream.id)
hide(status.stream.id)
}
if (!status.stream.cover.isNullOrEmpty()) {
DialogItem(string.feat_playlist_dialog_save_picture_title) {

View File

@ -97,7 +97,7 @@ internal object PlaylistDrawerDefaults {
fun rememberStreamMenuItems(
stream: Stream?,
onFavorite: (streamId: Int, target: Boolean) -> Unit,
ban: (streamId: Int) -> Unit,
hide: (streamId: Int) -> Unit,
createShortcut: (streamId: Int) -> Unit,
savePicture: (streamId: Int) -> Unit,
): ImmutableList<DrawerItem> {
@ -105,10 +105,10 @@ internal object PlaylistDrawerDefaults {
if (stream?.favourite == true) string.feat_playlist_dialog_favourite_cancel_title
else string.feat_playlist_dialog_favourite_title
)
val banTitle = stringResource(string.feat_playlist_dialog_mute_title)
val hideTitle = stringResource(string.feat_playlist_dialog_hide_title)
val createShortcutTitle = stringResource(string.feat_playlist_dialog_create_shortcut_title)
val savePictureTitle = stringResource(string.feat_playlist_dialog_save_picture_title)
return remember(stream, favouriteTitle, banTitle, createShortcutTitle, savePictureTitle) {
return remember(stream, favouriteTitle, hideTitle, createShortcutTitle, savePictureTitle) {
persistentListOf(
DrawerItem(
title = favouriteTitle,
@ -119,10 +119,10 @@ internal object PlaylistDrawerDefaults {
}
),
DrawerItem(
title = banTitle,
title = hideTitle,
icon = Icons.Rounded.Delete,
onClick = {
stream?.let { ban(it.id) }
stream?.let { hide(it.id) }
true
}
),

View File

@ -88,7 +88,7 @@ internal fun PlaylistScreenImpl(
navigateToStream: () -> Unit,
onRefresh: () -> Unit,
onFavorite: (streamId: Int, target: Boolean) -> Unit,
ban: (streamId: Int) -> Unit,
hide: (streamId: Int) -> Unit,
onSavePicture: (streamId: Int) -> Unit,
createShortcut: (streamId: Int) -> Unit,
isAtTopState: MutableState<Boolean>,
@ -251,7 +251,7 @@ internal fun PlaylistScreenImpl(
status = dialogStatus,
onUpdate = { dialogStatus = it },
onFavorite = onFavorite,
ban = ban,
hide = hide,
onSavePicture = onSavePicture,
createShortcut = createShortcut
)

View File

@ -44,7 +44,7 @@ internal fun TvPlaylistScreenImpl(
sorts: ImmutableList<Sort>,
sort: Sort,
onFavorite: (streamId: Int, target: Boolean) -> Unit,
ban: (streamId: Int) -> Unit,
hide: (streamId: Int) -> Unit,
savePicture: (streamId: Int) -> Unit,
createShortcut: (streamId: Int) -> Unit,
navigateToStream: () -> Unit,
@ -75,7 +75,7 @@ internal fun TvPlaylistScreenImpl(
items = PlaylistDrawerDefaults.rememberStreamMenuItems(
stream = press,
onFavorite = onFavorite,
ban = ban,
hide = hide,
createShortcut = createShortcut,
savePicture = savePicture
)

View File

@ -6,7 +6,7 @@ sealed interface SettingEvent {
data object Subscribe : SettingEvent
data class OnTitle(val title: String) : SettingEvent
data class OnUrl(val url: String) : SettingEvent
data class OnBanned(val id: Int) : SettingEvent
data class OnHidden(val id: Int) : SettingEvent
data class OpenDocument(val uri: Uri = Uri.EMPTY) : SettingEvent
data object OnLocalStorage : SettingEvent
}

View File

@ -77,7 +77,7 @@ fun SettingRoute(
val state by viewModel.state.collectAsStateWithLifecycle()
val packs by viewModel.packs.collectAsStateWithLifecycle()
val banneds by viewModel.banneds.collectAsStateWithLifecycle()
val hiddenStreams by viewModel.hiddenStreams.collectAsStateWithLifecycle()
val backingUpOrRestoring by viewModel.backingUpOrRestoring.collectAsStateWithLifecycle()
val helper = LocalHelper.current
@ -121,14 +121,14 @@ fun SettingRoute(
url = state.url,
backingUpOrRestoring = backingUpOrRestoring,
uriWrapper = rememberUriWrapper(state.uri),
banneds = banneds,
hiddenStreams = hiddenStreams,
onTitle = { viewModel.onEvent(SettingEvent.OnTitle(it)) },
onUrl = { viewModel.onEvent(SettingEvent.OnUrl(it)) },
onSubscribe = {
controller?.hide()
viewModel.onEvent(SettingEvent.Subscribe)
},
onBanned = { viewModel.onEvent(SettingEvent.OnBanned(it)) },
onHidden = { viewModel.onEvent(SettingEvent.OnHidden(it)) },
navigateToAbout = navigateToAbout,
localStorage = state.localStorage,
onLocalStorage = { viewModel.onEvent(SettingEvent.OnLocalStorage) },
@ -172,8 +172,8 @@ private fun SettingScreen(
onTitle: (String) -> Unit,
onUrl: (String) -> Unit,
onSubscribe: () -> Unit,
banneds: ImmutableList<Stream>,
onBanned: (Int) -> Unit,
hiddenStreams: ImmutableList<Stream>,
onHidden: (Int) -> Unit,
navigateToAbout: () -> Unit,
localStorage: Boolean,
onLocalStorage: () -> Unit,
@ -261,8 +261,8 @@ private fun SettingScreen(
url = url,
uriWrapper = uriWrapper,
backingUpOrRestoring = backingUpOrRestoring,
banneds = banneds,
onBanned = onBanned,
hiddenStreams = hiddenStreams,
onHidden = onHidden,
onTitle = onTitle,
onUrl = onUrl,
onSubscribe = onSubscribe,

View File

@ -20,18 +20,17 @@ import com.m3u.data.api.LocalPreparedService
import com.m3u.data.database.dao.ColorPackDao
import com.m3u.data.database.model.ColorPack
import com.m3u.data.database.model.Stream
import com.m3u.data.local.service.MessageManager
import com.m3u.data.service.MessageManager
import com.m3u.data.repository.StreamRepository
import com.m3u.data.repository.observeAll
import com.m3u.data.work.BackupWorker
import com.m3u.data.work.RestoreWorker
import com.m3u.data.work.SubscriptionWorker
import com.m3u.data.worker.BackupWorker
import com.m3u.data.worker.RestoreWorker
import com.m3u.data.worker.SubscriptionWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@ -60,8 +59,8 @@ class SettingViewModel @Inject constructor(
)
) {
internal var subscribeForTv by mutableStateOf(false)
internal val banneds: StateFlow<ImmutableList<Stream>> = streamRepository
.observeAll { it.banned }
internal val hiddenStreams: StateFlow<ImmutableList<Stream>> = streamRepository
.observeAll { it.hidden }
.map { it.toImmutableList() }
.stateIn(
scope = viewModelScope,
@ -83,7 +82,7 @@ class SettingViewModel @Inject constructor(
SettingEvent.Subscribe -> subscribe()
is SettingEvent.OnTitle -> onTitle(event.title)
is SettingEvent.OnUrl -> onUrl(event.url)
is SettingEvent.OnBanned -> onBanned(event.id)
is SettingEvent.OnHidden -> onHidden(event.id)
SettingEvent.OnLocalStorage -> onLocalStorage()
is SettingEvent.OpenDocument -> openDocument(event.uri)
}
@ -107,11 +106,11 @@ class SettingViewModel @Inject constructor(
}
}
private fun onBanned(streamId: Int) {
val banned = banneds.value.find { it.id == streamId }
if (banned != null) {
private fun onHidden(streamId: Int) {
val hidden = hiddenStreams.value.find { it.id == streamId }
if (hidden != null) {
viewModelScope.launch {
streamRepository.ban(streamId, false)
streamRepository.hide(streamId, false)
}
}
}
@ -194,7 +193,6 @@ class SettingViewModel @Inject constructor(
}
}
@OptIn(ExperimentalCoroutinesApi::class)
internal val backingUpOrRestoring: StateFlow<BackingUpAndRestoringState> = workManager
.getWorkInfosFlow(
WorkQuery.fromStates(

View File

@ -12,9 +12,9 @@ import androidx.compose.ui.text.style.TextOverflow
import com.m3u.data.database.model.Stream
@Composable
internal fun BannedStreamItem(
internal fun hiddenStreamstreamItem(
stream: Stream,
onBanned: () -> Unit,
onHidden: () -> Unit,
modifier: Modifier = Modifier
) {
ListItem(
@ -42,7 +42,7 @@ internal fun BannedStreamItem(
enabled = true,
onClickLabel = null,
role = Role.Button,
onClick = onBanned
onClick = onHidden
)
.then(modifier)
)

View File

@ -26,7 +26,7 @@ import com.m3u.core.architecture.pref.LocalPref
import com.m3u.data.database.model.Stream
import com.m3u.features.setting.BackingUpAndRestoringState
import com.m3u.features.setting.UriWrapper
import com.m3u.features.setting.components.BannedStreamItem
import com.m3u.features.setting.components.hiddenStreamstreamItem
import com.m3u.features.setting.components.LocalStorageButton
import com.m3u.features.setting.components.LocalStorageSwitch
import com.m3u.features.setting.components.RemoteControlSubscribeSwitch
@ -48,8 +48,8 @@ internal fun SubscriptionsFragment(
localStorage: Boolean,
subscribeForTv: Boolean,
backingUpOrRestoring: BackingUpAndRestoringState,
banneds: ImmutableList<Stream>,
onBanned: (Int) -> Unit,
hiddenStreams: ImmutableList<Stream>,
onHidden: (Int) -> Unit,
onTitle: (String) -> Unit,
onUrl: (String) -> Unit,
onClipboard: (String) -> Unit,
@ -75,13 +75,13 @@ internal fun SubscriptionsFragment(
verticalArrangement = Arrangement.spacedBy(spacing.small),
modifier = modifier
) {
if (banneds.isNotEmpty()) {
if (hiddenStreams.isNotEmpty()) {
item {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(string.feat_setting_label_muted_streams),
text = stringResource(string.feat_setting_label_hidden_streams),
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.fillMaxWidth()
@ -92,10 +92,10 @@ internal fun SubscriptionsFragment(
horizontal = spacing.medium
)
)
banneds.forEach { stream ->
BannedStreamItem(
hiddenStreams.forEach { stream ->
hiddenStreamstreamItem(
stream = stream,
onBanned = { onBanned(stream.id) }
onHidden = { onHidden(stream.id) }
)
}
}

View File

@ -32,8 +32,8 @@ import com.m3u.core.util.basic.rational
import com.m3u.core.util.context.isDarkMode
import com.m3u.core.util.context.isPortraitMode
import com.m3u.core.wrapper.Message
import com.m3u.data.local.service.MessageManager
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.MessageManager
import com.m3u.data.service.PlayerManager
import com.m3u.data.repository.StreamRepository
import com.m3u.ui.Toolkit
import com.m3u.ui.helper.Action
@ -68,7 +68,7 @@ class PlayerActivity : ComponentActivity() {
lateinit var pref: Pref
@Inject
@Logger.Message
@Logger.MessageImpl
lateinit var messager: Logger
@Inject

View File

@ -6,7 +6,7 @@ import androidx.media3.common.Tracks
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.architecture.logger.prefix
import com.m3u.core.architecture.viewmodel.BaseViewModel
import com.m3u.data.local.service.PlayerManager
import com.m3u.data.service.PlayerManager
import com.m3u.data.repository.PlaylistRepository
import com.m3u.data.repository.StreamRepository
import com.m3u.dlna.DLNACastManager

View File

@ -125,11 +125,10 @@ com-squareup-retrofit2-retrofit = { group = "com.squareup.retrofit2", name = "re
com-squareup-leakcanary-leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "squareup-leakcanary" }
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor-server" }
ktor-server-jetty = { group = "io.ktor", name = "ktor-server-jetty", version.ref = "ktor-server" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets-jvm", version.ref = "ktor-server" }
ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor-server" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation-jvm", version.ref = "ktor-server" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json-jvm", version.ref = "ktor-server" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor-server" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor-server" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
kotlinx-serialization-coverter-retrofit = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization-converter-retrofit" }

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="feat_foryou_muted_streams_playlist">directos bloqueados</string>
<string name="feat_foryou_hidden_streams_playlist">directos bloqueados</string>
<string name="feat_foryou_unsubscribe_playlist">desuscribirse</string>
<string name="feat_foryou_copy_playlist_url">copiar url</string>
<string name="feat_foryou_rename_playlist">renombrar</string>

View File

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="feat_playlist_scheme_unknown">desconocido</string>
<string name="feat_playlist_dialog_menu_title">¿deseas bloquear este directo?</string>
<string name="feat_playlist_dialog_favourite_title">marcar favorito</string>
<string name="feat_playlist_dialog_favourite_cancel_title">quitar favorito</string>
<string name="feat_playlist_dialog_mute_title">bloquear</string>
<string name="feat_playlist_dialog_hide_title">bloquear</string>
<string name="feat_playlist_dialog_save_picture_title">guardar a álbum</string>
<string name="feat_playlist_dialog_create_shortcut_title">crear atajo</string>
<string name="feat_playlist_error_playlist_not_found">la playlist no existe (%s)</string>

View File

@ -17,7 +17,7 @@
<string name="feat_setting_god_mode_description">ajusta la distribución con los botones del volumen</string>
<string name="feat_setting_experimental_mode">modo experimental</string>
<string name="feat_setting_experimental_mode_description">acciones no estables que traerían resultados inesperados</string>
<string name="feat_setting_label_muted_streams">directos bloqueados</string>
<string name="feat_setting_label_hidden_streams">directos bloqueados</string>
<string name="feat_setting_label_add_playlist">añadir playlist</string>
<string name="feat_setting_label_parse_from_clipboard">obtener del portapapeles</string>
<string name="feat_setting_label_select_from_local_storage">seleccionar archivo</string>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="feat_foryou_muted_streams_playlist">屏蔽的频道</string>
<string name="feat_foryou_hidden_streams_playlist">屏蔽的频道</string>
<string name="feat_foryou_unsubscribe_playlist">取消订阅</string>
<string name="feat_foryou_copy_playlist_url">复制链接</string>
<string name="feat_foryou_rename_playlist">重命名</string>

View File

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="feat_playlist_scheme_unknown">未知</string>
<string name="feat_playlist_dialog_menu_title">屏蔽这个频道?</string>
<string name="feat_playlist_dialog_favourite_title">喜欢</string>
<string name="feat_playlist_dialog_favourite_cancel_title">取消喜欢</string>
<string name="feat_playlist_dialog_mute_title">屏蔽</string>
<string name="feat_playlist_dialog_hide_title">屏蔽</string>
<string name="feat_playlist_dialog_save_picture_title">保存封面</string>
<string name="feat_playlist_dialog_create_shortcut_title">创建快捷方式</string>

View File

@ -20,7 +20,7 @@
<string name="feat_setting_god_mode_description">通过物理按键调节界面</string>
<string name="feat_setting_experimental_mode">试验特性</string>
<string name="feat_setting_experimental_mode_description">不稳定的功能可能引发致命错误</string>
<string name="feat_setting_label_muted_streams">屏蔽的频道</string>
<string name="feat_setting_label_hidden_streams">屏蔽的频道</string>
<string name="feat_setting_label_add_playlist">新增订阅</string>
<string name="feat_setting_label_parse_from_clipboard">解析剪贴板</string>
<string name="feat_setting_label_select_from_local_storage">选择文件</string>
@ -89,6 +89,8 @@
<string name="feat_setting_remote_control_tv_side">遥控器</string>
<string name="feat_setting_remote_control_tv_side_description">允许智能手机控制你的电视</string>
<string name="feat_setting_subscribe_for_tv">为电视订阅</string>
<string name="feat_setting_backing_up">备份所有播放列表和频道中</string>
<string name="feat_setting_restoring">恢复所有播放列表和频道中</string>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="feat_foryou_muted_streams_playlist">banned streams</string>
<string name="feat_foryou_hidden_streams_playlist">hidden streams</string>
<string name="feat_foryou_unsubscribe_playlist">unsubscribe</string>
<string name="feat_foryou_copy_playlist_url">copy url</string>
<string name="feat_foryou_rename_playlist">rename</string>

View File

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="feat_playlist_scheme_unknown">unknown</string>
<string name="feat_playlist_dialog_menu_title">do you want to ban this stream?</string>
<string name="feat_playlist_dialog_favourite_title">like</string>
<string name="feat_playlist_dialog_favourite_cancel_title">cancel like</string>
<string name="feat_playlist_dialog_mute_title">ban</string>
<string name="feat_playlist_dialog_hide_title">hide</string>
<string name="feat_playlist_dialog_save_picture_title">save to gallery</string>
<string name="feat_playlist_dialog_create_shortcut_title">create shortcut</string>
<string name="feat_playlist_error_playlist_not_found">playlist is not existed (%s)</string>

View File

@ -17,7 +17,7 @@
<string name="feat_setting_god_mode_description">adjust layouts by physical volume buttons</string>
<string name="feat_setting_experimental_mode">experimental mode</string>
<string name="feat_setting_experimental_mode_description">unstable features can throw fatal errors</string>
<string name="feat_setting_label_muted_streams">banned streams</string>
<string name="feat_setting_label_hidden_streams">hidden streams</string>
<string name="feat_setting_label_add_playlist">add playlist</string>
<string name="feat_setting_label_parse_from_clipboard">parse clipboard</string>
<string name="feat_setting_label_select_from_local_storage">select file</string>

View File

@ -46,6 +46,7 @@ infix fun PaddingValues.only(side: WindowInsetsSides): PaddingValues {
}
@Composable
infix fun PaddingValues.split(side: WindowInsetsSides): Pair<PaddingValues, PaddingValues> {
infix fun PaddingValues.split(side: WindowInsetsSides?): Pair<PaddingValues, PaddingValues> {
if (side == null) return PaddingValues() to this
return (this only side) to (this - (this only side))
}

View File

@ -16,7 +16,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetDefaults
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
@ -29,9 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import com.m3u.core.util.basic.title
import com.m3u.i18n.R.string
import com.m3u.material.components.CodeSkeleton
import com.m3u.material.model.LocalSpacing
import kotlinx.collections.immutable.ImmutableList
@ -130,41 +127,6 @@ private fun SortBottomSheetItem(
}
}
@Composable
fun ConnectBottomSheet(
visible: Boolean,
connecting: Boolean,
code: String,
sheetState: SheetState,
onCode: (String) -> Unit,
onConnect: () -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier
) {
if (visible) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
if (!connecting) onDismissRequest()
},
windowInsets = WindowInsets(0),
properties = ModalBottomSheetDefaults.properties(
shouldDismissOnBackPress = false
)
) {
CodeSkeleton(
title = stringResource(string.feat_foryou_connect_title).title(),
subtitle = stringResource(string.feat_foryou_connect_subtitle),
code = code,
onCode = onCode,
modifier = modifier,
loading = connecting,
onSubmit = onConnect
)
}
}
}
@Composable
fun BackendConnectBottomSheet(
visible: Boolean,