mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-12-22 20:57:54 +01:00
Merge pull request #523 from Shabinder/crashlytics&fixes
Major Refactoring and Bug Fixes
This commit is contained in:
commit
764876ec77
95
.github/workflows/build-desktop-jars.yml
vendored
Normal file
95
.github/workflows/build-desktop-jars.yml
vendored
Normal file
@ -0,0 +1,95 @@
|
||||
on:
|
||||
[workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
create-linux-package:
|
||||
runs-on: ubuntu-latest
|
||||
name: Create Deb Package
|
||||
steps:
|
||||
# Setup Java environment for the next steps
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 15
|
||||
|
||||
# Check out current repository
|
||||
- name: Fetch Sources
|
||||
uses: actions/checkout@v2.3.1
|
||||
|
||||
# Build Desktop application
|
||||
- name: Desktop App
|
||||
run: ./gradlew :desktop:packageUberJarForCurrentOS
|
||||
|
||||
# Create a Draft Release
|
||||
- name: Draft Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
draft: true
|
||||
tag: "3.3.0"
|
||||
artifacts: "desktop\build\compose\jars\*.jar"
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
commit: main
|
||||
# bodyFile: "body.md"
|
||||
#
|
||||
# create-macos-package:
|
||||
# runs-on: macos-latest
|
||||
## needs: create_staging_repository
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v2
|
||||
# - name: Configure JDK
|
||||
# uses: actions/setup-java@v1
|
||||
# with:
|
||||
# java-version: 14
|
||||
# - name: Publish
|
||||
# run: |
|
||||
# ./gradlew publishAllPublicationsToMavenRepository -PSONATYPE_REPOSITORY_ID=${{ needs.create_staging_repository.outputs.repository_id }}
|
||||
# env:
|
||||
# MANUAL_REPOSITORY: ${{ secrets.MANUAL_REPOSITORY }}
|
||||
# SONATYPE_REPOSITORY_ID: ${{ needs.create_staging_repository.outputs.repository_id }}
|
||||
# SONATYPE_USERNAME: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
|
||||
# SONATYPE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
|
||||
# GPG_PRIVATE_KEY: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_KEY }}
|
||||
# GPG_PRIVATE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_PASSWORD }}
|
||||
#
|
||||
# create-windows-package:
|
||||
# runs-on: windows-latest
|
||||
# needs: create_staging_repository
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v2
|
||||
# - name: Configure JDK
|
||||
# uses: actions/setup-java@v1
|
||||
# with:
|
||||
# java-version: 14
|
||||
# - name: Publish
|
||||
# run: |
|
||||
# ./gradlew publishMingwX64PublicationToMavenRepository -PSONATYPE_REPOSITORY_ID=${{ needs.create_staging_repository.outputs.repository_id }}
|
||||
# env:
|
||||
# MANUAL_REPOSITORY: ${{ secrets.MANUAL_REPOSITORY }}
|
||||
# SONATYPE_REPOSITORY_ID: ${{ needs.create_staging_repository.outputs.repository_id }}
|
||||
# SONATYPE_USERNAME: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
|
||||
# SONATYPE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
|
||||
# GPG_PRIVATE_KEY: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_KEY }}
|
||||
# GPG_PRIVATE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_PASSWORD }}
|
||||
#
|
||||
# finalize:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [create_staging_repository,macos,windows]
|
||||
# if: ${{ always() && needs.create_staging_repository.result == 'success' }}
|
||||
# steps:
|
||||
# - name: Discard
|
||||
# if: ${{ needs.macos.result != 'success' || needs.windows.result != 'success' }}
|
||||
# uses: nexus-actions/drop-nexus-staging-repo@main
|
||||
# with:
|
||||
# username: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
|
||||
# password: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
|
||||
# staging_repository_id: ${{ needs.create_staging_repository.outputs.repository_id }}
|
||||
# - name: Release
|
||||
# if: ${{ needs.macos.result == 'success' && needs.windows.result == 'success' }}
|
||||
# uses: nexus-actions/release-nexus-staging-repo@main
|
||||
# with:
|
||||
# base_url: https://s01.oss.sonatype.org/service/local/
|
||||
# username: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
|
||||
# password: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
|
||||
# staging_repository_id: ${{ needs.create_staging_repository.outputs.repository_id }}
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,3 +12,5 @@ terraform.tfvars
|
||||
Gemfile
|
||||
Gemfile.lock
|
||||
/maintenance-tasks/build/
|
||||
/android/.cxx/Debug/5k2s1t1p/x86/
|
||||
/ffmpeg/ffmpeg-kit-android-lib/.cxx/Debug/
|
||||
|
6
.gitmodules
vendored
6
.gitmodules
vendored
@ -1,6 +1,6 @@
|
||||
[submodule "spotiflyer-ios"]
|
||||
path = spotiflyer-ios
|
||||
url = https://github.com/Shabinder/spotiflyer-ios
|
||||
[submodule "mosaic"]
|
||||
path = mosaic
|
||||
url = https://github.com/JakeWharton/mosaic
|
||||
[submodule "ffmpeg/ffmpeg-android-maker"]
|
||||
path = ffmpeg/ffmpeg-android-maker
|
||||
url = https://github.com/Shabinder/ffmpeg-android-maker/
|
||||
|
@ -14,9 +14,9 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Extras.Android.Acra
|
||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||
import org.jetbrains.compose.compose
|
||||
import org.jetbrains.kotlin.kapt.cli.main
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@ -32,14 +32,10 @@ version = Versions.versionName
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Remove jcenter as soon as following issue closes
|
||||
// https://github.com/matomo-org/matomo-sdk-android/issues/301
|
||||
jcenter()
|
||||
}
|
||||
|
||||
android {
|
||||
val props = gradleLocalProperties(rootDir)
|
||||
|
||||
if (props.containsKey("storeFileDir")) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
@ -51,17 +47,16 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
compileSdkVersion(Versions.compileSdkVersion)
|
||||
compileSdk = Versions.compileSdkVersion
|
||||
buildToolsVersion = "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.shabinder.spotiflyer"
|
||||
minSdkVersion(Versions.minSdkVersion)
|
||||
targetSdkVersion(Versions.targetSdkVersion)
|
||||
minSdk = Versions.minSdkVersion
|
||||
targetSdk = Versions.targetSdkVersion
|
||||
versionCode = Versions.versionCode
|
||||
versionName = Versions.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
@ -69,11 +64,13 @@ android {
|
||||
if (props.containsKey("storeFileDir")) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
useIR = true
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
compileOptions {
|
||||
@ -91,9 +88,6 @@ android {
|
||||
exclude(group = "androidx.compose.ui")
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
exclude("META-INF/*")
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(compose.material)
|
||||
@ -106,6 +100,8 @@ dependencies {
|
||||
implementation(project(":common:root"))
|
||||
implementation(project(":common:dependency-injection"))
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:core-components"))
|
||||
implementation(project(":common:providers"))
|
||||
|
||||
// Koin
|
||||
implementation(Koin.android)
|
||||
@ -123,10 +119,8 @@ dependencies {
|
||||
|
||||
// Extras
|
||||
with(Extras.Android) {
|
||||
implementation(Acra.notification)
|
||||
implementation(Acra.http)
|
||||
implementation(countly)
|
||||
implementation(appUpdator)
|
||||
implementation(matomo)
|
||||
}
|
||||
|
||||
with(Versions.androidxLifecycle) {
|
||||
@ -138,7 +132,7 @@ dependencies {
|
||||
// implementation("com.jakewharton.timber:timber:4.7.1")
|
||||
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
||||
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
|
||||
implementation("com.google.accompanist:accompanist-insets:0.12.0")
|
||||
implementation("com.google.accompanist:accompanist-insets:0.16.1")
|
||||
|
||||
// Test
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
@ -40,6 +40,7 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
@ -48,11 +49,13 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:label="SpotiFlyer"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:forceDarkAllowed="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
tools:targetApi="q">
|
||||
<activity android:name=".MainActivity"
|
||||
@ -73,5 +76,16 @@
|
||||
</activity>
|
||||
|
||||
<service android:name=".service.ForegroundService"/>
|
||||
<service android:name="org.openudid.OpenUDID_service">
|
||||
<intent-filter>
|
||||
<action android:name="org.openudid.GETUDID" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name="ly.count.android.sdk.ReferrerReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.vending.INSTALL_REFERRER" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
@ -17,22 +17,12 @@
|
||||
package com.shabinder.spotiflyer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.shabinder.common.di.initKoin
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.spotiflyer.di.appModule
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.config.notification
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.logger.Level
|
||||
import org.matomo.sdk.Matomo
|
||||
import org.matomo.sdk.Tracker
|
||||
import org.matomo.sdk.TrackerBuilder
|
||||
|
||||
class App : Application(), KoinComponent {
|
||||
|
||||
@ -40,21 +30,6 @@ class App : Application(), KoinComponent {
|
||||
const val SIGNATURE_HEX = "53304f6d75736a2f30484230334c454b714753525763724259444d3d0a"
|
||||
}
|
||||
|
||||
val tracker: Tracker by lazy {
|
||||
TrackerBuilder.createDefault(
|
||||
"https://matomo.spotiflyer.ml/matomo.php", 1
|
||||
)
|
||||
.build(Matomo.getInstance(this)).apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
/*Timber.plant(DebugTree())
|
||||
addTrackingCallback {
|
||||
Timber.d(it.toMap().toString())
|
||||
it
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
@ -66,35 +41,4 @@ class App : Application(), KoinComponent {
|
||||
modules(appModule(loggingEnabled))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SpellCheckingInspection")
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
// Crashlytics
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
/*
|
||||
* Prompt User Before Sending Any Crash Report
|
||||
* Obeying `F-Droid Inclusion Privacy Rules`
|
||||
* */
|
||||
notification {
|
||||
title = Strings.acraNotificationTitle()
|
||||
text = Strings.acraNotificationText()
|
||||
channelName = "SpotiFlyer_Crashlytics"
|
||||
channelDescription = "Notification Channel to send Spotiflyer Crashes."
|
||||
sendOnClick = true
|
||||
}
|
||||
// Send Crash Report to self hosted Acrarium (FOSS)
|
||||
httpSender {
|
||||
uri = "https://acrarium.spotiflyer.ml/acrarium/report"
|
||||
basicAuthLogin = "sDj2xCKQIxw0dujf"
|
||||
basicAuthPassword = "O83du0TsgsDJ69zN"
|
||||
httpMethod = HttpSender.Method.POST
|
||||
connectionTimeout = 15000
|
||||
socketTimeout = 20000
|
||||
compress = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,12 +17,7 @@
|
||||
package com.shabinder.spotiflyer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
@ -40,20 +35,14 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponent
|
||||
import com.arkivanov.decompose.defaultComponentContext
|
||||
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
|
||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||
import com.codekidlabs.storagechooser.R
|
||||
@ -62,19 +51,15 @@ import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsPadding
|
||||
import com.google.accompanist.insets.statusBarsHeight
|
||||
import com.google.accompanist.insets.statusBarsPadding
|
||||
import com.shabinder.common.di.ConnectionLiveData
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
import com.shabinder.common.core_components.ConnectionLiveData
|
||||
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||
import com.shabinder.common.core_components.file_manager.FileManager
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.di.observeAsState
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.models.Actions
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.PlatformActions
|
||||
import com.shabinder.common.models.*
|
||||
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.common.providers.FetchPlatformQueryResult
|
||||
import com.shabinder.common.root.SpotiFlyerRoot
|
||||
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
|
||||
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.common.uikit.configurations.SpotiFlyerTheme
|
||||
@ -84,33 +69,29 @@ import com.shabinder.spotiflyer.service.ForegroundService
|
||||
import com.shabinder.spotiflyer.ui.AnalyticsDialog
|
||||
import com.shabinder.spotiflyer.ui.NetworkDialog
|
||||
import com.shabinder.spotiflyer.ui.PermissionDialog
|
||||
import com.shabinder.spotiflyer.utils.checkAppSignature
|
||||
import com.shabinder.spotiflyer.utils.checkIfLatestVersion
|
||||
import com.shabinder.spotiflyer.utils.checkPermissions
|
||||
import com.shabinder.spotiflyer.utils.disableDozeMode
|
||||
import com.shabinder.spotiflyer.utils.requestStoragePermission
|
||||
import com.shabinder.spotiflyer.utils.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.matomo.sdk.extra.TrackHelper
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import java.io.File
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val fetcher: FetchPlatformQueryResult by inject()
|
||||
private val dir: Dir by inject()
|
||||
private val fileManager: FileManager by inject()
|
||||
private val preferenceManager: PreferenceManager by inject()
|
||||
private lateinit var root: SpotiFlyerRoot
|
||||
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks
|
||||
private val analyticsManager: AnalyticsManager by inject { parametersOf(this) }
|
||||
private val callBacks: SpotiFlyerRootCallBacks get() = this.rootComponent.callBacks
|
||||
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
|
||||
private var permissionGranted = mutableStateOf(true)
|
||||
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
|
||||
private val tracker get() = (application as App).tracker
|
||||
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
|
||||
private lateinit var rootComponent: SpotiFlyerRoot
|
||||
// private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
|
||||
|
||||
// Variable for storing instance of our service class
|
||||
var foregroundService: ForegroundService? = null
|
||||
@ -120,9 +101,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
preferenceManager.analyticsManager = analyticsManager
|
||||
// This app draws behind the system bars, so we want to handle fitting system windows
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
this.rootComponent = spotiFlyerRoot(defaultComponentContext())
|
||||
setContent {
|
||||
SpotiFlyerTheme {
|
||||
Surface(contentColor = colorOffWhite) {
|
||||
@ -131,8 +113,8 @@ class MainActivity : ComponentActivity() {
|
||||
val view = LocalView.current
|
||||
|
||||
Box {
|
||||
root = SpotiFlyerRootContent(
|
||||
rememberRootComponent(::spotiFlyerRoot),
|
||||
SpotiFlyerRootContent(
|
||||
this@MainActivity.rootComponent,
|
||||
Modifier.statusBarsPadding().navigationBarsPadding()
|
||||
)
|
||||
Spacer(
|
||||
@ -186,11 +168,10 @@ class MainActivity : ComponentActivity() {
|
||||
* and Track Downloads for all other releases like F-Droid,
|
||||
* for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer
|
||||
* */
|
||||
if (isGithubRelease) { checkIfLatestVersion() }
|
||||
if (preferenceManager.isAnalyticsEnabled && !isGithubRelease) {
|
||||
// Download/App Install Event for F-Droid builds
|
||||
TrackHelper.track().download().with(tracker)
|
||||
if (isGithubRelease) {
|
||||
checkIfLatestVersion()
|
||||
}
|
||||
// TODO Track Download Event
|
||||
handleIntentFromExternalActivity()
|
||||
|
||||
initForegroundService()
|
||||
@ -260,7 +241,12 @@ class MainActivity : ComponentActivity() {
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
permissionGranted.value = checkPermissions()
|
||||
}
|
||||
@ -270,15 +256,18 @@ class MainActivity : ComponentActivity() {
|
||||
componentContext,
|
||||
dependencies = object : SpotiFlyerRoot.Dependencies {
|
||||
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory)
|
||||
override val database = this@MainActivity.dir.db
|
||||
override val database = this@MainActivity.fileManager.db
|
||||
override val fetchQuery = this@MainActivity.fetcher
|
||||
override val dir: Dir = this@MainActivity.dir
|
||||
override val fileManager: FileManager = this@MainActivity.fileManager
|
||||
override val preferenceManager = this@MainActivity.preferenceManager
|
||||
override val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
|
||||
override val analyticsManager: AnalyticsManager = this@MainActivity.analyticsManager
|
||||
override val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> =
|
||||
trackStatusFlow
|
||||
override val actions = object : Actions {
|
||||
|
||||
override val platformActions = object : PlatformActions {
|
||||
override val imageCacheDir: String = applicationContext.cacheDir.absolutePath + File.separator
|
||||
override val imageCacheDir: String =
|
||||
applicationContext.cacheDir.absolutePath + File.separator
|
||||
override val sharedPreferences = applicationContext.getSharedPreferences(
|
||||
SharedPreferencesKey,
|
||||
MODE_PRIVATE
|
||||
@ -292,19 +281,26 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun sendTracksToService(array: List<TrackDetails>) {
|
||||
if (foregroundService == null) initForegroundService()
|
||||
foregroundService?.downloadAllTracks(array)
|
||||
for (chunk in array.chunked(25)) {
|
||||
if (foregroundService == null) initForegroundService()
|
||||
foregroundService?.downloadAllTracks(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun showPopUpMessage(string: String, long: Boolean) = this@MainActivity.showPopUpMessage(string, long)
|
||||
override fun showPopUpMessage(string: String, long: Boolean) =
|
||||
this@MainActivity.showPopUpMessage(string, long)
|
||||
|
||||
override fun setDownloadDirectoryAction(callBack: (String) -> Unit) = setUpOnPrefClickListener(callBack)
|
||||
override fun setDownloadDirectoryAction(callBack: (String) -> Unit) =
|
||||
setUpOnPrefClickListener(callBack)
|
||||
|
||||
override fun queryActiveTracks() = this@MainActivity.queryActiveTracks()
|
||||
|
||||
override fun giveDonation() {
|
||||
openPlatform("", platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button")
|
||||
openPlatform(
|
||||
"",
|
||||
platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button"
|
||||
)
|
||||
}
|
||||
|
||||
override fun shareApp() {
|
||||
@ -341,47 +337,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeMp3Tags(trackDetails: TrackDetails) { /*IMPLEMENTED*/ }
|
||||
override fun writeMp3Tags(trackDetails: TrackDetails) {
|
||||
/*IMPLEMENTED*/
|
||||
}
|
||||
|
||||
override val isInternetAvailable get() = internetAvailability.value ?: true
|
||||
}
|
||||
|
||||
/*
|
||||
* Analytics Will Only Be Sent if User Granted us the Permission
|
||||
* */
|
||||
override val analytics = object : Analytics {
|
||||
override fun appLaunchEvent() {
|
||||
if (preferenceManager.isAnalyticsEnabled) {
|
||||
TrackHelper.track()
|
||||
.event("events", "App_Launch")
|
||||
.name("App Launch").with(tracker)
|
||||
}
|
||||
}
|
||||
|
||||
override fun homeScreenVisit() {
|
||||
if (preferenceManager.isAnalyticsEnabled) {
|
||||
// HomeScreen Visit Event
|
||||
TrackHelper.track().screen("/main_activity/home_screen")
|
||||
.title("HomeScreen").with(tracker)
|
||||
}
|
||||
}
|
||||
|
||||
override fun listScreenVisit() {
|
||||
if (preferenceManager.isAnalyticsEnabled) {
|
||||
// ListScreen Visit Event
|
||||
TrackHelper.track().screen("/main_activity/list_screen")
|
||||
.title("ListScreen").with(tracker)
|
||||
}
|
||||
}
|
||||
|
||||
override fun donationDialogVisit() {
|
||||
if (preferenceManager.isAnalyticsEnabled) {
|
||||
// Donation Dialog Open Event
|
||||
TrackHelper.track().screen("/main_activity/donation_dialog")
|
||||
.title("DonationDialog").with(tracker)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -423,7 +384,7 @@ class MainActivity : ComponentActivity() {
|
||||
// hell yeah :)
|
||||
preferenceManager.setDownloadDirectory(path)
|
||||
callBack(path)
|
||||
showPopUpMessage(Strings.downloadDirectorySetTo("\n${dir.defaultDir()}"))
|
||||
showPopUpMessage(Strings.downloadDirectorySetTo("\n${fileManager.defaultDir()}"))
|
||||
} else {
|
||||
showPopUpMessage(Strings.noWriteAccess("\n$path "))
|
||||
}
|
||||
@ -433,6 +394,7 @@ class MainActivity : ComponentActivity() {
|
||||
chooser.show()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
@ -466,10 +428,10 @@ class MainActivity : ComponentActivity() {
|
||||
val link = filterLinkRegex.find(string)?.value.toString()
|
||||
Log.i("Intent", link)
|
||||
lifecycleScope.launch {
|
||||
while (!this@MainActivity::root.isInitialized) {
|
||||
while (!this@MainActivity::rootComponent.isInitialized) {
|
||||
delay(100)
|
||||
}
|
||||
if (methods.value.isInternetAvailable)callBacks.searchLink(link)
|
||||
if (methods.value.isInternetAvailable) callBacks.searchLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -481,6 +443,16 @@ class MainActivity : ComponentActivity() {
|
||||
unbindService()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
analyticsManager.onStart()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
analyticsManager.onStop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val disableDozeCode = 1223
|
||||
}
|
||||
|
@ -33,19 +33,19 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
import com.shabinder.common.di.R
|
||||
import com.shabinder.common.di.downloadFile
|
||||
import com.shabinder.common.di.utils.ParallelExecutor
|
||||
import com.shabinder.common.core_components.file_manager.FileManager
|
||||
import com.shabinder.common.core_components.file_manager.downloadFile
|
||||
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.failure
|
||||
import com.shabinder.common.providers.FetchPlatformQueryResult
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.spotiflyer.utils.autoclear.AutoClear
|
||||
import com.shabinder.spotiflyer.R
|
||||
import com.shabinder.spotiflyer.utils.autoclear.autoClear
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@ -56,13 +56,19 @@ import java.io.File
|
||||
|
||||
class ForegroundService : LifecycleService() {
|
||||
|
||||
private var downloadService: AutoClear<ParallelExecutor> = autoClear { ParallelExecutor(Dispatchers.IO) }
|
||||
val trackStatusFlowMap by autoClear { TrackStatusFlowMap(MutableSharedFlow(replay = 1), lifecycleScope) }
|
||||
private lateinit var downloadService: ParallelExecutor
|
||||
val trackStatusFlowMap by autoClear {
|
||||
TrackStatusFlowMap(
|
||||
MutableSharedFlow(replay = 1),
|
||||
lifecycleScope
|
||||
)
|
||||
}
|
||||
private val fetcher: FetchPlatformQueryResult by inject()
|
||||
private val logger: Kermit by inject()
|
||||
private val dir: Dir by inject()
|
||||
private val dir: FileManager by inject()
|
||||
|
||||
private var messageList = java.util.Collections.synchronizedList(MutableList(5) { emptyMessage })
|
||||
private var messageList =
|
||||
java.util.Collections.synchronizedList(MutableList(5) { emptyMessage })
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
private val cancelIntent: PendingIntent by lazy {
|
||||
@ -81,6 +87,7 @@ class ForegroundService : LifecycleService() {
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
val service get() = this@ForegroundService
|
||||
}
|
||||
|
||||
private val myBinder: IBinder = DownloadServiceBinder()
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
@ -90,12 +97,14 @@ class ForegroundService : LifecycleService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
downloadService = ParallelExecutor(Dispatchers.IO)
|
||||
createNotificationChannel(CHANNEL_ID, "Downloader Service")
|
||||
}
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
downloadService.reviveIfClosed()
|
||||
// Send a notification that service is started
|
||||
Log.i(TAG, "Foreground Service Started.")
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
@ -127,6 +136,7 @@ class ForegroundService : LifecycleService() {
|
||||
* Function To Download All Tracks Available in a List
|
||||
**/
|
||||
fun downloadAllTracks(trackList: List<TrackDetails>) {
|
||||
downloadService.reviveIfClosed()
|
||||
trackList.size.also { size ->
|
||||
total += size
|
||||
isSingleDownload = (size == 1)
|
||||
@ -136,10 +146,10 @@ class ForegroundService : LifecycleService() {
|
||||
for (track in trackList) {
|
||||
trackStatusFlowMap[track.title] = DownloadStatus.Queued
|
||||
lifecycleScope.launch {
|
||||
downloadService.value.execute {
|
||||
fetcher.findMp3DownloadLink(track).fold(
|
||||
success = { url ->
|
||||
enqueueDownload(url, track)
|
||||
downloadService.executeSuspending {
|
||||
fetcher.findBestDownloadLink(track).fold(
|
||||
success = { res ->
|
||||
enqueueDownload(res.first, track.apply { audioQuality = res.second })
|
||||
},
|
||||
failure = { error ->
|
||||
failed++
|
||||
@ -163,7 +173,8 @@ class ForegroundService : LifecycleService() {
|
||||
is DownloadResult.Error -> {
|
||||
logger.d(TAG) { it.message }
|
||||
failed++
|
||||
trackStatusFlowMap[track.title] = DownloadStatus.Failed(it.cause ?: Exception(it.message))
|
||||
trackStatusFlowMap[track.title] =
|
||||
DownloadStatus.Failed(it.cause ?: Exception(it.message))
|
||||
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
|
||||
}
|
||||
|
||||
@ -176,17 +187,40 @@ class ForegroundService : LifecycleService() {
|
||||
coroutineScope {
|
||||
SuspendableEvent {
|
||||
// Save File and Embed Metadata
|
||||
val job = launch(Dispatchers.Default) { dir.saveFileWithMetadata(it.byteArray, track) {} }
|
||||
val job = launch(Dispatchers.Default) {
|
||||
dir.saveFileWithMetadata(
|
||||
it.byteArray,
|
||||
track
|
||||
) {}
|
||||
}
|
||||
|
||||
// Send Converting Status
|
||||
trackStatusFlowMap[track.title] = DownloadStatus.Converting
|
||||
addToNotification(Message(track.title, DownloadStatus.Converting))
|
||||
|
||||
// All Processing Completed for this Track
|
||||
job.invokeOnCompletion {
|
||||
job.invokeOnCompletion { throwable ->
|
||||
if (throwable != null /*&& throwable !is CancellationException*/) {
|
||||
// handle error
|
||||
failed++
|
||||
trackStatusFlowMap[track.title] =
|
||||
DownloadStatus.Failed(throwable)
|
||||
removeFromNotification(
|
||||
Message(
|
||||
track.title,
|
||||
DownloadStatus.Converting
|
||||
)
|
||||
)
|
||||
return@invokeOnCompletion
|
||||
}
|
||||
converted++
|
||||
trackStatusFlowMap[track.title] = DownloadStatus.Downloaded
|
||||
removeFromNotification(Message(track.title, DownloadStatus.Converting))
|
||||
removeFromNotification(
|
||||
Message(
|
||||
track.title,
|
||||
DownloadStatus.Converting
|
||||
)
|
||||
)
|
||||
}
|
||||
logger.d(TAG) { "${track.title} Download Completed" }
|
||||
downloaded++
|
||||
@ -240,9 +274,9 @@ class ForegroundService : LifecycleService() {
|
||||
messageList = messageList.getEmpty().apply {
|
||||
set(index = 0, Message(Strings.cleaningAndExiting(), DownloadStatus.NotDownloaded))
|
||||
}
|
||||
downloadService.getOrNull()?.close()
|
||||
downloadService.reset()
|
||||
downloadService.close()
|
||||
updateNotification()
|
||||
trackStatusFlowMap.clear()
|
||||
cleanFiles(File(dir.defaultDir()))
|
||||
// cleanFiles(File(dir.imageCacheDir()))
|
||||
messageList = messageList.getEmpty()
|
||||
@ -256,23 +290,24 @@ class ForegroundService : LifecycleService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||
setSmallIcon(R.drawable.ic_download_arrow)
|
||||
setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed")
|
||||
setSilent(true)
|
||||
setProgress(total, failed + converted, false)
|
||||
setStyle(
|
||||
NotificationCompat.InboxStyle().run {
|
||||
addLine(messageList[messageList.size - 1].asString())
|
||||
addLine(messageList[messageList.size - 2].asString())
|
||||
addLine(messageList[messageList.size - 3].asString())
|
||||
addLine(messageList[messageList.size - 4].asString())
|
||||
addLine(messageList[messageList.size - 5].asString())
|
||||
}
|
||||
)
|
||||
addAction(R.drawable.ic_round_cancel_24, Strings.exit(), cancelIntent)
|
||||
build()
|
||||
}
|
||||
private fun createNotification(): Notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||
setSmallIcon(R.drawable.ic_download_arrow)
|
||||
setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed")
|
||||
setSilent(true)
|
||||
setProgress(total, failed + converted, false)
|
||||
setStyle(
|
||||
NotificationCompat.InboxStyle().run {
|
||||
addLine(messageList[messageList.size - 1].asString())
|
||||
addLine(messageList[messageList.size - 2].asString())
|
||||
addLine(messageList[messageList.size - 3].asString())
|
||||
addLine(messageList[messageList.size - 4].asString())
|
||||
addLine(messageList[messageList.size - 5].asString())
|
||||
}
|
||||
)
|
||||
addAction(R.drawable.ic_round_cancel_24, Strings.exit(), cancelIntent)
|
||||
build()
|
||||
}
|
||||
|
||||
private fun addToNotification(message: Message) {
|
||||
synchronized(messageList) {
|
||||
@ -304,12 +339,16 @@ class ForegroundService : LifecycleService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isFinished) { killService() }
|
||||
if (isFinished) {
|
||||
killService()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
if (isFinished) { killService() }
|
||||
if (isFinished) {
|
||||
killService()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -10,8 +10,28 @@ class TrackStatusFlowMap(
|
||||
private val scope: CoroutineScope
|
||||
) : HashMap<String, DownloadStatus>() {
|
||||
override fun put(key: String, value: DownloadStatus): DownloadStatus? {
|
||||
val res = super.put(key, value)
|
||||
synchronized(this) {
|
||||
val res = super.put(key, value)
|
||||
emitValue()
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(this) {
|
||||
// Reset Statuses
|
||||
this.forEach { (title, status) ->
|
||||
if(status !is DownloadStatus.Failed && status !is DownloadStatus.Downloaded) {
|
||||
super.put(title,DownloadStatus.NotDownloaded)
|
||||
}
|
||||
}
|
||||
emitValue()
|
||||
//super.clear()
|
||||
//emitValue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitValue() {
|
||||
scope.launch { statusFlow.emit(this@TrackStatusFlowMap) }
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
@ -27,9 +27,14 @@ allprojects {
|
||||
// mavenLocal()
|
||||
maven(url = "https://jitpack.io")
|
||||
maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
maven(url = "https://dl.bintray.com/kotlin/kotlin-js-wrappers")
|
||||
maven(url = "https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven")
|
||||
}
|
||||
/*Fixes: Could not resolve org.nodejs:node*/
|
||||
plugins.withType<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin> {
|
||||
configure<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension> {
|
||||
download = false
|
||||
}
|
||||
}
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
dependsOn(":common:data-models:generateI18n4kFiles")
|
||||
kotlinOptions { jvmTarget = "1.8" }
|
||||
|
@ -16,15 +16,20 @@
|
||||
|
||||
@file:Suppress("MayBeConstant", "SpellCheckingInspection")
|
||||
|
||||
import org.gradle.api.artifacts.ExternalModuleDependency
|
||||
import org.gradle.api.artifacts.dsl.DependencyHandler
|
||||
import org.gradle.kotlin.dsl.accessors.runtime.addDependencyTo
|
||||
|
||||
object Versions {
|
||||
// App's Version (To be bumped at each update)
|
||||
const val versionName = "3.2.1"
|
||||
const val versionName = "3.3.0"
|
||||
|
||||
const val versionCode = 24
|
||||
|
||||
const val versionCode = 22
|
||||
// Kotlin
|
||||
const val kotlinVersion = "1.5.10"
|
||||
const val kotlinVersion = "1.5.21"
|
||||
|
||||
const val coroutinesVersion = "1.5.0"
|
||||
const val coroutinesVersion = "1.5.1"
|
||||
|
||||
// Code Formatting
|
||||
const val ktLint = "10.1.0"
|
||||
@ -41,38 +46,46 @@ object Versions {
|
||||
const val mokoParcelize = "0.7.1"
|
||||
|
||||
// Internet
|
||||
const val ktor = "1.6.0"
|
||||
const val ktor = "1.6.2"
|
||||
|
||||
const val kotlinxSerialization = "1.2.1"
|
||||
const val kotlinxSerialization = "1.2.2"
|
||||
|
||||
// Database
|
||||
const val sqlDelight = "1.5.0"
|
||||
const val sqlDelight = "1.5.1"
|
||||
|
||||
const val sqliteJdbcDriver = "3.34.0"
|
||||
const val slf4j = "1.7.31"
|
||||
|
||||
// Internationalisation
|
||||
const val i18n4k = "0.1.2"
|
||||
const val i18n4k = "0.1.3"
|
||||
|
||||
// Android
|
||||
const val minSdkVersion = 21
|
||||
const val compileSdkVersion = 30
|
||||
const val targetSdkVersion = 29
|
||||
const val androidxLifecycle = "2.3.1"
|
||||
const val androidxLifecycle = "2.4.0-alpha03"
|
||||
}
|
||||
|
||||
object HostOS {
|
||||
// Host OS Properties
|
||||
private val hostOs = System.getProperty("os.name")
|
||||
val isMingwX64 = hostOs.startsWith("Windows",true)
|
||||
val isMac = hostOs.startsWith("Mac",true)
|
||||
val isLinux = hostOs.startsWith("Linux",true)
|
||||
val isMingwX64 = hostOs.startsWith("Windows", true)
|
||||
val isMac = hostOs.startsWith("Mac", true)
|
||||
val isLinux = hostOs.startsWith("Linux", true)
|
||||
}
|
||||
|
||||
object MultiPlatformSettings {
|
||||
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7"
|
||||
}
|
||||
|
||||
object KotlinJSWrappers {
|
||||
private const val bomVersion = "0.0.1-pre.235-kotlin-1.5.21"
|
||||
val bom = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom:${bomVersion}"
|
||||
const val kotlinReact = "org.jetbrains.kotlin-wrappers:kotlin-react"
|
||||
const val kotlinReactDom = "org.jetbrains.kotlin-wrappers:kotlin-react-dom"
|
||||
const val kotlinStyled = "org.jetbrains.kotlin-wrappers:kotlin-styled"
|
||||
}
|
||||
|
||||
object Koin {
|
||||
val core = "io.insert-koin:koin-core:${Versions.koin}"
|
||||
val test = "io.insert-koin:koin-test:${Versions.koin}"
|
||||
@ -81,15 +94,15 @@ object Koin {
|
||||
}
|
||||
|
||||
object Androidx {
|
||||
const val androidxActivity = "androidx.activity:activity-compose:1.3.0-beta02"
|
||||
const val core = "androidx.core:core-ktx:1.5.0"
|
||||
const val androidxActivity = "androidx.activity:activity-compose:1.3.1"
|
||||
const val core = "androidx.core:core-ktx:1.6.0"
|
||||
const val palette = "androidx.palette:palette-ktx:1.0.0"
|
||||
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}"
|
||||
|
||||
const val junit = "androidx.test.ext:junit:1.1.2"
|
||||
const val expresso = "androidx.test.espresso:espresso-core:3.3.0"
|
||||
|
||||
const val gradlePlugin = "com.android.tools.build:gradle:4.1.1"
|
||||
const val gradlePlugin = "com.android.tools.build:gradle:7.0.1"
|
||||
}
|
||||
|
||||
object KTLint {
|
||||
@ -98,24 +111,28 @@ object KTLint {
|
||||
|
||||
object JetBrains {
|
||||
object Kotlin {
|
||||
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt"
|
||||
const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlinVersion}"
|
||||
const val serialization = "org.jetbrains.kotlin:kotlin-serialization:${Versions.kotlinVersion}"
|
||||
const val testCommon = "org.jetbrains.kotlin:kotlin-test-common:${Versions.kotlinVersion}"
|
||||
const val testJunit = "org.jetbrains.kotlin:kotlin-test-junit:${Versions.kotlinVersion}"
|
||||
const val testAnnotationsCommon = "org.jetbrains.kotlin:kotlin-test-annotations-common:${Versions.kotlinVersion}"
|
||||
const val testAnnotationsCommon =
|
||||
"org.jetbrains.kotlin:kotlin-test-annotations-common:${Versions.kotlinVersion}"
|
||||
}
|
||||
|
||||
object Compose {
|
||||
// __LATEST_COMPOSE_RELEASE_VERSION__
|
||||
const val VERSION = "0.4.0"
|
||||
private const val VERSION = "1.0.0-alpha2"
|
||||
const val gradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$VERSION"
|
||||
}
|
||||
}
|
||||
|
||||
object Mosaic {
|
||||
const val gradlePlugin = "com.jakewharton.mosaic:mosaic-gradle-plugin:${Versions.mosaic}"
|
||||
}
|
||||
|
||||
object Decompose {
|
||||
private const val VERSION = "0.2.6"
|
||||
private const val VERSION = "0.3.1"
|
||||
const val decompose = "com.arkivanov.decompose:decompose:$VERSION"
|
||||
const val decomposeIosX64 = "com.arkivanov.decompose:decompose-iosx64:$VERSION"
|
||||
const val decomposeIosArm64 = "com.arkivanov.decompose:decompose-iosarm64:$VERSION"
|
||||
@ -163,17 +180,16 @@ object Extras {
|
||||
const val mp3agic = "com.mpatric:mp3agic:0.9.0"
|
||||
const val jaudioTagger = "com.github.Shabinder:JAudioTagger-Android:1.0"
|
||||
const val kermit = "co.touchlab:kermit:${Versions.kermit}"
|
||||
|
||||
object Android {
|
||||
object Acra {
|
||||
// Self Hosted Crashlytics (FOSS)
|
||||
private const val VERSION = "5.8.3"
|
||||
val http = "ch.acra:acra-http:$VERSION"
|
||||
val notification = "ch.acra:acra-notification:$VERSION"
|
||||
}
|
||||
// Self Hosted Analytics (FOSS)
|
||||
val matomo = "org.matomo.sdk:tracker:4.1.2"
|
||||
// Self Hosted Analytics & Crashlytics (FOSS)
|
||||
val countly = "ly.count.android:sdk:20.11.8"
|
||||
val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0"
|
||||
}
|
||||
|
||||
object Desktop {
|
||||
val countly = "ly.count.sdk:java:20.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
object Serialization {
|
||||
@ -191,3 +207,10 @@ object SqlDelight {
|
||||
val nativeDriverMacos = "com.squareup.sqldelight:native-driver-macosx64:${Versions.sqlDelight}"
|
||||
val jdbcDriver = "org.xerial:sqlite-jdbc:${Versions.sqliteJdbcDriver}"
|
||||
}
|
||||
|
||||
fun DependencyHandler.`implementation`(
|
||||
dependencyNotation: String,
|
||||
dependencyConfiguration: ExternalModuleDependency.() -> Unit
|
||||
): ExternalModuleDependency = addDependencyTo(
|
||||
this, "implementation", dependencyNotation
|
||||
) { dependencyConfiguration() }
|
||||
|
@ -22,11 +22,11 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion(Versions.compileSdkVersion)
|
||||
compileSdk = Versions.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion(Versions.minSdkVersion)
|
||||
targetSdkVersion(Versions.targetSdkVersion)
|
||||
minSdk = Versions.minSdkVersion
|
||||
targetSdk = Versions.targetSdkVersion
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@ -37,7 +37,6 @@ android {
|
||||
sourceSets {
|
||||
named("main") {
|
||||
manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
java.srcDirs("src/androidMain/kotlin")
|
||||
res.srcDirs("src/androidMain/res")
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ kotlin {
|
||||
|
||||
implementation(Extras.kermit)
|
||||
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0-native-mt") {
|
||||
implementation(JetBrains.Kotlin.coroutines) {
|
||||
@Suppress("DEPRECATION")
|
||||
isForce = true
|
||||
}
|
||||
@ -51,7 +51,7 @@ kotlin {
|
||||
}
|
||||
named("androidMain") {
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.3.0")
|
||||
implementation(Androidx.androidxActivity)
|
||||
implementation(Androidx.core)
|
||||
}
|
||||
}
|
||||
|
@ -41,19 +41,19 @@ kotlin {
|
||||
sourceSets {
|
||||
named("commonTest") {
|
||||
dependencies {
|
||||
//implementation(JetBrains.Kotlin.testCommon)
|
||||
//implementation(JetBrains.Kotlin.testAnnotationsCommon)
|
||||
implementation(JetBrains.Kotlin.testCommon)
|
||||
implementation(JetBrains.Kotlin.testAnnotationsCommon)
|
||||
}
|
||||
}
|
||||
|
||||
named("androidTest") {
|
||||
dependencies {
|
||||
//implementation(JetBrains.Kotlin.testJunit)
|
||||
implementation(JetBrains.Kotlin.testJunit)
|
||||
}
|
||||
}
|
||||
named("desktopTest") {
|
||||
dependencies {
|
||||
//implementation(JetBrains.Kotlin.testJunit)
|
||||
implementation(JetBrains.Kotlin.testJunit)
|
||||
}
|
||||
}
|
||||
named("jsTest") {
|
||||
|
@ -38,7 +38,11 @@ kotlin {
|
||||
android()
|
||||
|
||||
js(BOTH) {
|
||||
browser()
|
||||
browser {
|
||||
commonWebpackConfig {
|
||||
cssSupport.enabled = true
|
||||
}
|
||||
}
|
||||
// nodejs()
|
||||
}
|
||||
|
||||
@ -66,7 +70,7 @@ kotlin {
|
||||
implementation(Serialization.json)
|
||||
implementation("co.touchlab:stately-common:1.1.7")
|
||||
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0-native-mt") {
|
||||
implementation(JetBrains.Kotlin.coroutines) {
|
||||
@Suppress("DEPRECATION")
|
||||
isForce = true
|
||||
}
|
||||
@ -75,7 +79,7 @@ kotlin {
|
||||
|
||||
named("androidMain") {
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.3.0")
|
||||
implementation(Androidx.androidxActivity)
|
||||
implementation(Androidx.core)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.material)
|
||||
@ -102,9 +106,13 @@ kotlin {
|
||||
named("jsMain") {
|
||||
dependencies {
|
||||
implementation(Ktor.clientJs)
|
||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-react:17.0.2-pre.213-kotlin-1.5.10")
|
||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom:17.0.2-pre.213-kotlin-1.5.10")
|
||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-styled:5.3.0-pre.213-kotlin-1.5.10")
|
||||
|
||||
/*with(KotlinJSWrappers) {
|
||||
implementation(enforcedPlatform(bom))
|
||||
implementation(kotlinReact)
|
||||
implementation(kotlinReactDom)
|
||||
implementation(kotlinStyled)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
if(HostOS.isMac){
|
||||
|
@ -34,6 +34,7 @@ kotlin {
|
||||
implementation(project(":common:main"))
|
||||
implementation(project(":common:list"))
|
||||
implementation(project(":common:preference"))
|
||||
implementation(project(":common:core-components"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:dependency-injection"))
|
||||
|
@ -11,8 +11,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.dispatcherIO
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.models.dispatcherIO
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
|
@ -3,7 +3,7 @@ package com.shabinder.common.uikit.configurations
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.shabinder.common.database.R
|
||||
import com.shabinder.common.models.R
|
||||
|
||||
actual fun montserratFont() = FontFamily(
|
||||
Font(R.font.montserrat_light, FontWeight.Light),
|
||||
@ -12,6 +12,6 @@ actual fun montserratFont() = FontFamily(
|
||||
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
actual fun pristineFont() = FontFamily(
|
||||
actual fun pristineFont(): FontFamily = FontFamily(
|
||||
Font(R.font.pristine_script, FontWeight.Bold)
|
||||
)
|
||||
|
@ -2,7 +2,7 @@ package com.shabinder.common.uikit
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
|
||||
@Composable
|
||||
expect fun ImageLoad(
|
||||
|
@ -53,7 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.list.SpotiFlyerList
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
|
@ -78,7 +78,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.main.SpotiFlyerMain
|
||||
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
|
||||
import com.shabinder.common.models.DownloadRecord
|
||||
|
@ -69,7 +69,9 @@ fun SpotiFlyerPreferenceContent(component: SpotiFlyerPreference) {
|
||||
title = "Preferred Audio Quality",
|
||||
value = model.preferredQuality.kbps + "KBPS"
|
||||
) { save ->
|
||||
val audioQualities = AudioQuality.values()
|
||||
val audioQualities = AudioQuality.values().toMutableList().apply {
|
||||
remove(AudioQuality.UNKNOWN)
|
||||
}
|
||||
|
||||
audioQualities.forEach { quality ->
|
||||
Row(
|
||||
|
@ -12,7 +12,7 @@ actual fun Dialog(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(isVisible) {
|
||||
androidx.compose.ui.window.v1.Dialog(onDismiss) {
|
||||
androidx.compose.ui.window.Dialog(onDismiss) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
@ -2,17 +2,12 @@ package com.shabinder.common.uikit
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.dispatcherIO
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.models.dispatcherIO
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
@ -31,6 +26,11 @@ actual fun ImageLoad(
|
||||
}
|
||||
|
||||
Crossfade(pic) {
|
||||
if (it == null) Image(PlaceHolderImage(), desc, modifier, contentScale = ContentScale.Crop) else Image(it, desc, modifier, contentScale = ContentScale.Crop)
|
||||
if (it == null) Image(PlaceHolderImage(), desc, modifier, contentScale = ContentScale.Crop) else Image(
|
||||
it,
|
||||
desc,
|
||||
modifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
39
common/core-components/build.gradle.kts
Normal file
39
common/core-components/build.gradle.kts
Normal file
@ -0,0 +1,39 @@
|
||||
plugins {
|
||||
id("multiplatform-setup")
|
||||
id("multiplatform-setup-test")
|
||||
kotlin("plugin.serialization")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:database"))
|
||||
implementation("org.jetbrains.kotlinx:atomicfu:0.16.2")
|
||||
api(MultiPlatformSettings.dep)
|
||||
implementation(MVIKotlin.rx)
|
||||
}
|
||||
}
|
||||
androidMain {
|
||||
dependencies {
|
||||
implementation(Extras.mp3agic)
|
||||
implementation(Extras.Android.countly)
|
||||
implementation(project(":ffmpeg:android-ffmpeg"))
|
||||
}
|
||||
}
|
||||
desktopMain {
|
||||
dependencies {
|
||||
implementation(Extras.mp3agic)
|
||||
implementation(Extras.Desktop.countly)
|
||||
implementation("com.github.kokorin.jaffree:jaffree:2021.08.16")
|
||||
}
|
||||
}
|
||||
jsMain {
|
||||
dependencies {
|
||||
implementation(npm("browser-id3-writer", "4.4.0"))
|
||||
implementation(npm("file-saver", "2.0.4"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
common/core-components/src/androidMain/AndroidManifest.xml
Normal file
20
common/core-components/src/androidMain/AndroidManifest.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ * Copyright (c) 2021 Shabinder Singh
|
||||
~ * This program is free software: you can redistribute it and/or modify
|
||||
~ * it under the terms of the GNU General Public License as published by
|
||||
~ * the Free Software Foundation, either version 3 of the License, or
|
||||
~ * (at your option) any later version.
|
||||
~ *
|
||||
~ * This program is distributed in the hope that it will be useful,
|
||||
~ * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ * GNU General Public License for more details.
|
||||
~ *
|
||||
~ * You should have received a copy of the GNU General Public License
|
||||
~ * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<manifest package="com.shabinder.common.core_components" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
</manifest>
|
@ -14,7 +14,7 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.CONNECTIVITY_SERVICE
|
||||
@ -24,6 +24,7 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.shabinder.common.core_components.utils.isInternetAccessible
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
@ -0,0 +1,71 @@
|
||||
package com.shabinder.common.core_components.analytics
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import ly.count.android.sdk.Countly
|
||||
import ly.count.android.sdk.CountlyConfig
|
||||
import ly.count.android.sdk.DeviceId
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal class AndroidAnalyticsManager(private val mainActivity: Activity) : AnalyticsManager {
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
Countly.sharedInstance().init(
|
||||
CountlyConfig(
|
||||
mainActivity.applicationContext as Application,
|
||||
COUNTLY_CONFIG.APP_KEY,
|
||||
COUNTLY_CONFIG.SERVER_URL
|
||||
).apply {
|
||||
setIdMode(DeviceId.Type.OPEN_UDID)
|
||||
setViewTracking(true)
|
||||
enableCrashReporting()
|
||||
setLoggingEnabled(false)
|
||||
setRecordAllThreadsWithCrash()
|
||||
setRequiresConsent(true)
|
||||
setShouldIgnoreAppCrawlers(true)
|
||||
setEventQueueSizeToSend(5)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
Countly.sharedInstance().onStart(mainActivity)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Countly.sharedInstance().onStop()
|
||||
}
|
||||
|
||||
override fun giveConsent() {
|
||||
Countly.sharedInstance().consent().giveConsentAll()
|
||||
}
|
||||
|
||||
override fun isTracking(): Boolean = Countly.sharedInstance().consent().getConsent(Countly.CountlyFeatureNames.events)
|
||||
|
||||
override fun revokeConsent() {
|
||||
Countly.sharedInstance().consent().removeConsentAll()
|
||||
}
|
||||
|
||||
override fun sendView(name: String, extras: MutableMap<String, Any>) {
|
||||
Countly.sharedInstance().views().recordView(name, extras)
|
||||
}
|
||||
|
||||
override fun sendEvent(eventName: String, extras: MutableMap<String, Any>) {
|
||||
Countly.sharedInstance().events().recordEvent(eventName, extras)
|
||||
}
|
||||
|
||||
override fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any>) {
|
||||
Countly.sharedInstance().crashes().recordUnhandledException(error, extras)
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun analyticsModule() = module {
|
||||
factory { (mainActivity: Activity) ->
|
||||
AndroidAnalyticsManager(mainActivity)
|
||||
} bind AnalyticsManager::class
|
||||
}
|
@ -0,0 +1,232 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.core_components.file_manager
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Environment
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.mpatric.mp3agic.InvalidDataException
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.shabinder.common.core_components.media_converter.MediaConverter
|
||||
import com.shabinder.common.core_components.media_converter.removeAllTags
|
||||
import com.shabinder.common.core_components.media_converter.setId3v1Tags
|
||||
import com.shabinder.common.core_components.media_converter.setId3v2TagsAndSaveFile
|
||||
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.getMemoryEfficientBitmap
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.dispatcherIO
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.failure
|
||||
import com.shabinder.common.models.event.coroutines.map
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.database.Database
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
internal actual fun fileManagerModule() = module {
|
||||
single { AndroidFileManager(get(), get(), get(), get()) } bind FileManager::class
|
||||
}
|
||||
|
||||
/*
|
||||
* Ignore Deprecation
|
||||
* `Deprecation is only a Suggestion P->`
|
||||
* */
|
||||
@Suppress("DEPRECATION")
|
||||
class AndroidFileManager(
|
||||
override val logger: Kermit,
|
||||
override val preferenceManager: PreferenceManager,
|
||||
override val mediaConverter: MediaConverter,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase
|
||||
) : FileManager {
|
||||
@Suppress("DEPRECATION")
|
||||
private val defaultBaseDir =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString()
|
||||
|
||||
override fun fileSeparator(): String = File.separator
|
||||
|
||||
override fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
|
||||
|
||||
// fun call in order to always access Updated Value
|
||||
override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
|
||||
File.separator + "SpotiFlyer" + File.separator
|
||||
|
||||
override fun isPresent(path: String): Boolean = File(path).exists()
|
||||
|
||||
override fun createDirectory(dirPath: String) {
|
||||
val yourAppDir = File(dirPath)
|
||||
|
||||
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
|
||||
if (yourAppDir.mkdirs()) {
|
||||
logger.i { "$dirPath created" }
|
||||
} else {
|
||||
logger.e { "Unable to create Dir: $dirPath!" }
|
||||
}
|
||||
} else {
|
||||
logger.i { "$dirPath already exists" }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
override suspend fun clearCache(): Unit = withContext(dispatcherIO) {
|
||||
File(imageCacheDir()).deleteRecursively()
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
override suspend fun saveFileWithMetadata(
|
||||
mp3ByteArray: ByteArray,
|
||||
trackDetails: TrackDetails,
|
||||
postProcess: (track: TrackDetails) -> Unit
|
||||
) = withContext(dispatcherIO) {
|
||||
val songFile = File(trackDetails.outputFilePath)
|
||||
try {
|
||||
/*
|
||||
* Check , if Fetch was Used, File is saved Already, else write byteArray we Received
|
||||
* */
|
||||
if (!songFile.exists()) {
|
||||
/*Make intermediate Dirs if they don't exist yet*/
|
||||
songFile.parentFile?.mkdirs()
|
||||
}
|
||||
// Write Bytes to Media File
|
||||
songFile.writeBytes(mp3ByteArray)
|
||||
|
||||
try {
|
||||
// Add Mp3 Tags and Add to Library
|
||||
Mp3File(File(songFile.absolutePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(songFile.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
// Media File Isn't MP3 lets Convert It first
|
||||
if (e is InvalidDataException) {
|
||||
val convertedFilePath =
|
||||
songFile.absolutePath.substringBeforeLast('.') + ".temp.mp3"
|
||||
|
||||
val conversionResult = mediaConverter.convertAudioFile(
|
||||
inputFilePath = songFile.absolutePath,
|
||||
outputFilePath = convertedFilePath,
|
||||
trackDetails.audioQuality
|
||||
)
|
||||
|
||||
conversionResult.map { outputFilePath ->
|
||||
Mp3File(File(outputFilePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath)
|
||||
|
||||
addToLibrary(trackDetails.outputFilePath)
|
||||
}.fold(
|
||||
success = {},
|
||||
failure = {
|
||||
throw it
|
||||
}
|
||||
)
|
||||
File(convertedFilePath).delete()
|
||||
} else throw e
|
||||
}
|
||||
SuspendableEvent.success(trackDetails.outputFilePath)
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
if (songFile.exists()) songFile.delete()
|
||||
logger.e { "${songFile.absolutePath} could not be created" }
|
||||
SuspendableEvent.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addToLibrary(path: String) = methods.value.platformActions.addToLibrary(path)
|
||||
|
||||
override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture =
|
||||
withContext(dispatcherIO) {
|
||||
val cachePath = imageCacheDir() + getNameURL(url)
|
||||
Picture(
|
||||
image = (loadCachedImage(cachePath, reqWidth, reqHeight) ?: freshImage(
|
||||
url,
|
||||
reqWidth,
|
||||
reqHeight
|
||||
))?.asImageBitmap()
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadCachedImage(cachePath: String, reqWidth: Int, reqHeight: Int): Bitmap? {
|
||||
return try {
|
||||
getMemoryEfficientBitmap(cachePath, reqWidth, reqHeight)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
|
||||
try {
|
||||
FileOutputStream(path).use { out ->
|
||||
(image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): Bitmap? =
|
||||
withContext(dispatcherIO) {
|
||||
try {
|
||||
val source = URL(url)
|
||||
val connection: HttpURLConnection = source.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 5000
|
||||
connection.connect()
|
||||
|
||||
val input: ByteArray = connection.inputStream.readBytes()
|
||||
|
||||
// Get Memory Efficient Bitmap
|
||||
val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight)
|
||||
|
||||
parallelExecutor.executeSuspending {
|
||||
// Decode and Cache Full Sized Image in Background
|
||||
cacheImage(
|
||||
BitmapFactory.decodeByteArray(input, 0, input.size),
|
||||
imageCacheDir() + getNameURL(url)
|
||||
)
|
||||
}
|
||||
bitmap // return Memory Efficient Bitmap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Parallel Executor with 2 concurrent operation at a time.
|
||||
* - We will use this to queue up operations and decode Full Sized Images
|
||||
* - Will Decode Only a small set of images at a time , to avoid going into `Out of Memory`
|
||||
* */
|
||||
private val parallelExecutor = ParallelExecutor(Dispatchers.IO, 2)
|
||||
|
||||
override val db: Database? = spotiFlyerDatabase.instance
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
package com.shabinder.common.core_components.media_converter
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import kotlinx.coroutines.delay
|
||||
import nl.bravobit.ffmpeg.ExecuteBinaryResponseHandler
|
||||
import nl.bravobit.ffmpeg.FFmpeg
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
||||
class AndroidMediaConverter(private val appContext: Context) : MediaConverter() {
|
||||
|
||||
override suspend fun convertAudioFile(
|
||||
inputFilePath: String,
|
||||
outputFilePath: String,
|
||||
audioQuality: AudioQuality,
|
||||
progressCallbacks: (Long) -> Unit,
|
||||
) = executeSafelyInPool {
|
||||
var progressing = true
|
||||
var error = ""
|
||||
var timeout = 600_000L * 2 // 20 min
|
||||
val progressDelayCheck = 500L
|
||||
// 192 is Default
|
||||
val audioBitrate =
|
||||
if (audioQuality == AudioQuality.UNKNOWN) 192 else audioQuality.kbps.toIntOrNull()
|
||||
?: 192
|
||||
FFmpeg.getInstance(appContext).execute(
|
||||
arrayOf(
|
||||
"-i",
|
||||
inputFilePath,
|
||||
"-y", /*"-acodec", "libmp3lame",*/
|
||||
"-b:a",
|
||||
"${audioBitrate}k",
|
||||
"-vn",
|
||||
outputFilePath
|
||||
), object : ExecuteBinaryResponseHandler() {
|
||||
override fun onSuccess(message: String?) {
|
||||
//Log.d("FFmpeg Command", "Success $message")
|
||||
progressing = false
|
||||
}
|
||||
|
||||
override fun onProgress(message: String?) {
|
||||
super.onProgress(message)
|
||||
Log.d("FFmpeg Progress", "Progress $message --- $inputFilePath")
|
||||
}
|
||||
|
||||
override fun onFailure(message: String?) {
|
||||
error = "Failed: $message $inputFilePath"
|
||||
error += "FFmpeg Support" + FFmpeg.getInstance(appContext).isSupported.toString()
|
||||
Log.d("FFmpeg Error", error)
|
||||
progressing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
while (progressing) {
|
||||
if (timeout < 0) throw SpotiFlyerException.MP3ConversionFailed("$error Conversion Timeout for $inputFilePath")
|
||||
delay(progressDelayCheck)
|
||||
timeout -= progressDelayCheck
|
||||
}
|
||||
if(error.isNotBlank()) throw SpotiFlyerException.MP3ConversionFailed(error)
|
||||
// Return output file path after successful conversion
|
||||
outputFilePath
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun mediaConverterModule() = module {
|
||||
single { AndroidMediaConverter(get()) } bind MediaConverter::class
|
||||
}
|
@ -14,12 +14,13 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components.media_converter
|
||||
|
||||
import android.util.Log
|
||||
import com.mpatric.mp3agic.ID3v1Tag
|
||||
import com.mpatric.mp3agic.ID3v24Tag
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.shabinder.common.core_components.file_manager.downloadFile
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import kotlinx.coroutines.flow.collect
|
||||
@ -48,7 +49,7 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
|
||||
val id3v2Tag = ID3v24Tag().apply {
|
||||
albumArtist = track.albumArtists.joinToString(", ")
|
||||
artist = track.artists.joinToString(", ")
|
||||
@ -71,7 +72,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
fis.close()
|
||||
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
|
||||
this.id3v2Tag = id3v2Tag
|
||||
saveFile(track.outputFilePath)
|
||||
saveFile(outputFilePath ?: track.outputFilePath)
|
||||
} catch (e: java.io.FileNotFoundException) {
|
||||
Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}")
|
||||
try {
|
||||
@ -83,7 +84,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
is DownloadResult.Success -> {
|
||||
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
|
||||
this.id3v2Tag = id3v2Tag
|
||||
saveFile(track.outputFilePath)
|
||||
saveFile(outputFilePath ?: track.outputFilePath)
|
||||
}
|
||||
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
|
||||
}
|
||||
@ -96,9 +97,11 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
}
|
||||
|
||||
fun Mp3File.saveFile(filePath: String) {
|
||||
save(filePath.substringBeforeLast('.') + ".new.mp3")
|
||||
val m4aFile = File(filePath)
|
||||
m4aFile.delete()
|
||||
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
|
||||
save(filePath.substringBeforeLast('.') + ".tagged.mp3")
|
||||
|
||||
val oldFile = File(filePath)
|
||||
oldFile.delete()
|
||||
|
||||
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
|
||||
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
|
||||
}
|
@ -20,9 +20,6 @@ import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
actual data class Picture(
|
||||
var image: ImageBitmap?
|
||||
)
|
||||
fun getMemoryEfficientBitmap(
|
||||
input: ByteArray,
|
||||
reqWidth: Int,
|
@ -0,0 +1,7 @@
|
||||
package com.shabinder.common.core_components.picture
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
actual data class Picture(
|
||||
var image: ImageBitmap?
|
||||
)
|
@ -0,0 +1,25 @@
|
||||
package com.shabinder.common.core_components
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.core_components.analytics.analyticsModule
|
||||
import com.shabinder.common.core_components.file_manager.fileManagerModule
|
||||
import com.shabinder.common.core_components.media_converter.mediaConverterModule
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.core_components.utils.createHttpClient
|
||||
import com.shabinder.common.database.getLogger
|
||||
import org.koin.dsl.module
|
||||
|
||||
fun coreComponentModules(enableLogging: Boolean) = listOf(
|
||||
commonModule(enableLogging),
|
||||
analyticsModule(),
|
||||
fileManagerModule(),
|
||||
mediaConverterModule()
|
||||
)
|
||||
|
||||
private fun commonModule(enableLogging: Boolean) = module {
|
||||
single { createHttpClient(enableLogging) }
|
||||
single { Settings() }
|
||||
single { Kermit(getLogger()) }
|
||||
single { PreferenceManager(get()) }
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package com.shabinder.common.core_components.analytics
|
||||
|
||||
import org.koin.core.module.Module
|
||||
|
||||
interface AnalyticsManager {
|
||||
fun init()
|
||||
fun onStart()
|
||||
fun onStop()
|
||||
fun giveConsent()
|
||||
fun isTracking(): Boolean
|
||||
fun revokeConsent()
|
||||
fun sendView(name: String, extras: MutableMap<String, Any> = mutableMapOf())
|
||||
fun sendEvent(eventName: String, extras: MutableMap<String, Any> = mutableMapOf())
|
||||
fun track(event: AnalyticsAction) = event.track(this)
|
||||
fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any> = mutableMapOf())
|
||||
|
||||
companion object {
|
||||
abstract class AnalyticsAction {
|
||||
abstract fun track(analyticsManager: AnalyticsManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ClassName", "SpellCheckingInspection")
|
||||
object COUNTLY_CONFIG {
|
||||
const val APP_KEY = "27820f304468cc651ef47d787f0cb5fe11c577df"
|
||||
const val SERVER_URL = "https://counlty.shabinder.in"
|
||||
}
|
||||
|
||||
internal expect fun analyticsModule(): Module
|
@ -0,0 +1,9 @@
|
||||
package com.shabinder.common.core_components.analytics
|
||||
|
||||
sealed class AnalyticsEvent(private val eventName: String, private val extras: MutableMap<String, Any> = mutableMapOf()): AnalyticsManager.Companion.AnalyticsAction() {
|
||||
|
||||
override fun track(analyticsManager: AnalyticsManager) = analyticsManager.sendEvent(eventName,extras)
|
||||
|
||||
object AppLaunch: AnalyticsEvent("app_launch")
|
||||
object DonationDialogOpen: AnalyticsEvent("donation_dialog_open")
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package com.shabinder.common.core_components.analytics
|
||||
|
||||
import com.shabinder.common.core_components.analytics.AnalyticsManager.Companion.AnalyticsAction
|
||||
|
||||
sealed class AnalyticsView(private val viewName: String, private val extras: MutableMap<String, Any> = mutableMapOf()) : AnalyticsAction() {
|
||||
override fun track(analyticsManager: AnalyticsManager) = analyticsManager.sendView(viewName,extras)
|
||||
|
||||
object HomeScreen: AnalyticsView("home_screen")
|
||||
object ListScreen: AnalyticsView("list_screen")
|
||||
}
|
@ -14,44 +14,65 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components.file_manager
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.utils.removeIllegalChars
|
||||
import com.shabinder.common.core_components.media_converter.MediaConverter
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.core_components.utils.createHttpClient
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.utils.removeIllegalChars
|
||||
import com.shabinder.database.Database
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.koin.core.module.Module
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
expect class Dir(
|
||||
logger: Kermit,
|
||||
preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
internal expect fun fileManagerModule(): Module
|
||||
|
||||
interface FileManager {
|
||||
|
||||
val logger: Kermit
|
||||
val preferenceManager: PreferenceManager
|
||||
val mediaConverter: MediaConverter
|
||||
val db: Database?
|
||||
|
||||
fun isPresent(path: String): Boolean
|
||||
|
||||
fun fileSeparator(): String
|
||||
|
||||
fun defaultDir(): String
|
||||
|
||||
fun imageCacheDir(): String
|
||||
|
||||
fun createDirectory(dirPath: String)
|
||||
|
||||
suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage
|
||||
|
||||
suspend fun loadImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): Picture
|
||||
|
||||
suspend fun clearCache()
|
||||
suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit = {})
|
||||
|
||||
suspend fun saveFileWithMetadata(
|
||||
mp3ByteArray: ByteArray,
|
||||
trackDetails: TrackDetails,
|
||||
postProcess: (track: TrackDetails) -> Unit = {}
|
||||
): SuspendableEvent<String, Throwable>
|
||||
|
||||
fun addToLibrary(path: String)
|
||||
}
|
||||
|
||||
/*
|
||||
* Call this function at startup!
|
||||
* */
|
||||
fun Dir.createDirectories() {
|
||||
fun FileManager.createDirectories() {
|
||||
try {
|
||||
createDirectory(defaultDir())
|
||||
createDirectory(imageCacheDir())
|
||||
@ -59,13 +80,21 @@ fun Dir.createDirectories() {
|
||||
createDirectory(defaultDir() + "Albums/")
|
||||
createDirectory(defaultDir() + "Playlists/")
|
||||
createDirectory(defaultDir() + "YT_Downloads/")
|
||||
} catch (e: Exception) {}
|
||||
} catch (ignored: Exception) {}
|
||||
}
|
||||
|
||||
fun Dir.finalOutputDir(itemName: String, type: String, subFolder: String, defaultDir: String, extension: String = ".mp3"): String =
|
||||
fun FileManager.finalOutputDir(
|
||||
itemName: String,
|
||||
type: String,
|
||||
subFolder: String,
|
||||
defaultDir: String,
|
||||
extension: String = ".mp3"
|
||||
): String =
|
||||
defaultDir + removeIllegalChars(type) + this.fileSeparator() +
|
||||
if (subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator() } +
|
||||
removeIllegalChars(itemName) + extension
|
||||
if (subFolder.isEmpty()) "" else {
|
||||
removeIllegalChars(subFolder) + this.fileSeparator()
|
||||
} +
|
||||
removeIllegalChars(itemName) + extension
|
||||
/*DIR Specific Operation End*/
|
||||
|
||||
fun getNameURL(url: String): String {
|
||||
@ -74,28 +103,26 @@ fun getNameURL(url: String): String {
|
||||
|
||||
suspend fun downloadFile(url: String): Flow<DownloadResult> {
|
||||
return flow {
|
||||
try {
|
||||
val client = createHttpClient()
|
||||
val response = client.get<HttpStatement>(url).execute()
|
||||
val data = ByteArray(response.contentLength()!!.toInt())
|
||||
var offset = 0
|
||||
do {
|
||||
// Set Length optimally, after how many kb you want a progress update, now it 0.25mb
|
||||
val currentRead = response.content.readAvailable(data, offset, 2_50_000)
|
||||
offset += currentRead
|
||||
val progress = (offset * 100f / data.size).roundToInt()
|
||||
emit(DownloadResult.Progress(progress))
|
||||
} while (currentRead > 0)
|
||||
if (response.status.isSuccess()) {
|
||||
emit(DownloadResult.Success(data))
|
||||
} else {
|
||||
emit(DownloadResult.Error("File not downloaded"))
|
||||
}
|
||||
client.close()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emit(DownloadResult.Error(e.message ?: "File not downloaded"))
|
||||
val client = createHttpClient()
|
||||
val response = client.get<HttpStatement>(url).execute()
|
||||
val data = ByteArray(response.contentLength()!!.toInt())
|
||||
var offset = 0
|
||||
do {
|
||||
// Set Length optimally, after how many kb you want a progress update, now it 0.25mb
|
||||
val currentRead = response.content.readAvailable(data, offset, 2_50_000)
|
||||
offset += currentRead
|
||||
val progress = (offset * 100f / data.size).roundToInt()
|
||||
emit(DownloadResult.Progress(progress))
|
||||
} while (currentRead > 0)
|
||||
if (response.status.isSuccess()) {
|
||||
emit(DownloadResult.Success(data))
|
||||
} else {
|
||||
emit(DownloadResult.Error("File not downloaded"))
|
||||
}
|
||||
client.close()
|
||||
}.catch { e ->
|
||||
e.printStackTrace()
|
||||
emit(DownloadResult.Error(e.message ?: "File not downloaded"))
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,28 @@
|
||||
package com.shabinder.common.core_components.media_converter
|
||||
|
||||
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
|
||||
import com.shabinder.common.core_components.parallel_executor.ParallelProcessor
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.dispatcherDefault
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import org.koin.core.module.Module
|
||||
|
||||
abstract class MediaConverter : ParallelProcessor {
|
||||
|
||||
/*
|
||||
* Operations Pool
|
||||
* */
|
||||
override val parallelExecutor = ParallelExecutor(dispatcherDefault)
|
||||
|
||||
/*
|
||||
* By Default AudioQuality Output will be equal to Input's Quality,i.e, Denoted by AudioQuality.UNKNOWN
|
||||
* */
|
||||
abstract suspend fun convertAudioFile(
|
||||
inputFilePath: String,
|
||||
outputFilePath: String,
|
||||
audioQuality: AudioQuality = AudioQuality.UNKNOWN,
|
||||
progressCallbacks: (Long) -> Unit = {},
|
||||
): SuspendableEvent<String, Throwable>
|
||||
}
|
||||
|
||||
internal expect fun mediaConverterModule(): Module
|
@ -14,40 +14,62 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di.utils
|
||||
package com.shabinder.common.core_components.parallel_executor
|
||||
|
||||
// Dependencies:
|
||||
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt")
|
||||
// implementation("org.jetbrains.kotlinx:atomicfu:0.14.4")
|
||||
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
|
||||
|
||||
import com.shabinder.common.di.dispatcherIO
|
||||
import com.shabinder.common.models.dispatcherIO
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class ParallelExecutor(
|
||||
parentContext: CoroutineContext = dispatcherIO,
|
||||
) : Closeable {
|
||||
interface ParallelProcessor {
|
||||
|
||||
private val concurrentOperationLimit = atomic(4)
|
||||
private val coroutineContext = parentContext + Job()
|
||||
val parallelExecutor: ParallelExecutor
|
||||
|
||||
suspend fun <T> executeSafelyInPool(block: suspend () -> T): SuspendableEvent<T, Throwable> {
|
||||
return SuspendableEvent {
|
||||
parallelExecutor.executeSuspending(block)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> executeSafelyInPool(
|
||||
onComplete: suspend (result: SuspendableEvent<T, Throwable>) -> Unit = {},
|
||||
block: suspend () -> T
|
||||
): SuspendableEvent<T, Throwable> {
|
||||
return SuspendableEvent {
|
||||
parallelExecutor.executeSuspending(block)
|
||||
}.also { onComplete(it) }
|
||||
}
|
||||
|
||||
suspend fun stopAllTasks() {
|
||||
parallelExecutor.closeAndReInit()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ParallelExecutor(
|
||||
private val context: CoroutineContext = dispatcherIO,
|
||||
concurrentOperationLimit: Int = 4
|
||||
) : Closeable, CoroutineScope {
|
||||
|
||||
private var service: Job = SupervisorJob()
|
||||
override val coroutineContext get() = context + service
|
||||
private var isClosed = atomic(false)
|
||||
private val killQueue = Channel<Unit>(Channel.UNLIMITED)
|
||||
private val operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
|
||||
private var killQueue = Channel<Unit>(Channel.UNLIMITED)
|
||||
private var operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
|
||||
private var concurrentOperationLimit = atomic(concurrentOperationLimit)
|
||||
|
||||
|
||||
init {
|
||||
startOrStopProcessors(expectedCount = concurrentOperationLimit.value, actualCount = 0)
|
||||
startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
@ -58,9 +80,29 @@ class ParallelExecutor(
|
||||
|
||||
killQueue.close(cause)
|
||||
operationQueue.close(cause)
|
||||
service.cancel(cause)
|
||||
coroutineContext.cancel(cause)
|
||||
}
|
||||
|
||||
fun reviveIfClosed() {
|
||||
if (!service.isActive) {
|
||||
closeAndReInit()
|
||||
}
|
||||
}
|
||||
|
||||
fun closeAndReInit(newConcurrentOperationLimit: Int = 4) {
|
||||
// Close Everything
|
||||
close()
|
||||
|
||||
// ReInit everything
|
||||
service = SupervisorJob()
|
||||
isClosed = atomic(false)
|
||||
killQueue = Channel(Channel.UNLIMITED)
|
||||
operationQueue = Channel(Channel.RENDEZVOUS)
|
||||
concurrentOperationLimit = atomic(newConcurrentOperationLimit)
|
||||
startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchProcessor() = launch {
|
||||
while (true) {
|
||||
val operation = select<Operation<*>?> {
|
||||
@ -72,7 +114,7 @@ class ParallelExecutor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <Result> execute(block: suspend () -> Result): Result =
|
||||
suspend fun <Result> executeSuspending(block: suspend () -> Result): Result =
|
||||
withContext(coroutineContext) {
|
||||
val operation = Operation(block)
|
||||
operationQueue.send(operation)
|
||||
@ -80,6 +122,15 @@ class ParallelExecutor(
|
||||
operation.result.await()
|
||||
}
|
||||
|
||||
fun <Result> execute(onComplete: (Result) -> Unit = {}, block: suspend () -> Result) {
|
||||
launch(coroutineContext) {
|
||||
val operation = Operation(block)
|
||||
operationQueue.send(operation)
|
||||
|
||||
onComplete(operation.result.await())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO This launches all coroutines in advance even if they're never needed. Find a lazy way to do this.
|
||||
fun setConcurrentOperationLimit(limit: Int) {
|
||||
require(limit >= 1) { "'limit' must be greater than zero: $limit" }
|
||||
@ -89,6 +140,7 @@ class ParallelExecutor(
|
||||
}
|
||||
|
||||
private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) {
|
||||
if (!service.isActive) service = SupervisorJob()
|
||||
if (expectedCount == actualCount)
|
||||
return
|
||||
|
||||
@ -100,9 +152,7 @@ class ParallelExecutor(
|
||||
change -= 1
|
||||
|
||||
if (change > 0)
|
||||
with(CoroutineScope(coroutineContext)) {
|
||||
repeat(change) { launchProcessor() }
|
||||
}
|
||||
repeat(change) { launchProcessor() }
|
||||
else
|
||||
repeat(-change) { killQueue.trySend(Unit).isSuccess }
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package com.shabinder.common.core_components.picture
|
||||
|
||||
expect class Picture
|
@ -1,9 +1,12 @@
|
||||
package com.shabinder.common.di.preference
|
||||
package com.shabinder.common.core_components.preference_manager
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
|
||||
class PreferenceManager(settings: Settings) : Settings by settings {
|
||||
class PreferenceManager(
|
||||
settings: Settings,
|
||||
) : Settings by settings {
|
||||
|
||||
companion object {
|
||||
const val DIR_KEY = "downloadDir"
|
||||
@ -13,9 +16,16 @@ class PreferenceManager(settings: Settings) : Settings by settings {
|
||||
const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality"
|
||||
}
|
||||
|
||||
lateinit var analyticsManager: AnalyticsManager
|
||||
|
||||
/* ANALYTICS */
|
||||
val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false
|
||||
fun toggleAnalytics(enabled: Boolean) = putBoolean(ANALYTICS_KEY, enabled)
|
||||
val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false
|
||||
fun toggleAnalytics(enabled: Boolean) {
|
||||
putBoolean(ANALYTICS_KEY, enabled)
|
||||
if (this::analyticsManager.isInitialized) {
|
||||
if (enabled) analyticsManager.giveConsent() else analyticsManager.revokeConsent()
|
||||
}
|
||||
}
|
||||
|
||||
/* DOWNLOAD DIRECTORY */
|
||||
val downloadDir get() = getStringOrNull(DIR_KEY)
|
@ -0,0 +1,57 @@
|
||||
package com.shabinder.common.core_components.utils
|
||||
|
||||
import com.shabinder.common.models.dispatcherIO
|
||||
import com.shabinder.common.utils.globalJson
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.features.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.native.concurrent.SharedImmutable
|
||||
|
||||
suspend fun isInternetAccessible(): Boolean {
|
||||
return withContext(dispatcherIO) {
|
||||
try {
|
||||
ktorHttpClient.head<String>("https://open.spotify.com/")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
|
||||
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
|
||||
install(JsonFeature) {
|
||||
serializer = KotlinxSerializer(globalJson)
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
socketTimeoutMillis = 520_000
|
||||
requestTimeoutMillis = 360_000
|
||||
connectTimeoutMillis = 360_000
|
||||
}
|
||||
// WorkAround for Freezing
|
||||
// Use httpClient.getData / httpClient.postData Extensions
|
||||
/*install(JsonFeature) {
|
||||
serializer = KotlinxSerializer(
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
)
|
||||
}*/
|
||||
if (enableNetworkLogs) {
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*Client Active Throughout App's Lifetime*/
|
||||
@SharedImmutable
|
||||
val ktorHttpClient = HttpClient {}
|
@ -1,4 +1,4 @@
|
||||
package com.shabinder.common.di.utils
|
||||
package com.shabinder.common.core_components.utils
|
||||
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.decompose.value.ValueObserver
|
@ -0,0 +1,82 @@
|
||||
package com.shabinder.common.core_components.analytics
|
||||
|
||||
import com.shabinder.common.core_components.file_manager.FileManager
|
||||
import ly.count.sdk.java.Config
|
||||
import ly.count.sdk.java.Config.DeviceIdStrategy
|
||||
import ly.count.sdk.java.Config.Feature
|
||||
import ly.count.sdk.java.ConfigCore.LoggingLevel
|
||||
import ly.count.sdk.java.Countly
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import java.io.File
|
||||
|
||||
internal class DesktopAnalyticsManager(
|
||||
private val fileManager: FileManager
|
||||
) : AnalyticsManager {
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
|
||||
val config: Config = Config(COUNTLY_CONFIG.SERVER_URL, COUNTLY_CONFIG.APP_KEY).apply {
|
||||
eventsBufferSize = 2
|
||||
loggingLevel = LoggingLevel.DEBUG
|
||||
setDeviceIdStrategy(DeviceIdStrategy.UUID)
|
||||
enableFeatures(*featuresSet)
|
||||
setRequiresConsent(true)
|
||||
}
|
||||
|
||||
Countly.init(File(fileManager.defaultDir()), config)
|
||||
|
||||
Countly.session().begin();
|
||||
}
|
||||
|
||||
override fun giveConsent() {
|
||||
Countly.onConsent(*featuresSet)
|
||||
}
|
||||
|
||||
override fun isTracking(): Boolean = Countly.isTracking(Feature.Events)
|
||||
|
||||
override fun revokeConsent() {
|
||||
Countly.onConsentRemoval(*featuresSet)
|
||||
}
|
||||
|
||||
override fun sendView(name: String, extras: MutableMap<String, Any>) {
|
||||
Countly.api().view(name)
|
||||
}
|
||||
|
||||
override fun sendEvent(eventName: String, extras: MutableMap<String, Any>) {
|
||||
Countly.api().event(eventName)
|
||||
.setSegmentation(extras.filterValues { it is String } as? MutableMap<String, String> ?: emptyMap()).record()
|
||||
}
|
||||
|
||||
override fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any>) {
|
||||
Countly.api().addCrashReport(
|
||||
error,
|
||||
extras.getOrDefault("fatal", true) as Boolean,
|
||||
error.javaClass.simpleName,
|
||||
extras.filterValues { it is String } as? MutableMap<String, String> ?: emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val featuresSet = arrayOf(
|
||||
Feature.Events,
|
||||
Feature.Sessions,
|
||||
Feature.CrashReporting,
|
||||
Feature.Views,
|
||||
Feature.UserProfiles,
|
||||
Feature.Location,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStart() {}
|
||||
|
||||
override fun onStop() {}
|
||||
}
|
||||
|
||||
actual fun analyticsModule() = module {
|
||||
single { DesktopAnalyticsManager(get()) } bind AnalyticsManager::class
|
||||
}
|
@ -14,21 +14,35 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components.file_manager
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.github.kokorin.jaffree.JaffreeException
|
||||
import com.mpatric.mp3agic.InvalidDataException
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.shabinder.common.core_components.media_converter.MediaConverter
|
||||
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.core_components.removeAllTags
|
||||
import com.shabinder.common.core_components.setId3v1Tags
|
||||
import com.shabinder.common.core_components.setId3v2TagsAndSaveFile
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.dispatcherIO
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.failure
|
||||
import com.shabinder.common.models.event.coroutines.map
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.database.Database
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.jetbrains.skija.Image
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
@ -38,33 +52,45 @@ import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
actual class Dir actual constructor(
|
||||
private val logger: Kermit,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
internal actual fun fileManagerModule() = module {
|
||||
single { DesktopFileManager(get(), get(), get(), get()) } bind FileManager::class
|
||||
}
|
||||
|
||||
val DownloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = MutableSharedFlow(1)
|
||||
|
||||
// Scope Allowing 4 Parallel Downloads
|
||||
val DownloadScope = ParallelExecutor(Dispatchers.IO)
|
||||
|
||||
class DesktopFileManager(
|
||||
override val logger: Kermit,
|
||||
override val preferenceManager: PreferenceManager,
|
||||
override val mediaConverter: MediaConverter,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
) : FileManager {
|
||||
|
||||
init {
|
||||
createDirectories()
|
||||
}
|
||||
|
||||
actual fun fileSeparator(): String = File.separator
|
||||
override fun fileSeparator(): String = File.separator
|
||||
|
||||
actual fun imageCacheDir(): String = System.getProperty("user.home") +
|
||||
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
|
||||
override fun imageCacheDir(): String = System.getProperty("user.home") +
|
||||
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
|
||||
|
||||
private val defaultBaseDir = System.getProperty("user.home")
|
||||
|
||||
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
|
||||
"SpotiFlyer" + fileSeparator()
|
||||
override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
|
||||
"SpotiFlyer" + fileSeparator()
|
||||
|
||||
actual fun isPresent(path: String): Boolean = File(path).exists()
|
||||
override fun isPresent(path: String): Boolean = File(path).exists()
|
||||
|
||||
actual fun createDirectory(dirPath: String) {
|
||||
override fun createDirectory(dirPath: String) {
|
||||
val yourAppDir = File(dirPath)
|
||||
|
||||
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
|
||||
if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else {
|
||||
if (yourAppDir.mkdirs()) {
|
||||
logger.i { "$dirPath created" }
|
||||
} else {
|
||||
logger.e { "Unable to create Dir: $dirPath!" }
|
||||
}
|
||||
} else {
|
||||
@ -72,11 +98,11 @@ actual class Dir actual constructor(
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun clearCache() {
|
||||
override suspend fun clearCache() {
|
||||
File(imageCacheDir()).deleteRecursively()
|
||||
}
|
||||
|
||||
actual suspend fun cacheImage(image: Any, path: String) {
|
||||
override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
|
||||
try {
|
||||
(image as? BufferedImage)?.let {
|
||||
ImageIO.write(it, "jpeg", File(path))
|
||||
@ -87,11 +113,11 @@ actual class Dir actual constructor(
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
actual suspend fun saveFileWithMetadata(
|
||||
override suspend fun saveFileWithMetadata(
|
||||
mp3ByteArray: ByteArray,
|
||||
trackDetails: TrackDetails,
|
||||
postProcess: (track: TrackDetails) -> Unit
|
||||
) {
|
||||
) = withContext(dispatcherIO) {
|
||||
val songFile = File(trackDetails.outputFilePath)
|
||||
try {
|
||||
/*
|
||||
@ -103,61 +129,52 @@ actual class Dir actual constructor(
|
||||
}
|
||||
|
||||
if (mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray)
|
||||
try {
|
||||
// Add Mp3 Tags and Add to Library
|
||||
Mp3File(File(songFile.absolutePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(songFile.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
// Media File Isn't MP3 lets Convert It first
|
||||
if (e is InvalidDataException) {
|
||||
val convertedFilePath = songFile.absolutePath.substringBeforeLast('.') + ".temp.mp3"
|
||||
|
||||
when (trackDetails.outputFilePath.substringAfterLast('.')) {
|
||||
".mp3" -> {
|
||||
Mp3File(File(songFile.absolutePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(songFile.absolutePath)
|
||||
}
|
||||
".m4a" -> {
|
||||
/*FFmpeg.executeAsync(
|
||||
"-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}"
|
||||
){ _, returnCode ->
|
||||
when (returnCode) {
|
||||
Config.RETURN_CODE_SUCCESS -> {
|
||||
//FFMPEG task Completed
|
||||
logger.d{ "Async command execution completed successfully." }
|
||||
scope.launch {
|
||||
Mp3File(File(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3")
|
||||
}
|
||||
}
|
||||
Config.RETURN_CODE_CANCEL -> {
|
||||
logger.d{"Async command execution cancelled by user."}
|
||||
}
|
||||
else -> {
|
||||
logger.d { "Async command execution failed with rc=$returnCode" }
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
else -> {
|
||||
try {
|
||||
Mp3File(File(songFile.absolutePath))
|
||||
val conversionResult = mediaConverter.convertAudioFile(
|
||||
inputFilePath = songFile.absolutePath,
|
||||
outputFilePath = convertedFilePath,
|
||||
trackDetails.audioQuality
|
||||
)
|
||||
|
||||
conversionResult.map { outputFilePath ->
|
||||
Mp3File(File(outputFilePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(songFile.absolutePath)
|
||||
} catch (e: Exception) { e.printStackTrace() }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show()
|
||||
.setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath)
|
||||
|
||||
addToLibrary(trackDetails.outputFilePath)
|
||||
}.fold(
|
||||
success = {},
|
||||
failure = {
|
||||
throw it
|
||||
}
|
||||
)
|
||||
File(convertedFilePath).delete()
|
||||
} else throw e
|
||||
}
|
||||
SuspendableEvent.success(trackDetails.outputFilePath)
|
||||
} catch (e: Throwable) {
|
||||
if(e is JaffreeException) methods.value.showPopUpMessage("No FFmpeg found at path.")
|
||||
if (songFile.exists()) songFile.delete()
|
||||
logger.e { "${songFile.absolutePath} could not be created" }
|
||||
SuspendableEvent.error(e)
|
||||
}
|
||||
}
|
||||
actual fun addToLibrary(path: String) {}
|
||||
|
||||
actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
|
||||
override fun addToLibrary(path: String) {}
|
||||
|
||||
override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
|
||||
val cachePath = imageCacheDir() + getNameURL(url)
|
||||
var picture: ImageBitmap? = loadCachedImage(cachePath, reqWidth, reqHeight)
|
||||
if (picture == null) picture = freshImage(url, reqWidth, reqHeight)
|
||||
@ -173,6 +190,7 @@ actual class Dir actual constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
@ -198,7 +216,7 @@ actual class Dir actual constructor(
|
||||
}
|
||||
}
|
||||
|
||||
actual val db: Database? = spotiFlyerDatabase.instance
|
||||
override val db: Database? = spotiFlyerDatabase.instance
|
||||
}
|
||||
|
||||
fun BufferedImage.toImageBitmap() = Image.makeFromEncoded(
|
@ -0,0 +1,43 @@
|
||||
package com.shabinder.common.core_components.media_converter
|
||||
|
||||
import com.github.kokorin.jaffree.ffmpeg.FFmpeg
|
||||
import com.github.kokorin.jaffree.ffmpeg.UrlInput
|
||||
import com.github.kokorin.jaffree.ffmpeg.UrlOutput
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import kotlin.io.path.Path
|
||||
|
||||
class DesktopMediaConverter : MediaConverter() {
|
||||
|
||||
override suspend fun convertAudioFile(
|
||||
inputFilePath: String,
|
||||
outputFilePath: String,
|
||||
audioQuality: AudioQuality,
|
||||
progressCallbacks: (Long) -> Unit,
|
||||
) = executeSafelyInPool {
|
||||
val audioBitrate =
|
||||
if (audioQuality == AudioQuality.UNKNOWN) 192 else audioQuality.kbps.toIntOrNull()
|
||||
?: 192
|
||||
FFmpeg.atPath().run {
|
||||
addInput(UrlInput.fromUrl(inputFilePath))
|
||||
setOverwriteOutput(true)
|
||||
if (audioQuality != AudioQuality.UNKNOWN) {
|
||||
addArguments("-b:a", "${audioBitrate}k")
|
||||
}
|
||||
addArguments("-acodec", "libmp3lame")
|
||||
addArgument("-vn")
|
||||
addOutput(UrlOutput.toUrl(outputFilePath))
|
||||
setProgressListener {
|
||||
progressCallbacks(it.timeMillis)
|
||||
}
|
||||
execute()
|
||||
|
||||
return@run outputFilePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun mediaConverterModule() = module {
|
||||
single { DesktopMediaConverter() } bind MediaConverter::class
|
||||
}
|
@ -14,11 +14,12 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components
|
||||
|
||||
import com.mpatric.mp3agic.ID3v1Tag
|
||||
import com.mpatric.mp3agic.ID3v24Tag
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.shabinder.common.core_components.file_manager.downloadFile
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import kotlinx.coroutines.flow.collect
|
||||
@ -47,16 +48,22 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
|
||||
val id3v2Tag = ID3v24Tag().apply {
|
||||
artist = track.artists.joinToString(",")
|
||||
albumArtist = track.albumArtists.joinToString(", ")
|
||||
artist = track.artists.joinToString(", ")
|
||||
title = track.title
|
||||
album = track.albumName
|
||||
year = track.year
|
||||
comment = "Genres:${track.comment}"
|
||||
lyrics = "Gonna Implement Soon"
|
||||
|
||||
genreDescription = "Genre: " + track.genre.joinToString(", ")
|
||||
comment = track.comment
|
||||
lyrics = track.lyrics ?: ""
|
||||
url = track.trackUrl
|
||||
if (track.trackNumber != null)
|
||||
this.track = track.trackNumber.toString()
|
||||
}
|
||||
try {
|
||||
val art = File(track.albumArtPath)
|
||||
@ -66,7 +73,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
fis.close()
|
||||
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
|
||||
this.id3v2Tag = id3v2Tag
|
||||
saveFile(track.outputFilePath)
|
||||
saveFile(outputFilePath ?: track.outputFilePath)
|
||||
} catch (e: java.io.FileNotFoundException) {
|
||||
try {
|
||||
// Image Still Not Downloaded!
|
||||
@ -77,21 +84,23 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
||||
is DownloadResult.Success -> {
|
||||
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
|
||||
this.id3v2Tag = id3v2Tag
|
||||
saveFile(track.outputFilePath)
|
||||
saveFile(outputFilePath ?: track.outputFilePath)
|
||||
}
|
||||
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// log("Error", "Couldn't Write Mp3 Album Art, error: ${e.stackTrace}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Mp3File.saveFile(filePath: String) {
|
||||
save(filePath.substringBeforeLast('.') + ".new.mp3")
|
||||
val m4aFile = File(filePath)
|
||||
m4aFile.delete()
|
||||
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
|
||||
save(filePath.substringBeforeLast('.') + ".tagged.mp3")
|
||||
|
||||
val oldFile = File(filePath)
|
||||
oldFile.delete()
|
||||
|
||||
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
|
||||
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package com.shabinder.common.core_components.picture
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
actual data class Picture(
|
||||
var image: ImageBitmap?
|
||||
)
|
@ -17,7 +17,7 @@
|
||||
@file:JsModule("file-saver")
|
||||
@file:JsNonModule
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components
|
||||
|
||||
import org.w3c.files.Blob
|
||||
|
@ -14,7 +14,7 @@
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
package com.shabinder.common.core_components
|
||||
|
||||
import org.khronos.webgl.ArrayBuffer
|
||||
import org.w3c.files.Blob
|
@ -0,0 +1,30 @@
|
||||
package com.shabinder.common.core_components.analytics
|
||||
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
// TODO("Not yet implemented")
|
||||
private val webAnalytics =
|
||||
object : AnalyticsManager {
|
||||
override fun init() {}
|
||||
|
||||
override fun onStart() {}
|
||||
|
||||
override fun onStop() {}
|
||||
|
||||
override fun giveConsent() {}
|
||||
|
||||
override fun isTracking(): Boolean = false
|
||||
|
||||
override fun revokeConsent() {}
|
||||
|
||||
override fun sendView(name: String, extras: MutableMap<String, Any>) {}
|
||||
|
||||
override fun sendEvent(eventName: String, extras: MutableMap<String, Any>) {}
|
||||
|
||||
override fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any>) {}
|
||||
}
|
||||
|
||||
actual fun analyticsModule() = module {
|
||||
single { webAnalytics } bind AnalyticsManager::class
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.core_components.file_manager
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.core_components.ID3Writer
|
||||
import com.shabinder.common.core_components.media_converter.MediaConverter
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.core_components.saveAs
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.corsApi
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.utils.removeIllegalChars
|
||||
import com.shabinder.database.Database
|
||||
import kotlinext.js.Object
|
||||
import kotlinext.js.js
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import org.khronos.webgl.ArrayBuffer
|
||||
import org.khronos.webgl.Int8Array
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.w3c.dom.ImageBitmap
|
||||
|
||||
|
||||
internal actual fun fileManagerModule() = module {
|
||||
single { WebFileManager(get(), get(), get(), get()) } bind FileManager::class
|
||||
}
|
||||
|
||||
class WebFileManager(
|
||||
override val logger: Kermit,
|
||||
override val preferenceManager: PreferenceManager,
|
||||
override val mediaConverter: MediaConverter,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) : FileManager {
|
||||
/*init {
|
||||
createDirectories()
|
||||
}*/
|
||||
|
||||
/*
|
||||
* TODO
|
||||
* */
|
||||
override fun fileSeparator(): String = "/"
|
||||
|
||||
override fun imageCacheDir(): String = "TODO" +
|
||||
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
|
||||
|
||||
override fun defaultDir(): String = "TODO" + fileSeparator() +
|
||||
"SpotiFlyer" + fileSeparator()
|
||||
|
||||
override fun isPresent(path: String): Boolean = false
|
||||
|
||||
override fun createDirectory(dirPath: String) {}
|
||||
|
||||
override suspend fun clearCache() {}
|
||||
|
||||
override suspend fun cacheImage(image: Any, path: String) {}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
override suspend fun saveFileWithMetadata(
|
||||
mp3ByteArray: ByteArray,
|
||||
trackDetails: TrackDetails,
|
||||
postProcess: (track: TrackDetails) -> Unit
|
||||
): SuspendableEvent<String, Throwable> {
|
||||
return SuspendableEvent {
|
||||
val writer = ID3Writer(mp3ByteArray.toArrayBuffer())
|
||||
val albumArt = downloadFile(corsApi + trackDetails.albumArtURL)
|
||||
albumArt.collect {
|
||||
when (it) {
|
||||
is DownloadResult.Success -> {
|
||||
logger.d { "Album Art Downloaded Success" }
|
||||
val albumArtObj = js {
|
||||
this["type"] = 3
|
||||
this["data"] = it.byteArray.toArrayBuffer()
|
||||
this["description"] = "Cover Art"
|
||||
}
|
||||
writeTagsAndSave(writer, albumArtObj as Object, trackDetails)
|
||||
}
|
||||
is DownloadResult.Error -> {
|
||||
logger.d { "Album Art Downloading Error" }
|
||||
writeTagsAndSave(writer, null, trackDetails)
|
||||
}
|
||||
is DownloadResult.Progress -> logger.d { "Album Art Downloading: ${it.progress}" }
|
||||
}
|
||||
}
|
||||
trackDetails.outputFilePath
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeTagsAndSave(writer: ID3Writer, albumArt: Object?, trackDetails: TrackDetails) {
|
||||
writer.apply {
|
||||
setFrame("TIT2", trackDetails.title)
|
||||
setFrame("TPE1", trackDetails.artists.toTypedArray())
|
||||
setFrame("TALB", trackDetails.albumName ?: "")
|
||||
try {
|
||||
trackDetails.year?.substring(0, 4)?.toInt()?.let { setFrame("TYER", it) }
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
setFrame("TPE2", trackDetails.artists.joinToString(","))
|
||||
setFrame("WOAS", trackDetails.source.toString())
|
||||
setFrame("TLEN", trackDetails.durationSec)
|
||||
albumArt?.let { setFrame("APIC", it) }
|
||||
}
|
||||
writer.addTag()
|
||||
allTracksStatus[trackDetails.title] = DownloadStatus.Downloaded
|
||||
DownloadProgressFlow.emit(allTracksStatus)
|
||||
saveAs(writer.getBlob(), "${removeIllegalChars(trackDetails.title)}.mp3")
|
||||
}
|
||||
|
||||
override fun addToLibrary(path: String) {}
|
||||
|
||||
override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
|
||||
return Picture(url)
|
||||
}
|
||||
|
||||
private fun loadCachedImage(cachePath: String): ImageBitmap? = null
|
||||
|
||||
private suspend fun freshImage(url: String): ImageBitmap? = null
|
||||
|
||||
override val db: Database? = spotiFlyerDatabase.instance
|
||||
}
|
||||
|
||||
fun ByteArray.toArrayBuffer(): ArrayBuffer {
|
||||
return this.unsafeCast<Int8Array>().buffer
|
||||
}
|
||||
|
||||
val DownloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = MutableSharedFlow(1)
|
||||
|
||||
// Error:https://github.com/Kotlin/kotlinx.atomicfu/issues/182
|
||||
// val DownloadScope = ParallelExecutor(Dispatchers.Default) //Download Pool of 4 parallel
|
||||
val allTracksStatus: HashMap<String, DownloadStatus> = hashMapOf()
|
@ -0,0 +1,23 @@
|
||||
package com.shabinder.common.core_components.media_converter
|
||||
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.event.Event
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
class WebMediaConverter: MediaConverter() {
|
||||
override suspend fun convertAudioFile(
|
||||
inputFilePath: String,
|
||||
outputFilePath: String,
|
||||
audioQuality: AudioQuality,
|
||||
progressCallbacks: (Long) -> Unit
|
||||
): SuspendableEvent<String, Throwable> {
|
||||
// TODO("Not yet implemented")
|
||||
return SuspendableEvent.error(NotImplementedError())
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun mediaConverterModule() = module {
|
||||
single { WebMediaConverter() } bind MediaConverter::class
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package com.shabinder.common.core_components.picture
|
||||
|
||||
actual data class Picture(
|
||||
var imageUrl: String
|
||||
)
|
@ -57,8 +57,5 @@ kotlin {
|
||||
api(Internationalization.dep)
|
||||
}
|
||||
}
|
||||
androidMain {
|
||||
dependencies {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
package com.shabinder.common.models
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
// IO-Dispatcher
|
||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
@ -1,3 +0,0 @@
|
||||
package com.shabinder.common
|
||||
|
||||
fun <T : Any?> T?.requireNotNull(): T = requireNotNull(this)
|
@ -5,7 +5,8 @@ enum class AudioQuality(val kbps: String) {
|
||||
KBPS160("160"),
|
||||
KBPS192("192"),
|
||||
KBPS256("256"),
|
||||
KBPS320("320");
|
||||
KBPS320("320"),
|
||||
UNKNOWN("-1");
|
||||
|
||||
companion object {
|
||||
fun getQuality(kbps: String): AudioQuality {
|
||||
@ -15,6 +16,7 @@ enum class AudioQuality(val kbps: String) {
|
||||
"192" -> KBPS192
|
||||
"256" -> KBPS256
|
||||
"320" -> KBPS320
|
||||
"-1" -> UNKNOWN
|
||||
else -> KBPS160 // Use 160 as baseline
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,10 @@
|
||||
package com.shabinder.common.models
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
// IO-Dispatcher
|
||||
expect val dispatcherIO: CoroutineDispatcher
|
||||
|
||||
// Default-Dispatcher
|
||||
val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default
|
@ -41,9 +41,12 @@ data class TrackDetails(
|
||||
val progress: Int = 2,
|
||||
val downloadLink: String? = null,
|
||||
val downloaded: DownloadStatus = DownloadStatus.NotDownloaded,
|
||||
var audioQuality: AudioQuality = AudioQuality.KBPS192,
|
||||
var outputFilePath: String, // UriString in Android
|
||||
var videoID: String? = null,
|
||||
) : Parcelable
|
||||
) : Parcelable {
|
||||
val outputMp3Path get() = outputFilePath.substringBeforeLast(".") + ".mp3"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class DownloadStatus : Parcelable {
|
||||
|
@ -9,7 +9,7 @@ sealed class SpotiFlyerException(override val message: String) : Exception(messa
|
||||
|
||||
data class MP3ConversionFailed(
|
||||
val extraInfo: String? = null,
|
||||
override val message: String = "${Strings.mp3ConverterBusy()} \nCAUSE:$extraInfo"
|
||||
override val message: String = /*${Strings.mp3ConverterBusy()} */"CAUSE:$extraInfo"
|
||||
) : SpotiFlyerException(message)
|
||||
|
||||
data class UnknownReason(
|
||||
@ -28,13 +28,17 @@ sealed class SpotiFlyerException(override val message: String) : Exception(messa
|
||||
) : SpotiFlyerException(message)
|
||||
|
||||
data class DownloadLinkFetchFailed(
|
||||
val trackName: String,
|
||||
val jioSaavnError: Throwable,
|
||||
val ytMusicError: Throwable,
|
||||
override val message: String = "${Strings.noLinkFound()}: $trackName," +
|
||||
" \n YtMusic Error's StackTrace: ${ytMusicError.stackTraceToString()} \n " +
|
||||
" \n JioSaavn Error's StackTrace: ${jioSaavnError.stackTraceToString()} \n "
|
||||
) : SpotiFlyerException(message)
|
||||
val errorTrace: String
|
||||
) : SpotiFlyerException(errorTrace) {
|
||||
constructor(
|
||||
trackName: String,
|
||||
jioSaavnError: Throwable,
|
||||
ytMusicError: Throwable,
|
||||
errorTrace: String = "${Strings.noLinkFound()}: $trackName," +
|
||||
" \n YtMusic Error's StackTrace: ${ytMusicError.stackTraceToString()} \n " +
|
||||
" \n JioSaavn Error's StackTrace: ${jioSaavnError.stackTraceToString()} \n "
|
||||
): this(errorTrace)
|
||||
}
|
||||
|
||||
data class LinkInvalid(
|
||||
val link: String? = null,
|
||||
|
@ -155,6 +155,8 @@ sealed class SuspendableEvent<out V : Any?, out E : Throwable> : ReadOnlyPropert
|
||||
// Factory methods
|
||||
fun <E : Throwable> error(ex: E) = Failure<Nothing, E>(ex)
|
||||
|
||||
fun <V : Any> success(res: V) = Success<V, Throwable>(res)
|
||||
|
||||
inline fun <V : Any?> of(value: V?, crossinline fail: (() -> Throwable) = { Throwable() }): SuspendableEvent<V, Throwable> {
|
||||
return value?.let { Success<V, Nothing>(it) } ?: error(fail())
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.shabinder.common.models.saavn
|
||||
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
@ -43,4 +44,6 @@ data class SaavnSong @OptIn(ExperimentalSerializationApi::class) constructor(
|
||||
val vlink: String? = null,
|
||||
val year: String,
|
||||
var downloaded: DownloadStatus = DownloadStatus.NotDownloaded
|
||||
)
|
||||
) {
|
||||
val audioQuality get() = if (is320Kbps) AudioQuality.KBPS320 else AudioQuality.KBPS160
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
package com.shabinder.common.utils
|
||||
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
fun <T : Any?> T?.requireNotNull(): T = requireNotNull(this)
|
||||
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline fun buildString(track: TrackDetails, builderAction: StringBuilder.() -> Unit): String {
|
||||
contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) }
|
||||
return StringBuilder().run {
|
||||
appendLine("Find Link for ${track.title} ${if (!track.videoID.isNullOrBlank()) "-> VideoID:" + track.videoID else ""}")
|
||||
apply(builderAction)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
fun StringBuilder.appendPadded(data: Any?) {
|
||||
appendLine().append(data).appendLine()
|
||||
}
|
||||
|
||||
fun StringBuilder.appendPadded(header: Any?, data: Any?) {
|
||||
appendLine().append(header).appendLine(data).appendLine()
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package com.shabinder.common.di.utils
|
||||
package com.shabinder.common.utils
|
||||
|
||||
/*
|
||||
* JSON UTILS
|
@ -1,20 +1,4 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di.utils
|
||||
package com.shabinder.common.utils
|
||||
|
||||
import io.github.shabinder.TargetPlatforms
|
||||
import io.github.shabinder.activePlatform
|
||||
@ -22,7 +6,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlin.native.concurrent.ThreadLocal
|
||||
|
||||
@ThreadLocal
|
||||
val json by lazy {
|
||||
val globalJson by lazy {
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
@ -0,0 +1,7 @@
|
||||
package com.shabinder.common.models
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
// IO-Dispatcher
|
||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
@ -1,4 +1,5 @@
|
||||
package com.shabinder.common.models
|
||||
|
||||
|
||||
actual interface PlatformActions
|
||||
actual val StubPlatformActions = object : PlatformActions {}
|
||||
actual val StubPlatformActions = object : PlatformActions {}
|
@ -0,0 +1,6 @@
|
||||
package com.shabinder.common.models
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default
|
@ -1,3 +1,3 @@
|
||||
package com.shabinder.common.models
|
||||
|
||||
actual class NativeAtomicReference<T> actual constructor(actual var value: T)
|
||||
actual class NativeAtomicReference<T> actual constructor(actual var value: T)
|
@ -30,31 +30,8 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:database"))
|
||||
implementation("org.jetbrains.kotlinx:atomicfu:0.16.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
|
||||
api(MultiPlatformSettings.dep)
|
||||
implementation(Extras.youtubeDownloader)
|
||||
implementation(Extras.fuzzyWuzzy)
|
||||
implementation(MVIKotlin.rx)
|
||||
}
|
||||
}
|
||||
androidMain {
|
||||
dependencies {
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(Extras.mp3agic)
|
||||
// implementation(files("$rootDir/libs/mobile-ffmpeg.aar"))
|
||||
}
|
||||
}
|
||||
desktopMain {
|
||||
dependencies {
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(Extras.mp3agic)
|
||||
}
|
||||
}
|
||||
jsMain {
|
||||
dependencies {
|
||||
implementation(npm("browser-id3-writer", "4.4.0"))
|
||||
implementation(npm("file-saver", "2.0.4"))
|
||||
implementation(project(":common:providers"))
|
||||
implementation(project(":common:core-components"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,205 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Environment
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.utils.ParallelExecutor
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.database.Database
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/*
|
||||
* Ignore Deprecation
|
||||
* Deprecation is only a Suggestion P-)
|
||||
* */
|
||||
@Suppress("DEPRECATION")
|
||||
actual class Dir actual constructor(
|
||||
private val logger: Kermit,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
private val defaultBaseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString()
|
||||
|
||||
actual fun fileSeparator(): String = File.separator
|
||||
|
||||
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
|
||||
|
||||
// fun call in order to always access Updated Value
|
||||
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
|
||||
File.separator + "SpotiFlyer" + File.separator
|
||||
|
||||
actual fun isPresent(path: String): Boolean = File(path).exists()
|
||||
|
||||
actual fun createDirectory(dirPath: String) {
|
||||
val yourAppDir = File(dirPath)
|
||||
|
||||
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
|
||||
if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else {
|
||||
logger.e { "Unable to create Dir: $dirPath!" }
|
||||
}
|
||||
} else {
|
||||
logger.i { "$dirPath already exists" }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
actual suspend fun clearCache(): Unit = withContext(dispatcherIO) {
|
||||
File(imageCacheDir()).deleteRecursively()
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
actual suspend fun saveFileWithMetadata(
|
||||
mp3ByteArray: ByteArray,
|
||||
trackDetails: TrackDetails,
|
||||
postProcess: (track: TrackDetails) -> Unit
|
||||
) = withContext(dispatcherIO) {
|
||||
val songFile = File(trackDetails.outputFilePath)
|
||||
try {
|
||||
/*
|
||||
* Check , if Fetch was Used, File is saved Already, else write byteArray we Received
|
||||
* */
|
||||
if (!songFile.exists()) {
|
||||
/*Make intermediate Dirs if they don't exist yet*/
|
||||
songFile.parentFile?.mkdirs()
|
||||
}
|
||||
// Write Bytes to Media File
|
||||
songFile.writeBytes(mp3ByteArray)
|
||||
|
||||
when (trackDetails.outputFilePath.substringAfterLast('.')) {
|
||||
".mp3" -> {
|
||||
Mp3File(File(songFile.absolutePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(songFile.absolutePath)
|
||||
}
|
||||
".m4a" -> {
|
||||
/*FFmpeg.executeAsync(
|
||||
"-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}"
|
||||
){ _, returnCode ->
|
||||
when (returnCode) {
|
||||
Config.RETURN_CODE_SUCCESS -> {
|
||||
//FFMPEG task Completed
|
||||
logger.d{ "Async command execution completed successfully." }
|
||||
scope.launch {
|
||||
Mp3File(File(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3")
|
||||
}
|
||||
}
|
||||
Config.RETURN_CODE_CANCEL -> {
|
||||
logger.d{"Async command execution cancelled by user."}
|
||||
}
|
||||
else -> {
|
||||
logger.d { "Async command execution failed with rc=$returnCode" }
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
else -> {
|
||||
try {
|
||||
Mp3File(File(songFile.absolutePath))
|
||||
.removeAllTags()
|
||||
.setId3v1Tags(trackDetails)
|
||||
.setId3v2TagsAndSaveFile(trackDetails)
|
||||
addToLibrary(songFile.absolutePath)
|
||||
} catch (e: Exception) { e.printStackTrace() }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (songFile.exists()) songFile.delete()
|
||||
logger.e { "${songFile.absolutePath} could not be created" }
|
||||
}
|
||||
}
|
||||
|
||||
actual fun addToLibrary(path: String) = methods.value.platformActions.addToLibrary(path)
|
||||
|
||||
actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture = withContext(dispatcherIO) {
|
||||
val cachePath = imageCacheDir() + getNameURL(url)
|
||||
Picture(image = (loadCachedImage(cachePath, reqWidth, reqHeight) ?: freshImage(url, reqWidth, reqHeight))?.asImageBitmap())
|
||||
}
|
||||
|
||||
private fun loadCachedImage(cachePath: String, reqWidth: Int, reqHeight: Int): Bitmap? {
|
||||
return try {
|
||||
getMemoryEfficientBitmap(cachePath, reqWidth, reqHeight)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
actual suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
|
||||
try {
|
||||
FileOutputStream(path).use { out ->
|
||||
(image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): Bitmap? = withContext(dispatcherIO) {
|
||||
try {
|
||||
val source = URL(url)
|
||||
val connection: HttpURLConnection = source.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 5000
|
||||
connection.connect()
|
||||
|
||||
val input: ByteArray = connection.inputStream.readBytes()
|
||||
|
||||
// Get Memory Efficient Bitmap
|
||||
val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight)
|
||||
|
||||
parallelExecutor.execute {
|
||||
// Decode and Cache Full Sized Image in Background
|
||||
cacheImage(BitmapFactory.decodeByteArray(input, 0, input.size), imageCacheDir() + getNameURL(url))
|
||||
}
|
||||
bitmap // return Memory Efficient Bitmap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Parallel Executor with 4 concurrent operation at a time.
|
||||
* - We will use this to queue up operations and decode Full Sized Images
|
||||
* - Will Decode Only 4 at a time , to avoid going into `Out of Memory`
|
||||
* */
|
||||
private val parallelExecutor = ParallelExecutor(Dispatchers.IO)
|
||||
|
||||
actual val db: Database? = spotiFlyerDatabase.instance
|
||||
}
|
@ -16,80 +16,30 @@
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.core_components.coreComponentModules
|
||||
import com.shabinder.common.database.databaseModule
|
||||
import com.shabinder.common.database.getLogger
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.providers.providersModule
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.features.logging.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import com.shabinder.common.providers.providersModule
|
||||
import org.koin.core.KoinApplication
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.KoinAppDeclaration
|
||||
import org.koin.dsl.module
|
||||
import kotlin.native.concurrent.SharedImmutable
|
||||
import kotlin.native.concurrent.ThreadLocal
|
||||
|
||||
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
|
||||
startKoin {
|
||||
appDeclaration()
|
||||
|
||||
modules(
|
||||
commonModule(enableNetworkLogs = enableNetworkLogs),
|
||||
providersModule(),
|
||||
databaseModule()
|
||||
coreComponentModules(enableNetworkLogs),
|
||||
listOf(
|
||||
providersModule(enableNetworkLogs),
|
||||
databaseModule(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Called by IOS
|
||||
fun initKoin() = initKoin(enableNetworkLogs = false) { }
|
||||
|
||||
fun commonModule(enableNetworkLogs: Boolean) = module {
|
||||
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
|
||||
single { Dir(get(), get(), get()) }
|
||||
single { Settings() }
|
||||
single { PreferenceManager(get()) }
|
||||
single { Kermit(getLogger()) }
|
||||
single { TokenStore(get(), get()) }
|
||||
}
|
||||
|
||||
@ThreadLocal
|
||||
val globalJson = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
|
||||
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
|
||||
install(JsonFeature) {
|
||||
serializer = KotlinxSerializer(globalJson)
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
socketTimeoutMillis = 520_000
|
||||
requestTimeoutMillis = 360_000
|
||||
connectTimeoutMillis = 360_000
|
||||
}
|
||||
// WorkAround for Freezing
|
||||
// Use httpClient.getData / httpClient.postData Extensions
|
||||
/*install(JsonFeature) {
|
||||
serializer = KotlinxSerializer(
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
)
|
||||
}*/
|
||||
if (enableNetworkLogs) {
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*Client Active Throughout App's Lifetime*/
|
||||
@SharedImmutable
|
||||
val ktorHttpClient = HttpClient {}
|
||||
private fun KoinApplication.modules(vararg moduleLists: List<Module>): KoinApplication {
|
||||
return modules(moduleLists.toList().flatten())
|
||||
}
|
@ -1,156 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.database.DownloadRecordDatabaseQueries
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.providers.GaanaProvider
|
||||
import com.shabinder.common.di.providers.SaavnProvider
|
||||
import com.shabinder.common.di.providers.SpotifyProvider
|
||||
import com.shabinder.common.di.providers.YoutubeMp3
|
||||
import com.shabinder.common.di.providers.YoutubeMusic
|
||||
import com.shabinder.common.di.providers.YoutubeProvider
|
||||
import com.shabinder.common.di.providers.get
|
||||
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.PlatformQueryResult
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.flatMap
|
||||
import com.shabinder.common.models.event.coroutines.flatMapError
|
||||
import com.shabinder.common.models.event.coroutines.success
|
||||
import com.shabinder.common.models.spotify.Source
|
||||
import com.shabinder.common.requireNotNull
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FetchPlatformQueryResult(
|
||||
private val gaanaProvider: GaanaProvider,
|
||||
private val spotifyProvider: SpotifyProvider,
|
||||
private val youtubeProvider: YoutubeProvider,
|
||||
private val saavnProvider: SaavnProvider,
|
||||
private val youtubeMusic: YoutubeMusic,
|
||||
private val youtubeMp3: YoutubeMp3,
|
||||
private val audioToMp3: AudioToMp3,
|
||||
val dir: Dir,
|
||||
val preferenceManager: PreferenceManager,
|
||||
val logger: Kermit
|
||||
) {
|
||||
private val db: DownloadRecordDatabaseQueries?
|
||||
get() = dir.db?.downloadRecordDatabaseQueries
|
||||
|
||||
suspend fun authenticateSpotifyClient() = spotifyProvider.authenticateSpotifyClient()
|
||||
|
||||
suspend fun query(link: String): SuspendableEvent<PlatformQueryResult, Throwable> {
|
||||
val result = when {
|
||||
// SPOTIFY
|
||||
link.contains("spotify", true) ->
|
||||
spotifyProvider.query(link)
|
||||
|
||||
// YOUTUBE
|
||||
link.contains("youtube.com", true) || link.contains("youtu.be", true) ->
|
||||
youtubeProvider.query(link)
|
||||
|
||||
// Jio Saavn
|
||||
link.contains("saavn", true) ->
|
||||
saavnProvider.query(link)
|
||||
|
||||
// GAANA
|
||||
link.contains("gaana", true) ->
|
||||
gaanaProvider.query(link)
|
||||
|
||||
else -> {
|
||||
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link))
|
||||
}
|
||||
}
|
||||
result.success {
|
||||
addToDatabaseAsync(
|
||||
link,
|
||||
it.copy() // Send a copy in order to not to freeze Result itself
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 1) Try Finding on JioSaavn (better quality upto 320KBPS)
|
||||
// 2) If Not found try finding on Youtube Music
|
||||
suspend fun findMp3DownloadLink(
|
||||
track: TrackDetails,
|
||||
preferredQuality: AudioQuality = preferenceManager.audioQuality
|
||||
): SuspendableEvent<String, Throwable> =
|
||||
if (track.videoID != null) {
|
||||
// We Already have VideoID
|
||||
when (track.source) {
|
||||
Source.JioSaavn -> {
|
||||
saavnProvider.getSongFromID(track.videoID.requireNotNull()).flatMap { song ->
|
||||
song.media_url?.let { audioToMp3.convertToMp3(it) } ?: findMp3Link(track, preferredQuality)
|
||||
}
|
||||
}
|
||||
Source.YouTube -> {
|
||||
youtubeMp3.getMp3DownloadLink(track.videoID.requireNotNull(), preferredQuality).flatMapError {
|
||||
logger.e("Yt1sMp3 Failed") { it.message ?: "couldn't fetch link for ${track.videoID} ,trying manual extraction" }
|
||||
youtubeProvider.ytDownloader.getVideo(track.videoID!!).get()?.url?.let { m4aLink ->
|
||||
audioToMp3.convertToMp3(m4aLink)
|
||||
} ?: throw SpotiFlyerException.YoutubeLinkNotFound(track.videoID)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
/*We should never reach here for now*/
|
||||
findMp3Link(track, preferredQuality)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
findMp3Link(track, preferredQuality)
|
||||
}
|
||||
|
||||
private suspend fun findMp3Link(
|
||||
track: TrackDetails,
|
||||
preferredQuality: AudioQuality
|
||||
): SuspendableEvent<String, Throwable> {
|
||||
// Try Fetching Track from Jio Saavn
|
||||
return saavnProvider.findMp3SongDownloadURL(
|
||||
trackName = track.title,
|
||||
trackArtists = track.artists,
|
||||
preferredQuality = preferredQuality
|
||||
).flatMapError { saavnError ->
|
||||
logger.e { "Fetching From Saavn Failed: \n${saavnError.stackTraceToString()}" }
|
||||
// Saavn Failed, Lets Try Fetching Now From Youtube Music
|
||||
youtubeMusic.findMp3SongDownloadURLYT(track, preferredQuality).flatMapError { ytMusicError ->
|
||||
// If Both Failed Bubble the Exception Up with both StackTraces
|
||||
SuspendableEvent.error(
|
||||
SpotiFlyerException.DownloadLinkFetchFailed(
|
||||
trackName = track.title,
|
||||
ytMusicError = ytMusicError,
|
||||
jioSaavnError = saavnError
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) {
|
||||
GlobalScope.launch(dispatcherIO) {
|
||||
db?.add(
|
||||
result.folderType, result.title, link, result.coverUrl, result.trackList.size.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
expect class Picture
|
@ -1,16 +0,0 @@
|
||||
package com.shabinder.common.di.providers
|
||||
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||
import org.koin.dsl.module
|
||||
|
||||
fun providersModule() = module {
|
||||
single { AudioToMp3(get(), get()) }
|
||||
single { SpotifyProvider(get(), get(), get()) }
|
||||
single { GaanaProvider(get(), get(), get()) }
|
||||
single { SaavnProvider(get(), get(), get(), get()) }
|
||||
single { YoutubeProvider(get(), get(), get()) }
|
||||
single { YoutubeMp3(get(), get()) }
|
||||
single { YoutubeMusic(get(), get(), get(), get(), get()) }
|
||||
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di.providers
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.di.providers.requests.youtubeMp3.Yt1sMp3
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.corsApi
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.map
|
||||
import io.ktor.client.*
|
||||
|
||||
interface YoutubeMp3 : Yt1sMp3 {
|
||||
|
||||
companion object {
|
||||
operator fun invoke(
|
||||
client: HttpClient,
|
||||
logger: Kermit
|
||||
): YoutubeMp3 {
|
||||
return object : YoutubeMp3 {
|
||||
override val httpClient: HttpClient = client
|
||||
override val logger: Kermit = logger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMp3DownloadLink(videoID: String, quality: AudioQuality): SuspendableEvent<String, Throwable> = getLinkFromYt1sMp3(videoID, quality).map {
|
||||
corsApi + it
|
||||
}
|
||||
}
|
@ -1,125 +0,0 @@
|
||||
package com.shabinder.common.di.providers.requests.audioToMp3
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
interface AudioToMp3 {
|
||||
|
||||
val client: HttpClient
|
||||
val logger: Kermit
|
||||
|
||||
companion object {
|
||||
operator fun invoke(
|
||||
client: HttpClient,
|
||||
logger: Kermit
|
||||
): AudioToMp3 {
|
||||
return object : AudioToMp3 {
|
||||
override val client: HttpClient = client
|
||||
override val logger: Kermit = logger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun convertToMp3(
|
||||
URL: String,
|
||||
audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)),
|
||||
): SuspendableEvent<String, Throwable> = SuspendableEvent {
|
||||
// Active Host ex - https://hostveryfast.onlineconverter.com/file/send
|
||||
// Convert Job Request ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
|
||||
var (activeHost, jobLink) = convertRequest(URL, audioQuality).value
|
||||
|
||||
// (jobStatus.contains("d")) == COMPLETION
|
||||
var jobStatus: String
|
||||
var retryCount = 40 // Set it to optimal level
|
||||
|
||||
do {
|
||||
jobStatus = try {
|
||||
client.get(
|
||||
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
if (e is ClientRequestException && e.response.status.value == 404) {
|
||||
// No Need to Retry, Host/Converter is Busy
|
||||
throw SpotiFlyerException.MP3ConversionFailed(e.message)
|
||||
}
|
||||
// Try Using New Host/Converter
|
||||
convertRequest(URL, audioQuality).value.also {
|
||||
activeHost = it.first
|
||||
jobLink = it.second
|
||||
}
|
||||
""
|
||||
}
|
||||
retryCount--
|
||||
logger.i("Job Status") { jobStatus }
|
||||
if (!jobStatus.contains("d")) delay(600) // Add Delay , to give Server Time to process audio
|
||||
} while (!jobStatus.contains("d", true) && retryCount > 0)
|
||||
|
||||
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
|
||||
}
|
||||
|
||||
/*
|
||||
* Response Link Ex : `https://www.onlineconverter.com/convert/11affb6d88d31861fe5bcd33da7b10a26c`
|
||||
* - to start the conversion
|
||||
* */
|
||||
private suspend fun convertRequest(
|
||||
URL: String,
|
||||
audioQuality: AudioQuality = AudioQuality.KBPS160,
|
||||
): SuspendableEvent<Pair<String, String>, Throwable> = SuspendableEvent {
|
||||
val activeHost by getHost()
|
||||
val convertJob = client.submitFormWithBinaryData<String>(
|
||||
url = activeHost,
|
||||
formData = formData {
|
||||
append("class", "audio")
|
||||
append("from", "audio")
|
||||
append("to", "mp3")
|
||||
append("source", "url")
|
||||
append("url", URL.replace("https:", "http:"))
|
||||
append("audio_quality", audioQuality.kbps)
|
||||
}
|
||||
) {
|
||||
headers {
|
||||
header("Host", activeHost.getHostDomain().also { logger.i("AudioToMp3 Host") { it } })
|
||||
header("Origin", "https://www.onlineconverter.com")
|
||||
header("Referer", "https://www.onlineconverter.com/")
|
||||
}
|
||||
}.run {
|
||||
// logger.d { this }
|
||||
dropLast(3) // last 3 are useless unicode char
|
||||
}
|
||||
|
||||
val job = client.get<HttpStatement>(convertJob) {
|
||||
headers {
|
||||
header("Host", "www.onlineconverter.com")
|
||||
}
|
||||
}.execute()
|
||||
logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() }
|
||||
|
||||
Pair(activeHost, convertJob)
|
||||
}
|
||||
|
||||
// Active Host free to process conversion
|
||||
// ex - https://hostveryfast.onlineconverter.com/file/send
|
||||
private suspend fun getHost(): SuspendableEvent<String, Throwable> = SuspendableEvent {
|
||||
client.get<String>("https://www.onlineconverter.com/get/host") {
|
||||
headers {
|
||||
header("Host", "www.onlineconverter.com")
|
||||
}
|
||||
} // .also { logger.i("Active Host") { it } }
|
||||
}
|
||||
|
||||
// Extract full Domain from URL
|
||||
// ex - hostveryfast.onlineconverter.com
|
||||
private fun String.getHostDomain(): String {
|
||||
return this.removePrefix("https://").substringBeforeLast(".") + "." + this.substringAfterLast(".").substringBefore("/")
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
actual data class Picture(
|
||||
var image: ImageBitmap?
|
||||
)
|
@ -1,123 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.utils.removeIllegalChars
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.corsApi
|
||||
import com.shabinder.database.Database
|
||||
import kotlinext.js.Object
|
||||
import kotlinext.js.js
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import org.khronos.webgl.ArrayBuffer
|
||||
import org.khronos.webgl.Int8Array
|
||||
import org.w3c.dom.ImageBitmap
|
||||
|
||||
actual class Dir actual constructor(
|
||||
private val logger: Kermit,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
/*init {
|
||||
createDirectories()
|
||||
}*/
|
||||
|
||||
/*
|
||||
* TODO
|
||||
* */
|
||||
actual fun fileSeparator(): String = "/"
|
||||
|
||||
actual fun imageCacheDir(): String = "TODO" +
|
||||
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
|
||||
|
||||
actual fun defaultDir(): String = "TODO" + fileSeparator() +
|
||||
"SpotiFlyer" + fileSeparator()
|
||||
|
||||
actual fun isPresent(path: String): Boolean = false
|
||||
|
||||
actual fun createDirectory(dirPath: String) {}
|
||||
|
||||
actual suspend fun clearCache() {}
|
||||
|
||||
actual suspend fun cacheImage(image: Any, path: String) {}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
actual suspend fun saveFileWithMetadata(
|
||||
mp3ByteArray: ByteArray,
|
||||
trackDetails: TrackDetails,
|
||||
postProcess: (track: TrackDetails) -> Unit
|
||||
) {
|
||||
val writer = ID3Writer(mp3ByteArray.toArrayBuffer())
|
||||
val albumArt = downloadFile(corsApi + trackDetails.albumArtURL)
|
||||
albumArt.collect {
|
||||
when (it) {
|
||||
is DownloadResult.Success -> {
|
||||
logger.d { "Album Art Downloaded Success" }
|
||||
val albumArtObj = js {
|
||||
this["type"] = 3
|
||||
this["data"] = it.byteArray.toArrayBuffer()
|
||||
this["description"] = "Cover Art"
|
||||
}
|
||||
writeTagsAndSave(writer, albumArtObj as Object, trackDetails)
|
||||
}
|
||||
is DownloadResult.Error -> {
|
||||
logger.d { "Album Art Downloading Error" }
|
||||
writeTagsAndSave(writer, null, trackDetails)
|
||||
}
|
||||
is DownloadResult.Progress -> logger.d { "Album Art Downloading: ${it.progress}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeTagsAndSave(writer: ID3Writer, albumArt: Object?, trackDetails: TrackDetails) {
|
||||
writer.apply {
|
||||
setFrame("TIT2", trackDetails.title)
|
||||
setFrame("TPE1", trackDetails.artists.toTypedArray())
|
||||
setFrame("TALB", trackDetails.albumName ?: "")
|
||||
try { trackDetails.year?.substring(0, 4)?.toInt()?.let { setFrame("TYER", it) } } catch (e: Exception) {}
|
||||
setFrame("TPE2", trackDetails.artists.joinToString(","))
|
||||
setFrame("WOAS", trackDetails.source.toString())
|
||||
setFrame("TLEN", trackDetails.durationSec)
|
||||
albumArt?.let { setFrame("APIC", it) }
|
||||
}
|
||||
writer.addTag()
|
||||
allTracksStatus[trackDetails.title] = DownloadStatus.Downloaded
|
||||
DownloadProgressFlow.emit(allTracksStatus)
|
||||
saveAs(writer.getBlob(), "${removeIllegalChars(trackDetails.title)}.mp3")
|
||||
}
|
||||
|
||||
actual fun addToLibrary(path: String) {}
|
||||
|
||||
actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
|
||||
return Picture(url)
|
||||
}
|
||||
|
||||
private fun loadCachedImage(cachePath: String): ImageBitmap? = null
|
||||
|
||||
private suspend fun freshImage(url: String): ImageBitmap? = null
|
||||
|
||||
actual val db: Database? = spotiFlyerDatabase.instance
|
||||
}
|
||||
|
||||
fun ByteArray.toArrayBuffer(): ArrayBuffer {
|
||||
return this.unsafeCast<Int8Array>().buffer
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
actual data class Picture(
|
||||
var imageUrl: String
|
||||
)
|
@ -28,6 +28,8 @@ kotlin {
|
||||
implementation(project(":common:dependency-injection"))
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:providers"))
|
||||
implementation(project(":common:core-components"))
|
||||
implementation(SqlDelight.coroutineExtensions)
|
||||
}
|
||||
}
|
||||
|
@ -19,15 +19,15 @@ package com.shabinder.common.list
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.core_components.file_manager.FileManager
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.list.integration.SpotiFlyerListImpl
|
||||
import com.shabinder.common.models.Consumer
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.PlatformQueryResult
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.providers.FetchPlatformQueryResult
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
interface SpotiFlyerList {
|
||||
@ -67,7 +67,7 @@ interface SpotiFlyerList {
|
||||
interface Dependencies {
|
||||
val storeFactory: StoreFactory
|
||||
val fetchQuery: FetchPlatformQueryResult
|
||||
val dir: Dir
|
||||
val fileManager: FileManager
|
||||
val preferenceManager: PreferenceManager
|
||||
val link: String
|
||||
val listOutput: Consumer<Output>
|
||||
|
@ -18,11 +18,11 @@ package com.shabinder.common.list.integration
|
||||
|
||||
import co.touchlab.stately.ensureNeverFrozen
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.lifecycle.doOnResume
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.essenty.lifecycle.doOnResume
|
||||
import com.shabinder.common.caching.Cache
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.utils.asValue
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.utils.asValue
|
||||
import com.shabinder.common.list.SpotiFlyerList
|
||||
import com.shabinder.common.list.SpotiFlyerList.Dependencies
|
||||
import com.shabinder.common.list.SpotiFlyerList.State
|
||||
@ -45,14 +45,7 @@ internal class SpotiFlyerListImpl(
|
||||
|
||||
private val store =
|
||||
instanceKeeper.getStore {
|
||||
SpotiFlyerListStoreProvider(
|
||||
dir = this.dir,
|
||||
preferenceManager = preferenceManager,
|
||||
storeFactory = storeFactory,
|
||||
fetchQuery = fetchQuery,
|
||||
downloadProgressFlow = downloadProgressFlow,
|
||||
link = link
|
||||
).provide()
|
||||
SpotiFlyerListStoreProvider(dependencies).provide()
|
||||
}
|
||||
|
||||
private val cache = Cache.Builder
|
||||
@ -84,8 +77,8 @@ internal class SpotiFlyerListImpl(
|
||||
|
||||
override suspend fun loadImage(url: String, isCover: Boolean): Picture {
|
||||
return cache.get(url) {
|
||||
if (isCover) dir.loadImage(url, 350, 350)
|
||||
else dir.loadImage(url, 150, 150)
|
||||
if (isCover) fileManager.loadImage(url, 350, 350)
|
||||
else fileManager.loadImage(url, 150, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,8 +16,8 @@
|
||||
|
||||
package com.shabinder.common.list.store
|
||||
|
||||
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
|
||||
import com.arkivanov.decompose.instancekeeper.getOrCreate
|
||||
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
|
||||
import com.arkivanov.essenty.instancekeeper.getOrCreate
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
|
||||
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =
|
||||
|
@ -19,29 +19,19 @@ package com.shabinder.common.list.store
|
||||
import com.arkivanov.mvikotlin.core.store.Reducer
|
||||
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
import com.shabinder.common.di.downloadTracks
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.list.SpotiFlyerList
|
||||
import com.shabinder.common.list.SpotiFlyerList.State
|
||||
import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.PlatformQueryResult
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.methods
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import com.shabinder.common.providers.downloadTracks
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
internal class SpotiFlyerListStoreProvider(
|
||||
private val dir: Dir,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
private val storeFactory: StoreFactory,
|
||||
private val fetchQuery: FetchPlatformQueryResult,
|
||||
private val link: String,
|
||||
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||
) {
|
||||
internal class SpotiFlyerListStoreProvider(dependencies: SpotiFlyerList.Dependencies) :
|
||||
SpotiFlyerList.Dependencies by dependencies {
|
||||
fun provide(): SpotiFlyerListStore =
|
||||
object :
|
||||
SpotiFlyerListStore,
|
||||
@ -66,7 +56,7 @@ internal class SpotiFlyerListStoreProvider(
|
||||
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
||||
executeIntent(Intent.SearchLink(link), getState)
|
||||
|
||||
dir.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
|
||||
fileManager.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
|
||||
// See if It's Time we can request for support for maintaining this project or not
|
||||
fetchQuery.logger.d(message = { "Database List Last ID: $it" }, tag = "Database Last ID")
|
||||
val offset = preferenceManager.getDonationOffset
|
||||
@ -92,7 +82,12 @@ internal class SpotiFlyerListStoreProvider(
|
||||
resp.fold(
|
||||
success = { result ->
|
||||
result.trackList = result.trackList.toMutableList()
|
||||
dispatch((Result.ResultFetched(result, result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() }))))
|
||||
dispatch(
|
||||
(Result.ResultFetched(
|
||||
result,
|
||||
result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() })
|
||||
))
|
||||
)
|
||||
executeIntent(Intent.RefreshTracksStatuses, getState)
|
||||
},
|
||||
failure = {
|
||||
@ -103,25 +98,32 @@ internal class SpotiFlyerListStoreProvider(
|
||||
|
||||
is Intent.StartDownloadAll -> {
|
||||
val list = intent.trackList.map {
|
||||
if (it.downloaded == DownloadStatus.NotDownloaded)
|
||||
if (it.downloaded is DownloadStatus.NotDownloaded || it.downloaded is DownloadStatus.Failed)
|
||||
return@map it.copy(downloaded = DownloadStatus.Queued)
|
||||
it
|
||||
}
|
||||
dispatch(Result.UpdateTrackList(list.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() })))
|
||||
dispatch(
|
||||
Result.UpdateTrackList(
|
||||
list.updateTracksStatuses(
|
||||
downloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { hashMapOf() })
|
||||
)
|
||||
)
|
||||
|
||||
val finalList =
|
||||
intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
|
||||
if (finalList.isNullOrEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
|
||||
else downloadTracks(finalList, fetchQuery, dir)
|
||||
val finalList = intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
|
||||
if (finalList.isEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
|
||||
else downloadTracks(finalList, fetchQuery, fileManager)
|
||||
}
|
||||
is Intent.StartDownload -> {
|
||||
dispatch(Result.UpdateTrackItem(intent.track.copy(downloaded = DownloadStatus.Queued)))
|
||||
downloadTracks(listOf(intent.track), fetchQuery, dir)
|
||||
downloadTracks(listOf(intent.track), fetchQuery, fileManager)
|
||||
}
|
||||
is Intent.RefreshTracksStatuses -> methods.value.queryActiveTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object ReducerImpl : Reducer<State, Result> {
|
||||
override fun State.reduce(result: Result): State =
|
||||
when (result) {
|
||||
@ -140,6 +142,7 @@ internal class SpotiFlyerListStoreProvider(
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<TrackDetails>.updateTracksStatuses(map: HashMap<String, DownloadStatus>): List<TrackDetails> {
|
||||
val titleList = this.map { it.title }
|
||||
val updatedList = mutableListOf<TrackDetails>().also { it.addAll(this) }
|
||||
@ -147,7 +150,11 @@ internal class SpotiFlyerListStoreProvider(
|
||||
for (newTrack in map) {
|
||||
titleList.indexOf(newTrack.key).let { position ->
|
||||
if (position != -1) {
|
||||
updatedList.getOrNull(position)?.copy(downloaded = newTrack.value, progress = (newTrack.value as? DownloadStatus.Downloading)?.progress ?: updatedList[position].progress)?.also { updatedTrack ->
|
||||
updatedList.getOrNull(position)?.copy(
|
||||
downloaded = newTrack.value,
|
||||
progress = (newTrack.value as? DownloadStatus.Downloading)?.progress
|
||||
?: updatedList[position].progress
|
||||
)?.also { updatedTrack ->
|
||||
updatedList[position] = updatedTrack
|
||||
// logger.d("$position) ${updatedTrack.downloaded} - ${updatedTrack.title}","List Store Track Update")
|
||||
}
|
||||
|
@ -28,6 +28,8 @@ kotlin {
|
||||
implementation(project(":common:dependency-injection"))
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:providers"))
|
||||
implementation(project(":common:core-components"))
|
||||
implementation(SqlDelight.coroutineExtensions)
|
||||
}
|
||||
}
|
||||
|
@ -19,9 +19,10 @@ package com.shabinder.common.main
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||
import com.shabinder.common.core_components.file_manager.FileManager
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.main.integration.SpotiFlyerMainImpl
|
||||
import com.shabinder.common.models.Consumer
|
||||
import com.shabinder.common.models.DownloadRecord
|
||||
@ -65,8 +66,9 @@ interface SpotiFlyerMain {
|
||||
val mainOutput: Consumer<Output>
|
||||
val storeFactory: StoreFactory
|
||||
val database: Database?
|
||||
val dir: Dir
|
||||
val fileManager: FileManager
|
||||
val preferenceManager: PreferenceManager
|
||||
val analyticsManager: AnalyticsManager
|
||||
val mainAnalytics: Analytics
|
||||
}
|
||||
|
||||
|
@ -18,16 +18,13 @@ package com.shabinder.common.main.integration
|
||||
|
||||
import co.touchlab.stately.ensureNeverFrozen
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.lifecycle.doOnResume
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.essenty.lifecycle.doOnResume
|
||||
import com.shabinder.common.caching.Cache
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.utils.asValue
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.utils.asValue
|
||||
import com.shabinder.common.main.SpotiFlyerMain
|
||||
import com.shabinder.common.main.SpotiFlyerMain.Dependencies
|
||||
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
|
||||
import com.shabinder.common.main.SpotiFlyerMain.Output
|
||||
import com.shabinder.common.main.SpotiFlyerMain.State
|
||||
import com.shabinder.common.main.SpotiFlyerMain.*
|
||||
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
|
||||
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
|
||||
import com.shabinder.common.main.store.getStore
|
||||
@ -47,12 +44,7 @@ internal class SpotiFlyerMainImpl(
|
||||
|
||||
private val store =
|
||||
instanceKeeper.getStore {
|
||||
SpotiFlyerMainStoreProvider(
|
||||
preferenceManager = preferenceManager,
|
||||
storeFactory = storeFactory,
|
||||
database = database,
|
||||
dir = dir
|
||||
).provide()
|
||||
SpotiFlyerMainStoreProvider(dependencies).provide()
|
||||
}
|
||||
|
||||
private val cache = Cache.Builder
|
||||
@ -83,7 +75,7 @@ internal class SpotiFlyerMainImpl(
|
||||
|
||||
override suspend fun loadImage(url: String): Picture {
|
||||
return cache.get(url) {
|
||||
dir.loadImage(url, 150, 150)
|
||||
fileManager.loadImage(url, 150, 150)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -16,8 +16,8 @@
|
||||
|
||||
package com.shabinder.common.main.store
|
||||
|
||||
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
|
||||
import com.arkivanov.decompose.instancekeeper.getOrCreate
|
||||
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
|
||||
import com.arkivanov.essenty.instancekeeper.getOrCreate
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
|
||||
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =
|
||||
|
@ -19,16 +19,12 @@ package com.shabinder.common.main.store
|
||||
import com.arkivanov.mvikotlin.core.store.Reducer
|
||||
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.main.SpotiFlyerMain
|
||||
import com.shabinder.common.main.SpotiFlyerMain.State
|
||||
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
|
||||
import com.shabinder.common.models.DownloadRecord
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.database.Database
|
||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -36,12 +32,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal class SpotiFlyerMainStoreProvider(
|
||||
private val storeFactory: StoreFactory,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
private val dir: Dir,
|
||||
database: Database?
|
||||
) {
|
||||
internal class SpotiFlyerMainStoreProvider(dependencies: SpotiFlyerMain.Dependencies): SpotiFlyerMain.Dependencies by dependencies {
|
||||
|
||||
fun provide(): SpotiFlyerMainStore =
|
||||
object :
|
||||
|
@ -28,6 +28,8 @@ kotlin {
|
||||
implementation(project(":common:dependency-injection"))
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:core-components"))
|
||||
implementation(project(":common:providers"))
|
||||
implementation(SqlDelight.coroutineExtensions)
|
||||
}
|
||||
}
|
||||
|
@ -19,9 +19,10 @@ package com.shabinder.common.preference
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||
import com.shabinder.common.core_components.file_manager.FileManager
|
||||
import com.shabinder.common.core_components.picture.Picture
|
||||
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||
import com.shabinder.common.models.Actions
|
||||
import com.shabinder.common.models.AudioQuality
|
||||
import com.shabinder.common.models.Consumer
|
||||
@ -44,8 +45,9 @@ interface SpotiFlyerPreference {
|
||||
interface Dependencies {
|
||||
val prefOutput: Consumer<Output>
|
||||
val storeFactory: StoreFactory
|
||||
val dir: Dir
|
||||
val fileManager: FileManager
|
||||
val preferenceManager: PreferenceManager
|
||||
val analyticsManager: AnalyticsManager
|
||||
val actions: Actions
|
||||
val preferenceAnalytics: Analytics
|
||||
}
|
||||
@ -64,5 +66,8 @@ interface SpotiFlyerPreference {
|
||||
}
|
||||
|
||||
@Suppress("FunctionName") // Factory function
|
||||
fun SpotiFlyerPreference(componentContext: ComponentContext, dependencies: SpotiFlyerPreference.Dependencies): SpotiFlyerPreference =
|
||||
fun SpotiFlyerPreference(
|
||||
componentContext: ComponentContext,
|
||||
dependencies: SpotiFlyerPreference.Dependencies
|
||||
): SpotiFlyerPreference =
|
||||
SpotiFlyerPreferenceImpl(componentContext, dependencies)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user