diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 352f60d45..5174b64c7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(project(":core:media")) implementation(project(":core:model")) implementation(project(":core:ui")) + implementation(projects.core.remotesubs) implementation(project(":feature:videopicker")) implementation(project(":feature:player")) implementation(project(":feature:settings")) diff --git a/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt b/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt index 4d6fa9bf3..95aeaf67a 100644 --- a/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt +++ b/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt @@ -28,6 +28,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import dagger.hilt.android.AndroidEntryPoint +import dev.anilbeesetti.nextplayer.core.common.services.SystemService import dev.anilbeesetti.nextplayer.core.common.storagePermission import dev.anilbeesetti.nextplayer.core.media.services.MediaService import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer @@ -42,6 +43,9 @@ import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var systemService: SystemService + @Inject lateinit var synchronizer: MediaSynchronizer @@ -53,6 +57,7 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + systemService.initialize(this@MainActivity) mediaService.initialize(this@MainActivity) var uiState: MainActivityUiState by mutableStateOf(MainActivityUiState.Loading) diff --git a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/di/ServicesModule.kt b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/di/ServicesModule.kt new file mode 100644 index 000000000..cf5d822b0 --- /dev/null +++ b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/di/ServicesModule.kt @@ -0,0 +1,20 @@ +package dev.anilbeesetti.nextplayer.core.common.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.anilbeesetti.nextplayer.core.common.services.RealSystemService +import dev.anilbeesetti.nextplayer.core.common.services.SystemService +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface ServicesModule { + + @Binds + @Singleton + fun providesSystemService( + systemService: RealSystemService, + ): SystemService +} diff --git a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt index a0095de6b..875cfc26a 100644 --- a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt +++ b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/Context.kt @@ -21,6 +21,7 @@ import android.util.TypedValue import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.core.content.ContextCompat import androidx.core.text.isDigitsOnly import java.io.BufferedInputStream import java.io.BufferedReader @@ -30,8 +31,10 @@ import java.io.FileWriter import java.io.InputStreamReader import java.nio.charset.Charset import java.nio.charset.StandardCharsets +import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.mozilla.universalchardet.UniversalDetector @@ -410,3 +413,25 @@ suspend fun ContentResolver.deleteMedia( false } } + +fun Context.hasPermission(permission: String) = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + +fun Context.hasWriteStoragePermissionBelowQ() = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || hasPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + +suspend fun Context.scanFilePath(filePath: String, mimeType: String): Uri? { + return suspendCancellableCoroutine { continuation -> + MediaScannerConnection.scanFile( + this@scanFilePath, + arrayOf(filePath), + arrayOf(mimeType), + ) { _, scannedUri -> + if (scannedUri == null) { + continuation.cancel(Exception("File $filePath could not be scanned")) + } else { + continuation.resume(scannedUri) + } + } + } +} diff --git a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/File.kt b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/File.kt index d676c46e7..c8f82e97e 100644 --- a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/File.kt +++ b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/extensions/File.kt @@ -12,6 +12,13 @@ fun File.getSubtitles(): List { return subs } +fun File.getAllSubtitlesInFolder(forFile: File): List { + if (!this.isDirectory) return emptyList() + return listFiles { file -> + file.nameWithoutExtension.startsWith(forFile.nameWithoutExtension) && file.isSubtitle() + }?.toList() ?: emptyList() +} + fun String.getThumbnail(): File? { val filePathWithoutExtension = this.substringBeforeLast(".") val imageExtensions = listOf("png", "jpg", "jpeg") diff --git a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/services/RealSystemService.kt b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/services/RealSystemService.kt new file mode 100644 index 000000000..f0355fcae --- /dev/null +++ b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/services/RealSystemService.kt @@ -0,0 +1,34 @@ +package dev.anilbeesetti.nextplayer.core.common.services + +import android.app.Activity +import android.content.Context +import android.widget.Toast +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class RealSystemService @Inject constructor( + @ApplicationContext private val applicationContext: Context, +) : SystemService { + + private lateinit var activity: Activity + + override fun initialize(activity: Activity) { + this.activity = activity + } + + override fun getString(id: Int): String { + return applicationContext.getString(id) + } + + override fun getString(id: Int, vararg formatArgs: Any): String { + return applicationContext.getString(id, *formatArgs) + } + + override fun showToast(message: String, showLong: Boolean) { + Toast.makeText(activity, message, if (showLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() + } + + override fun versionName(): String { + return applicationContext.packageManager.getPackageInfo(applicationContext.packageName, 0).versionName + } +} diff --git a/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/services/SystemService.kt b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/services/SystemService.kt new file mode 100644 index 000000000..77bb5b48f --- /dev/null +++ b/core/common/src/main/java/dev/anilbeesetti/nextplayer/core/common/services/SystemService.kt @@ -0,0 +1,13 @@ +package dev.anilbeesetti.nextplayer.core.common.services + +import android.app.Activity +import androidx.annotation.StringRes + +interface SystemService { + fun initialize(activity: Activity) + fun getString(@StringRes id: Int): String + fun getString(id: Int, vararg formatArgs: Any): String + fun showToast(message: String, showLong: Boolean = false) + fun showToast(@StringRes id: Int, showLong: Boolean = false) = showToast(getString(id), showLong) + fun versionName(): String +} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index eff009f29..9e058bd91 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +7,8 @@ plugins { } tasks.withType { - kotlinOptions { - jvmTarget = libs.versions.android.jvm.get() + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.android.jvm.get())) } } diff --git a/core/remotesubs/.gitignore b/core/remotesubs/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/remotesubs/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/remotesubs/build.gradle.kts b/core/remotesubs/build.gradle.kts new file mode 100644 index 000000000..de0107203 --- /dev/null +++ b/core/remotesubs/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "dev.anilbeesetti.nextplayer.core.remotesubs" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.android.jvm.get().toInt()) + targetCompatibility = JavaVersion.toVersion(libs.versions.android.jvm.get().toInt()) + } + + kotlinOptions { + jvmTarget = libs.versions.android.jvm.get() + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + implementation(projects.core.ui) + implementation(libs.kotlinx.serialization.json) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.logging) +} diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/DataModule.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/DataModule.kt new file mode 100644 index 000000000..f63189bd8 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/DataModule.kt @@ -0,0 +1,52 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.anilbeesetti.nextplayer.core.remotesubs.service.OpenSubtitlesComSubtitlesService +import dev.anilbeesetti.nextplayer.core.remotesubs.service.SubtitlesService +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.kotlinx.json.json +import javax.inject.Singleton +import kotlinx.serialization.json.Json + +@Module +@InstallIn(SingletonComponent::class) +interface DataBindingModule { + + @Binds + fun bindsSubtitlesService( + subtitleService: OpenSubtitlesComSubtitlesService, + ): SubtitlesService +} + +@Module +@InstallIn(SingletonComponent::class) +object DataModule { + + @Singleton + @Provides + fun provideHttpClient() = HttpClient(OkHttp) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + + if (BuildConfig.DEBUG) { + install(Logging) { + level = LogLevel.ALL + logger = object : Logger { + override fun log(message: String) { + println(message) + } + } + } + } + } +} diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/OpenSubtitlesHasher.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/OpenSubtitlesHasher.kt new file mode 100644 index 000000000..d6228b566 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/OpenSubtitlesHasher.kt @@ -0,0 +1,95 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs + +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max +import kotlin.math.min + +@Singleton +class OpenSubtitlesHasher @Inject constructor( + @ApplicationContext private val context: Context, +) { + companion object { + private const val HASH_CHUNK_SIZE = 64 * 1024 + } + + @Throws(IOException::class) + fun computeHash(file: File): String { + val size = file.length() + val chunkSizeForFile = min(HASH_CHUNK_SIZE.toLong(), size) + FileInputStream(file).channel.use { fileChannel -> + val head = computeChunkHash( + fileChannel.map( + FileChannel.MapMode.READ_ONLY, + 0, + chunkSizeForFile, + ), + ) + val tail = computeChunkHash( + fileChannel.map( + FileChannel.MapMode.READ_ONLY, + max(size - HASH_CHUNK_SIZE, 0), + chunkSizeForFile, + ), + ) + return String.format("%016x", size + head + tail) + } + } + + fun computeHash(uri: Uri, length: Long): String { + context.contentResolver.openInputStream(uri).use { inputStream -> + inputStream ?: throw IllegalStateException("Unable to open input stream") + + val chunkSize = min(HASH_CHUNK_SIZE.toLong(), length).toInt() + val chunkBytes = ByteArray(min(2 * HASH_CHUNK_SIZE.toLong(), length).toInt()) + + // Read first chunk + inputStream.readExactly(chunkBytes, 0, chunkSize) + + // Skip to tail chunk if necessary + val tailChunkPosition = length - chunkSize + if (tailChunkPosition > chunkSize) { + inputStream.skip(tailChunkPosition - chunkSize) + } + + // Read second chunk or remaining data + inputStream.readExactly(chunkBytes, chunkSize, chunkBytes.size - chunkSize) + + val head = computeChunkHash(ByteBuffer.wrap(chunkBytes, 0, chunkSize)) + val tail = computeChunkHash(ByteBuffer.wrap(chunkBytes, chunkBytes.size - chunkSize, chunkSize)) + + return "%016x".format(length + head + tail) + } + } + + private fun InputStream.readExactly(buffer: ByteArray, offset: Int, length: Int) { + var bytesRead = 0 + while (bytesRead < length) { + val bytesReadThisIteration = read(buffer, offset + bytesRead, length - bytesRead) + if (bytesReadThisIteration < 0) break // End of stream reached + bytesRead += bytesReadThisIteration + } + if (bytesRead < length) { + throw IllegalStateException("Unexpected end of stream: Read $bytesRead bytes, expected $length") + } + } + + private fun computeChunkHash(buffer: ByteBuffer): Long { + val longBuffer = buffer.order(ByteOrder.LITTLE_ENDIAN).asLongBuffer() + var hash: Long = 0 + while (longBuffer.hasRemaining()) { + hash += longBuffer.get() + } + return hash + } +} diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/OpenSubtitlesComApi.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/OpenSubtitlesComApi.kt new file mode 100644 index 000000000..12965b5e5 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/OpenSubtitlesComApi.kt @@ -0,0 +1,110 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles + +import dev.anilbeesetti.nextplayer.core.common.services.SystemService +import dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model.OpenSubDownloadLinks +import dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model.OpenSubDownloadLinksError +import dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model.OpenSubtitlesSearchResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.appendPathSegments +import io.ktor.http.contentType +import io.ktor.http.userAgent +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +private data class DownloadRequest( + @SerialName("file_id") + val fileId: Int, +) + +@Singleton +class OpenSubtitlesComApi @Inject constructor( + private val client: HttpClient, + systemService: SystemService, +) { + + companion object { + private const val BASE_URL = "https://api.opensubtitles.com/api/v1" + private const val API_KEY_HEADER = "Api-Key" + } + + private val apiKey = "c5Ja67pxZiCNgBbQ5g8ENmsF6BBeihvC" + private val userAgent = "NextPlayer v${systemService.versionName()}" + + suspend fun search( + fileHash: String, + searchText: String?, + languages: List, + ): Result { + return try { + val response = client.get(BASE_URL) { + url { + appendPathSegments("subtitles") + parameter("languages", languages.joinToString()) + parameter("moviehash", fileHash) + parameter("order_by", "from_trusted,ratings,download_count") + if (!searchText.isNullOrBlank()) { + parameter("query", searchText) + } + } + userAgent(userAgent) + header(API_KEY_HEADER, apiKey) + } + return when (response.status) { + HttpStatusCode.OK -> { + runCatching { response.body() } + } + + else -> { + Result.failure(RuntimeException("Failed to search subtitles")) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun download(fileId: Int): Result { + return try { + val response = client.post(BASE_URL) { + url { + appendPathSegments("download") + } + setBody(DownloadRequest(fileId)) + contentType(ContentType.Application.Json) + userAgent(userAgent) + header(API_KEY_HEADER, apiKey) + } + return when (response.status) { + HttpStatusCode.OK -> { + runCatching { response.body() } + } + + in HttpStatusCode.BadRequest..HttpStatusCode.InternalServerError -> { + try { + val error = response.body() + Result.failure(error) + } catch (e: Exception) { + Result.failure(e) + } + } + + else -> { + Result.failure(RuntimeException("Failed to download subtitle")) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/Attributes.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/Attributes.kt new file mode 100644 index 000000000..a4f78ca46 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/Attributes.kt @@ -0,0 +1,44 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Attributes( + @SerialName("download_count") + val downloadCount: Int, + @SerialName("file_hashes") + val fileHashes: List, + @SerialName("files") + val files: List, + @SerialName("foreign_parts_only") + val foreignPartsOnly: Boolean, + @SerialName("fps") + val fps: Double, + @SerialName("from_trusted") + val fromTrusted: Boolean, + @SerialName("hd") + val hd: Boolean, + @SerialName("hearing_impaired") + val hearingImpaired: Boolean, + @SerialName("language") + val language: String, + @SerialName("machine_translated") + val machineTranslated: Boolean, + @SerialName("nb_cd") + val nbCd: Int, + @SerialName("new_download_count") + val newDownloadCount: Int, + @SerialName("ratings") + val ratings: Double, + @SerialName("release") + val release: String, + @SerialName("subtitle_id") + val subtitleId: String, + @SerialName("upload_date") + val uploadDate: String, + @SerialName("url") + val url: String, + @SerialName("votes") + val votes: Int, +) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/Data.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/Data.kt new file mode 100644 index 000000000..ee27727a4 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/Data.kt @@ -0,0 +1,14 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Data( + @SerialName("attributes") + val attributes: Attributes, + @SerialName("id") + val id: String, + @SerialName("type") + val type: String, +) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/FeatureDetails.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/FeatureDetails.kt new file mode 100644 index 000000000..6a95f1f98 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/FeatureDetails.kt @@ -0,0 +1,34 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FeatureDetails( + @SerialName("episode_number") + val episodeNumber: Int, + @SerialName("feature_id") + val featureId: Int, + @SerialName("feature_type") + val featureType: String, + @SerialName("imdb_id") + val imdbId: Int, + @SerialName("movie_name") + val movieName: String, + @SerialName("parent_feature_id") + val parentFeatureId: Int, + @SerialName("parent_imdb_id") + val parentImdbId: Int, + @SerialName("parent_title") + val parentTitle: String, + @SerialName("parent_tmdb_id") + val parentTmdbId: Int, + @SerialName("season_number") + val seasonNumber: Int, + @SerialName("title") + val title: String, + @SerialName("tmdb_id") + val tmdbId: Int, + @SerialName("year") + val year: Int, +) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/File.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/File.kt new file mode 100644 index 000000000..012a2e9f4 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/File.kt @@ -0,0 +1,14 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class File( + @SerialName("cd_number") + val cdNumber: Int, + @SerialName("file_id") + val fileId: Int, + @SerialName("file_name") + val fileName: String, +) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/OpenSubDownloadLinks.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/OpenSubDownloadLinks.kt new file mode 100644 index 000000000..0fc290745 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/OpenSubDownloadLinks.kt @@ -0,0 +1,36 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenSubDownloadLinks( + @SerialName("file_name") + val fileName: String, + @SerialName("link") + val link: String, + @SerialName("message") + val message: String, + @SerialName("remaining") + val remaining: Int, + @SerialName("requests") + val requests: Int, + @SerialName("reset_time") + val resetTime: String, + @SerialName("reset_time_utc") + val resetTimeUtc: String, +) + +@Serializable +data class OpenSubDownloadLinksError( + @SerialName("message") + override val message: String, + @SerialName("remaining") + val remaining: Int, + @SerialName("requests") + val requests: Int, + @SerialName("reset_time") + val resetTime: String, + @SerialName("reset_time_utc") + val resetTimeUtc: String, +) : Throwable(message = message) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/OpenSubtitlesSearchResponse.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/OpenSubtitlesSearchResponse.kt new file mode 100644 index 000000000..23d17ce6c --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/OpenSubtitlesSearchResponse.kt @@ -0,0 +1,18 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenSubtitlesSearchResponse( + @SerialName("data") + val data: List, + @SerialName("page") + val page: Int, + @SerialName("per_page") + val perPage: Int, + @SerialName("total_count") + val totalCount: Int, + @SerialName("total_pages") + val totalPages: Int, +) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/RelatedLink.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/RelatedLink.kt new file mode 100644 index 000000000..254e002f3 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/api/opensubtitles/model/RelatedLink.kt @@ -0,0 +1,14 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RelatedLink( + @SerialName("img_url") + val imgUrl: String, + @SerialName("label") + val label: String, + @SerialName("url") + val url: String, +) diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/service/OpenSubtitlesComSubtitlesService.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/service/OpenSubtitlesComSubtitlesService.kt new file mode 100644 index 000000000..3bd6aaef4 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/service/OpenSubtitlesComSubtitlesService.kt @@ -0,0 +1,91 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.service + +import android.content.Context +import android.net.Uri +import android.os.Environment +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.anilbeesetti.nextplayer.core.common.extensions.hasWriteStoragePermissionBelowQ +import dev.anilbeesetti.nextplayer.core.common.extensions.scanFilePath +import dev.anilbeesetti.nextplayer.core.common.services.SystemService +import dev.anilbeesetti.nextplayer.core.model.Video +import dev.anilbeesetti.nextplayer.core.remotesubs.OpenSubtitlesHasher +import dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.OpenSubtitlesComApi +import dev.anilbeesetti.nextplayer.core.remotesubs.api.opensubtitles.model.OpenSubDownloadLinksError +import dev.anilbeesetti.nextplayer.core.ui.R +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class OpenSubtitlesComSubtitlesService @Inject constructor( + private val openSubtitlesComApi: OpenSubtitlesComApi, + private val openSubtitlesHasher: OpenSubtitlesHasher, + private val systemService: SystemService, + @ApplicationContext private val context: Context, +) : SubtitlesService { + + override suspend fun search(video: Video, searchText: String?, languages: List): Result> { + val hash = openSubtitlesHasher.computeHash(Uri.parse(video.uriString), video.size) + return openSubtitlesComApi.search( + fileHash = hash, + searchText = searchText, + languages = languages, + ).map { response -> + response.data.map { dto -> + Subtitle( + id = dto.attributes.files.first().fileId, + name = dto.attributes.files.first().fileName, + language = dto.attributes.language, + rating = "${dto.attributes.ratings}/10".takeIf { dto.attributes.ratings > 0 }, + ) + } + } + } + + override suspend fun download( + subtitle: Subtitle, + name: String, + fullName: String, + ): Result = withContext(Dispatchers.IO) { + val folder = if (context.hasWriteStoragePermissionBelowQ()) { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES) + } else { + context.filesDir + } + + val destinationUri = File(folder, fullName).toUri() + openSubtitlesComApi.download(subtitle.id).onSuccess { response -> + try { + val url = URL(response.link) + val urlConnection = url.openConnection() as HttpURLConnection + val inputStream = urlConnection.inputStream + + context.contentResolver.openOutputStream(destinationUri)?.use { outputStream -> + inputStream.copyTo(outputStream) + } ?: return@withContext Result.failure( + IllegalStateException("Failed to open output stream for uri: $destinationUri"), + ) + inputStream.close() + destinationUri.path?.let { context.scanFilePath(it, "application/x-subrip") } + return@withContext Result.success( + SubtitleDownloadResponse( + uri = destinationUri, + message = systemService.getString(R.string.downloads_remaining, "${response.remaining}/${response.requests + response.remaining}"), + ), + ) + } catch (e: Exception) { + return@withContext Result.failure(e) + } + }.onFailure { + if (it is OpenSubDownloadLinksError && it.remaining <= 0) { + val errorMessage = systemService.getString(R.string.download_quota_reached, it.resetTime) + return@withContext Result.failure(Throwable(errorMessage)) + } + return@withContext Result.failure(it) + } + return@withContext Result.failure(UnknownError()) + } +} diff --git a/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/service/SubtitlesService.kt b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/service/SubtitlesService.kt new file mode 100644 index 000000000..d56f19e44 --- /dev/null +++ b/core/remotesubs/src/main/java/dev/anilbeesetti/nextplayer/core/remotesubs/service/SubtitlesService.kt @@ -0,0 +1,26 @@ +package dev.anilbeesetti.nextplayer.core.remotesubs.service + +import android.net.Uri +import dev.anilbeesetti.nextplayer.core.model.Video +import java.util.Locale +import kotlinx.serialization.Serializable + +interface SubtitlesService { + suspend fun search(video: Video, searchText: String?, languages: List): Result> + suspend fun download(subtitle: Subtitle, name: String, fullName: String = "$name.${subtitle.language}.srt"): Result +} + +@Serializable +data class Subtitle( + val id: Int, + val name: String, + val language: String, + val rating: String?, +) { + val languageName: String = Locale.forLanguageTag(language).displayName +} + +data class SubtitleDownloadResponse( + val uri: Uri, + val message: String? = null, +) diff --git a/core/ui/src/main/java/dev/anilbeesetti/nextplayer/core/ui/designsystem/NextIcons.kt b/core/ui/src/main/java/dev/anilbeesetti/nextplayer/core/ui/designsystem/NextIcons.kt index 4957e9fa7..1f3841021 100644 --- a/core/ui/src/main/java/dev/anilbeesetti/nextplayer/core/ui/designsystem/NextIcons.kt +++ b/core/ui/src/main/java/dev/anilbeesetti/nextplayer/core/ui/designsystem/NextIcons.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.rounded.CalendarMonth import androidx.compose.material.icons.rounded.CenterFocusStrong import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ClosedCaption import androidx.compose.material.icons.rounded.Contrast import androidx.compose.material.icons.rounded.DarkMode @@ -24,7 +25,6 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.FastForward import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.FlipToBack -import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.FolderOff import androidx.compose.material.icons.rounded.FontDownload import androidx.compose.material.icons.rounded.FormatBold @@ -75,6 +75,7 @@ object NextIcons { val Check = Icons.Rounded.Check val CheckBox = Icons.Rounded.CheckCircle val CheckBoxOutline = Icons.Rounded.RadioButtonUnchecked + val Close = Icons.Rounded.Close val Contrast = Icons.Rounded.Contrast val DarkMode = Icons.Rounded.DarkMode val DashBoard = Icons.Rounded.Dashboard @@ -85,7 +86,6 @@ object NextIcons { val Fast = Icons.Rounded.FastForward val FileOpen = Icons.Rounded.FileOpen val Focus = Icons.Rounded.CenterFocusStrong - val Folder = Icons.Rounded.Folder val FolderOff = Icons.Rounded.FolderOff val Font = Icons.Rounded.FontDownload val FontSize = Icons.Rounded.FormatSize diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index a3b9ff912..793b61a76 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -200,4 +200,19 @@ Exit Play next video Skip silence + Download + Get subtitles online + Error + An unknown error has occurred. Please try again + OK + Loading... + Searching for subtitles... + Subtitle downloaded successfully + Error downloading subtitle. Try again + Downloading subtitles... + %1$s downloads remaining + "Download quota ended. Quota resets in %1$s" + Search subtitle from opensubtitles.com + Search + Subtitles \ No newline at end of file diff --git a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Uri.kt b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Uri.kt index bd02993ae..fbb24bf5d 100644 --- a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Uri.kt +++ b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Uri.kt @@ -5,9 +5,11 @@ import android.content.Context import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Environment import android.os.Parcelable import androidx.core.net.toUri import androidx.media3.common.MimeTypes +import dev.anilbeesetti.nextplayer.core.common.extensions.getAllSubtitlesInFolder import dev.anilbeesetti.nextplayer.core.common.extensions.getFilenameFromUri import dev.anilbeesetti.nextplayer.core.common.extensions.getPath import dev.anilbeesetti.nextplayer.core.common.extensions.getSubtitles @@ -40,7 +42,15 @@ val Uri.isSchemaContent: Boolean fun Uri.getLocalSubtitles(context: Context, excludeSubsList: List = emptyList()): List { return context.getPath(this)?.let { path -> val excludeSubsPathList = excludeSubsList.mapNotNull { context.getPath(it) } - File(path).getSubtitles().mapNotNull { file -> + val mediaFile = File(path) + buildList { + addAll(mediaFile.getSubtitles()) + addAll( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES) + .getAllSubtitlesInFolder(forFile = mediaFile), + ) + addAll(context.filesDir.getAllSubtitlesInFolder(forFile = mediaFile)) + }.mapNotNull { file -> if (file.path !in excludeSubsPathList) { Subtitle( name = file.name, diff --git a/feature/videopicker/build.gradle.kts b/feature/videopicker/build.gradle.kts index cc4882d34..1391dedbf 100644 --- a/feature/videopicker/build.gradle.kts +++ b/feature/videopicker/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(project(":core:domain")) implementation(project(":core:media")) implementation(project(":core:model")) + implementation(project(":core:remotesubs")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/VideosView.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/VideosView.kt index 6e95eb41c..25033c70d 100644 --- a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/VideosView.kt +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/VideosView.kt @@ -59,6 +59,7 @@ fun VideosView( onRenameVideoClick: (Uri, String) -> Unit, onDeleteVideoClick: (String) -> Unit, onVideoLoaded: (Uri) -> Unit = {}, + onGetSubtitlesOnline: (Video) -> Unit = {}, ) { val haptic = LocalHapticFeedback.current var showMediaActionsFor: Video? by rememberSaveable { mutableStateOf(null) } @@ -132,6 +133,16 @@ fun VideosView( } }, ) + BottomSheetItem( + text = stringResource(id = R.string.get_subtitles_online), + icon = NextIcons.Subtitle, + onClick = { + onGetSubtitlesOnline(it) + scope.launch { bottomSheetState.hide() }.invokeOnCompletion { + if (!bottomSheetState.isVisible) showMediaActionsFor = null + } + }, + ) BottomSheetItem( text = stringResource(R.string.properties), icon = NextIcons.Info, diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/ErrorDialogComponent.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/ErrorDialogComponent.kt new file mode 100644 index 000000000..a7ac89d84 --- /dev/null +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/ErrorDialogComponent.kt @@ -0,0 +1,33 @@ +package dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dev.anilbeesetti.nextplayer.core.ui.R +import dev.anilbeesetti.nextplayer.core.ui.components.NextDialog + +@Composable +fun ErrorDialogComponent( + errorMessage: String? = null, + onDismissRequest: () -> Unit, +) { + NextDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(id = R.string.error)) + }, + content = { + Text( + text = errorMessage ?: stringResource(id = R.string.unknown_error_try_again), + style = MaterialTheme.typography.bodyLarge, + ) + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = R.string.ok)) + } + }, + ) +} diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/GetSubtitlesOnlineDialogComponent.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/GetSubtitlesOnlineDialogComponent.kt new file mode 100644 index 000000000..a7b619453 --- /dev/null +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/GetSubtitlesOnlineDialogComponent.kt @@ -0,0 +1,79 @@ +package dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.anilbeesetti.nextplayer.core.model.Video +import dev.anilbeesetti.nextplayer.core.ui.R +import dev.anilbeesetti.nextplayer.core.ui.components.NextDialog +import dev.anilbeesetti.nextplayer.core.ui.designsystem.NextIcons + +@Composable +fun GetSubtitlesOnlineDialogComponent( + modifier: Modifier = Modifier, + video: Video, + onDismissRequest: () -> Unit, + onConfirm: (searchText: String?, language: String) -> Unit, +) { + var searchText by rememberSaveable { mutableStateOf(video.displayName) } + var language by rememberSaveable { mutableStateOf("en") } + + NextDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text( + text = stringResource(id = R.string.get_subtitles_online), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + Column { + Text(text = stringResource(id = R.string.search_subtitles_from_open_subtitles)) + Text(text = "${stringResource(id = R.string.language)}: $language") + Spacer(modifier = Modifier.size(16.dp)) + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + label = { Text(text = stringResource(id = R.string.search)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailingIcon = { + IconButton(onClick = { searchText = "" }) { + Icon( + imageVector = NextIcons.Close, + contentDescription = null, + ) + } + }, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(searchText, language) }) { + Text(text = stringResource(id = R.string.okay)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + ) +} diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/LoadingDialogComponent.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/LoadingDialogComponent.kt new file mode 100644 index 000000000..f2958d3ac --- /dev/null +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/LoadingDialogComponent.kt @@ -0,0 +1,38 @@ +package dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.unit.dp +import dev.anilbeesetti.nextplayer.core.ui.R +import dev.anilbeesetti.nextplayer.core.ui.components.NextDialog + +@Composable +fun LoadingDialogComponent(message: String? = null) { + NextDialog( + onDismissRequest = {}, + title = {}, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + Text( + text = message ?: stringResource(id = R.string.loading), + style = MaterialTheme.typography.bodyLarge, + ) + } + }, + confirmButton = {}, + ) +} diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/SubtitleResultDialogComponent.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/SubtitleResultDialogComponent.kt new file mode 100644 index 000000000..ef0043cc5 --- /dev/null +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/composables/dialogs/SubtitleResultDialogComponent.kt @@ -0,0 +1,103 @@ +package dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.anilbeesetti.nextplayer.core.remotesubs.service.Subtitle +import dev.anilbeesetti.nextplayer.core.ui.R +import dev.anilbeesetti.nextplayer.core.ui.components.NextDialog + +@Composable +fun SubtitleResultDialogComponent( + modifier: Modifier = Modifier, + data: List, + onDismissRequest: () -> Unit, + onSubtitleSelected: (Subtitle) -> Unit, +) { + var selectedData: Subtitle? by rememberSaveable { mutableStateOf(null) } + + NextDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text( + text = stringResource(id = R.string.subtitles), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + LazyColumn( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(data) { subtitle -> + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = selectedData == subtitle, + onValueChange = { selectedData = subtitle }, + role = Role.RadioButton, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RadioButton( + selected = selectedData == subtitle, + onClick = null, + ) + Column { + Text( + text = subtitle.name, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = buildString { + append(subtitle.languageName) + if (subtitle.rating != null) { + append(", ${subtitle.rating}") + } + }, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { selectedData?.let { onSubtitleSelected(it) } }, + enabled = selectedData != null, + ) { + Text(text = stringResource(id = R.string.download)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + ) +} diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/MediaCommonViewModel.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/MediaCommonViewModel.kt new file mode 100644 index 000000000..c5302203f --- /dev/null +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/MediaCommonViewModel.kt @@ -0,0 +1,141 @@ +package dev.anilbeesetti.nextplayer.feature.videopicker.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.anilbeesetti.nextplayer.core.common.services.SystemService +import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer +import dev.anilbeesetti.nextplayer.core.model.Video +import dev.anilbeesetti.nextplayer.core.remotesubs.service.Subtitle +import dev.anilbeesetti.nextplayer.core.remotesubs.service.SubtitlesService +import dev.anilbeesetti.nextplayer.core.ui.R +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel +class MediaCommonViewModel @Inject constructor( + private val systemService: SystemService, + private val subtitlesService: SubtitlesService, + private val mediaSynchronizer: MediaSynchronizer, +) : ViewModel() { + + private val uiStateInternal = MutableStateFlow(MediaCommonUiState()) + val uiState = uiStateInternal.asStateFlow() + + fun getSubtitlesOnline(video: Video) { + uiStateInternal.update { + it.copy( + dialog = MediaCommonDialog.GetSubtitlesOnline( + video = video, + onDismiss = { dismissDialog() }, + onConfirm = { searchText, language -> + uiStateInternal.update { currentState -> + currentState.copy( + dialog = MediaCommonDialog.Loading( + message = systemService.getString(R.string.searching_subtitles), + ), + ) + } + getSubtitleResultsAndShowDialog( + video = video, + searchText = searchText, + language = language, + ) + }, + ), + ) + } + } + + fun onRefreshClicked() { + viewModelScope.launch { + uiStateInternal.update { it.copy(isRefreshing = true) } + mediaSynchronizer.refresh() + uiStateInternal.update { it.copy(isRefreshing = false) } + } + } + + private fun getSubtitleResultsAndShowDialog(video: Video, searchText: String?, language: String) { + viewModelScope.launch { + subtitlesService.search( + video = video, + searchText = searchText, + languages = listOf(language), + ).onSuccess { response -> + uiStateInternal.update { currentState -> + currentState.copy( + dialog = MediaCommonDialog.SubtitleResults( + results = response, + onDismiss = { dismissDialog() }, + onSubtitleSelected = { + downloadSubtitle(it, video) + dismissDialog() + }, + ), + ) + } + }.onFailure { error -> + uiStateInternal.update { + it.copy( + dialog = MediaCommonDialog.Error( + message = error.localizedMessage ?: error.cause?.localizedMessage, + onDismiss = { dismissDialog() }, + ), + ) + } + } + } + } + + private fun downloadSubtitle(subtitle: Subtitle, video: Video) { + systemService.showToast(R.string.downloading_subtitle) + viewModelScope.launch { + subtitlesService.download( + subtitle = subtitle, + name = video.displayName, + ).onSuccess { + if (it.message != null) { + systemService.showToast(it.message!!) + } else { + systemService.showToast(R.string.subtitle_downloaded) + } + }.onFailure { + if (it.message != null) { + systemService.showToast(it.message!!) + } else { + systemService.showToast(R.string.error_downloading_subtitle) + } + } + } + } + + private fun dismissDialog() { + uiStateInternal.update { + it.copy(dialog = null) + } + } +} + +data class MediaCommonUiState( + val isRefreshing: Boolean = false, + val dialog: MediaCommonDialog? = null, +) + +sealed interface MediaCommonDialog { + data class Loading(val message: String?) : MediaCommonDialog + data class Error(val message: String?, val onDismiss: () -> Unit) : MediaCommonDialog + data class GetSubtitlesOnline( + val video: Video, + val onDismiss: () -> Unit, + val onConfirm: (searchText: String?, language: String) -> Unit, + ) : MediaCommonDialog + + data class SubtitleResults( + val results: List, + val onDismiss: () -> Unit, + val onSubtitleSelected: (Subtitle) -> Unit, + ) : MediaCommonDialog +} diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerScreen.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerScreen.kt index 68ff9f4a6..6fed5b022 100644 --- a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerScreen.kt +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerScreen.kt @@ -77,7 +77,14 @@ import dev.anilbeesetti.nextplayer.feature.videopicker.composables.FoldersView import dev.anilbeesetti.nextplayer.feature.videopicker.composables.QuickSettingsDialog import dev.anilbeesetti.nextplayer.feature.videopicker.composables.TextIconToggleButton import dev.anilbeesetti.nextplayer.feature.videopicker.composables.VideosView +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.ErrorDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.GetSubtitlesOnlineDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.LoadingDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.SubtitleResultDialogComponent import dev.anilbeesetti.nextplayer.feature.videopicker.screens.FoldersState +import dev.anilbeesetti.nextplayer.feature.videopicker.screens.MediaCommonDialog +import dev.anilbeesetti.nextplayer.feature.videopicker.screens.MediaCommonUiState +import dev.anilbeesetti.nextplayer.feature.videopicker.screens.MediaCommonViewModel import dev.anilbeesetti.nextplayer.feature.videopicker.screens.VideosState const val CIRCULAR_PROGRESS_INDICATOR_TEST_TAG = "circularProgressIndicator" @@ -88,19 +95,20 @@ fun MediaPickerRoute( onPlayVideo: (uri: Uri) -> Unit, onFolderClick: (folderPath: String) -> Unit, viewModel: MediaPickerViewModel = hiltViewModel(), + mediaCommonViewModel: MediaCommonViewModel = hiltViewModel(), ) { val videosState by viewModel.videosState.collectAsStateWithLifecycle() val foldersState by viewModel.foldersState.collectAsStateWithLifecycle() val preferences by viewModel.preferences.collectAsStateWithLifecycle() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val commonUiState by mediaCommonViewModel.uiState.collectAsStateWithLifecycle() val permissionState = rememberPermissionState(permission = storagePermission) MediaPickerScreen( + commonUiState = commonUiState, videosState = videosState, foldersState = foldersState, preferences = preferences, - isRefreshing = uiState.refreshing, permissionState = permissionState, onPlayVideo = onPlayVideo, onFolderClick = onFolderClick, @@ -110,17 +118,18 @@ fun MediaPickerRoute( onDeleteFolderClick = { viewModel.deleteFolders(listOf(it)) }, onAddToSync = viewModel::addToMediaInfoSynchronizer, onRenameVideoClick = viewModel::renameVideo, - onRefreshClicked = viewModel::onRefreshClicked, + onGetSubtitlesOnline = mediaCommonViewModel::getSubtitlesOnline, + onRefreshClicked = mediaCommonViewModel::onRefreshClicked, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MediaPickerScreen( + commonUiState: MediaCommonUiState, videosState: VideosState, foldersState: FoldersState, preferences: ApplicationPreferences, - isRefreshing: Boolean = false, permissionState: PermissionState = GrantedPermissionState, onPlayVideo: (uri: Uri) -> Unit = {}, onFolderClick: (folderPath: String) -> Unit = {}, @@ -130,6 +139,7 @@ internal fun MediaPickerScreen( onRenameVideoClick: (Uri, String) -> Unit = { _, _ -> }, onDeleteFolderClick: (String) -> Unit, onAddToSync: (Uri) -> Unit = {}, + onGetSubtitlesOnline: (Video) -> Unit = {}, onRefreshClicked: () -> Unit = {}, ) { var showQuickSettingsDialog by rememberSaveable { mutableStateOf(false) } @@ -147,8 +157,8 @@ internal fun MediaPickerScreen( } } - LaunchedEffect(isRefreshing) { - if (isRefreshing) { + LaunchedEffect(commonUiState.isRefreshing) { + if (commonUiState.isRefreshing) { pullToRefreshState.startRefresh() } else { pullToRefreshState.endRefresh() @@ -250,6 +260,7 @@ internal fun MediaPickerScreen( onDeleteVideoClick = onDeleteVideoClick, onVideoLoaded = onAddToSync, onRenameVideoClick = onRenameVideoClick, + onGetSubtitlesOnline = onGetSubtitlesOnline, ) } } @@ -276,6 +287,37 @@ internal fun MediaPickerScreen( onDone = { onPlayVideo(Uri.parse(it)) }, ) } + + commonUiState.dialog?.let { dialog -> + when (dialog) { + is MediaCommonDialog.Loading -> { + LoadingDialogComponent(message = dialog.message) + } + + is MediaCommonDialog.Error -> { + ErrorDialogComponent( + errorMessage = dialog.message, + onDismissRequest = dialog.onDismiss, + ) + } + + is MediaCommonDialog.GetSubtitlesOnline -> { + GetSubtitlesOnlineDialogComponent( + video = dialog.video, + onDismissRequest = dialog.onDismiss, + onConfirm = dialog.onConfirm, + ) + } + + is MediaCommonDialog.SubtitleResults -> { + SubtitleResultDialogComponent( + data = dialog.results, + onDismissRequest = dialog.onDismiss, + onSubtitleSelected = dialog.onSubtitleSelected, + ) + } + } + } } @Composable @@ -346,6 +388,7 @@ fun MediaPickerScreenPreview( videosState = VideosState.Success( data = videos, ), + commonUiState = MediaCommonUiState(), foldersState = FoldersState.Loading, preferences = ApplicationPreferences().copy(groupVideosByFolder = false), onPlayVideo = {}, @@ -379,6 +422,7 @@ fun MediaPickerNoVideosFoundPreview() { foldersState = FoldersState.Success( data = emptyList(), ), + commonUiState = MediaCommonUiState(), preferences = ApplicationPreferences(), onPlayVideo = {}, onFolderClick = {}, @@ -397,6 +441,7 @@ fun MediaPickerLoadingPreview() { MediaPickerScreen( videosState = VideosState.Loading, foldersState = FoldersState.Loading, + commonUiState = MediaCommonUiState(), preferences = ApplicationPreferences(), onPlayVideo = {}, onFolderClick = {}, diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerViewModel.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerViewModel.kt index f7dbfb15c..793e3f9db 100644 --- a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerViewModel.kt +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/media/MediaPickerViewModel.kt @@ -10,18 +10,14 @@ import dev.anilbeesetti.nextplayer.core.domain.GetSortedFoldersUseCase import dev.anilbeesetti.nextplayer.core.domain.GetSortedVideosUseCase import dev.anilbeesetti.nextplayer.core.media.services.MediaService import dev.anilbeesetti.nextplayer.core.media.sync.MediaInfoSynchronizer -import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer import dev.anilbeesetti.nextplayer.core.model.ApplicationPreferences import dev.anilbeesetti.nextplayer.feature.videopicker.screens.FoldersState import dev.anilbeesetti.nextplayer.feature.videopicker.screens.VideosState import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -32,12 +28,8 @@ class MediaPickerViewModel @Inject constructor( private val mediaRepository: MediaRepository, private val preferencesRepository: PreferencesRepository, private val mediaInfoSynchronizer: MediaInfoSynchronizer, - private val mediaSynchronizer: MediaSynchronizer, ) : ViewModel() { - private val uiStateInternal = MutableStateFlow(MediaPickerUiState()) - val uiState = uiStateInternal.asStateFlow() - val videosState = getSortedVideosUseCase.invoke() .map { VideosState.Success(it) } .stateIn( @@ -95,16 +87,4 @@ class MediaPickerViewModel @Inject constructor( mediaService.renameMedia(uri, to) } } - - fun onRefreshClicked() { - viewModelScope.launch { - uiStateInternal.update { it.copy(refreshing = true) } - mediaSynchronizer.refresh() - uiStateInternal.update { it.copy(refreshing = false) } - } - } } - -data class MediaPickerUiState( - val refreshing: Boolean = false, -) diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderScreen.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderScreen.kt index a15f4ebed..5c0c4de5c 100644 --- a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderScreen.kt +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderScreen.kt @@ -23,21 +23,30 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.anilbeesetti.nextplayer.core.common.extensions.prettyName import dev.anilbeesetti.nextplayer.core.model.ApplicationPreferences +import dev.anilbeesetti.nextplayer.core.model.Video import dev.anilbeesetti.nextplayer.core.ui.R import dev.anilbeesetti.nextplayer.core.ui.components.NextTopAppBar import dev.anilbeesetti.nextplayer.core.ui.designsystem.NextIcons +import dev.anilbeesetti.nextplayer.core.ui.theme.NextPlayerTheme import dev.anilbeesetti.nextplayer.feature.videopicker.composables.VideosView +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.ErrorDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.GetSubtitlesOnlineDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.LoadingDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.composables.dialogs.SubtitleResultDialogComponent +import dev.anilbeesetti.nextplayer.feature.videopicker.screens.MediaCommonDialog +import dev.anilbeesetti.nextplayer.feature.videopicker.screens.MediaCommonUiState +import dev.anilbeesetti.nextplayer.feature.videopicker.screens.MediaCommonViewModel import dev.anilbeesetti.nextplayer.feature.videopicker.screens.VideosState -import java.io.File @Composable fun MediaPickerFolderRoute( viewModel: MediaPickerFolderViewModel = hiltViewModel(), + mediaCommonViewModel: MediaCommonViewModel = hiltViewModel(), onVideoClick: (uri: Uri) -> Unit, onNavigateUp: () -> Unit, ) { @@ -45,34 +54,36 @@ fun MediaPickerFolderRoute( // By adding Lifecycle.State.RESUMED, we ensure that we wait until the first render completes. val videosState by viewModel.videos.collectAsStateWithLifecycle(minActiveState = Lifecycle.State.RESUMED) val preferences by viewModel.preferences.collectAsStateWithLifecycle() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val commonUiState by mediaCommonViewModel.uiState.collectAsStateWithLifecycle() MediaPickerFolderScreen( - folderPath = viewModel.folderPath, + commonUiState = commonUiState, + folderName = viewModel.folderName, videosState = videosState, preferences = preferences, - isRefreshing = uiState.refreshing, onPlayVideo = onVideoClick, onNavigateUp = onNavigateUp, onDeleteVideoClick = { viewModel.deleteVideos(listOf(it)) }, onAddToSync = viewModel::addToMediaInfoSynchronizer, onRenameVideoClick = viewModel::renameVideo, - onRefreshClicked = viewModel::onRefreshClicked, + onGetSubtitlesOnline = mediaCommonViewModel::getSubtitlesOnline, + onRefreshClicked = mediaCommonViewModel::onRefreshClicked, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MediaPickerFolderScreen( - folderPath: String, + commonUiState: MediaCommonUiState, + folderName: String, videosState: VideosState, preferences: ApplicationPreferences, - isRefreshing: Boolean = false, onNavigateUp: () -> Unit, onPlayVideo: (Uri) -> Unit, onDeleteVideoClick: (String) -> Unit, onRenameVideoClick: (Uri, String) -> Unit = { _, _ -> }, onAddToSync: (Uri) -> Unit, + onGetSubtitlesOnline: (Video) -> Unit, onRefreshClicked: () -> Unit = {}, ) { val pullToRefreshState = rememberPullToRefreshState() @@ -83,8 +94,8 @@ internal fun MediaPickerFolderScreen( } } - LaunchedEffect(isRefreshing) { - if (isRefreshing) { + LaunchedEffect(commonUiState.isRefreshing) { + if (commonUiState.isRefreshing) { pullToRefreshState.startRefresh() } else { pullToRefreshState.endRefresh() @@ -95,7 +106,7 @@ internal fun MediaPickerFolderScreen( modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)), topBar = { NextTopAppBar( - title = File(folderPath).prettyName, + title = folderName, navigationIcon = { IconButton(onClick = onNavigateUp) { Icon( @@ -138,6 +149,7 @@ internal fun MediaPickerFolderScreen( onDeleteVideoClick = onDeleteVideoClick, onVideoLoaded = onAddToSync, onRenameVideoClick = onRenameVideoClick, + onGetSubtitlesOnline = onGetSubtitlesOnline, ) PullToRefreshContainer( @@ -146,4 +158,55 @@ internal fun MediaPickerFolderScreen( ) } } + + commonUiState.dialog?.let { dialog -> + when (dialog) { + is MediaCommonDialog.Loading -> { + LoadingDialogComponent(message = dialog.message) + } + + is MediaCommonDialog.Error -> { + ErrorDialogComponent( + errorMessage = dialog.message, + onDismissRequest = dialog.onDismiss, + ) + } + + is MediaCommonDialog.GetSubtitlesOnline -> { + GetSubtitlesOnlineDialogComponent( + video = dialog.video, + onDismissRequest = dialog.onDismiss, + onConfirm = dialog.onConfirm, + ) + } + + is MediaCommonDialog.SubtitleResults -> { + SubtitleResultDialogComponent( + data = dialog.results, + onDismissRequest = dialog.onDismiss, + onSubtitleSelected = dialog.onSubtitleSelected, + ) + } + } + } +} + +@Preview +@Composable +private fun MediaPickerFolderScreenPreview() { + NextPlayerTheme { + MediaPickerFolderScreen( + folderName = "Download", + videosState = VideosState.Success( + data = List(10) { Video.sample.copy(path = it.toString()) }, + ), + preferences = ApplicationPreferences(), + commonUiState = MediaCommonUiState(), + onNavigateUp = {}, + onPlayVideo = {}, + onDeleteVideoClick = {}, + onAddToSync = {}, + onGetSubtitlesOnline = {}, + ) + } } diff --git a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderViewModel.kt b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderViewModel.kt index 14df46fda..d1bcc7dc7 100644 --- a/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderViewModel.kt +++ b/feature/videopicker/src/main/java/dev/anilbeesetti/nextplayer/feature/videopicker/screens/mediaFolder/MediaPickerFolderViewModel.kt @@ -5,21 +5,19 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.anilbeesetti.nextplayer.core.common.extensions.prettyName import dev.anilbeesetti.nextplayer.core.data.repository.PreferencesRepository import dev.anilbeesetti.nextplayer.core.domain.GetSortedVideosUseCase import dev.anilbeesetti.nextplayer.core.media.services.MediaService import dev.anilbeesetti.nextplayer.core.media.sync.MediaInfoSynchronizer -import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer import dev.anilbeesetti.nextplayer.core.model.ApplicationPreferences import dev.anilbeesetti.nextplayer.feature.videopicker.navigation.FolderArgs import dev.anilbeesetti.nextplayer.feature.videopicker.screens.VideosState +import java.io.File import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -29,15 +27,12 @@ class MediaPickerFolderViewModel @Inject constructor( private val mediaService: MediaService, private val preferencesRepository: PreferencesRepository, private val mediaInfoSynchronizer: MediaInfoSynchronizer, - private val mediaSynchronizer: MediaSynchronizer, ) : ViewModel() { private val folderArgs = FolderArgs(savedStateHandle) val folderPath = folderArgs.folderId - - private val uiStateInternal = MutableStateFlow(MediaPickerFolderUiState()) - val uiState = uiStateInternal.asStateFlow() + val folderName = File(folderPath).prettyName val videos = getSortedVideosUseCase.invoke(folderPath) .map { VideosState.Success(it) } @@ -71,16 +66,4 @@ class MediaPickerFolderViewModel @Inject constructor( mediaService.renameMedia(uri, to) } } - - fun onRefreshClicked() { - viewModelScope.launch { - uiStateInternal.update { it.copy(refreshing = true) } - mediaSynchronizer.refresh() - uiStateInternal.update { it.copy(refreshing = false) } - } - } } - -data class MediaPickerFolderUiState( - val refreshing: Boolean = false, -) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 685841f59..b3893e6ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ nextlib = "0.7.1" room = "2.6.1" timber = "5.0.1" universalChardet = "2.5.0" - +ktor = "2.3.12" [libraries] aboutlibraries-compose = { group = "com.mikepenz", name = "aboutlibraries-compose", version.ref = "aboutlibraries" } @@ -57,7 +57,6 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } -androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "androidxMedia3" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "androidxMedia3" } androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "androidxMedia3" } @@ -72,7 +71,7 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -github-albfernandez-juniversalchardet = { group="com.github.albfernandez", name = "juniversalchardet", version.ref="universalChardet" } +github-albfernandez-juniversalchardet = { group = "com.github.albfernandez", name = "juniversalchardet", version.ref = "universalChardet" } github-anilbeesetti-nextlib-media3ext = { group = "com.github.anilbeesetti.nextlib", name = "nextlib-media3ext", version.ref = "nextlib" } github-anilbeesetti-nextlib-mediainfo = { group = "com.github.anilbeesetti.nextlib", name = "nextlib-mediainfo", version.ref = "nextlib" } google-android-material = { group = "com.google.android.material", name = "material", version.ref = "androidMaterial" } @@ -82,6 +81,11 @@ junit4 = { group = "junit", name = "junit", version.ref = "junit4" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } [plugins] diff --git a/settings.gradle.kts b/settings.gradle.kts index 7111763d2..c8736479c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,4 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { repositories { google() @@ -44,3 +45,4 @@ include(":core:ui") include(":feature:player") include(":feature:settings") include(":feature:videopicker") +include(":core:remotesubs")