diff --git a/app/src/main/java/com/dd3boh/outertune/MainActivity.kt b/app/src/main/java/com/dd3boh/outertune/MainActivity.kt index 8325258ec..80ba7edcf 100644 --- a/app/src/main/java/com/dd3boh/outertune/MainActivity.kt +++ b/app/src/main/java/com/dd3boh/outertune/MainActivity.kt @@ -387,10 +387,10 @@ class MainActivity : ComponentActivity() { // Check if the permissions for local media access if (firstSetupPassed && localLibEnable && autoScan && checkSelfPermission(MEDIA_PERMISSION_LEVEL) == PackageManager.PERMISSION_GRANTED) { - val scanner = LocalMediaScanner.getScanner(this@MainActivity, scannerImpl) // equivalent to (quick scan) try { + val scanner = LocalMediaScanner.getScanner(this@MainActivity, scannerImpl) val directoryStructure = scanner.scanLocal( database, scanPaths.split('\n'), diff --git a/app/src/main/java/com/dd3boh/outertune/ui/component/Preference.kt b/app/src/main/java/com/dd3boh/outertune/ui/component/Preference.kt index 721f56522..bab193d92 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/component/Preference.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/component/Preference.kt @@ -96,6 +96,7 @@ fun ListPreference( valueText: @Composable (T) -> String, onValueSelected: (T) -> Unit, isEnabled: Boolean = true, + disabled: (T) -> Boolean = { false } ) { var showDialog by remember { mutableStateOf(false) @@ -105,11 +106,12 @@ fun ListPreference( onDismiss = { showDialog = false } ) { items(values) { value -> + val isDisabled = disabled(value) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { + .clickable(enabled = !isDisabled) { showDialog = false onValueSelected(value) } @@ -117,13 +119,16 @@ fun ListPreference( ) { RadioButton( selected = value == selectedValue, - onClick = null + onClick = null, + enabled = !isDisabled ) Text( text = valueText(value), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier + .padding(start = 16.dp) + .alpha(if (isDisabled) 0.5f else 1f) ) } } @@ -149,7 +154,8 @@ inline fun > EnumListPreference( noinline valueText: @Composable (T) -> String, noinline onValueSelected: (T) -> Unit, isEnabled: Boolean = true, - values: List = enumValues().toList() + values: List = enumValues().toList(), + noinline disabled: (T) -> Boolean = { false } ) { ListPreference( modifier = modifier, @@ -159,7 +165,8 @@ inline fun > EnumListPreference( values = values, valueText = valueText, onValueSelected = onValueSelected, - isEnabled = isEnabled + isEnabled = isEnabled, + disabled = disabled ) } diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt index 71645e89e..77e8178a8 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/ExperimentalSettings.kt @@ -136,7 +136,7 @@ fun ExperimentalSettings( onClick = { Toast.makeText(context, "Starting migration...", Toast.LENGTH_SHORT).show() coroutineScope.launch(Dispatchers.IO) { - val scanner = LocalMediaScanner.getScanner(context, scannerImpl) + val scanner = LocalMediaScanner.getScanner(context, ScannerImpl.TAGLIB) Timber.tag("Settings").d("Force Migrating local artists to YTM (MANUAL TRIGGERED)") scanner.localToRemoteArtist(database) } diff --git a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt index 828d84f54..564cf52f2 100644 --- a/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt +++ b/app/src/main/java/com/dd3boh/outertune/ui/screens/settings/LocalPlayerSettings.kt @@ -2,6 +2,10 @@ package com.dd3boh.outertune.ui.screens.settings import android.Manifest import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.Looper @@ -40,6 +44,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -78,6 +84,7 @@ import com.dd3boh.outertune.ui.component.SwitchPreference import com.dd3boh.outertune.ui.utils.DEFAULT_SCAN_PATH import com.dd3boh.outertune.ui.utils.backToMain import com.dd3boh.outertune.ui.utils.cacheDirectoryTree +import com.dd3boh.outertune.utils.isPackageInstalled import com.dd3boh.outertune.utils.purgeCache import com.dd3boh.outertune.utils.rememberEnumPreference import com.dd3boh.outertune.utils.rememberPreference @@ -92,6 +99,7 @@ import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.ZoneOffset + val MEDIA_PERMISSION_LEVEL = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO else Manifest.permission.READ_EXTERNAL_STORAGE @@ -332,10 +340,10 @@ fun LocalPlayerSettings( scannerFailure = false coroutineScope.launch(Dispatchers.IO) { - val scanner = getScanner(context, scannerImpl) // full rescan if (fullRescan) { try { + val scanner = getScanner(context, scannerImpl) val directoryStructure = scanner.scanLocal( database, @@ -378,6 +386,7 @@ fun LocalPlayerSettings( } else { // quick scan try { + val scanner = getScanner(context, scannerImpl) val directoryStructure = scanner.scanLocal( database, scanPaths.split('\n'), @@ -536,24 +545,38 @@ fun LocalPlayerSettings( onCheckedChange = onStrictExtensionsChange ) // scanner type - if (true) { // todo: detect if ext library is installed - EnumListPreference( - title = { Text(stringResource(R.string.scanner_type_title)) }, - icon = { Icon(Icons.Rounded.Speed, null) }, - selectedValue = scannerImpl, - onValueSelected = onScannerImplChange, - valueText = { - when (it) { - ScannerImpl.TAGLIB -> stringResource(R.string.scanner_type_taglib) - ScannerImpl.FFMPEG_EXT -> stringResource(R.string.scanner_type_ffmpeg_ext) - } - } - ) - } - } - + val isFFmpegInstalled = rememberFFmpegAvailability() + // if plugin is not found, although we reset if a scan is run, ensure the user is made aware if in settings page + LaunchedEffect(isFFmpegInstalled) { + if (scannerImpl == ScannerImpl.FFMPEG_EXT && !isFFmpegInstalled) { + onScannerImplChange(ScannerImpl.TAGLIB) + } + } + EnumListPreference( + title = { Text(stringResource(R.string.scanner_type_title)) }, + icon = { Icon(Icons.Rounded.Speed, null) }, + selectedValue = scannerImpl, + onValueSelected = { + if (it == ScannerImpl.FFMPEG_EXT && isFFmpegInstalled) { + onScannerImplChange(it) + } else { + Toast.makeText(context, "FFmpeg extractor not detected.", Toast.LENGTH_LONG).show() + // Explicitly revert to TagLib if FFmpeg is not available + onScannerImplChange(ScannerImpl.TAGLIB) + } + }, + valueText = { + when (it) { + ScannerImpl.TAGLIB -> stringResource(R.string.scanner_type_taglib) + ScannerImpl.FFMPEG_EXT -> stringResource(R.string.scanner_type_ffmpeg_ext) + } + }, + values = ScannerImpl.entries, + disabled = { it == ScannerImpl.FFMPEG_EXT && !isFFmpegInstalled } + ) + } TopAppBar( title = { Text(stringResource(R.string.local_player_settings_title)) }, @@ -570,4 +593,44 @@ fun LocalPlayerSettings( }, scrollBehavior = scrollBehavior ) +} + +@Composable +fun rememberFFmpegAvailability(): Boolean { + val context = LocalContext.current + var isFFmpegInstalled by remember { + mutableStateOf(isPackageInstalled("wah.mikooomich.ffMetadataEx", context.packageManager)) + } + + DisposableEffect(context) { + val packageReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_PACKAGE_REMOVED, + Intent.ACTION_PACKAGE_ADDED -> { + isFFmpegInstalled = context?.packageManager?.let { + isPackageInstalled( + "wah.mikooomich.ffMetadataEx", + it + ) + } == true + } + } + } + } + + val filter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_ADDED) + addDataScheme("package") + } + + context.registerReceiver(packageReceiver, filter) + + onDispose { + context.unregisterReceiver(packageReceiver) + } + } + + return isFFmpegInstalled } \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/utils/Utils.kt b/app/src/main/java/com/dd3boh/outertune/utils/Utils.kt index d3a4568ab..2ad01aba6 100644 --- a/app/src/main/java/com/dd3boh/outertune/utils/Utils.kt +++ b/app/src/main/java/com/dd3boh/outertune/utils/Utils.kt @@ -1,5 +1,6 @@ package com.dd3boh.outertune.utils +import android.content.pm.PackageManager import com.dd3boh.outertune.db.entities.Artist import com.dd3boh.outertune.ui.screens.settings.NavigationTab @@ -102,4 +103,16 @@ fun numberToAlpha(l: Long): String { alphabetMap[it.digitToInt()] } }.joinToString("") +} + +/** + * Check if a package with the specified package name is installed + */ +fun isPackageInstalled(packageName: String, packageManager: PackageManager): Boolean { + return try { + packageManager.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } } \ No newline at end of file diff --git a/app/src/main/java/com/dd3boh/outertune/utils/scanners/LocalMediaScanner.kt b/app/src/main/java/com/dd3boh/outertune/utils/scanners/LocalMediaScanner.kt index a5833c2a0..0779c02f1 100644 --- a/app/src/main/java/com/dd3boh/outertune/utils/scanners/LocalMediaScanner.kt +++ b/app/src/main/java/com/dd3boh/outertune/utils/scanners/LocalMediaScanner.kt @@ -3,7 +3,13 @@ package com.dd3boh.outertune.utils.scanners import android.content.Context import android.media.MediaPlayer import android.os.Environment +import androidx.datastore.dataStore +import androidx.datastore.preferences.core.edit +import com.dd3boh.outertune.MainActivity +import com.dd3boh.outertune.constants.AutomaticScannerKey +import com.dd3boh.outertune.constants.PlayerVolumeKey import com.dd3boh.outertune.constants.ScannerImpl +import com.dd3boh.outertune.constants.ScannerImplKey import com.dd3boh.outertune.constants.ScannerMatchCriteria import com.dd3boh.outertune.db.MusicDatabase import com.dd3boh.outertune.db.entities.ArtistEntity @@ -22,6 +28,8 @@ import com.dd3boh.outertune.ui.utils.SYNC_SCANNER import com.dd3boh.outertune.ui.utils.cacheDirectoryTree import com.dd3boh.outertune.ui.utils.scannerSession import com.dd3boh.outertune.utils.closestMatch +import com.dd3boh.outertune.utils.dataStore +import com.dd3boh.outertune.utils.isPackageInstalled import com.dd3boh.outertune.utils.reportException import com.zionhuang.innertube.YouTube import kotlinx.coroutines.Deferred @@ -744,8 +752,24 @@ class LocalMediaScanner(val context: Context, val scannerImpl: ScannerImpl) { * Trust me bro, it should never be null */ fun getScanner(context: Context, scannerImpl: ScannerImpl): LocalMediaScanner { + /* + if the FFmpeg extractor is suddenly removed and a scan is ran, reset to taglib, disable auto scanner. + we don't want to run the taglib scanner fallback if the user explicitly selected FFmpeg as differences + can muck with the song detection. Throw the error to the ui where it can be handled there + */ + val isFFmpegInstalled = isPackageInstalled("wah.mikooomich.ffMetadataEx", context.packageManager) + if (scannerImpl == ScannerImpl.FFMPEG_EXT && !isFFmpegInstalled) { + runBlocking { + context.dataStore.edit { settings -> + settings[ScannerImplKey] = ScannerImpl.TAGLIB.toString() + settings[AutomaticScannerKey] = false + } + } + throw ScannerAbortException("FFmpeg extractor was selected, but the package is no longer available. Reset to taglib scanner and disabled automatic scanning") + } + if (localScanner == null) { - localScanner = LocalMediaScanner(context, scannerImpl) + localScanner = LocalMediaScanner(context, if (isFFmpegInstalled) scannerImpl else ScannerImpl.TAGLIB) } return localScanner!!