mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-11-26 02:24:31 +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
|
||||||
Gemfile.lock
|
Gemfile.lock
|
||||||
/maintenance-tasks/build/
|
/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"]
|
[submodule "spotiflyer-ios"]
|
||||||
path = spotiflyer-ios
|
path = spotiflyer-ios
|
||||||
url = https://github.com/Shabinder/spotiflyer-ios
|
url = https://github.com/Shabinder/spotiflyer-ios
|
||||||
[submodule "mosaic"]
|
[submodule "ffmpeg/ffmpeg-android-maker"]
|
||||||
path = mosaic
|
path = ffmpeg/ffmpeg-android-maker
|
||||||
url = https://github.com/JakeWharton/mosaic
|
url = https://github.com/Shabinder/ffmpeg-android-maker/
|
||||||
|
@ -14,9 +14,9 @@
|
|||||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* * 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 com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||||
import org.jetbrains.compose.compose
|
import org.jetbrains.compose.compose
|
||||||
|
import org.jetbrains.kotlin.kapt.cli.main
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@ -32,14 +32,10 @@ version = Versions.versionName
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
// Remove jcenter as soon as following issue closes
|
|
||||||
// https://github.com/matomo-org/matomo-sdk-android/issues/301
|
|
||||||
jcenter()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
val props = gradleLocalProperties(rootDir)
|
val props = gradleLocalProperties(rootDir)
|
||||||
|
|
||||||
if (props.containsKey("storeFileDir")) {
|
if (props.containsKey("storeFileDir")) {
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
@ -51,17 +47,16 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileSdkVersion(Versions.compileSdkVersion)
|
compileSdk = Versions.compileSdkVersion
|
||||||
buildToolsVersion = "30.0.3"
|
buildToolsVersion = "30.0.3"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.shabinder.spotiflyer"
|
applicationId = "com.shabinder.spotiflyer"
|
||||||
minSdkVersion(Versions.minSdkVersion)
|
minSdk = Versions.minSdkVersion
|
||||||
targetSdkVersion(Versions.targetSdkVersion)
|
targetSdk = Versions.targetSdkVersion
|
||||||
versionCode = Versions.versionCode
|
versionCode = Versions.versionCode
|
||||||
versionName = Versions.versionName
|
versionName = Versions.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@ -69,11 +64,13 @@ android {
|
|||||||
if (props.containsKey("storeFileDir")) {
|
if (props.containsKey("storeFileDir")) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
useIR = true
|
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@ -91,9 +88,6 @@ android {
|
|||||||
exclude(group = "androidx.compose.ui")
|
exclude(group = "androidx.compose.ui")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
packagingOptions {
|
|
||||||
exclude("META-INF/*")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(compose.material)
|
implementation(compose.material)
|
||||||
@ -106,6 +100,8 @@ dependencies {
|
|||||||
implementation(project(":common:root"))
|
implementation(project(":common:root"))
|
||||||
implementation(project(":common:dependency-injection"))
|
implementation(project(":common:dependency-injection"))
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
|
implementation(project(":common:core-components"))
|
||||||
|
implementation(project(":common:providers"))
|
||||||
|
|
||||||
// Koin
|
// Koin
|
||||||
implementation(Koin.android)
|
implementation(Koin.android)
|
||||||
@ -123,10 +119,8 @@ dependencies {
|
|||||||
|
|
||||||
// Extras
|
// Extras
|
||||||
with(Extras.Android) {
|
with(Extras.Android) {
|
||||||
implementation(Acra.notification)
|
implementation(countly)
|
||||||
implementation(Acra.http)
|
|
||||||
implementation(appUpdator)
|
implementation(appUpdator)
|
||||||
implementation(matomo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with(Versions.androidxLifecycle) {
|
with(Versions.androidxLifecycle) {
|
||||||
@ -138,7 +132,7 @@ dependencies {
|
|||||||
// implementation("com.jakewharton.timber:timber:4.7.1")
|
// implementation("com.jakewharton.timber:timber:4.7.1")
|
||||||
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
||||||
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
|
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
|
// Test
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
@ -40,6 +40,7 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
@ -48,11 +49,13 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
|
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:label="SpotiFlyer"
|
android:label="SpotiFlyer"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:configChanges="orientation|screenSize"
|
android:configChanges="orientation|screenSize"
|
||||||
android:forceDarkAllowed="true"
|
android:forceDarkAllowed="true"
|
||||||
|
android:extractNativeLibs="true"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
tools:targetApi="q">
|
tools:targetApi="q">
|
||||||
<activity android:name=".MainActivity"
|
<activity android:name=".MainActivity"
|
||||||
@ -73,5 +76,16 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name=".service.ForegroundService"/>
|
<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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
@ -17,22 +17,12 @@
|
|||||||
package com.shabinder.spotiflyer
|
package com.shabinder.spotiflyer
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
|
||||||
import com.shabinder.common.di.initKoin
|
import com.shabinder.common.di.initKoin
|
||||||
import com.shabinder.common.translations.Strings
|
|
||||||
import com.shabinder.spotiflyer.di.appModule
|
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.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.logger.Level
|
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 {
|
class App : Application(), KoinComponent {
|
||||||
|
|
||||||
@ -40,21 +30,6 @@ class App : Application(), KoinComponent {
|
|||||||
const val SIGNATURE_HEX = "53304f6d75736a2f30484230334c454b714753525763724259444d3d0a"
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
@ -66,35 +41,4 @@ class App : Application(), KoinComponent {
|
|||||||
modules(appModule(loggingEnabled))
|
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
|
package com.shabinder.spotiflyer
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.*
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.MediaScannerConnection
|
import android.media.MediaScannerConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@ -40,20 +35,14 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
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.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.arkivanov.decompose.ComponentContext
|
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.logging.store.LoggingStoreFactory
|
||||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||||
import com.codekidlabs.storagechooser.R
|
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.navigationBarsPadding
|
||||||
import com.google.accompanist.insets.statusBarsHeight
|
import com.google.accompanist.insets.statusBarsHeight
|
||||||
import com.google.accompanist.insets.statusBarsPadding
|
import com.google.accompanist.insets.statusBarsPadding
|
||||||
import com.shabinder.common.di.ConnectionLiveData
|
import com.shabinder.common.core_components.ConnectionLiveData
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
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.observeAsState
|
||||||
import com.shabinder.common.di.preference.PreferenceManager
|
import com.shabinder.common.models.*
|
||||||
import com.shabinder.common.models.Actions
|
|
||||||
import com.shabinder.common.models.DownloadStatus
|
|
||||||
import com.shabinder.common.models.PlatformActions
|
|
||||||
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
|
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.providers.FetchPlatformQueryResult
|
||||||
import com.shabinder.common.models.methods
|
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot
|
import com.shabinder.common.root.SpotiFlyerRoot
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
|
|
||||||
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
||||||
import com.shabinder.common.translations.Strings
|
import com.shabinder.common.translations.Strings
|
||||||
import com.shabinder.common.uikit.configurations.SpotiFlyerTheme
|
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.AnalyticsDialog
|
||||||
import com.shabinder.spotiflyer.ui.NetworkDialog
|
import com.shabinder.spotiflyer.ui.NetworkDialog
|
||||||
import com.shabinder.spotiflyer.ui.PermissionDialog
|
import com.shabinder.spotiflyer.ui.PermissionDialog
|
||||||
import com.shabinder.spotiflyer.utils.checkAppSignature
|
import com.shabinder.spotiflyer.utils.*
|
||||||
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 kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.conflate
|
import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.matomo.sdk.extra.TrackHelper
|
import org.koin.core.parameter.parametersOf
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@ExperimentalAnimationApi
|
@ExperimentalAnimationApi
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
private val fetcher: FetchPlatformQueryResult by inject()
|
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 val preferenceManager: PreferenceManager by inject()
|
||||||
private lateinit var root: SpotiFlyerRoot
|
private val analyticsManager: AnalyticsManager by inject { parametersOf(this) }
|
||||||
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks
|
private val callBacks: SpotiFlyerRootCallBacks get() = this.rootComponent.callBacks
|
||||||
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
|
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
|
||||||
private var permissionGranted = mutableStateOf(true)
|
private var permissionGranted = mutableStateOf(true)
|
||||||
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
|
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
|
||||||
private val tracker get() = (application as App).tracker
|
private lateinit var rootComponent: SpotiFlyerRoot
|
||||||
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
|
// private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
|
||||||
|
|
||||||
// Variable for storing instance of our service class
|
// Variable for storing instance of our service class
|
||||||
var foregroundService: ForegroundService? = null
|
var foregroundService: ForegroundService? = null
|
||||||
@ -120,9 +101,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
preferenceManager.analyticsManager = analyticsManager
|
||||||
// This app draws behind the system bars, so we want to handle fitting system windows
|
// This app draws behind the system bars, so we want to handle fitting system windows
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
this.rootComponent = spotiFlyerRoot(defaultComponentContext())
|
||||||
setContent {
|
setContent {
|
||||||
SpotiFlyerTheme {
|
SpotiFlyerTheme {
|
||||||
Surface(contentColor = colorOffWhite) {
|
Surface(contentColor = colorOffWhite) {
|
||||||
@ -131,8 +113,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
root = SpotiFlyerRootContent(
|
SpotiFlyerRootContent(
|
||||||
rememberRootComponent(::spotiFlyerRoot),
|
this@MainActivity.rootComponent,
|
||||||
Modifier.statusBarsPadding().navigationBarsPadding()
|
Modifier.statusBarsPadding().navigationBarsPadding()
|
||||||
)
|
)
|
||||||
Spacer(
|
Spacer(
|
||||||
@ -186,11 +168,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
* and Track Downloads for all other releases like F-Droid,
|
* 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
|
* for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer
|
||||||
* */
|
* */
|
||||||
if (isGithubRelease) { checkIfLatestVersion() }
|
if (isGithubRelease) {
|
||||||
if (preferenceManager.isAnalyticsEnabled && !isGithubRelease) {
|
checkIfLatestVersion()
|
||||||
// Download/App Install Event for F-Droid builds
|
|
||||||
TrackHelper.track().download().with(tracker)
|
|
||||||
}
|
}
|
||||||
|
// TODO Track Download Event
|
||||||
handleIntentFromExternalActivity()
|
handleIntentFromExternalActivity()
|
||||||
|
|
||||||
initForegroundService()
|
initForegroundService()
|
||||||
@ -260,7 +241,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
).show()
|
).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)
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
permissionGranted.value = checkPermissions()
|
permissionGranted.value = checkPermissions()
|
||||||
}
|
}
|
||||||
@ -270,15 +256,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
componentContext,
|
componentContext,
|
||||||
dependencies = object : SpotiFlyerRoot.Dependencies {
|
dependencies = object : SpotiFlyerRoot.Dependencies {
|
||||||
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory)
|
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 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 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 actions = object : Actions {
|
||||||
|
|
||||||
override val platformActions = object : PlatformActions {
|
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(
|
override val sharedPreferences = applicationContext.getSharedPreferences(
|
||||||
SharedPreferencesKey,
|
SharedPreferencesKey,
|
||||||
MODE_PRIVATE
|
MODE_PRIVATE
|
||||||
@ -292,19 +281,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun sendTracksToService(array: List<TrackDetails>) {
|
override fun sendTracksToService(array: List<TrackDetails>) {
|
||||||
if (foregroundService == null) initForegroundService()
|
for (chunk in array.chunked(25)) {
|
||||||
foregroundService?.downloadAllTracks(array)
|
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 queryActiveTracks() = this@MainActivity.queryActiveTracks()
|
||||||
|
|
||||||
override fun giveDonation() {
|
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() {
|
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
|
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 :)
|
// hell yeah :)
|
||||||
preferenceManager.setDownloadDirectory(path)
|
preferenceManager.setDownloadDirectory(path)
|
||||||
callBack(path)
|
callBack(path)
|
||||||
showPopUpMessage(Strings.downloadDirectorySetTo("\n${dir.defaultDir()}"))
|
showPopUpMessage(Strings.downloadDirectorySetTo("\n${fileManager.defaultDir()}"))
|
||||||
} else {
|
} else {
|
||||||
showPopUpMessage(Strings.noWriteAccess("\n$path "))
|
showPopUpMessage(Strings.noWriteAccess("\n$path "))
|
||||||
}
|
}
|
||||||
@ -433,6 +394,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
chooser.show()
|
chooser.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
@SuppressLint("ObsoleteSdkInt")
|
@SuppressLint("ObsoleteSdkInt")
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
@ -466,10 +428,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
val link = filterLinkRegex.find(string)?.value.toString()
|
val link = filterLinkRegex.find(string)?.value.toString()
|
||||||
Log.i("Intent", link)
|
Log.i("Intent", link)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
while (!this@MainActivity::root.isInitialized) {
|
while (!this@MainActivity::rootComponent.isInitialized) {
|
||||||
delay(100)
|
delay(100)
|
||||||
}
|
}
|
||||||
if (methods.value.isInternetAvailable)callBacks.searchLink(link)
|
if (methods.value.isInternetAvailable) callBacks.searchLink(link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -481,6 +443,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
unbindService()
|
unbindService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
analyticsManager.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
analyticsManager.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val disableDozeCode = 1223
|
const val disableDozeCode = 1223
|
||||||
}
|
}
|
||||||
|
@ -33,19 +33,19 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.core_components.file_manager.FileManager
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
import com.shabinder.common.core_components.file_manager.downloadFile
|
||||||
import com.shabinder.common.di.R
|
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
|
||||||
import com.shabinder.common.di.downloadFile
|
|
||||||
import com.shabinder.common.di.utils.ParallelExecutor
|
|
||||||
import com.shabinder.common.models.DownloadResult
|
import com.shabinder.common.models.DownloadResult
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
import com.shabinder.common.models.event.coroutines.failure
|
import com.shabinder.common.models.event.coroutines.failure
|
||||||
|
import com.shabinder.common.providers.FetchPlatformQueryResult
|
||||||
import com.shabinder.common.translations.Strings
|
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 com.shabinder.spotiflyer.utils.autoclear.autoClear
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@ -56,13 +56,19 @@ import java.io.File
|
|||||||
|
|
||||||
class ForegroundService : LifecycleService() {
|
class ForegroundService : LifecycleService() {
|
||||||
|
|
||||||
private var downloadService: AutoClear<ParallelExecutor> = autoClear { ParallelExecutor(Dispatchers.IO) }
|
private lateinit var downloadService: ParallelExecutor
|
||||||
val trackStatusFlowMap by autoClear { TrackStatusFlowMap(MutableSharedFlow(replay = 1), lifecycleScope) }
|
val trackStatusFlowMap by autoClear {
|
||||||
|
TrackStatusFlowMap(
|
||||||
|
MutableSharedFlow(replay = 1),
|
||||||
|
lifecycleScope
|
||||||
|
)
|
||||||
|
}
|
||||||
private val fetcher: FetchPlatformQueryResult by inject()
|
private val fetcher: FetchPlatformQueryResult by inject()
|
||||||
private val logger: Kermit 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 wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isServiceStarted = false
|
private var isServiceStarted = false
|
||||||
private val cancelIntent: PendingIntent by lazy {
|
private val cancelIntent: PendingIntent by lazy {
|
||||||
@ -81,6 +87,7 @@ class ForegroundService : LifecycleService() {
|
|||||||
inner class DownloadServiceBinder : Binder() {
|
inner class DownloadServiceBinder : Binder() {
|
||||||
val service get() = this@ForegroundService
|
val service get() = this@ForegroundService
|
||||||
}
|
}
|
||||||
|
|
||||||
private val myBinder: IBinder = DownloadServiceBinder()
|
private val myBinder: IBinder = DownloadServiceBinder()
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
override fun onBind(intent: Intent): IBinder {
|
||||||
@ -90,12 +97,14 @@ class ForegroundService : LifecycleService() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
downloadService = ParallelExecutor(Dispatchers.IO)
|
||||||
createNotificationChannel(CHANNEL_ID, "Downloader Service")
|
createNotificationChannel(CHANNEL_ID, "Downloader Service")
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("WakelockTimeout")
|
@SuppressLint("WakelockTimeout")
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
downloadService.reviveIfClosed()
|
||||||
// Send a notification that service is started
|
// Send a notification that service is started
|
||||||
Log.i(TAG, "Foreground Service Started.")
|
Log.i(TAG, "Foreground Service Started.")
|
||||||
startForeground(NOTIFICATION_ID, createNotification())
|
startForeground(NOTIFICATION_ID, createNotification())
|
||||||
@ -127,6 +136,7 @@ class ForegroundService : LifecycleService() {
|
|||||||
* Function To Download All Tracks Available in a List
|
* Function To Download All Tracks Available in a List
|
||||||
**/
|
**/
|
||||||
fun downloadAllTracks(trackList: List<TrackDetails>) {
|
fun downloadAllTracks(trackList: List<TrackDetails>) {
|
||||||
|
downloadService.reviveIfClosed()
|
||||||
trackList.size.also { size ->
|
trackList.size.also { size ->
|
||||||
total += size
|
total += size
|
||||||
isSingleDownload = (size == 1)
|
isSingleDownload = (size == 1)
|
||||||
@ -136,10 +146,10 @@ class ForegroundService : LifecycleService() {
|
|||||||
for (track in trackList) {
|
for (track in trackList) {
|
||||||
trackStatusFlowMap[track.title] = DownloadStatus.Queued
|
trackStatusFlowMap[track.title] = DownloadStatus.Queued
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
downloadService.value.execute {
|
downloadService.executeSuspending {
|
||||||
fetcher.findMp3DownloadLink(track).fold(
|
fetcher.findBestDownloadLink(track).fold(
|
||||||
success = { url ->
|
success = { res ->
|
||||||
enqueueDownload(url, track)
|
enqueueDownload(res.first, track.apply { audioQuality = res.second })
|
||||||
},
|
},
|
||||||
failure = { error ->
|
failure = { error ->
|
||||||
failed++
|
failed++
|
||||||
@ -163,7 +173,8 @@ class ForegroundService : LifecycleService() {
|
|||||||
is DownloadResult.Error -> {
|
is DownloadResult.Error -> {
|
||||||
logger.d(TAG) { it.message }
|
logger.d(TAG) { it.message }
|
||||||
failed++
|
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()))
|
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,17 +187,40 @@ class ForegroundService : LifecycleService() {
|
|||||||
coroutineScope {
|
coroutineScope {
|
||||||
SuspendableEvent {
|
SuspendableEvent {
|
||||||
// Save File and Embed Metadata
|
// 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
|
// Send Converting Status
|
||||||
trackStatusFlowMap[track.title] = DownloadStatus.Converting
|
trackStatusFlowMap[track.title] = DownloadStatus.Converting
|
||||||
addToNotification(Message(track.title, DownloadStatus.Converting))
|
addToNotification(Message(track.title, DownloadStatus.Converting))
|
||||||
|
|
||||||
// All Processing Completed for this Track
|
// 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++
|
converted++
|
||||||
trackStatusFlowMap[track.title] = DownloadStatus.Downloaded
|
trackStatusFlowMap[track.title] = DownloadStatus.Downloaded
|
||||||
removeFromNotification(Message(track.title, DownloadStatus.Converting))
|
removeFromNotification(
|
||||||
|
Message(
|
||||||
|
track.title,
|
||||||
|
DownloadStatus.Converting
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
logger.d(TAG) { "${track.title} Download Completed" }
|
logger.d(TAG) { "${track.title} Download Completed" }
|
||||||
downloaded++
|
downloaded++
|
||||||
@ -240,9 +274,9 @@ class ForegroundService : LifecycleService() {
|
|||||||
messageList = messageList.getEmpty().apply {
|
messageList = messageList.getEmpty().apply {
|
||||||
set(index = 0, Message(Strings.cleaningAndExiting(), DownloadStatus.NotDownloaded))
|
set(index = 0, Message(Strings.cleaningAndExiting(), DownloadStatus.NotDownloaded))
|
||||||
}
|
}
|
||||||
downloadService.getOrNull()?.close()
|
downloadService.close()
|
||||||
downloadService.reset()
|
|
||||||
updateNotification()
|
updateNotification()
|
||||||
|
trackStatusFlowMap.clear()
|
||||||
cleanFiles(File(dir.defaultDir()))
|
cleanFiles(File(dir.defaultDir()))
|
||||||
// cleanFiles(File(dir.imageCacheDir()))
|
// cleanFiles(File(dir.imageCacheDir()))
|
||||||
messageList = messageList.getEmpty()
|
messageList = messageList.getEmpty()
|
||||||
@ -256,23 +290,24 @@ class ForegroundService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID).run {
|
private fun createNotification(): Notification =
|
||||||
setSmallIcon(R.drawable.ic_download_arrow)
|
NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||||
setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed")
|
setSmallIcon(R.drawable.ic_download_arrow)
|
||||||
setSilent(true)
|
setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed")
|
||||||
setProgress(total, failed + converted, false)
|
setSilent(true)
|
||||||
setStyle(
|
setProgress(total, failed + converted, false)
|
||||||
NotificationCompat.InboxStyle().run {
|
setStyle(
|
||||||
addLine(messageList[messageList.size - 1].asString())
|
NotificationCompat.InboxStyle().run {
|
||||||
addLine(messageList[messageList.size - 2].asString())
|
addLine(messageList[messageList.size - 1].asString())
|
||||||
addLine(messageList[messageList.size - 3].asString())
|
addLine(messageList[messageList.size - 2].asString())
|
||||||
addLine(messageList[messageList.size - 4].asString())
|
addLine(messageList[messageList.size - 3].asString())
|
||||||
addLine(messageList[messageList.size - 5].asString())
|
addLine(messageList[messageList.size - 4].asString())
|
||||||
}
|
addLine(messageList[messageList.size - 5].asString())
|
||||||
)
|
}
|
||||||
addAction(R.drawable.ic_round_cancel_24, Strings.exit(), cancelIntent)
|
)
|
||||||
build()
|
addAction(R.drawable.ic_round_cancel_24, Strings.exit(), cancelIntent)
|
||||||
}
|
build()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addToNotification(message: Message) {
|
private fun addToNotification(message: Message) {
|
||||||
synchronized(messageList) {
|
synchronized(messageList) {
|
||||||
@ -304,12 +339,16 @@ class ForegroundService : LifecycleService() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
if (isFinished) { killService() }
|
if (isFinished) {
|
||||||
|
killService()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
super.onTaskRemoved(rootIntent)
|
super.onTaskRemoved(rootIntent)
|
||||||
if (isFinished) { killService() }
|
if (isFinished) {
|
||||||
|
killService()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -10,8 +10,28 @@ class TrackStatusFlowMap(
|
|||||||
private val scope: CoroutineScope
|
private val scope: CoroutineScope
|
||||||
) : HashMap<String, DownloadStatus>() {
|
) : HashMap<String, DownloadStatus>() {
|
||||||
override fun put(key: String, value: DownloadStatus): 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) }
|
scope.launch { statusFlow.emit(this@TrackStatusFlowMap) }
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,9 +27,14 @@ allprojects {
|
|||||||
// mavenLocal()
|
// mavenLocal()
|
||||||
maven(url = "https://jitpack.io")
|
maven(url = "https://jitpack.io")
|
||||||
maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
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")
|
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 {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
dependsOn(":common:data-models:generateI18n4kFiles")
|
dependsOn(":common:data-models:generateI18n4kFiles")
|
||||||
kotlinOptions { jvmTarget = "1.8" }
|
kotlinOptions { jvmTarget = "1.8" }
|
||||||
|
@ -16,15 +16,20 @@
|
|||||||
|
|
||||||
@file:Suppress("MayBeConstant", "SpellCheckingInspection")
|
@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 {
|
object Versions {
|
||||||
// App's Version (To be bumped at each update)
|
// 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
|
// 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
|
// Code Formatting
|
||||||
const val ktLint = "10.1.0"
|
const val ktLint = "10.1.0"
|
||||||
@ -41,38 +46,46 @@ object Versions {
|
|||||||
const val mokoParcelize = "0.7.1"
|
const val mokoParcelize = "0.7.1"
|
||||||
|
|
||||||
// Internet
|
// 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
|
// Database
|
||||||
const val sqlDelight = "1.5.0"
|
const val sqlDelight = "1.5.1"
|
||||||
|
|
||||||
const val sqliteJdbcDriver = "3.34.0"
|
const val sqliteJdbcDriver = "3.34.0"
|
||||||
const val slf4j = "1.7.31"
|
const val slf4j = "1.7.31"
|
||||||
|
|
||||||
// Internationalisation
|
// Internationalisation
|
||||||
const val i18n4k = "0.1.2"
|
const val i18n4k = "0.1.3"
|
||||||
|
|
||||||
// Android
|
// Android
|
||||||
const val minSdkVersion = 21
|
const val minSdkVersion = 21
|
||||||
const val compileSdkVersion = 30
|
const val compileSdkVersion = 30
|
||||||
const val targetSdkVersion = 29
|
const val targetSdkVersion = 29
|
||||||
const val androidxLifecycle = "2.3.1"
|
const val androidxLifecycle = "2.4.0-alpha03"
|
||||||
}
|
}
|
||||||
|
|
||||||
object HostOS {
|
object HostOS {
|
||||||
// Host OS Properties
|
// Host OS Properties
|
||||||
private val hostOs = System.getProperty("os.name")
|
private val hostOs = System.getProperty("os.name")
|
||||||
val isMingwX64 = hostOs.startsWith("Windows",true)
|
val isMingwX64 = hostOs.startsWith("Windows", true)
|
||||||
val isMac = hostOs.startsWith("Mac",true)
|
val isMac = hostOs.startsWith("Mac", true)
|
||||||
val isLinux = hostOs.startsWith("Linux",true)
|
val isLinux = hostOs.startsWith("Linux", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
object MultiPlatformSettings {
|
object MultiPlatformSettings {
|
||||||
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7"
|
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 {
|
object Koin {
|
||||||
val core = "io.insert-koin:koin-core:${Versions.koin}"
|
val core = "io.insert-koin:koin-core:${Versions.koin}"
|
||||||
val test = "io.insert-koin:koin-test:${Versions.koin}"
|
val test = "io.insert-koin:koin-test:${Versions.koin}"
|
||||||
@ -81,15 +94,15 @@ object Koin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object Androidx {
|
object Androidx {
|
||||||
const val androidxActivity = "androidx.activity:activity-compose:1.3.0-beta02"
|
const val androidxActivity = "androidx.activity:activity-compose:1.3.1"
|
||||||
const val core = "androidx.core:core-ktx:1.5.0"
|
const val core = "androidx.core:core-ktx:1.6.0"
|
||||||
const val palette = "androidx.palette:palette-ktx:1.0.0"
|
const val palette = "androidx.palette:palette-ktx:1.0.0"
|
||||||
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}"
|
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}"
|
||||||
|
|
||||||
const val junit = "androidx.test.ext:junit:1.1.2"
|
const val junit = "androidx.test.ext:junit:1.1.2"
|
||||||
const val expresso = "androidx.test.espresso:espresso-core:3.3.0"
|
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 {
|
object KTLint {
|
||||||
@ -98,24 +111,28 @@ object KTLint {
|
|||||||
|
|
||||||
object JetBrains {
|
object JetBrains {
|
||||||
object Kotlin {
|
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 gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlinVersion}"
|
||||||
const val serialization = "org.jetbrains.kotlin:kotlin-serialization:${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 testCommon = "org.jetbrains.kotlin:kotlin-test-common:${Versions.kotlinVersion}"
|
||||||
const val testJunit = "org.jetbrains.kotlin:kotlin-test-junit:${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 {
|
object Compose {
|
||||||
// __LATEST_COMPOSE_RELEASE_VERSION__
|
// __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"
|
const val gradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$VERSION"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object Mosaic {
|
object Mosaic {
|
||||||
const val gradlePlugin = "com.jakewharton.mosaic:mosaic-gradle-plugin:${Versions.mosaic}"
|
const val gradlePlugin = "com.jakewharton.mosaic:mosaic-gradle-plugin:${Versions.mosaic}"
|
||||||
}
|
}
|
||||||
|
|
||||||
object Decompose {
|
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 decompose = "com.arkivanov.decompose:decompose:$VERSION"
|
||||||
const val decomposeIosX64 = "com.arkivanov.decompose:decompose-iosx64:$VERSION"
|
const val decomposeIosX64 = "com.arkivanov.decompose:decompose-iosx64:$VERSION"
|
||||||
const val decomposeIosArm64 = "com.arkivanov.decompose:decompose-iosarm64:$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 mp3agic = "com.mpatric:mp3agic:0.9.0"
|
||||||
const val jaudioTagger = "com.github.Shabinder:JAudioTagger-Android:1.0"
|
const val jaudioTagger = "com.github.Shabinder:JAudioTagger-Android:1.0"
|
||||||
const val kermit = "co.touchlab:kermit:${Versions.kermit}"
|
const val kermit = "co.touchlab:kermit:${Versions.kermit}"
|
||||||
|
|
||||||
object Android {
|
object Android {
|
||||||
object Acra {
|
// Self Hosted Analytics & Crashlytics (FOSS)
|
||||||
// Self Hosted Crashlytics (FOSS)
|
val countly = "ly.count.android:sdk:20.11.8"
|
||||||
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"
|
|
||||||
val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0"
|
val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Desktop {
|
||||||
|
val countly = "ly.count.sdk:java:20.11.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object Serialization {
|
object Serialization {
|
||||||
@ -191,3 +207,10 @@ object SqlDelight {
|
|||||||
val nativeDriverMacos = "com.squareup.sqldelight:native-driver-macosx64:${Versions.sqlDelight}"
|
val nativeDriverMacos = "com.squareup.sqldelight:native-driver-macosx64:${Versions.sqlDelight}"
|
||||||
val jdbcDriver = "org.xerial:sqlite-jdbc:${Versions.sqliteJdbcDriver}"
|
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 {
|
android {
|
||||||
compileSdkVersion(Versions.compileSdkVersion)
|
compileSdk = Versions.compileSdkVersion
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion(Versions.minSdkVersion)
|
minSdk = Versions.minSdkVersion
|
||||||
targetSdkVersion(Versions.targetSdkVersion)
|
targetSdk = Versions.targetSdkVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@ -37,7 +37,6 @@ android {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
named("main") {
|
named("main") {
|
||||||
manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||||
java.srcDirs("src/androidMain/kotlin")
|
|
||||||
res.srcDirs("src/androidMain/res")
|
res.srcDirs("src/androidMain/res")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,7 @@ kotlin {
|
|||||||
|
|
||||||
implementation(Extras.kermit)
|
implementation(Extras.kermit)
|
||||||
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
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")
|
@Suppress("DEPRECATION")
|
||||||
isForce = true
|
isForce = true
|
||||||
}
|
}
|
||||||
@ -51,7 +51,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
named("androidMain") {
|
named("androidMain") {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.appcompat:appcompat:1.3.0")
|
implementation(Androidx.androidxActivity)
|
||||||
implementation(Androidx.core)
|
implementation(Androidx.core)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,19 +41,19 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
named("commonTest") {
|
named("commonTest") {
|
||||||
dependencies {
|
dependencies {
|
||||||
//implementation(JetBrains.Kotlin.testCommon)
|
implementation(JetBrains.Kotlin.testCommon)
|
||||||
//implementation(JetBrains.Kotlin.testAnnotationsCommon)
|
implementation(JetBrains.Kotlin.testAnnotationsCommon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
named("androidTest") {
|
named("androidTest") {
|
||||||
dependencies {
|
dependencies {
|
||||||
//implementation(JetBrains.Kotlin.testJunit)
|
implementation(JetBrains.Kotlin.testJunit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
named("desktopTest") {
|
named("desktopTest") {
|
||||||
dependencies {
|
dependencies {
|
||||||
//implementation(JetBrains.Kotlin.testJunit)
|
implementation(JetBrains.Kotlin.testJunit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
named("jsTest") {
|
named("jsTest") {
|
||||||
|
@ -38,7 +38,11 @@ kotlin {
|
|||||||
android()
|
android()
|
||||||
|
|
||||||
js(BOTH) {
|
js(BOTH) {
|
||||||
browser()
|
browser {
|
||||||
|
commonWebpackConfig {
|
||||||
|
cssSupport.enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
// nodejs()
|
// nodejs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +70,7 @@ kotlin {
|
|||||||
implementation(Serialization.json)
|
implementation(Serialization.json)
|
||||||
implementation("co.touchlab:stately-common:1.1.7")
|
implementation("co.touchlab:stately-common:1.1.7")
|
||||||
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
|
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")
|
@Suppress("DEPRECATION")
|
||||||
isForce = true
|
isForce = true
|
||||||
}
|
}
|
||||||
@ -75,7 +79,7 @@ kotlin {
|
|||||||
|
|
||||||
named("androidMain") {
|
named("androidMain") {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.appcompat:appcompat:1.3.0")
|
implementation(Androidx.androidxActivity)
|
||||||
implementation(Androidx.core)
|
implementation(Androidx.core)
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.material)
|
implementation(compose.material)
|
||||||
@ -102,9 +106,13 @@ kotlin {
|
|||||||
named("jsMain") {
|
named("jsMain") {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(Ktor.clientJs)
|
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")
|
/*with(KotlinJSWrappers) {
|
||||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-styled:5.3.0-pre.213-kotlin-1.5.10")
|
implementation(enforcedPlatform(bom))
|
||||||
|
implementation(kotlinReact)
|
||||||
|
implementation(kotlinReactDom)
|
||||||
|
implementation(kotlinStyled)
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(HostOS.isMac){
|
if(HostOS.isMac){
|
||||||
|
@ -34,6 +34,7 @@ kotlin {
|
|||||||
implementation(project(":common:main"))
|
implementation(project(":common:main"))
|
||||||
implementation(project(":common:list"))
|
implementation(project(":common:list"))
|
||||||
implementation(project(":common:preference"))
|
implementation(project(":common:preference"))
|
||||||
|
implementation(project(":common:core-components"))
|
||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
implementation(project(":common:dependency-injection"))
|
implementation(project(":common:dependency-injection"))
|
||||||
|
@ -11,8 +11,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.dispatcherIO
|
import com.shabinder.common.models.dispatcherIO
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -3,7 +3,7 @@ package com.shabinder.common.uikit.configurations
|
|||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import com.shabinder.common.database.R
|
import com.shabinder.common.models.R
|
||||||
|
|
||||||
actual fun montserratFont() = FontFamily(
|
actual fun montserratFont() = FontFamily(
|
||||||
Font(R.font.montserrat_light, FontWeight.Light),
|
Font(R.font.montserrat_light, FontWeight.Light),
|
||||||
@ -12,6 +12,6 @@ actual fun montserratFont() = FontFamily(
|
|||||||
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
|
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
|
||||||
)
|
)
|
||||||
|
|
||||||
actual fun pristineFont() = FontFamily(
|
actual fun pristineFont(): FontFamily = FontFamily(
|
||||||
Font(R.font.pristine_script, FontWeight.Bold)
|
Font(R.font.pristine_script, FontWeight.Bold)
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,7 @@ package com.shabinder.common.uikit
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun ImageLoad(
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
|
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.list.SpotiFlyerList
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.TrackDetails
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
|
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
|
||||||
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
|
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
|
||||||
import com.shabinder.common.models.DownloadRecord
|
import com.shabinder.common.models.DownloadRecord
|
||||||
|
@ -69,7 +69,9 @@ fun SpotiFlyerPreferenceContent(component: SpotiFlyerPreference) {
|
|||||||
title = "Preferred Audio Quality",
|
title = "Preferred Audio Quality",
|
||||||
value = model.preferredQuality.kbps + "KBPS"
|
value = model.preferredQuality.kbps + "KBPS"
|
||||||
) { save ->
|
) { save ->
|
||||||
val audioQualities = AudioQuality.values()
|
val audioQualities = AudioQuality.values().toMutableList().apply {
|
||||||
|
remove(AudioQuality.UNKNOWN)
|
||||||
|
}
|
||||||
|
|
||||||
audioQualities.forEach { quality ->
|
audioQualities.forEach { quality ->
|
||||||
Row(
|
Row(
|
||||||
|
@ -12,7 +12,7 @@ actual fun Dialog(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(isVisible) {
|
AnimatedVisibility(isVisible) {
|
||||||
androidx.compose.ui.window.v1.Dialog(onDismiss) {
|
androidx.compose.ui.window.Dialog(onDismiss) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,12 @@ package com.shabinder.common.uikit
|
|||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
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.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.dispatcherIO
|
import com.shabinder.common.models.dispatcherIO
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -31,6 +26,11 @@ actual fun ImageLoad(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Crossfade(pic) {
|
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/>.
|
* * 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
|
||||||
import android.content.Context.CONNECTIVITY_SERVICE
|
import android.content.Context.CONNECTIVITY_SERVICE
|
||||||
@ -24,6 +24,7 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
|
|||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.shabinder.common.core_components.utils.isInternetAccessible
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
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/>.
|
* * 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 android.util.Log
|
||||||
import com.mpatric.mp3agic.ID3v1Tag
|
import com.mpatric.mp3agic.ID3v1Tag
|
||||||
import com.mpatric.mp3agic.ID3v24Tag
|
import com.mpatric.mp3agic.ID3v24Tag
|
||||||
import com.mpatric.mp3agic.Mp3File
|
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.DownloadResult
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
@ -48,7 +49,7 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
|
||||||
val id3v2Tag = ID3v24Tag().apply {
|
val id3v2Tag = ID3v24Tag().apply {
|
||||||
albumArtist = track.albumArtists.joinToString(", ")
|
albumArtist = track.albumArtists.joinToString(", ")
|
||||||
artist = track.artists.joinToString(", ")
|
artist = track.artists.joinToString(", ")
|
||||||
@ -71,7 +72,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
|||||||
fis.close()
|
fis.close()
|
||||||
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
|
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
|
||||||
this.id3v2Tag = id3v2Tag
|
this.id3v2Tag = id3v2Tag
|
||||||
saveFile(track.outputFilePath)
|
saveFile(outputFilePath ?: track.outputFilePath)
|
||||||
} catch (e: java.io.FileNotFoundException) {
|
} catch (e: java.io.FileNotFoundException) {
|
||||||
Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}")
|
Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}")
|
||||||
try {
|
try {
|
||||||
@ -83,7 +84,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
|||||||
is DownloadResult.Success -> {
|
is DownloadResult.Success -> {
|
||||||
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
|
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
|
||||||
this.id3v2Tag = id3v2Tag
|
this.id3v2Tag = id3v2Tag
|
||||||
saveFile(track.outputFilePath)
|
saveFile(outputFilePath ?: track.outputFilePath)
|
||||||
}
|
}
|
||||||
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
|
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) {
|
fun Mp3File.saveFile(filePath: String) {
|
||||||
save(filePath.substringBeforeLast('.') + ".new.mp3")
|
save(filePath.substringBeforeLast('.') + ".tagged.mp3")
|
||||||
val m4aFile = File(filePath)
|
|
||||||
m4aFile.delete()
|
val oldFile = File(filePath)
|
||||||
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
|
oldFile.delete()
|
||||||
|
|
||||||
|
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
|
||||||
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
|
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
|
||||||
}
|
}
|
@ -20,9 +20,6 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
|
||||||
actual data class Picture(
|
|
||||||
var image: ImageBitmap?
|
|
||||||
)
|
|
||||||
fun getMemoryEfficientBitmap(
|
fun getMemoryEfficientBitmap(
|
||||||
input: ByteArray,
|
input: ByteArray,
|
||||||
reqWidth: Int,
|
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/>.
|
* * 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 co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
import com.shabinder.common.core_components.media_converter.MediaConverter
|
||||||
import com.shabinder.common.di.preference.PreferenceManager
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.utils.removeIllegalChars
|
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.DownloadResult
|
||||||
import com.shabinder.common.models.TrackDetails
|
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 com.shabinder.database.Database
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.koin.core.module.Module
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
expect class Dir(
|
internal expect fun fileManagerModule(): Module
|
||||||
logger: Kermit,
|
|
||||||
preferenceManager: PreferenceManager,
|
interface FileManager {
|
||||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
|
||||||
) {
|
val logger: Kermit
|
||||||
|
val preferenceManager: PreferenceManager
|
||||||
|
val mediaConverter: MediaConverter
|
||||||
val db: Database?
|
val db: Database?
|
||||||
|
|
||||||
fun isPresent(path: String): Boolean
|
fun isPresent(path: String): Boolean
|
||||||
|
|
||||||
fun fileSeparator(): String
|
fun fileSeparator(): String
|
||||||
|
|
||||||
fun defaultDir(): String
|
fun defaultDir(): String
|
||||||
|
|
||||||
fun imageCacheDir(): String
|
fun imageCacheDir(): String
|
||||||
|
|
||||||
fun createDirectory(dirPath: String)
|
fun createDirectory(dirPath: String)
|
||||||
|
|
||||||
suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage
|
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 loadImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): Picture
|
||||||
|
|
||||||
suspend fun clearCache()
|
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)
|
fun addToLibrary(path: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Call this function at startup!
|
* Call this function at startup!
|
||||||
* */
|
* */
|
||||||
fun Dir.createDirectories() {
|
fun FileManager.createDirectories() {
|
||||||
try {
|
try {
|
||||||
createDirectory(defaultDir())
|
createDirectory(defaultDir())
|
||||||
createDirectory(imageCacheDir())
|
createDirectory(imageCacheDir())
|
||||||
@ -59,13 +80,21 @@ fun Dir.createDirectories() {
|
|||||||
createDirectory(defaultDir() + "Albums/")
|
createDirectory(defaultDir() + "Albums/")
|
||||||
createDirectory(defaultDir() + "Playlists/")
|
createDirectory(defaultDir() + "Playlists/")
|
||||||
createDirectory(defaultDir() + "YT_Downloads/")
|
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() +
|
defaultDir + removeIllegalChars(type) + this.fileSeparator() +
|
||||||
if (subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator() } +
|
if (subFolder.isEmpty()) "" else {
|
||||||
removeIllegalChars(itemName) + extension
|
removeIllegalChars(subFolder) + this.fileSeparator()
|
||||||
|
} +
|
||||||
|
removeIllegalChars(itemName) + extension
|
||||||
/*DIR Specific Operation End*/
|
/*DIR Specific Operation End*/
|
||||||
|
|
||||||
fun getNameURL(url: String): String {
|
fun getNameURL(url: String): String {
|
||||||
@ -74,28 +103,26 @@ fun getNameURL(url: String): String {
|
|||||||
|
|
||||||
suspend fun downloadFile(url: String): Flow<DownloadResult> {
|
suspend fun downloadFile(url: String): Flow<DownloadResult> {
|
||||||
return flow {
|
return flow {
|
||||||
try {
|
val client = createHttpClient()
|
||||||
val client = createHttpClient()
|
val response = client.get<HttpStatement>(url).execute()
|
||||||
val response = client.get<HttpStatement>(url).execute()
|
val data = ByteArray(response.contentLength()!!.toInt())
|
||||||
val data = ByteArray(response.contentLength()!!.toInt())
|
var offset = 0
|
||||||
var offset = 0
|
do {
|
||||||
do {
|
// Set Length optimally, after how many kb you want a progress update, now it 0.25mb
|
||||||
// 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)
|
||||||
val currentRead = response.content.readAvailable(data, offset, 2_50_000)
|
offset += currentRead
|
||||||
offset += currentRead
|
val progress = (offset * 100f / data.size).roundToInt()
|
||||||
val progress = (offset * 100f / data.size).roundToInt()
|
emit(DownloadResult.Progress(progress))
|
||||||
emit(DownloadResult.Progress(progress))
|
} while (currentRead > 0)
|
||||||
} while (currentRead > 0)
|
if (response.status.isSuccess()) {
|
||||||
if (response.status.isSuccess()) {
|
emit(DownloadResult.Success(data))
|
||||||
emit(DownloadResult.Success(data))
|
} else {
|
||||||
} else {
|
emit(DownloadResult.Error("File not downloaded"))
|
||||||
emit(DownloadResult.Error("File not downloaded"))
|
|
||||||
}
|
|
||||||
client.close()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
emit(DownloadResult.Error(e.message ?: "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/>.
|
* * 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:
|
// Dependencies:
|
||||||
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt")
|
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt")
|
||||||
// implementation("org.jetbrains.kotlinx:atomicfu:0.14.4")
|
// implementation("org.jetbrains.kotlinx:atomicfu:0.14.4")
|
||||||
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
|
// 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 io.ktor.utils.io.core.*
|
||||||
import kotlinx.atomicfu.atomic
|
import kotlinx.atomicfu.atomic
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.selects.select
|
import kotlinx.coroutines.selects.select
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
class ParallelExecutor(
|
interface ParallelProcessor {
|
||||||
parentContext: CoroutineContext = dispatcherIO,
|
|
||||||
) : Closeable {
|
|
||||||
|
|
||||||
private val concurrentOperationLimit = atomic(4)
|
val parallelExecutor: ParallelExecutor
|
||||||
private val coroutineContext = parentContext + Job()
|
|
||||||
|
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 var isClosed = atomic(false)
|
||||||
private val killQueue = Channel<Unit>(Channel.UNLIMITED)
|
private var killQueue = Channel<Unit>(Channel.UNLIMITED)
|
||||||
private val operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
|
private var operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
|
||||||
|
private var concurrentOperationLimit = atomic(concurrentOperationLimit)
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
startOrStopProcessors(expectedCount = concurrentOperationLimit.value, actualCount = 0)
|
startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
@ -58,9 +80,29 @@ class ParallelExecutor(
|
|||||||
|
|
||||||
killQueue.close(cause)
|
killQueue.close(cause)
|
||||||
operationQueue.close(cause)
|
operationQueue.close(cause)
|
||||||
|
service.cancel(cause)
|
||||||
coroutineContext.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 {
|
private fun CoroutineScope.launchProcessor() = launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
val operation = select<Operation<*>?> {
|
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) {
|
withContext(coroutineContext) {
|
||||||
val operation = Operation(block)
|
val operation = Operation(block)
|
||||||
operationQueue.send(operation)
|
operationQueue.send(operation)
|
||||||
@ -80,6 +122,15 @@ class ParallelExecutor(
|
|||||||
operation.result.await()
|
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.
|
// TODO This launches all coroutines in advance even if they're never needed. Find a lazy way to do this.
|
||||||
fun setConcurrentOperationLimit(limit: Int) {
|
fun setConcurrentOperationLimit(limit: Int) {
|
||||||
require(limit >= 1) { "'limit' must be greater than zero: $limit" }
|
require(limit >= 1) { "'limit' must be greater than zero: $limit" }
|
||||||
@ -89,6 +140,7 @@ class ParallelExecutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) {
|
private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) {
|
||||||
|
if (!service.isActive) service = SupervisorJob()
|
||||||
if (expectedCount == actualCount)
|
if (expectedCount == actualCount)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -100,9 +152,7 @@ class ParallelExecutor(
|
|||||||
change -= 1
|
change -= 1
|
||||||
|
|
||||||
if (change > 0)
|
if (change > 0)
|
||||||
with(CoroutineScope(coroutineContext)) {
|
repeat(change) { launchProcessor() }
|
||||||
repeat(change) { launchProcessor() }
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
repeat(-change) { killQueue.trySend(Unit).isSuccess }
|
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.russhwolf.settings.Settings
|
||||||
|
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||||
import com.shabinder.common.models.AudioQuality
|
import com.shabinder.common.models.AudioQuality
|
||||||
|
|
||||||
class PreferenceManager(settings: Settings) : Settings by settings {
|
class PreferenceManager(
|
||||||
|
settings: Settings,
|
||||||
|
) : Settings by settings {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DIR_KEY = "downloadDir"
|
const val DIR_KEY = "downloadDir"
|
||||||
@ -13,9 +16,16 @@ class PreferenceManager(settings: Settings) : Settings by settings {
|
|||||||
const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality"
|
const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lateinit var analyticsManager: AnalyticsManager
|
||||||
|
|
||||||
/* ANALYTICS */
|
/* ANALYTICS */
|
||||||
val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false
|
val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false
|
||||||
fun toggleAnalytics(enabled: Boolean) = putBoolean(ANALYTICS_KEY, enabled)
|
fun toggleAnalytics(enabled: Boolean) {
|
||||||
|
putBoolean(ANALYTICS_KEY, enabled)
|
||||||
|
if (this::analyticsManager.isInitialized) {
|
||||||
|
if (enabled) analyticsManager.giveConsent() else analyticsManager.revokeConsent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* DOWNLOAD DIRECTORY */
|
/* DOWNLOAD DIRECTORY */
|
||||||
val downloadDir get() = getStringOrNull(DIR_KEY)
|
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.Value
|
||||||
import com.arkivanov.decompose.value.ValueObserver
|
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/>.
|
* * 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.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
|
import com.github.kokorin.jaffree.JaffreeException
|
||||||
|
import com.mpatric.mp3agic.InvalidDataException
|
||||||
import com.mpatric.mp3agic.Mp3File
|
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.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.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 com.shabinder.database.Database
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jetbrains.skija.Image
|
import org.jetbrains.skija.Image
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -38,33 +52,45 @@ import java.net.HttpURLConnection
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
actual class Dir actual constructor(
|
internal actual fun fileManagerModule() = module {
|
||||||
private val logger: Kermit,
|
single { DesktopFileManager(get(), get(), get(), get()) } bind FileManager::class
|
||||||
private val preferenceManager: PreferenceManager,
|
}
|
||||||
|
|
||||||
|
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,
|
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||||
) {
|
) : FileManager {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
createDirectories()
|
createDirectories()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun fileSeparator(): String = File.separator
|
override fun fileSeparator(): String = File.separator
|
||||||
|
|
||||||
actual fun imageCacheDir(): String = System.getProperty("user.home") +
|
override fun imageCacheDir(): String = System.getProperty("user.home") +
|
||||||
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
|
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
|
||||||
|
|
||||||
private val defaultBaseDir = System.getProperty("user.home")
|
private val defaultBaseDir = System.getProperty("user.home")
|
||||||
|
|
||||||
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
|
override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
|
||||||
"SpotiFlyer" + 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)
|
val yourAppDir = File(dirPath)
|
||||||
|
|
||||||
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
|
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!" }
|
logger.e { "Unable to create Dir: $dirPath!" }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -72,11 +98,11 @@ actual class Dir actual constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun clearCache() {
|
override suspend fun clearCache() {
|
||||||
File(imageCacheDir()).deleteRecursively()
|
File(imageCacheDir()).deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun cacheImage(image: Any, path: String) {
|
override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
|
||||||
try {
|
try {
|
||||||
(image as? BufferedImage)?.let {
|
(image as? BufferedImage)?.let {
|
||||||
ImageIO.write(it, "jpeg", File(path))
|
ImageIO.write(it, "jpeg", File(path))
|
||||||
@ -87,11 +113,11 @@ actual class Dir actual constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
actual suspend fun saveFileWithMetadata(
|
override suspend fun saveFileWithMetadata(
|
||||||
mp3ByteArray: ByteArray,
|
mp3ByteArray: ByteArray,
|
||||||
trackDetails: TrackDetails,
|
trackDetails: TrackDetails,
|
||||||
postProcess: (track: TrackDetails) -> Unit
|
postProcess: (track: TrackDetails) -> Unit
|
||||||
) {
|
) = withContext(dispatcherIO) {
|
||||||
val songFile = File(trackDetails.outputFilePath)
|
val songFile = File(trackDetails.outputFilePath)
|
||||||
try {
|
try {
|
||||||
/*
|
/*
|
||||||
@ -103,61 +129,52 @@ actual class Dir actual constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray)
|
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('.')) {
|
val conversionResult = mediaConverter.convertAudioFile(
|
||||||
".mp3" -> {
|
inputFilePath = songFile.absolutePath,
|
||||||
Mp3File(File(songFile.absolutePath))
|
outputFilePath = convertedFilePath,
|
||||||
.removeAllTags()
|
trackDetails.audioQuality
|
||||||
.setId3v1Tags(trackDetails)
|
)
|
||||||
.setId3v2TagsAndSaveFile(trackDetails)
|
|
||||||
addToLibrary(songFile.absolutePath)
|
conversionResult.map { outputFilePath ->
|
||||||
}
|
Mp3File(File(outputFilePath))
|
||||||
".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()
|
.removeAllTags()
|
||||||
.setId3v1Tags(trackDetails)
|
.setId3v1Tags(trackDetails)
|
||||||
.setId3v2TagsAndSaveFile(trackDetails)
|
.setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath)
|
||||||
addToLibrary(songFile.absolutePath)
|
|
||||||
} catch (e: Exception) { e.printStackTrace() }
|
addToLibrary(trackDetails.outputFilePath)
|
||||||
}
|
}.fold(
|
||||||
}
|
success = {},
|
||||||
} catch (e: Exception) {
|
failure = {
|
||||||
withContext(Dispatchers.Main) {
|
throw it
|
||||||
// Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show()
|
}
|
||||||
|
)
|
||||||
|
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()
|
if (songFile.exists()) songFile.delete()
|
||||||
logger.e { "${songFile.absolutePath} could not be created" }
|
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)
|
val cachePath = imageCacheDir() + getNameURL(url)
|
||||||
var picture: ImageBitmap? = loadCachedImage(cachePath, reqWidth, reqHeight)
|
var picture: ImageBitmap? = loadCachedImage(cachePath, reqWidth, reqHeight)
|
||||||
if (picture == null) picture = freshImage(url, reqWidth, reqHeight)
|
if (picture == null) picture = freshImage(url, reqWidth, reqHeight)
|
||||||
@ -173,6 +190,7 @@ actual class Dir actual constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? {
|
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? {
|
||||||
return withContext(Dispatchers.IO) {
|
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(
|
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/>.
|
* * 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.ID3v1Tag
|
||||||
import com.mpatric.mp3agic.ID3v24Tag
|
import com.mpatric.mp3agic.ID3v24Tag
|
||||||
import com.mpatric.mp3agic.Mp3File
|
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.DownloadResult
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
@ -47,16 +48,22 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
|
||||||
val id3v2Tag = ID3v24Tag().apply {
|
val id3v2Tag = ID3v24Tag().apply {
|
||||||
artist = track.artists.joinToString(",")
|
albumArtist = track.albumArtists.joinToString(", ")
|
||||||
|
artist = track.artists.joinToString(", ")
|
||||||
title = track.title
|
title = track.title
|
||||||
album = track.albumName
|
album = track.albumName
|
||||||
year = track.year
|
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
|
url = track.trackUrl
|
||||||
|
if (track.trackNumber != null)
|
||||||
|
this.track = track.trackNumber.toString()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val art = File(track.albumArtPath)
|
val art = File(track.albumArtPath)
|
||||||
@ -66,7 +73,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
|||||||
fis.close()
|
fis.close()
|
||||||
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
|
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
|
||||||
this.id3v2Tag = id3v2Tag
|
this.id3v2Tag = id3v2Tag
|
||||||
saveFile(track.outputFilePath)
|
saveFile(outputFilePath ?: track.outputFilePath)
|
||||||
} catch (e: java.io.FileNotFoundException) {
|
} catch (e: java.io.FileNotFoundException) {
|
||||||
try {
|
try {
|
||||||
// Image Still Not Downloaded!
|
// Image Still Not Downloaded!
|
||||||
@ -77,21 +84,23 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
|
|||||||
is DownloadResult.Success -> {
|
is DownloadResult.Success -> {
|
||||||
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
|
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
|
||||||
this.id3v2Tag = id3v2Tag
|
this.id3v2Tag = id3v2Tag
|
||||||
saveFile(track.outputFilePath)
|
saveFile(outputFilePath ?: track.outputFilePath)
|
||||||
}
|
}
|
||||||
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
|
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// log("Error", "Couldn't Write Mp3 Album Art, error: ${e.stackTrace}")
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Mp3File.saveFile(filePath: String) {
|
fun Mp3File.saveFile(filePath: String) {
|
||||||
save(filePath.substringBeforeLast('.') + ".new.mp3")
|
save(filePath.substringBeforeLast('.') + ".tagged.mp3")
|
||||||
val m4aFile = File(filePath)
|
|
||||||
m4aFile.delete()
|
val oldFile = File(filePath)
|
||||||
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
|
oldFile.delete()
|
||||||
|
|
||||||
|
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
|
||||||
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".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:JsModule("file-saver")
|
||||||
@file:JsNonModule
|
@file:JsNonModule
|
||||||
|
|
||||||
package com.shabinder.common.di
|
package com.shabinder.common.core_components
|
||||||
|
|
||||||
import org.w3c.files.Blob
|
import org.w3c.files.Blob
|
||||||
|
|
@ -14,7 +14,7 @@
|
|||||||
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* * 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.khronos.webgl.ArrayBuffer
|
||||||
import org.w3c.files.Blob
|
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)
|
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"),
|
KBPS160("160"),
|
||||||
KBPS192("192"),
|
KBPS192("192"),
|
||||||
KBPS256("256"),
|
KBPS256("256"),
|
||||||
KBPS320("320");
|
KBPS320("320"),
|
||||||
|
UNKNOWN("-1");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getQuality(kbps: String): AudioQuality {
|
fun getQuality(kbps: String): AudioQuality {
|
||||||
@ -15,6 +16,7 @@ enum class AudioQuality(val kbps: String) {
|
|||||||
"192" -> KBPS192
|
"192" -> KBPS192
|
||||||
"256" -> KBPS256
|
"256" -> KBPS256
|
||||||
"320" -> KBPS320
|
"320" -> KBPS320
|
||||||
|
"-1" -> UNKNOWN
|
||||||
else -> KBPS160 // Use 160 as baseline
|
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 progress: Int = 2,
|
||||||
val downloadLink: String? = null,
|
val downloadLink: String? = null,
|
||||||
val downloaded: DownloadStatus = DownloadStatus.NotDownloaded,
|
val downloaded: DownloadStatus = DownloadStatus.NotDownloaded,
|
||||||
|
var audioQuality: AudioQuality = AudioQuality.KBPS192,
|
||||||
var outputFilePath: String, // UriString in Android
|
var outputFilePath: String, // UriString in Android
|
||||||
var videoID: String? = null,
|
var videoID: String? = null,
|
||||||
) : Parcelable
|
) : Parcelable {
|
||||||
|
val outputMp3Path get() = outputFilePath.substringBeforeLast(".") + ".mp3"
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class DownloadStatus : Parcelable {
|
sealed class DownloadStatus : Parcelable {
|
||||||
|
@ -9,7 +9,7 @@ sealed class SpotiFlyerException(override val message: String) : Exception(messa
|
|||||||
|
|
||||||
data class MP3ConversionFailed(
|
data class MP3ConversionFailed(
|
||||||
val extraInfo: String? = null,
|
val extraInfo: String? = null,
|
||||||
override val message: String = "${Strings.mp3ConverterBusy()} \nCAUSE:$extraInfo"
|
override val message: String = /*${Strings.mp3ConverterBusy()} */"CAUSE:$extraInfo"
|
||||||
) : SpotiFlyerException(message)
|
) : SpotiFlyerException(message)
|
||||||
|
|
||||||
data class UnknownReason(
|
data class UnknownReason(
|
||||||
@ -28,13 +28,17 @@ sealed class SpotiFlyerException(override val message: String) : Exception(messa
|
|||||||
) : SpotiFlyerException(message)
|
) : SpotiFlyerException(message)
|
||||||
|
|
||||||
data class DownloadLinkFetchFailed(
|
data class DownloadLinkFetchFailed(
|
||||||
val trackName: String,
|
val errorTrace: String
|
||||||
val jioSaavnError: Throwable,
|
) : SpotiFlyerException(errorTrace) {
|
||||||
val ytMusicError: Throwable,
|
constructor(
|
||||||
override val message: String = "${Strings.noLinkFound()}: $trackName," +
|
trackName: String,
|
||||||
" \n YtMusic Error's StackTrace: ${ytMusicError.stackTraceToString()} \n " +
|
jioSaavnError: Throwable,
|
||||||
" \n JioSaavn Error's StackTrace: ${jioSaavnError.stackTraceToString()} \n "
|
ytMusicError: Throwable,
|
||||||
) : SpotiFlyerException(message)
|
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(
|
data class LinkInvalid(
|
||||||
val link: String? = null,
|
val link: String? = null,
|
||||||
|
@ -155,6 +155,8 @@ sealed class SuspendableEvent<out V : Any?, out E : Throwable> : ReadOnlyPropert
|
|||||||
// Factory methods
|
// Factory methods
|
||||||
fun <E : Throwable> error(ex: E) = Failure<Nothing, E>(ex)
|
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> {
|
inline fun <V : Any?> of(value: V?, crossinline fail: (() -> Throwable) = { Throwable() }): SuspendableEvent<V, Throwable> {
|
||||||
return value?.let { Success<V, Nothing>(it) } ?: error(fail())
|
return value?.let { Success<V, Nothing>(it) } ?: error(fail())
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package com.shabinder.common.models.saavn
|
package com.shabinder.common.models.saavn
|
||||||
|
|
||||||
|
import com.shabinder.common.models.AudioQuality
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@ -43,4 +44,6 @@ data class SaavnSong @OptIn(ExperimentalSerializationApi::class) constructor(
|
|||||||
val vlink: String? = null,
|
val vlink: String? = null,
|
||||||
val year: String,
|
val year: String,
|
||||||
var downloaded: DownloadStatus = DownloadStatus.NotDownloaded
|
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
|
* JSON UTILS
|
@ -1,20 +1,4 @@
|
|||||||
/*
|
package com.shabinder.common.utils
|
||||||
* * 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
|
|
||||||
|
|
||||||
import io.github.shabinder.TargetPlatforms
|
import io.github.shabinder.TargetPlatforms
|
||||||
import io.github.shabinder.activePlatform
|
import io.github.shabinder.activePlatform
|
||||||
@ -22,7 +6,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlin.native.concurrent.ThreadLocal
|
import kotlin.native.concurrent.ThreadLocal
|
||||||
|
|
||||||
@ThreadLocal
|
@ThreadLocal
|
||||||
val json by lazy {
|
val globalJson by lazy {
|
||||||
Json {
|
Json {
|
||||||
isLenient = true
|
isLenient = true
|
||||||
ignoreUnknownKeys = 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
|
package com.shabinder.common.models
|
||||||
|
|
||||||
|
|
||||||
actual interface PlatformActions
|
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
|
@ -30,31 +30,8 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
implementation("org.jetbrains.kotlinx:atomicfu:0.16.2")
|
implementation(project(":common:providers"))
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
|
implementation(project(":common:core-components"))
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import com.shabinder.common.core_components.coreComponentModules
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.shabinder.common.database.databaseModule
|
import com.shabinder.common.database.databaseModule
|
||||||
import com.shabinder.common.database.getLogger
|
import com.shabinder.common.providers.providersModule
|
||||||
import com.shabinder.common.di.preference.PreferenceManager
|
import org.koin.core.KoinApplication
|
||||||
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 org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.module.Module
|
||||||
import org.koin.dsl.KoinAppDeclaration
|
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 = {}) =
|
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
|
||||||
startKoin {
|
startKoin {
|
||||||
appDeclaration()
|
appDeclaration()
|
||||||
|
|
||||||
modules(
|
modules(
|
||||||
commonModule(enableNetworkLogs = enableNetworkLogs),
|
coreComponentModules(enableNetworkLogs),
|
||||||
providersModule(),
|
listOf(
|
||||||
databaseModule()
|
providersModule(enableNetworkLogs),
|
||||||
|
databaseModule(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by IOS
|
// Called by IOS
|
||||||
fun initKoin() = initKoin(enableNetworkLogs = false) { }
|
fun initKoin() = initKoin(enableNetworkLogs = false) { }
|
||||||
|
|
||||||
fun commonModule(enableNetworkLogs: Boolean) = module {
|
private fun KoinApplication.modules(vararg moduleLists: List<Module>): KoinApplication {
|
||||||
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
|
return modules(moduleLists.toList().flatten())
|
||||||
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 {}
|
|
||||||
|
@ -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:dependency-injection"))
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
|
implementation(project(":common:providers"))
|
||||||
|
implementation(project(":common:core-components"))
|
||||||
implementation(SqlDelight.coroutineExtensions)
|
implementation(SqlDelight.coroutineExtensions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,15 +19,15 @@ package com.shabinder.common.list
|
|||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.core_components.file_manager.FileManager
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.preference_manager.PreferenceManager
|
||||||
import com.shabinder.common.di.preference.PreferenceManager
|
|
||||||
import com.shabinder.common.list.integration.SpotiFlyerListImpl
|
import com.shabinder.common.list.integration.SpotiFlyerListImpl
|
||||||
import com.shabinder.common.models.Consumer
|
import com.shabinder.common.models.Consumer
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.PlatformQueryResult
|
import com.shabinder.common.models.PlatformQueryResult
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
|
import com.shabinder.common.providers.FetchPlatformQueryResult
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
interface SpotiFlyerList {
|
interface SpotiFlyerList {
|
||||||
@ -67,7 +67,7 @@ interface SpotiFlyerList {
|
|||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
val storeFactory: StoreFactory
|
val storeFactory: StoreFactory
|
||||||
val fetchQuery: FetchPlatformQueryResult
|
val fetchQuery: FetchPlatformQueryResult
|
||||||
val dir: Dir
|
val fileManager: FileManager
|
||||||
val preferenceManager: PreferenceManager
|
val preferenceManager: PreferenceManager
|
||||||
val link: String
|
val link: String
|
||||||
val listOutput: Consumer<Output>
|
val listOutput: Consumer<Output>
|
||||||
|
@ -18,11 +18,11 @@ package com.shabinder.common.list.integration
|
|||||||
|
|
||||||
import co.touchlab.stately.ensureNeverFrozen
|
import co.touchlab.stately.ensureNeverFrozen
|
||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
import com.arkivanov.decompose.lifecycle.doOnResume
|
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
|
import com.arkivanov.essenty.lifecycle.doOnResume
|
||||||
import com.shabinder.common.caching.Cache
|
import com.shabinder.common.caching.Cache
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.utils.asValue
|
import com.shabinder.common.core_components.utils.asValue
|
||||||
import com.shabinder.common.list.SpotiFlyerList
|
import com.shabinder.common.list.SpotiFlyerList
|
||||||
import com.shabinder.common.list.SpotiFlyerList.Dependencies
|
import com.shabinder.common.list.SpotiFlyerList.Dependencies
|
||||||
import com.shabinder.common.list.SpotiFlyerList.State
|
import com.shabinder.common.list.SpotiFlyerList.State
|
||||||
@ -45,14 +45,7 @@ internal class SpotiFlyerListImpl(
|
|||||||
|
|
||||||
private val store =
|
private val store =
|
||||||
instanceKeeper.getStore {
|
instanceKeeper.getStore {
|
||||||
SpotiFlyerListStoreProvider(
|
SpotiFlyerListStoreProvider(dependencies).provide()
|
||||||
dir = this.dir,
|
|
||||||
preferenceManager = preferenceManager,
|
|
||||||
storeFactory = storeFactory,
|
|
||||||
fetchQuery = fetchQuery,
|
|
||||||
downloadProgressFlow = downloadProgressFlow,
|
|
||||||
link = link
|
|
||||||
).provide()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cache = Cache.Builder
|
private val cache = Cache.Builder
|
||||||
@ -84,8 +77,8 @@ internal class SpotiFlyerListImpl(
|
|||||||
|
|
||||||
override suspend fun loadImage(url: String, isCover: Boolean): Picture {
|
override suspend fun loadImage(url: String, isCover: Boolean): Picture {
|
||||||
return cache.get(url) {
|
return cache.get(url) {
|
||||||
if (isCover) dir.loadImage(url, 350, 350)
|
if (isCover) fileManager.loadImage(url, 350, 350)
|
||||||
else dir.loadImage(url, 150, 150)
|
else fileManager.loadImage(url, 150, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
package com.shabinder.common.list.store
|
package com.shabinder.common.list.store
|
||||||
|
|
||||||
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
|
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
|
||||||
import com.arkivanov.decompose.instancekeeper.getOrCreate
|
import com.arkivanov.essenty.instancekeeper.getOrCreate
|
||||||
import com.arkivanov.mvikotlin.core.store.Store
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
|
|
||||||
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =
|
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.Reducer
|
||||||
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||||
import com.arkivanov.mvikotlin.core.store.Store
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
|
||||||
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.list.SpotiFlyerList
|
||||||
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.State
|
import com.shabinder.common.list.SpotiFlyerList.State
|
||||||
import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
|
import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.PlatformQueryResult
|
import com.shabinder.common.models.PlatformQueryResult
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import com.shabinder.common.providers.downloadTracks
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
internal class SpotiFlyerListStoreProvider(
|
internal class SpotiFlyerListStoreProvider(dependencies: SpotiFlyerList.Dependencies) :
|
||||||
private val dir: Dir,
|
SpotiFlyerList.Dependencies by dependencies {
|
||||||
private val preferenceManager: PreferenceManager,
|
|
||||||
private val storeFactory: StoreFactory,
|
|
||||||
private val fetchQuery: FetchPlatformQueryResult,
|
|
||||||
private val link: String,
|
|
||||||
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
|
||||||
) {
|
|
||||||
fun provide(): SpotiFlyerListStore =
|
fun provide(): SpotiFlyerListStore =
|
||||||
object :
|
object :
|
||||||
SpotiFlyerListStore,
|
SpotiFlyerListStore,
|
||||||
@ -66,7 +56,7 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
||||||
executeIntent(Intent.SearchLink(link), getState)
|
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
|
// 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")
|
fetchQuery.logger.d(message = { "Database List Last ID: $it" }, tag = "Database Last ID")
|
||||||
val offset = preferenceManager.getDonationOffset
|
val offset = preferenceManager.getDonationOffset
|
||||||
@ -92,7 +82,12 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
resp.fold(
|
resp.fold(
|
||||||
success = { result ->
|
success = { result ->
|
||||||
result.trackList = result.trackList.toMutableList()
|
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)
|
executeIntent(Intent.RefreshTracksStatuses, getState)
|
||||||
},
|
},
|
||||||
failure = {
|
failure = {
|
||||||
@ -103,25 +98,32 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
|
|
||||||
is Intent.StartDownloadAll -> {
|
is Intent.StartDownloadAll -> {
|
||||||
val list = intent.trackList.map {
|
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)
|
return@map it.copy(downloaded = DownloadStatus.Queued)
|
||||||
it
|
it
|
||||||
}
|
}
|
||||||
dispatch(Result.UpdateTrackList(list.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() })))
|
dispatch(
|
||||||
|
Result.UpdateTrackList(
|
||||||
|
list.updateTracksStatuses(
|
||||||
|
downloadProgressFlow.replayCache.getOrElse(
|
||||||
|
0
|
||||||
|
) { hashMapOf() })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
val finalList =
|
val finalList = intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
|
||||||
intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
|
if (finalList.isEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
|
||||||
if (finalList.isNullOrEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
|
else downloadTracks(finalList, fetchQuery, fileManager)
|
||||||
else downloadTracks(finalList, fetchQuery, dir)
|
|
||||||
}
|
}
|
||||||
is Intent.StartDownload -> {
|
is Intent.StartDownload -> {
|
||||||
dispatch(Result.UpdateTrackItem(intent.track.copy(downloaded = DownloadStatus.Queued)))
|
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()
|
is Intent.RefreshTracksStatuses -> methods.value.queryActiveTracks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private object ReducerImpl : Reducer<State, Result> {
|
private object ReducerImpl : Reducer<State, Result> {
|
||||||
override fun State.reduce(result: Result): State =
|
override fun State.reduce(result: Result): State =
|
||||||
when (result) {
|
when (result) {
|
||||||
@ -140,6 +142,7 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<TrackDetails>.updateTracksStatuses(map: HashMap<String, DownloadStatus>): List<TrackDetails> {
|
private fun List<TrackDetails>.updateTracksStatuses(map: HashMap<String, DownloadStatus>): List<TrackDetails> {
|
||||||
val titleList = this.map { it.title }
|
val titleList = this.map { it.title }
|
||||||
val updatedList = mutableListOf<TrackDetails>().also { it.addAll(this) }
|
val updatedList = mutableListOf<TrackDetails>().also { it.addAll(this) }
|
||||||
@ -147,7 +150,11 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
for (newTrack in map) {
|
for (newTrack in map) {
|
||||||
titleList.indexOf(newTrack.key).let { position ->
|
titleList.indexOf(newTrack.key).let { position ->
|
||||||
if (position != -1) {
|
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
|
updatedList[position] = updatedTrack
|
||||||
// logger.d("$position) ${updatedTrack.downloaded} - ${updatedTrack.title}","List Store Track Update")
|
// logger.d("$position) ${updatedTrack.downloaded} - ${updatedTrack.title}","List Store Track Update")
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,8 @@ kotlin {
|
|||||||
implementation(project(":common:dependency-injection"))
|
implementation(project(":common:dependency-injection"))
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
|
implementation(project(":common:providers"))
|
||||||
|
implementation(project(":common:core-components"))
|
||||||
implementation(SqlDelight.coroutineExtensions)
|
implementation(SqlDelight.coroutineExtensions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,9 +19,10 @@ package com.shabinder.common.main
|
|||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.file_manager.FileManager
|
||||||
import com.shabinder.common.di.preference.PreferenceManager
|
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.main.integration.SpotiFlyerMainImpl
|
||||||
import com.shabinder.common.models.Consumer
|
import com.shabinder.common.models.Consumer
|
||||||
import com.shabinder.common.models.DownloadRecord
|
import com.shabinder.common.models.DownloadRecord
|
||||||
@ -65,8 +66,9 @@ interface SpotiFlyerMain {
|
|||||||
val mainOutput: Consumer<Output>
|
val mainOutput: Consumer<Output>
|
||||||
val storeFactory: StoreFactory
|
val storeFactory: StoreFactory
|
||||||
val database: Database?
|
val database: Database?
|
||||||
val dir: Dir
|
val fileManager: FileManager
|
||||||
val preferenceManager: PreferenceManager
|
val preferenceManager: PreferenceManager
|
||||||
|
val analyticsManager: AnalyticsManager
|
||||||
val mainAnalytics: Analytics
|
val mainAnalytics: Analytics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,16 +18,13 @@ package com.shabinder.common.main.integration
|
|||||||
|
|
||||||
import co.touchlab.stately.ensureNeverFrozen
|
import co.touchlab.stately.ensureNeverFrozen
|
||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
import com.arkivanov.decompose.lifecycle.doOnResume
|
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
|
import com.arkivanov.essenty.lifecycle.doOnResume
|
||||||
import com.shabinder.common.caching.Cache
|
import com.shabinder.common.caching.Cache
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.utils.asValue
|
import com.shabinder.common.core_components.utils.asValue
|
||||||
import com.shabinder.common.main.SpotiFlyerMain
|
import com.shabinder.common.main.SpotiFlyerMain
|
||||||
import com.shabinder.common.main.SpotiFlyerMain.Dependencies
|
import com.shabinder.common.main.SpotiFlyerMain.*
|
||||||
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.store.SpotiFlyerMainStore.Intent
|
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
|
||||||
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
|
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
|
||||||
import com.shabinder.common.main.store.getStore
|
import com.shabinder.common.main.store.getStore
|
||||||
@ -47,12 +44,7 @@ internal class SpotiFlyerMainImpl(
|
|||||||
|
|
||||||
private val store =
|
private val store =
|
||||||
instanceKeeper.getStore {
|
instanceKeeper.getStore {
|
||||||
SpotiFlyerMainStoreProvider(
|
SpotiFlyerMainStoreProvider(dependencies).provide()
|
||||||
preferenceManager = preferenceManager,
|
|
||||||
storeFactory = storeFactory,
|
|
||||||
database = database,
|
|
||||||
dir = dir
|
|
||||||
).provide()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cache = Cache.Builder
|
private val cache = Cache.Builder
|
||||||
@ -83,7 +75,7 @@ internal class SpotiFlyerMainImpl(
|
|||||||
|
|
||||||
override suspend fun loadImage(url: String): Picture {
|
override suspend fun loadImage(url: String): Picture {
|
||||||
return cache.get(url) {
|
return cache.get(url) {
|
||||||
dir.loadImage(url, 150, 150)
|
fileManager.loadImage(url, 150, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
package com.shabinder.common.main.store
|
package com.shabinder.common.main.store
|
||||||
|
|
||||||
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
|
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
|
||||||
import com.arkivanov.decompose.instancekeeper.getOrCreate
|
import com.arkivanov.essenty.instancekeeper.getOrCreate
|
||||||
import com.arkivanov.mvikotlin.core.store.Store
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
|
|
||||||
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =
|
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.Reducer
|
||||||
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||||
import com.arkivanov.mvikotlin.core.store.Store
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
|
||||||
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
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
|
||||||
import com.shabinder.common.main.SpotiFlyerMain.State
|
import com.shabinder.common.main.SpotiFlyerMain.State
|
||||||
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
|
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
|
||||||
import com.shabinder.common.models.DownloadRecord
|
import com.shabinder.common.models.DownloadRecord
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
import com.shabinder.database.Database
|
|
||||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -36,12 +32,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
internal class SpotiFlyerMainStoreProvider(
|
internal class SpotiFlyerMainStoreProvider(dependencies: SpotiFlyerMain.Dependencies): SpotiFlyerMain.Dependencies by dependencies {
|
||||||
private val storeFactory: StoreFactory,
|
|
||||||
private val preferenceManager: PreferenceManager,
|
|
||||||
private val dir: Dir,
|
|
||||||
database: Database?
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun provide(): SpotiFlyerMainStore =
|
fun provide(): SpotiFlyerMainStore =
|
||||||
object :
|
object :
|
||||||
|
@ -28,6 +28,8 @@ kotlin {
|
|||||||
implementation(project(":common:dependency-injection"))
|
implementation(project(":common:dependency-injection"))
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
|
implementation(project(":common:core-components"))
|
||||||
|
implementation(project(":common:providers"))
|
||||||
implementation(SqlDelight.coroutineExtensions)
|
implementation(SqlDelight.coroutineExtensions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,9 +19,10 @@ package com.shabinder.common.preference
|
|||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.core_components.analytics.AnalyticsManager
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.file_manager.FileManager
|
||||||
import com.shabinder.common.di.preference.PreferenceManager
|
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.Actions
|
||||||
import com.shabinder.common.models.AudioQuality
|
import com.shabinder.common.models.AudioQuality
|
||||||
import com.shabinder.common.models.Consumer
|
import com.shabinder.common.models.Consumer
|
||||||
@ -44,8 +45,9 @@ interface SpotiFlyerPreference {
|
|||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
val prefOutput: Consumer<Output>
|
val prefOutput: Consumer<Output>
|
||||||
val storeFactory: StoreFactory
|
val storeFactory: StoreFactory
|
||||||
val dir: Dir
|
val fileManager: FileManager
|
||||||
val preferenceManager: PreferenceManager
|
val preferenceManager: PreferenceManager
|
||||||
|
val analyticsManager: AnalyticsManager
|
||||||
val actions: Actions
|
val actions: Actions
|
||||||
val preferenceAnalytics: Analytics
|
val preferenceAnalytics: Analytics
|
||||||
}
|
}
|
||||||
@ -64,5 +66,8 @@ interface SpotiFlyerPreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("FunctionName") // Factory function
|
@Suppress("FunctionName") // Factory function
|
||||||
fun SpotiFlyerPreference(componentContext: ComponentContext, dependencies: SpotiFlyerPreference.Dependencies): SpotiFlyerPreference =
|
fun SpotiFlyerPreference(
|
||||||
|
componentContext: ComponentContext,
|
||||||
|
dependencies: SpotiFlyerPreference.Dependencies
|
||||||
|
): SpotiFlyerPreference =
|
||||||
SpotiFlyerPreferenceImpl(componentContext, dependencies)
|
SpotiFlyerPreferenceImpl(componentContext, dependencies)
|
||||||
|
@ -20,8 +20,8 @@ import co.touchlab.stately.ensureNeverFrozen
|
|||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
import com.shabinder.common.caching.Cache
|
import com.shabinder.common.caching.Cache
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.core_components.picture.Picture
|
||||||
import com.shabinder.common.di.utils.asValue
|
import com.shabinder.common.core_components.utils.asValue
|
||||||
import com.shabinder.common.models.AudioQuality
|
import com.shabinder.common.models.AudioQuality
|
||||||
import com.shabinder.common.preference.SpotiFlyerPreference
|
import com.shabinder.common.preference.SpotiFlyerPreference
|
||||||
import com.shabinder.common.preference.SpotiFlyerPreference.Dependencies
|
import com.shabinder.common.preference.SpotiFlyerPreference.Dependencies
|
||||||
@ -41,12 +41,7 @@ internal class SpotiFlyerPreferenceImpl(
|
|||||||
|
|
||||||
private val store =
|
private val store =
|
||||||
instanceKeeper.getStore {
|
instanceKeeper.getStore {
|
||||||
SpotiFlyerPreferenceStoreProvider(
|
SpotiFlyerPreferenceStoreProvider(dependencies).provide()
|
||||||
storeFactory = storeFactory,
|
|
||||||
preferenceManager = preferenceManager,
|
|
||||||
dir = dir,
|
|
||||||
actions = actions
|
|
||||||
).provide()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cache = Cache.Builder
|
private val cache = Cache.Builder
|
||||||
@ -74,7 +69,7 @@ internal class SpotiFlyerPreferenceImpl(
|
|||||||
|
|
||||||
override suspend fun loadImage(url: String): Picture {
|
override suspend fun loadImage(url: String): Picture {
|
||||||
return cache.get(url) {
|
return cache.get(url) {
|
||||||
dir.loadImage(url, 150, 150)
|
fileManager.loadImage(url, 150, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user