Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Online subs using opensubtitles #1006

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +43,9 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

@Inject
lateinit var systemService: SystemService

@Inject
lateinit var synchronizer: MediaSynchronizer

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ fun File.getSubtitles(): List<File> {
return subs
}

fun File.getAllSubtitlesInFolder(forFile: File): List<File> {
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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 3 additions & 2 deletions core/model/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
Expand All @@ -6,8 +7,8 @@ plugins {
}

tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = libs.versions.android.jvm.get()
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget(libs.versions.android.jvm.get()))
}
}

Expand Down
1 change: 1 addition & 0 deletions core/remotesubs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
46 changes: 46 additions & 0 deletions core/remotesubs/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading