mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-11-22 09:04:32 +01:00
Merge pull request #187 from Shabinder/better_error_handling
Many Changes, see message 👀
- Better Error handling (Done) , Bubble up the exception to the caller and we will show it to the user in GUI (TODO)
- Bound Service , Removed Broadcast Receivers
- Notification Cleanup, Basic ProgressBar Added
- Internationalization Support (WIP)
- Preference Screen (WIP)
- Code Cleanup and refactoring
This commit is contained in:
commit
00b8c55e6e
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -1,3 +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"]
|
||||||
|
path = mosaic
|
||||||
|
url = https://github.com/JakeWharton/mosaic
|
||||||
|
@ -121,17 +121,23 @@ dependencies {
|
|||||||
implementation(MVIKotlin.mvikotlinTimeTravel)
|
implementation(MVIKotlin.mvikotlinTimeTravel)
|
||||||
|
|
||||||
// Extras
|
// Extras
|
||||||
Extras.Android.apply {
|
with(Extras.Android) {
|
||||||
implementation(Acra.notification)
|
implementation(Acra.notification)
|
||||||
implementation(Acra.http)
|
implementation(Acra.http)
|
||||||
implementation(appUpdator)
|
implementation(appUpdator)
|
||||||
implementation(matomo)
|
implementation(matomo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with(Versions.androidxLifecycle) {
|
||||||
|
implementation("androidx.lifecycle:lifecycle-service:$this")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-common-java8:$this")
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation(Extras.kermit)
|
||||||
//implementation("com.jakewharton.timber:timber:4.7.1")
|
//implementation("com.jakewharton.timber:timber:4.7.1")
|
||||||
implementation("dev.icerock.moko:parcelize:0.7.0")
|
implementation("dev.icerock.moko:parcelize:0.7.0")
|
||||||
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.11.1")
|
implementation("com.google.accompanist:accompanist-insets:0.12.0")
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
@ -72,6 +72,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name="com.shabinder.common.di.worker.ForegroundService"/>
|
<service android:name=".service.ForegroundService"/>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
@ -19,6 +19,7 @@ package com.shabinder.spotiflyer
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
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.httpSender
|
||||||
import org.acra.config.notification
|
import org.acra.config.notification
|
||||||
@ -77,10 +78,10 @@ class App: Application(), KoinComponent {
|
|||||||
* Obeying `F-Droid Inclusion Privacy Rules`
|
* Obeying `F-Droid Inclusion Privacy Rules`
|
||||||
* */
|
* */
|
||||||
notification {
|
notification {
|
||||||
title = getString(R.string.acra_notification_title)
|
title = Strings.acraNotificationTitle()
|
||||||
text = getString(R.string.acra_notification_text)
|
text = Strings.acraNotificationText()
|
||||||
channelName = getString(R.string.acra_notification_channel)
|
channelName = "SpotiFlyer_Crashlytics"
|
||||||
channelDescription = getString(R.string.acra_notification_channel_desc)
|
channelDescription = "Notification Channel to send Spotiflyer Crashes."
|
||||||
sendOnClick = true
|
sendOnClick = true
|
||||||
}
|
}
|
||||||
// Send Crash Report to self hosted Acrarium (FOSS)
|
// Send Crash Report to self hosted Acrarium (FOSS)
|
||||||
|
@ -17,15 +17,16 @@
|
|||||||
package com.shabinder.spotiflyer
|
package com.shabinder.spotiflyer
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
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
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@ -51,18 +52,18 @@ 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.*
|
import com.shabinder.common.di.*
|
||||||
import com.shabinder.common.di.worker.ForegroundService
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.models.Actions
|
import com.shabinder.common.models.Actions
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.PlatformActions
|
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.Status
|
|
||||||
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 com.shabinder.common.root.SpotiFlyerRoot
|
import com.shabinder.common.root.SpotiFlyerRoot
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
|
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.uikit.*
|
import com.shabinder.common.uikit.*
|
||||||
|
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
|
||||||
@ -78,14 +79,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val fetcher: FetchPlatformQueryResult by inject()
|
private val fetcher: FetchPlatformQueryResult by inject()
|
||||||
private val dir: Dir by inject()
|
private val dir: Dir by inject()
|
||||||
|
private val preferenceManager: PreferenceManager by inject()
|
||||||
private lateinit var root: SpotiFlyerRoot
|
private lateinit var root: SpotiFlyerRoot
|
||||||
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks
|
private val callBacks: SpotiFlyerRootCallBacks get() = root.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 lateinit var updateUIReceiver: BroadcastReceiver
|
|
||||||
private lateinit var queryReceiver: BroadcastReceiver
|
|
||||||
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
|
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
|
||||||
private val tracker get() = (application as App).tracker
|
private val tracker get() = (application as App).tracker
|
||||||
|
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
|
||||||
|
|
||||||
|
// Variable for storing instance of our service class
|
||||||
|
var foregroundService: ForegroundService? = null
|
||||||
|
|
||||||
|
// Boolean to check if our activity is bound to service or not
|
||||||
|
var isServiceBound: Boolean? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@ -124,18 +131,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
AnalyticsDialog(
|
AnalyticsDialog(
|
||||||
askForAnalyticsPermission,
|
askForAnalyticsPermission,
|
||||||
enableAnalytics = {
|
enableAnalytics = {
|
||||||
dir.toggleAnalytics(true)
|
preferenceManager.toggleAnalytics(true)
|
||||||
dir.firstLaunchDone()
|
preferenceManager.firstLaunchDone()
|
||||||
},
|
},
|
||||||
dismissDialog = {
|
dismissDialog = {
|
||||||
askForAnalyticsPermission = false
|
askForAnalyticsPermission = false
|
||||||
dir.firstLaunchDone()
|
preferenceManager.firstLaunchDone()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(view) {
|
LaunchedEffect(view) {
|
||||||
permissionGranted.value = checkPermissions()
|
permissionGranted.value = checkPermissions()
|
||||||
if(dir.isFirstLaunch) {
|
if(preferenceManager.isFirstLaunch) {
|
||||||
delay(2500)
|
delay(2500)
|
||||||
// Ask For Analytics Permission on first Dialog
|
// Ask For Analytics Permission on first Dialog
|
||||||
askForAnalyticsPermission = true
|
askForAnalyticsPermission = true
|
||||||
@ -149,63 +156,79 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initialise() {
|
private fun initialise() {
|
||||||
val isGithubRelease = checkAppSignature(this).also {
|
val isGithubRelease = checkAppSignature(this)
|
||||||
Log.i("SpotiFlyer Github Rel.:",it.toString())
|
|
||||||
}
|
|
||||||
/*
|
/*
|
||||||
* Only Send an `Update Notification` on Github Release Builds
|
* Only Send an `Update Notification` on Github Release Builds
|
||||||
* 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) { checkIfLatestVersion() }
|
||||||
if(dir.isAnalyticsEnabled && !isGithubRelease) {
|
if(preferenceManager.isAnalyticsEnabled && !isGithubRelease) {
|
||||||
// Download/App Install Event for F-Droid builds
|
// Download/App Install Event for F-Droid builds
|
||||||
TrackHelper.track().download().with(tracker)
|
TrackHelper.track().download().with(tracker)
|
||||||
}
|
}
|
||||||
handleIntentFromExternalActivity()
|
handleIntentFromExternalActivity()
|
||||||
|
|
||||||
|
initForegroundService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*START: Foreground Service Handlers*/
|
||||||
|
private fun initForegroundService() {
|
||||||
|
// Start and then Bind to the Service
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
this@MainActivity,
|
||||||
|
Intent(this, ForegroundService::class.java)
|
||||||
|
)
|
||||||
|
bindService()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for getting the instance of binder from our service class
|
||||||
|
* So client can get instance of our service class and can directly communicate with it.
|
||||||
|
*/
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
val tag = "Service Connection"
|
||||||
|
|
||||||
|
override fun onServiceConnected(className: ComponentName, iBinder: IBinder) {
|
||||||
|
Log.d(tag, "connected to service.")
|
||||||
|
// We've bound to MyService, cast the IBinder and get MyBinder instance
|
||||||
|
val binder = iBinder as ForegroundService.DownloadServiceBinder
|
||||||
|
foregroundService = binder.service
|
||||||
|
isServiceBound = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
foregroundService?.trackStatusFlowMap?.statusFlow?.let {
|
||||||
|
trackStatusFlow.emitAll(it.conflate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||||
|
Log.d(tag, "disconnected from service.")
|
||||||
|
isServiceBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*Used to bind to our service class*/
|
||||||
|
private fun bindService() {
|
||||||
|
Intent(this, ForegroundService::class.java).also { intent ->
|
||||||
|
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*Used to unbind from our service class*/
|
||||||
|
private fun unbindService() {
|
||||||
|
Intent(this, ForegroundService::class.java).also {
|
||||||
|
unbindService(serviceConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*END: Foreground Service Handlers*/
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun isInternetAvailableState(): State<Boolean?> {
|
private fun isInternetAvailableState(): State<Boolean?> {
|
||||||
return internetAvailability.observeAsState()
|
return internetAvailability.observeAsState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun setUpOnPrefClickListener() {
|
|
||||||
// Initialize Builder
|
|
||||||
val chooser = StorageChooser.Builder()
|
|
||||||
.withActivity(this)
|
|
||||||
.withFragmentManager(fragmentManager)
|
|
||||||
.withMemoryBar(true)
|
|
||||||
.setTheme(StorageChooser.Theme(applicationContext).apply {
|
|
||||||
scheme = applicationContext.resources.getIntArray(R.array.default_dark)
|
|
||||||
})
|
|
||||||
.setDialogTitle("Set Download Directory")
|
|
||||||
.allowCustomPath(true)
|
|
||||||
.setType(StorageChooser.DIRECTORY_CHOOSER)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// get path that the user has chosen
|
|
||||||
chooser.setOnSelectListener { path ->
|
|
||||||
Log.d("Setting Base Path", path)
|
|
||||||
val f = File(path)
|
|
||||||
if (f.canWrite()) {
|
|
||||||
// hell yeah :)
|
|
||||||
dir.setDownloadDirectory(path)
|
|
||||||
showPopUpMessage(
|
|
||||||
"Download Directory Set to:\n${dir.defaultDir()} "
|
|
||||||
)
|
|
||||||
}else{
|
|
||||||
showPopUpMessage(
|
|
||||||
"NO WRITE ACCESS on \n$path ,\nReverting Back to Previous"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show dialog whenever you want by
|
|
||||||
chooser.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showPopUpMessage(string: String, long: Boolean = false) {
|
private fun showPopUpMessage(string: String, long: Boolean = false) {
|
||||||
android.widget.Toast.makeText(
|
android.widget.Toast.makeText(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@ -225,9 +248,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
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.dir.db
|
||||||
override val fetchPlatformQueryResult = this@MainActivity.fetcher
|
override val fetchQuery = this@MainActivity.fetcher
|
||||||
override val directories: Dir = this@MainActivity.dir
|
override val dir: Dir = this@MainActivity.dir
|
||||||
override val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
|
override val preferenceManager = this@MainActivity.preferenceManager
|
||||||
|
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 {
|
||||||
@ -243,12 +267,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendTracksToService(array: ArrayList<TrackDetails>) {
|
override fun sendTracksToService(array: List<TrackDetails>) {
|
||||||
for (list in array.chunked(50)) {
|
if (foregroundService == null) initForegroundService()
|
||||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
foregroundService?.downloadAllTracks(array)
|
||||||
serviceIntent.putParcelableArrayListExtra("object", list as ArrayList)
|
|
||||||
ContextCompat.startForegroundService(this@MainActivity, serviceIntent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,12 +277,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun setDownloadDirectoryAction() = setUpOnPrefClickListener()
|
override fun setDownloadDirectoryAction() = setUpOnPrefClickListener()
|
||||||
|
|
||||||
override fun queryActiveTracks() {
|
override fun queryActiveTracks() = this@MainActivity.queryActiveTracks()
|
||||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java).apply {
|
|
||||||
action = "query"
|
|
||||||
}
|
|
||||||
ContextCompat.startForegroundService(this@MainActivity, serviceIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
||||||
@ -303,7 +319,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
* */
|
* */
|
||||||
override val analytics = object: Analytics {
|
override val analytics = object: Analytics {
|
||||||
override fun appLaunchEvent() {
|
override fun appLaunchEvent() {
|
||||||
if(dir.isAnalyticsEnabled){
|
if(preferenceManager.isAnalyticsEnabled){
|
||||||
TrackHelper.track()
|
TrackHelper.track()
|
||||||
.event("events","App_Launch")
|
.event("events","App_Launch")
|
||||||
.name("App Launch").with(tracker)
|
.name("App Launch").with(tracker)
|
||||||
@ -311,7 +327,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun homeScreenVisit() {
|
override fun homeScreenVisit() {
|
||||||
if(dir.isAnalyticsEnabled){
|
if(preferenceManager.isAnalyticsEnabled){
|
||||||
// HomeScreen Visit Event
|
// HomeScreen Visit Event
|
||||||
TrackHelper.track().screen("/main_activity/home_screen")
|
TrackHelper.track().screen("/main_activity/home_screen")
|
||||||
.title("HomeScreen").with(tracker)
|
.title("HomeScreen").with(tracker)
|
||||||
@ -319,7 +335,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun listScreenVisit() {
|
override fun listScreenVisit() {
|
||||||
if(dir.isAnalyticsEnabled){
|
if(preferenceManager.isAnalyticsEnabled){
|
||||||
// ListScreen Visit Event
|
// ListScreen Visit Event
|
||||||
TrackHelper.track().screen("/main_activity/list_screen")
|
TrackHelper.track().screen("/main_activity/list_screen")
|
||||||
.title("ListScreen").with(tracker)
|
.title("ListScreen").with(tracker)
|
||||||
@ -327,7 +343,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun donationDialogVisit() {
|
override fun donationDialogVisit() {
|
||||||
if (dir.isAnalyticsEnabled) {
|
if (preferenceManager.isAnalyticsEnabled) {
|
||||||
// Donation Dialog Open Event
|
// Donation Dialog Open Event
|
||||||
TrackHelper.track().screen("/main_activity/donation_dialog")
|
TrackHelper.track().screen("/main_activity/donation_dialog")
|
||||||
.title("DonationDialog").with(tracker)
|
.title("DonationDialog").with(tracker)
|
||||||
@ -337,6 +353,54 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun queryActiveTracks() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
foregroundService?.trackStatusFlowMap?.let { tracksStatus ->
|
||||||
|
trackStatusFlow.emit(tracksStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
queryActiveTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun setUpOnPrefClickListener() {
|
||||||
|
// Initialize Builder
|
||||||
|
val chooser = StorageChooser.Builder()
|
||||||
|
.withActivity(this)
|
||||||
|
.withFragmentManager(fragmentManager)
|
||||||
|
.withMemoryBar(true)
|
||||||
|
.setTheme(StorageChooser.Theme(applicationContext).apply {
|
||||||
|
scheme = applicationContext.resources.getIntArray(R.array.default_dark)
|
||||||
|
})
|
||||||
|
.setDialogTitle("Set Download Directory")
|
||||||
|
.allowCustomPath(true)
|
||||||
|
.setType(StorageChooser.DIRECTORY_CHOOSER)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// get path that the user has chosen
|
||||||
|
chooser.setOnSelectListener { path ->
|
||||||
|
Log.d("Setting Base Path", path)
|
||||||
|
val f = File(path)
|
||||||
|
if (f.canWrite()) {
|
||||||
|
// hell yeah :)
|
||||||
|
preferenceManager.setDownloadDirectory(path)
|
||||||
|
showPopUpMessage(
|
||||||
|
"Download Directory Set to:\n${dir.defaultDir()} "
|
||||||
|
)
|
||||||
|
}else{
|
||||||
|
showPopUpMessage(
|
||||||
|
"NO WRITE ACCESS on \n$path ,\nReverting Back to Previous"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dialog whenever you want by
|
||||||
|
chooser.show()
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("ObsoleteSdkInt")
|
@SuppressLint("ObsoleteSdkInt")
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
@ -357,76 +421,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Broadcast Handlers
|
|
||||||
* */
|
|
||||||
private fun initializeBroadcast(){
|
|
||||||
val intentFilter = IntentFilter().apply {
|
|
||||||
addAction(Status.QUEUED.name)
|
|
||||||
addAction(Status.FAILED.name)
|
|
||||||
addAction(Status.DOWNLOADING.name)
|
|
||||||
addAction(Status.COMPLETED.name)
|
|
||||||
addAction("Progress")
|
|
||||||
addAction("Converting")
|
|
||||||
}
|
|
||||||
updateUIReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
//Update Flow with latest details
|
|
||||||
if (intent != null) {
|
|
||||||
val trackDetails = intent.getParcelableExtra<TrackDetails?>("track")
|
|
||||||
trackDetails?.let { track ->
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val latestMap = trackStatusFlow.replayCache.getOrElse(0
|
|
||||||
) { hashMapOf() }.apply {
|
|
||||||
this[track.title] = when (intent.action) {
|
|
||||||
Status.QUEUED.name -> DownloadStatus.Queued
|
|
||||||
Status.FAILED.name -> DownloadStatus.Failed
|
|
||||||
Status.DOWNLOADING.name -> DownloadStatus.Downloading()
|
|
||||||
"Progress" -> DownloadStatus.Downloading(intent.getIntExtra("progress", 0))
|
|
||||||
"Converting" -> DownloadStatus.Converting
|
|
||||||
Status.COMPLETED.name -> DownloadStatus.Downloaded
|
|
||||||
else -> DownloadStatus.NotDownloaded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trackStatusFlow.emit(latestMap)
|
|
||||||
Log.i("Track Update",track.title + track.downloaded.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val queryFilter = IntentFilter().apply { addAction("query_result") }
|
|
||||||
queryReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
//UI update here
|
|
||||||
if (intent != null){
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val trackList = intent.getSerializableExtra("tracks") as? HashMap<String, DownloadStatus>?
|
|
||||||
trackList?.let { list ->
|
|
||||||
Log.i("Service Response", "${list.size} Tracks Active")
|
|
||||||
lifecycleScope.launch {
|
|
||||||
trackStatusFlow.emit(list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
registerReceiver(updateUIReceiver, intentFilter)
|
|
||||||
registerReceiver(queryReceiver, queryFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
initializeBroadcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
unregisterReceiver(updateUIReceiver)
|
|
||||||
unregisterReceiver(queryReceiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntentFromExternalActivity(intent)
|
handleIntentFromExternalActivity(intent)
|
||||||
@ -451,6 +445,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
unbindService()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val disableDozeCode = 1223
|
const val disableDozeCode = 1223
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
* 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.spotiflyer.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import co.touchlab.kermit.Kermit
|
||||||
|
import com.shabinder.common.di.Dir
|
||||||
|
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||||
|
import com.shabinder.common.di.R
|
||||||
|
import com.shabinder.common.di.downloadFile
|
||||||
|
import com.shabinder.common.di.utils.ParallelExecutor
|
||||||
|
import com.shabinder.common.models.DownloadResult
|
||||||
|
import com.shabinder.common.models.DownloadStatus
|
||||||
|
import com.shabinder.common.models.TrackDetails
|
||||||
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
|
import com.shabinder.common.models.event.coroutines.failure
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.AutoClear
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.autoClear
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ForegroundService : LifecycleService() {
|
||||||
|
|
||||||
|
private var downloadService: AutoClear<ParallelExecutor> = autoClear { ParallelExecutor(Dispatchers.IO) }
|
||||||
|
val trackStatusFlowMap by autoClear { TrackStatusFlowMap(MutableSharedFlow(replay = 1),lifecycleScope) }
|
||||||
|
private val fetcher: FetchPlatformQueryResult by inject()
|
||||||
|
private val logger: Kermit by inject()
|
||||||
|
private val dir: Dir by inject()
|
||||||
|
|
||||||
|
private var messageList = MutableList(5) { emptyMessage }
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var isServiceStarted = false
|
||||||
|
private val cancelIntent: PendingIntent by lazy {
|
||||||
|
val intent = Intent(this, ForegroundService::class.java).apply { action = "kill" }
|
||||||
|
PendingIntent.getService(this, 0, intent, FLAG_CANCEL_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variables Holding Download State */
|
||||||
|
private var total = 0
|
||||||
|
private var converted = 0
|
||||||
|
private var downloaded = 0
|
||||||
|
private var failed = 0
|
||||||
|
private val isFinished get() = converted + failed == total
|
||||||
|
private var isSingleDownload = false
|
||||||
|
|
||||||
|
inner class DownloadServiceBinder : Binder() {
|
||||||
|
val service get() = this@ForegroundService
|
||||||
|
}
|
||||||
|
private val myBinder: IBinder = DownloadServiceBinder()
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder {
|
||||||
|
super.onBind(intent)
|
||||||
|
return myBinder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel(CHANNEL_ID, "Downloader Service")
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("WakelockTimeout")
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
// Send a notification that service is started
|
||||||
|
Log.i(TAG, "Foreground Service Started.")
|
||||||
|
startForeground(NOTIFICATION_ID, createNotification())
|
||||||
|
|
||||||
|
intent?.let {
|
||||||
|
when (it.action) {
|
||||||
|
"kill" -> killService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake locks and misc tasks from here :
|
||||||
|
return if (isServiceStarted) {
|
||||||
|
// Service Already Started
|
||||||
|
START_STICKY
|
||||||
|
} else {
|
||||||
|
isServiceStarted = true
|
||||||
|
Log.i(TAG, "Starting the foreground service task")
|
||||||
|
wakeLock =
|
||||||
|
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||||
|
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply {
|
||||||
|
acquire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function To Download All Tracks Available in a List
|
||||||
|
**/
|
||||||
|
fun downloadAllTracks(trackList: List<TrackDetails>) {
|
||||||
|
trackList.size.also { size ->
|
||||||
|
total += size
|
||||||
|
isSingleDownload = (size == 1)
|
||||||
|
updateNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
trackList.forEach {
|
||||||
|
trackStatusFlowMap[it.title] = DownloadStatus.Queued
|
||||||
|
lifecycleScope.launch {
|
||||||
|
downloadService.value.execute {
|
||||||
|
fetcher.findMp3DownloadLink(it).fold(
|
||||||
|
success = { url ->
|
||||||
|
enqueueDownload(url, it)
|
||||||
|
},
|
||||||
|
failure = { error ->
|
||||||
|
failed++
|
||||||
|
updateNotification()
|
||||||
|
trackStatusFlowMap[it.title] = DownloadStatus.Failed(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun enqueueDownload(url: String, track: TrackDetails) {
|
||||||
|
// Initiating Download
|
||||||
|
addToNotification(Message(track.title, DownloadStatus.Downloading()))
|
||||||
|
trackStatusFlowMap[track.title] = DownloadStatus.Downloading()
|
||||||
|
|
||||||
|
// Enqueueing Download
|
||||||
|
downloadFile(url).collect {
|
||||||
|
when (it) {
|
||||||
|
is DownloadResult.Error -> {
|
||||||
|
logger.d(TAG) { it.message }
|
||||||
|
failed++
|
||||||
|
trackStatusFlowMap[track.title] = DownloadStatus.Failed(it.cause ?: Exception(it.message))
|
||||||
|
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
|
||||||
|
}
|
||||||
|
|
||||||
|
is DownloadResult.Progress -> {
|
||||||
|
trackStatusFlowMap[track.title] = DownloadStatus.Downloading(it.progress)
|
||||||
|
// updateProgressInNotification(Message(track.title,DownloadStatus.Downloading(it.progress)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is DownloadResult.Success -> {
|
||||||
|
coroutineScope {
|
||||||
|
SuspendableEvent {
|
||||||
|
// Save File and Embed Metadata
|
||||||
|
val job = launch(Dispatchers.Default) { dir.saveFileWithMetadata(it.byteArray, track) {} }
|
||||||
|
|
||||||
|
// Send Converting Status
|
||||||
|
trackStatusFlowMap[track.title] = DownloadStatus.Converting
|
||||||
|
addToNotification(Message(track.title, DownloadStatus.Converting))
|
||||||
|
|
||||||
|
// All Processing Completed for this Track
|
||||||
|
job.invokeOnCompletion {
|
||||||
|
converted++
|
||||||
|
trackStatusFlowMap[track.title] = DownloadStatus.Downloaded
|
||||||
|
removeFromNotification(Message(track.title, DownloadStatus.Converting))
|
||||||
|
}
|
||||||
|
logger.d(TAG) { "${track.title} Download Completed" }
|
||||||
|
downloaded++
|
||||||
|
}.failure { error ->
|
||||||
|
error.printStackTrace()
|
||||||
|
// Download Failed
|
||||||
|
failed++
|
||||||
|
trackStatusFlowMap[track.title] = DownloadStatus.Failed(error)
|
||||||
|
}
|
||||||
|
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
logger.d(TAG) { "Releasing Wake Lock" }
|
||||||
|
try {
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.d(TAG) { "Service stopped without being started: ${e.message}" }
|
||||||
|
}
|
||||||
|
isServiceStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SameParameterValue")
|
||||||
|
private fun createNotificationChannel(channelId: String, channelName: String) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
channelId,
|
||||||
|
channelName, NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
)
|
||||||
|
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
service.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Time To Wrap UP
|
||||||
|
* - `Clean Up` and `Stop this Foreground Service`
|
||||||
|
* */
|
||||||
|
private fun killService() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
logger.d(TAG) { "Killing Self" }
|
||||||
|
messageList = messageList.getEmpty().apply {
|
||||||
|
set(index = 0, Message(Strings.cleaningAndExiting(),DownloadStatus.NotDownloaded))
|
||||||
|
}
|
||||||
|
downloadService.value.close()
|
||||||
|
downloadService.reset()
|
||||||
|
updateNotification()
|
||||||
|
cleanFiles(File(dir.defaultDir()))
|
||||||
|
// cleanFiles(File(dir.imageCacheDir()))
|
||||||
|
messageList = messageList.getEmpty()
|
||||||
|
releaseWakeLock()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID).run {
|
||||||
|
setSmallIcon(R.drawable.ic_download_arrow)
|
||||||
|
setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed")
|
||||||
|
setSilent(true)
|
||||||
|
setProgress(total,failed+converted,false)
|
||||||
|
setStyle(
|
||||||
|
NotificationCompat.InboxStyle().run {
|
||||||
|
addLine(messageList[messageList.size - 1].asString())
|
||||||
|
addLine(messageList[messageList.size - 2].asString())
|
||||||
|
addLine(messageList[messageList.size - 3].asString())
|
||||||
|
addLine(messageList[messageList.size - 4].asString())
|
||||||
|
addLine(messageList[messageList.size - 5].asString())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
addAction(R.drawable.ic_round_cancel_24, Strings.exit(), cancelIntent)
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addToNotification(message: Message) {
|
||||||
|
messageList.add(message)
|
||||||
|
updateNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeFromNotification(message: Message) {
|
||||||
|
messageList.removeAll { it.title == message.title }
|
||||||
|
updateNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProgressInNotification(message: Message) {
|
||||||
|
val index = messageList.indexOfFirst { it.title == message.title }
|
||||||
|
messageList[index] = message
|
||||||
|
updateNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification() {
|
||||||
|
val mNotificationManager: NotificationManager =
|
||||||
|
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
mNotificationManager.notify(NOTIFICATION_ID, createNotification())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
if (isFinished) { killService() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
if (isFinished) { killService() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG: String = "Foreground Service"
|
||||||
|
private const val CHANNEL_ID = "ForegroundDownloaderService"
|
||||||
|
private const val NOTIFICATION_ID = 101
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package com.shabinder.spotiflyer.service
|
||||||
|
|
||||||
|
import com.shabinder.common.models.DownloadStatus
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
|
||||||
|
typealias Message = Pair<String, DownloadStatus>
|
||||||
|
|
||||||
|
val Message.title: String get() = first
|
||||||
|
|
||||||
|
val Message.downloadStatus: DownloadStatus get() = second
|
||||||
|
|
||||||
|
val Message.progress: String get() = when (downloadStatus) {
|
||||||
|
is DownloadStatus.Downloading -> "-> ${(downloadStatus as DownloadStatus.Downloading).progress}%"
|
||||||
|
is DownloadStatus.Converting -> "-> 100%"
|
||||||
|
is DownloadStatus.Downloaded -> "-> ${Strings.downloadDone}"
|
||||||
|
is DownloadStatus.Failed -> "-> ${Strings.failed()}"
|
||||||
|
is DownloadStatus.Queued -> "-> ${Strings.queued()}"
|
||||||
|
is DownloadStatus.NotDownloaded -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val emptyMessage = Message("",DownloadStatus.NotDownloaded)
|
||||||
|
|
||||||
|
// `Progress` is not being shown because we don't get get consistent Updates from Download Fun ,
|
||||||
|
// all Progress data is emitted all together from fun
|
||||||
|
fun Message.asString(): String {
|
||||||
|
val statusString = when(downloadStatus){
|
||||||
|
is DownloadStatus.Downloading -> Strings.downloading()
|
||||||
|
is DownloadStatus.Converting -> Strings.processing()
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
return "$statusString $title ${""/*progress*/}".trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<Message>.getEmpty(): MutableList<Message> = MutableList(size) { emptyMessage }
|
@ -0,0 +1,17 @@
|
|||||||
|
package com.shabinder.spotiflyer.service
|
||||||
|
|
||||||
|
import com.shabinder.common.models.DownloadStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class TrackStatusFlowMap(
|
||||||
|
val statusFlow: MutableSharedFlow<HashMap<String,DownloadStatus>>,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
): HashMap<String,DownloadStatus>() {
|
||||||
|
override fun put(key: String, value: DownloadStatus): DownloadStatus? {
|
||||||
|
val res = super.put(key, value)
|
||||||
|
scope.launch { statusFlow.emit(this@TrackStatusFlowMap) }
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,22 @@
|
|||||||
package com.shabinder.common.di.worker
|
package com.shabinder.spotiflyer.service
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import android.util.Log
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleaning All Residual Files except Mp3 Files
|
* Cleaning All Residual Files except Mp3 Files
|
||||||
**/
|
**/
|
||||||
fun cleanFiles(dir: File, logger: Kermit) {
|
fun cleanFiles(dir: File) {
|
||||||
try {
|
try {
|
||||||
logger.d("File Cleaning") { "Starting Cleaning in ${dir.path} " }
|
Log.d("File Cleaning","Starting Cleaning in ${dir.path} ")
|
||||||
val fList = dir.listFiles()
|
val fList = dir.listFiles()
|
||||||
fList?.let {
|
fList?.let {
|
||||||
for (file in fList) {
|
for (file in fList) {
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
cleanFiles(file, logger)
|
cleanFiles(file)
|
||||||
} else if (file.isFile) {
|
} else if (file.isFile) {
|
||||||
if (file.path.toString().substringAfterLast(".") != "mp3") {
|
if (file.path.toString().substringAfterLast(".") != "mp3") {
|
||||||
logger.d("Files Cleaning") { "Cleaning ${file.path}" }
|
Log.d("Files Cleaning","Cleaning ${file.path}")
|
||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -24,3 +24,4 @@ fun cleanFiles(dir: File, logger: Kermit) {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) { e.printStackTrace() }
|
} catch (e: Exception) { e.printStackTrace() }
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
|||||||
|
package com.shabinder.spotiflyer.utils.autoclear
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.shabinder.common.requireNotNull
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.AutoClear.Companion.TRIGGER
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleCreateAndDestroyObserver
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleResumeAndPauseObserver
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleStartAndStopObserver
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
class AutoClear<T : Any?>(
|
||||||
|
lifecycle: Lifecycle,
|
||||||
|
private val initializer: (() -> T)?,
|
||||||
|
private val trigger: TRIGGER = TRIGGER.ON_CREATE,
|
||||||
|
) : ReadWriteProperty<LifecycleOwner, T?> {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
enum class TRIGGER {
|
||||||
|
ON_CREATE,
|
||||||
|
ON_START,
|
||||||
|
ON_RESUME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _value: T?
|
||||||
|
get() = observer.value
|
||||||
|
set(value) { observer.value = value }
|
||||||
|
|
||||||
|
val value: T get() = _value.requireNotNull()
|
||||||
|
|
||||||
|
private val observer: LifecycleAutoInitializer<T?> by lazy {
|
||||||
|
when(trigger) {
|
||||||
|
TRIGGER.ON_CREATE -> LifecycleCreateAndDestroyObserver(initializer)
|
||||||
|
TRIGGER.ON_START -> LifecycleStartAndStopObserver(initializer)
|
||||||
|
TRIGGER.ON_RESUME -> LifecycleResumeAndPauseObserver(initializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: LifecycleOwner, property: KProperty<*>): T {
|
||||||
|
|
||||||
|
if (_value != null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// If for Some Reason Initializer is not invoked even after Initialisation, invoke it after checking state
|
||||||
|
if (thisRef.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||||
|
return initializer?.invoke().also { _value = it }
|
||||||
|
?: throw IllegalStateException("The value has not yet been set or no default initializer provided")
|
||||||
|
} else {
|
||||||
|
throw IllegalStateException("Activity might have been destroyed or not initialized yet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setValue(thisRef: LifecycleOwner, property: KProperty<*>, value: T?) {
|
||||||
|
this._value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
this._value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> LifecycleOwner.autoClear(
|
||||||
|
trigger: TRIGGER = TRIGGER.ON_CREATE,
|
||||||
|
initializer: () -> T
|
||||||
|
): AutoClear<T> {
|
||||||
|
return AutoClear(this.lifecycle, initializer, trigger)
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
package com.shabinder.spotiflyer.utils.autoclear
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
class AutoClearFragment<T : Any?>(
|
||||||
|
fragment: Fragment,
|
||||||
|
private val initializer: (() -> T)?
|
||||||
|
) : ReadWriteProperty<Fragment, T?> {
|
||||||
|
|
||||||
|
private var _value: T? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
val viewLifecycleOwnerObserver = Observer<LifecycleOwner?> { viewLifecycleOwner ->
|
||||||
|
|
||||||
|
viewLifecycleOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
_value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(owner: LifecycleOwner) {
|
||||||
|
fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||||
|
val value = _value
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thisRef.viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||||
|
return initializer?.invoke().also { _value = it }
|
||||||
|
?: throw IllegalStateException("The value has not yet been set or no default initializer provided")
|
||||||
|
} else {
|
||||||
|
throw IllegalStateException("Fragment might have been destroyed or not initialized yet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T?) {
|
||||||
|
_value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> Fragment.autoClear(initializer: () -> T): AutoClearFragment<T> {
|
||||||
|
return AutoClearFragment(this, initializer)
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package com.shabinder.spotiflyer.utils.autoclear
|
||||||
|
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
|
||||||
|
interface LifecycleAutoInitializer<T>: DefaultLifecycleObserver {
|
||||||
|
var value: T?
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer
|
||||||
|
|
||||||
|
class LifecycleCreateAndDestroyObserver<T: Any?>(
|
||||||
|
private val initializer: (() -> T)?
|
||||||
|
) : LifecycleAutoInitializer<T> {
|
||||||
|
|
||||||
|
override var value: T? = null
|
||||||
|
|
||||||
|
override fun onCreate(owner: LifecycleOwner) {
|
||||||
|
super.onCreate(owner)
|
||||||
|
value = initializer?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
super.onDestroy(owner)
|
||||||
|
value = null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer
|
||||||
|
|
||||||
|
class LifecycleResumeAndPauseObserver<T: Any?>(
|
||||||
|
private val initializer: (() -> T)?
|
||||||
|
) : LifecycleAutoInitializer<T> {
|
||||||
|
|
||||||
|
override var value: T? = null
|
||||||
|
|
||||||
|
override fun onResume(owner: LifecycleOwner) {
|
||||||
|
super.onResume(owner)
|
||||||
|
value = initializer?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause(owner: LifecycleOwner) {
|
||||||
|
super.onPause(owner)
|
||||||
|
value = null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer
|
||||||
|
|
||||||
|
class LifecycleStartAndStopObserver<T: Any?>(
|
||||||
|
private val initializer: (() -> T)?
|
||||||
|
) : LifecycleAutoInitializer<T> {
|
||||||
|
|
||||||
|
override var value: T? = null
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
super.onStart(owner)
|
||||||
|
value = initializer?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
super.onStop(owner)
|
||||||
|
value = null
|
||||||
|
}
|
||||||
|
}
|
@ -1,37 +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/>.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">SpotiFlyer</string>
|
|
||||||
<string name="home_about">About</string>
|
|
||||||
<string name="home_history">History</string>
|
|
||||||
<string name="supported_platform">Supported Platforms</string>
|
|
||||||
<string name="support_development">Support Development</string>
|
|
||||||
<string name="github_star">Star / Fork the project on Github.</string>
|
|
||||||
<string name="github">GitHub</string>
|
|
||||||
<string name="translate">Translate</string>
|
|
||||||
<string name="help_us_translate">Help us translate this app in your local language.</string>
|
|
||||||
<string name="donate">Donate</string>
|
|
||||||
<string name="donate_subtitle">If you think I deserve to get paid for my work, you can leave me some money here.</string>
|
|
||||||
<string name="share">Share</string>
|
|
||||||
<string name="share_subtitle">Share this app with your friends and family.</string>
|
|
||||||
<string name="made_with_love">Made with</string>
|
|
||||||
<string name="in_india">in India</string>
|
|
||||||
<string name="acra_notification_title">OOPS, SpotiFlyer Crashed</string>
|
|
||||||
<string name="acra_notification_text">Please Send Crash Report to App Developers, So this unfortunate event may not happen again.</string>
|
|
||||||
<string name="acra_notification_channel">SpotiFlyer_Crashlytics</string>
|
|
||||||
<string name="acra_notification_channel_desc">Notification Channel to send Spotiflyer Crashes.</string>
|
|
||||||
</resources>
|
|
@ -33,12 +33,17 @@ allprojects {
|
|||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
useIR = true
|
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
project.extensions.findByType<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension>()?.let { kmpExt ->
|
project.extensions.findByType<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension>()?.let { kmpExt ->
|
||||||
kmpExt.sourceSets.removeAll { it.name == "androidAndroidTestRelease" }
|
kmpExt.sourceSets.run {
|
||||||
|
all {
|
||||||
|
languageSettings.useExperimentalAnnotation("kotlinx.serialization.ExperimentalSerializationApi")
|
||||||
|
}
|
||||||
|
removeAll { it.name == "androidAndroidTestRelease" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,11 +31,12 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.android.tools.build:gradle:4.1.1")
|
implementation("com.android.tools.build:gradle:4.1.1")
|
||||||
implementation("org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktLint}")
|
|
||||||
implementation(JetBrains.Compose.gradlePlugin)
|
implementation(JetBrains.Compose.gradlePlugin)
|
||||||
implementation(JetBrains.Kotlin.gradlePlugin)
|
implementation(JetBrains.Kotlin.gradlePlugin)
|
||||||
implementation(JetBrains.Kotlin.serialization)
|
implementation(JetBrains.Kotlin.serialization)
|
||||||
implementation(SqlDelight.gradlePlugin)
|
implementation(SqlDelight.gradlePlugin)
|
||||||
|
implementation("org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktLint}")
|
||||||
|
implementation("de.comahe.i18n4k:i18n4k-gradle-plugin:0.1.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
|
@ -49,7 +49,7 @@ object Versions {
|
|||||||
const val minSdkVersion = 21
|
const val minSdkVersion = 21
|
||||||
const val compileSdkVersion = 29
|
const val compileSdkVersion = 29
|
||||||
const val targetSdkVersion = 29
|
const val targetSdkVersion = 29
|
||||||
const val androidLifecycle = "2.3.0"
|
const val androidxLifecycle = "2.3.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
object HostOS {
|
object HostOS {
|
||||||
@ -60,6 +60,10 @@ object HostOS {
|
|||||||
val isLinux = hostOs.startsWith("Linux",true)
|
val isLinux = hostOs.startsWith("Linux",true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object MultiPlatformSettings {
|
||||||
|
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7"
|
||||||
|
}
|
||||||
|
|
||||||
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}"
|
||||||
@ -141,6 +145,10 @@ object Ktor {
|
|||||||
val clientJs = "io.ktor:ktor-client-js:${Versions.ktor}"
|
val clientJs = "io.ktor:ktor-client-js:${Versions.ktor}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Internationalization {
|
||||||
|
const val dep = "de.comahe.i18n4k:i18n4k-core:0.1.1"
|
||||||
|
}
|
||||||
|
|
||||||
object Extras {
|
object Extras {
|
||||||
const val youtubeDownloader = "io.github.shabinder:youtube-api-dl:1.2"
|
const val youtubeDownloader = "io.github.shabinder:youtube-api-dl:1.2"
|
||||||
const val fuzzyWuzzy = "io.github.shabinder:fuzzywuzzy:1.1"
|
const val fuzzyWuzzy = "io.github.shabinder:fuzzywuzzy:1.1"
|
||||||
|
@ -26,6 +26,7 @@ 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.database.R
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
actual fun montserratFont() = FontFamily(
|
actual fun montserratFont() = FontFamily(
|
||||||
@ -43,7 +44,7 @@ actual fun pristineFont() = FontFamily(
|
|||||||
actual fun DownloadImageTick() {
|
actual fun DownloadImageTick() {
|
||||||
Image(
|
Image(
|
||||||
painterResource(R.drawable.ic_tick),
|
painterResource(R.drawable.ic_tick),
|
||||||
"Download Done"
|
Strings.downloadDone()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ actual fun DownloadImageTick() {
|
|||||||
actual fun DownloadImageError() {
|
actual fun DownloadImageError() {
|
||||||
Image(
|
Image(
|
||||||
painterResource(R.drawable.ic_error),
|
painterResource(R.drawable.ic_error),
|
||||||
"Error! Cant Download this track"
|
Strings.downloadError()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +60,7 @@ actual fun DownloadImageError() {
|
|||||||
actual fun DownloadImageArrow(modifier: Modifier) {
|
actual fun DownloadImageArrow(modifier: Modifier) {
|
||||||
Image(
|
Image(
|
||||||
painterResource(R.drawable.ic_arrow),
|
painterResource(R.drawable.ic_arrow),
|
||||||
"Start Download",
|
Strings.downloadStart(),
|
||||||
modifier
|
modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
|
||||||
@OptIn(ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -44,7 +45,7 @@ actual fun DonationDialog(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"We Need Your Support!",
|
Strings.supportUs(),
|
||||||
style = SpotiFlyerTypography.h5,
|
style = SpotiFlyerTypography.h5,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = colorAccent,
|
color = colorAccent,
|
||||||
@ -69,7 +70,7 @@ actual fun DonationDialog(
|
|||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Worldwide Donations",
|
text = Strings.worldWideDonations(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -92,7 +93,7 @@ actual fun DonationDialog(
|
|||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "International Donations (Outside India).",
|
text = Strings.worldWideDonations(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -115,7 +116,7 @@ actual fun DonationDialog(
|
|||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Indian Donations (UPI / PayTM / PhonePe / Cards).",
|
text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).",
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -126,11 +127,11 @@ actual fun DonationDialog(
|
|||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth()
|
modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
OutlinedButton(onClick = onSnooze) {
|
OutlinedButton(onClick = onDismiss) {
|
||||||
Text("Dismiss.")
|
Text(Strings.dismiss())
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss, colors = ButtonDefaults.buttonColors()) {
|
TextButton(onClick = onSnooze, colors = ButtonDefaults.buttonColors()) {
|
||||||
Text("Remind Later!")
|
Text(Strings.remindLater())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
val colorPrimary = Color(0xFFFC5C7D)
|
val colorPrimary = Color(0xFFFC5C7D)
|
||||||
val colorPrimaryDark = Color(0xFFCE1CFF)
|
val colorPrimaryDark = Color(0xFFCE1CFF)
|
||||||
val colorAccent = Color(0xFF9AB3FF)
|
val colorAccent = Color(0xFF9AB3FF)
|
||||||
|
val colorAccentVariant = Color(0xFF3457D5)
|
||||||
val colorRedError = Color(0xFFFF9494)
|
val colorRedError = Color(0xFFFF9494)
|
||||||
val colorSuccessGreen = Color(0xFF59C351)
|
val colorSuccessGreen = Color(0xFF59C351)
|
||||||
val darkBackgroundColor = Color(0xFF000000)
|
val darkBackgroundColor = Color(0xFF000000)
|
||||||
|
@ -17,12 +17,29 @@
|
|||||||
package com.shabinder.common.uikit
|
package com.shabinder.common.uikit
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -37,6 +54,8 @@ 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
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
import com.shabinder.common.uikit.dialogs.DonationDialogComponent
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -49,10 +68,11 @@ fun SpotiFlyerListContent(
|
|||||||
LaunchedEffect(model.errorOccurred) {
|
LaunchedEffect(model.errorOccurred) {
|
||||||
/*Handle if Any Exception Occurred*/
|
/*Handle if Any Exception Occurred*/
|
||||||
model.errorOccurred?.let {
|
model.errorOccurred?.let {
|
||||||
methods.value.showPopUpMessage(it.message ?: "An Error Occurred, Check your Link / Connection")
|
methods.value.showPopUpMessage(it.message ?: Strings.errorOccurred())
|
||||||
component.onBackPressed()
|
component.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
val result = model.queryResult
|
val result = model.queryResult
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
@ -60,7 +80,7 @@ fun SpotiFlyerListContent(
|
|||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
Spacer(modifier.padding(8.dp))
|
Spacer(modifier.padding(8.dp))
|
||||||
Text("Loading..", style = appNameStyle, color = colorPrimary)
|
Text("${Strings.loading()}...", style = appNameStyle, color = colorPrimary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@ -83,25 +103,19 @@ fun SpotiFlyerListContent(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Donation Dialog Visibility
|
// Donation Dialog Visibility
|
||||||
var visibilty by remember { mutableStateOf(false) }
|
val (openDonationDialog,dismissDonationDialog,snoozeDonationDialog) = DonationDialogComponent {
|
||||||
DonationDialog(
|
component.dismissDonationDialogSetOffset()
|
||||||
isVisible = visibilty,
|
}
|
||||||
onDismiss = {
|
|
||||||
visibilty = false
|
|
||||||
},
|
|
||||||
onSnooze = {
|
|
||||||
visibilty = false
|
|
||||||
component.snoozeDonationDialog()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DownloadAllButton(
|
DownloadAllButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
component.onDownloadAllClicked(model.trackList)
|
component.onDownloadAllClicked(model.trackList)
|
||||||
// Check If we are allowed to show donation Dialog
|
// Check If we are allowed to show donation Dialog
|
||||||
if (model.askForDonation) {
|
if (model.askForDonation) {
|
||||||
// Show Donation Dialog
|
// Show Donation Dialog
|
||||||
visibilty = true
|
openDonationDialog()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(bottom = 24.dp).align(Alignment.BottomCenter)
|
modifier = Modifier.padding(bottom = 24.dp).align(Alignment.BottomCenter)
|
||||||
@ -129,7 +143,7 @@ fun TrackCard(
|
|||||||
ImageLoad(
|
ImageLoad(
|
||||||
track.albumArtURL,
|
track.albumArtURL,
|
||||||
{ loadImage() },
|
{ loadImage() },
|
||||||
"Album Art",
|
Strings.albumArt(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(70.dp)
|
.width(70.dp)
|
||||||
.height(70.dp)
|
.height(70.dp)
|
||||||
@ -143,7 +157,7 @@ fun TrackCard(
|
|||||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
|
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
|
||||||
) {
|
) {
|
||||||
Text("${track.artists.firstOrNull()}...", fontSize = 12.sp, maxLines = 1)
|
Text("${track.artists.firstOrNull()}...", fontSize = 12.sp, maxLines = 1)
|
||||||
Text("${track.durationSec / 60} min, ${track.durationSec % 60} sec", fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text("${track.durationSec / 60} ${Strings.minute()}, ${track.durationSec % 60} ${Strings.second()}", fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (track.downloaded) {
|
when (track.downloaded) {
|
||||||
@ -189,7 +203,7 @@ fun CoverImage(
|
|||||||
ImageLoad(
|
ImageLoad(
|
||||||
coverURL,
|
coverURL,
|
||||||
{ loadImage(coverURL, true) },
|
{ loadImage(coverURL, true) },
|
||||||
"Cover Image",
|
Strings.coverImage(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
.width(190.dp)
|
.width(190.dp)
|
||||||
@ -212,9 +226,9 @@ fun CoverImage(
|
|||||||
@Composable
|
@Composable
|
||||||
fun DownloadAllButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
fun DownloadAllButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
text = { Text("Download All") },
|
text = { Text(Strings.downloadAll()) },
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
icon = { Icon(DownloadAllImage(), "Download All Button", tint = Color(0xFF000000)) },
|
icon = { Icon(DownloadAllImage(), Strings.downloadAll() + Strings.button(), tint = Color(0xFF000000)) },
|
||||||
backgroundColor = colorAccent,
|
backgroundColor = colorAccent,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
@ -17,21 +17,54 @@
|
|||||||
package com.shabinder.common.uikit
|
package com.shabinder.common.uikit
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.*
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.Card
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedButton
|
||||||
|
import androidx.compose.material.Switch
|
||||||
|
import androidx.compose.material.SwitchDefaults
|
||||||
|
import androidx.compose.material.Tab
|
||||||
|
import androidx.compose.material.TabPosition
|
||||||
|
import androidx.compose.material.TabRow
|
||||||
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextField
|
||||||
import androidx.compose.material.TextFieldDefaults.textFieldColors
|
import androidx.compose.material.TextFieldDefaults.textFieldColors
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.History
|
import androidx.compose.material.icons.outlined.History
|
||||||
import androidx.compose.material.icons.outlined.Info
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import androidx.compose.material.icons.rounded.*
|
import androidx.compose.material.icons.rounded.CardGiftcard
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
|
import androidx.compose.material.icons.rounded.Insights
|
||||||
|
import androidx.compose.material.icons.rounded.Share
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -50,11 +83,17 @@ 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
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
import com.shabinder.common.uikit.dialogs.DonationDialogComponent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
|
fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
|
||||||
val model by component.model.subscribeAsState()
|
val model by component.model.subscribeAsState()
|
||||||
|
|
||||||
|
val (openDonationDialog,_,_) = DonationDialogComponent {
|
||||||
|
component.dismissDonationDialogOffset()
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
SearchPanel(
|
SearchPanel(
|
||||||
model.link,
|
model.link,
|
||||||
@ -65,14 +104,17 @@ fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
|
|||||||
HomeTabBar(
|
HomeTabBar(
|
||||||
model.selectedCategory,
|
model.selectedCategory,
|
||||||
HomeCategory.values(),
|
HomeCategory.values(),
|
||||||
component::selectCategory
|
component::selectCategory,
|
||||||
)
|
)
|
||||||
|
|
||||||
when (model.selectedCategory) {
|
when (model.selectedCategory) {
|
||||||
HomeCategory.About -> AboutColumn(
|
HomeCategory.About -> AboutColumn(
|
||||||
analyticsEnabled = model.isAnalyticsEnabled,
|
analyticsEnabled = model.isAnalyticsEnabled,
|
||||||
donationDialogOpenEvent = { component.analytics.donationDialogVisit() },
|
toggleAnalytics = component::toggleAnalytics,
|
||||||
toggleAnalytics = component::toggleAnalytics
|
openDonationDialog = {
|
||||||
|
component.analytics.donationDialogVisit()
|
||||||
|
openDonationDialog()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
HomeCategory.History -> HistoryColumn(
|
HomeCategory.History -> HistoryColumn(
|
||||||
model.records.sortedByDescending { it.id },
|
model.records.sortedByDescending { it.id },
|
||||||
@ -98,6 +140,7 @@ fun HomeTabBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TabRow(
|
TabRow(
|
||||||
|
backgroundColor = transparent,
|
||||||
selectedTabIndex = selectedIndex,
|
selectedTabIndex = selectedIndex,
|
||||||
indicator = indicator,
|
indicator = indicator,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@ -109,16 +152,16 @@ fun HomeTabBar(
|
|||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = when (category) {
|
text = when (category) {
|
||||||
HomeCategory.About -> "About"
|
HomeCategory.About -> Strings.about()
|
||||||
HomeCategory.History -> "History"
|
HomeCategory.History -> Strings.history()
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.body2
|
style = MaterialTheme.typography.body2
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
when (category) {
|
when (category) {
|
||||||
HomeCategory.About -> Icon(Icons.Outlined.Info, "Info Tab")
|
HomeCategory.About -> Icon(Icons.Outlined.Info, Strings.infoTab())
|
||||||
HomeCategory.History -> Icon(Icons.Outlined.History, "History Tab")
|
HomeCategory.History -> Icon(Icons.Outlined.History, Strings.historyTab())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -141,9 +184,9 @@ fun SearchPanel(
|
|||||||
value = link,
|
value = link,
|
||||||
onValueChange = updateLink,
|
onValueChange = updateLink,
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(Icons.Rounded.Edit, "Link Text Box", tint = Color.LightGray)
|
Icon(Icons.Rounded.Edit, Strings.linkTextBox(), tint = Color.LightGray)
|
||||||
},
|
},
|
||||||
label = { Text(text = "Paste Link Here...", color = Color.LightGray) },
|
label = { Text(text = Strings.pasteLinkHere(), color = Color.LightGray) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = TextStyle.Default.merge(TextStyle(fontSize = 18.sp, color = Color.White)),
|
textStyle = TextStyle.Default.merge(TextStyle(fontSize = 18.sp, color = Color.White)),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
@ -170,7 +213,7 @@ fun SearchPanel(
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
modifier = Modifier.padding(12.dp).wrapContentWidth(),
|
modifier = Modifier.padding(12.dp).wrapContentWidth(),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (link.isBlank()) methods.value.showPopUpMessage("Enter A Link!")
|
if (link.isBlank()) methods.value.showPopUpMessage(Strings.enterALink())
|
||||||
else {
|
else {
|
||||||
// TODO if(!isOnline(ctx)) showPopUpMessage("Check Your Internet Connection") else
|
// TODO if(!isOnline(ctx)) showPopUpMessage("Check Your Internet Connection") else
|
||||||
onSearch(link)
|
onSearch(link)
|
||||||
@ -186,7 +229,7 @@ fun SearchPanel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text(text = "Search", style = SpotiFlyerTypography.h6, modifier = Modifier.padding(4.dp))
|
Text(text = Strings.search(), style = SpotiFlyerTypography.h6, modifier = Modifier.padding(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,7 +238,7 @@ fun SearchPanel(
|
|||||||
fun AboutColumn(
|
fun AboutColumn(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
analyticsEnabled:Boolean,
|
analyticsEnabled:Boolean,
|
||||||
donationDialogOpenEvent: () -> Unit,
|
openDonationDialog: () -> Unit,
|
||||||
toggleAnalytics: (enabled: Boolean) -> Unit
|
toggleAnalytics: (enabled: Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -209,7 +252,7 @@ fun AboutColumn(
|
|||||||
) {
|
) {
|
||||||
Column(modifier.padding(12.dp)) {
|
Column(modifier.padding(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Supported Platforms",
|
text = Strings.supportedPlatforms(),
|
||||||
style = SpotiFlyerTypography.body1,
|
style = SpotiFlyerTypography.body1,
|
||||||
color = colorAccent
|
color = colorAccent
|
||||||
)
|
)
|
||||||
@ -217,7 +260,7 @@ fun AboutColumn(
|
|||||||
Row(horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth()) {
|
||||||
Icon(
|
Icon(
|
||||||
SpotifyLogo(),
|
SpotifyLogo(),
|
||||||
"Open Spotify",
|
"${Strings.open()} Spotify",
|
||||||
tint = Color.Unspecified,
|
tint = Color.Unspecified,
|
||||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||||
onClick = { methods.value.openPlatform("com.spotify.music", "http://open.spotify.com") }
|
onClick = { methods.value.openPlatform("com.spotify.music", "http://open.spotify.com") }
|
||||||
@ -226,7 +269,7 @@ fun AboutColumn(
|
|||||||
Spacer(modifier = modifier.padding(start = 16.dp))
|
Spacer(modifier = modifier.padding(start = 16.dp))
|
||||||
Icon(
|
Icon(
|
||||||
GaanaLogo(),
|
GaanaLogo(),
|
||||||
"Open Gaana",
|
"${Strings.open()} Gaana",
|
||||||
tint = Color.Unspecified,
|
tint = Color.Unspecified,
|
||||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||||
onClick = { methods.value.openPlatform("com.gaana", "https://www.gaana.com") }
|
onClick = { methods.value.openPlatform("com.gaana", "https://www.gaana.com") }
|
||||||
@ -235,7 +278,7 @@ fun AboutColumn(
|
|||||||
Spacer(modifier = modifier.padding(start = 16.dp))
|
Spacer(modifier = modifier.padding(start = 16.dp))
|
||||||
Icon(
|
Icon(
|
||||||
SaavnLogo(),
|
SaavnLogo(),
|
||||||
"Open Jio Saavn",
|
"${Strings.open()} Jio Saavn",
|
||||||
tint = Color.Unspecified,
|
tint = Color.Unspecified,
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
onClick = { methods.value.openPlatform("com.jio.media.jiobeats", "https://www.jiosaavn.com/") }
|
onClick = { methods.value.openPlatform("com.jio.media.jiobeats", "https://www.jiosaavn.com/") }
|
||||||
@ -244,7 +287,7 @@ fun AboutColumn(
|
|||||||
Spacer(modifier = modifier.padding(start = 16.dp))
|
Spacer(modifier = modifier.padding(start = 16.dp))
|
||||||
Icon(
|
Icon(
|
||||||
YoutubeLogo(),
|
YoutubeLogo(),
|
||||||
"Open Youtube",
|
"${Strings.open()} Youtube",
|
||||||
tint = Color.Unspecified,
|
tint = Color.Unspecified,
|
||||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||||
onClick = { methods.value.openPlatform("com.google.android.youtube", "https://m.youtube.com") }
|
onClick = { methods.value.openPlatform("com.google.android.youtube", "https://m.youtube.com") }
|
||||||
@ -253,7 +296,7 @@ fun AboutColumn(
|
|||||||
Spacer(modifier = modifier.padding(start = 12.dp))
|
Spacer(modifier = modifier.padding(start = 12.dp))
|
||||||
Icon(
|
Icon(
|
||||||
YoutubeMusicLogo(),
|
YoutubeMusicLogo(),
|
||||||
"Open Youtube Music",
|
"${Strings.open()} Youtube Music",
|
||||||
tint = Color.Unspecified,
|
tint = Color.Unspecified,
|
||||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||||
onClick = { methods.value.openPlatform("com.google.android.apps.youtube.music", "https://music.youtube.com/") }
|
onClick = { methods.value.openPlatform("com.google.android.apps.youtube.music", "https://music.youtube.com/") }
|
||||||
@ -269,7 +312,7 @@ fun AboutColumn(
|
|||||||
) {
|
) {
|
||||||
Column(modifier.padding(12.dp)) {
|
Column(modifier.padding(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Support Development",
|
text = Strings.supportDevelopment(),
|
||||||
style = SpotiFlyerTypography.body1,
|
style = SpotiFlyerTypography.body1,
|
||||||
color = colorAccent
|
color = colorAccent
|
||||||
)
|
)
|
||||||
@ -281,7 +324,7 @@ fun AboutColumn(
|
|||||||
)
|
)
|
||||||
.padding(vertical = 6.dp)
|
.padding(vertical = 6.dp)
|
||||||
) {
|
) {
|
||||||
Icon(GithubLogo(), "Open Project Repo", Modifier.size(32.dp), tint = Color(0xFFCCCCCC))
|
Icon(GithubLogo(), Strings.openProjectRepo(), Modifier.size(32.dp), tint = Color(0xFFCCCCCC))
|
||||||
Spacer(modifier = Modifier.padding(start = 16.dp))
|
Spacer(modifier = Modifier.padding(start = 16.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
@ -289,7 +332,7 @@ fun AboutColumn(
|
|||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Star / Fork the project on Github.",
|
text = Strings.starOrForkProject(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -299,51 +342,34 @@ fun AboutColumn(
|
|||||||
.clickable(onClick = { methods.value.openPlatform("", "http://github.com/Shabinder/SpotiFlyer") }),
|
.clickable(onClick = { methods.value.openPlatform("", "http://github.com/Shabinder/SpotiFlyer") }),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Flag, "Help Translate", Modifier.size(32.dp))
|
Icon(Icons.Rounded.Flag, Strings.help() + Strings.translate(), Modifier.size(32.dp))
|
||||||
Spacer(modifier = Modifier.padding(start = 16.dp))
|
Spacer(modifier = Modifier.padding(start = 16.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Translate",
|
text = Strings.translate(),
|
||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Help us translate this app in your local language.",
|
text = Strings.helpTranslateDescription(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDonationDialogVisible by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
DonationDialog(
|
|
||||||
isDonationDialogVisible,
|
|
||||||
onDismiss = {
|
|
||||||
isDonationDialogVisible = false
|
|
||||||
},
|
|
||||||
onSnooze = {
|
|
||||||
isDonationDialogVisible = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)
|
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)
|
||||||
.clickable(
|
.clickable(onClick = openDonationDialog),
|
||||||
onClick = {
|
|
||||||
isDonationDialogVisible = true
|
|
||||||
donationDialogOpenEvent()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.CardGiftcard, "Support Developer", Modifier.size(32.dp))
|
Icon(Icons.Rounded.CardGiftcard, Strings.supportDeveloper(), Modifier.size(32.dp))
|
||||||
Spacer(modifier = Modifier.padding(start = 16.dp))
|
Spacer(modifier = Modifier.padding(start = 16.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Donate",
|
text = Strings.donate(),
|
||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "If you think I deserve to get paid for my work, you can support me here.",
|
text = Strings.donateDescription(),
|
||||||
// text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.",
|
// text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.",
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
@ -358,15 +384,15 @@ fun AboutColumn(
|
|||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Share, "Share SpotiFlyer App", Modifier.size(32.dp))
|
Icon(Icons.Rounded.Share, Strings.share() + Strings.title() + "App", Modifier.size(32.dp))
|
||||||
Spacer(modifier = Modifier.padding(start = 16.dp))
|
Spacer(modifier = Modifier.padding(start = 16.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Share",
|
text = Strings.share(),
|
||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Share this app with your friends and family.",
|
text = Strings.shareDescription(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -380,17 +406,17 @@ fun AboutColumn(
|
|||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Insights, "Analytics Status", Modifier.size(32.dp))
|
Icon(Icons.Rounded.Insights, Strings.analytics() + Strings.status(), Modifier.size(32.dp))
|
||||||
Spacer(modifier = Modifier.padding(start = 16.dp))
|
Spacer(modifier = Modifier.padding(start = 16.dp))
|
||||||
Column(
|
Column(
|
||||||
Modifier.weight(1f)
|
Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Analytics",
|
text = Strings.analytics(),
|
||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Your Data is Anonymized and never shared with 3rd party service",
|
text = Strings.analyticsDescription(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -421,10 +447,10 @@ fun HistoryColumn(
|
|||||||
if (it.isEmpty()) {
|
if (it.isEmpty()) {
|
||||||
Column(Modifier.padding(8.dp).fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.padding(8.dp).fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Info, "No History Available Yet", modifier = Modifier.size(80.dp),
|
Icons.Outlined.Info, Strings.noHistoryAvailable(), modifier = Modifier.size(80.dp),
|
||||||
colorOffWhite
|
colorOffWhite
|
||||||
)
|
)
|
||||||
Text("No History Available", style = SpotiFlyerTypography.h4.copy(fontWeight = FontWeight.Light), textAlign = TextAlign.Center)
|
Text(Strings.noHistoryAvailable(), style = SpotiFlyerTypography.h4.copy(fontWeight = FontWeight.Light), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box {
|
Box {
|
||||||
@ -470,7 +496,7 @@ fun DownloadRecordItem(
|
|||||||
ImageLoad(
|
ImageLoad(
|
||||||
item.coverUrl,
|
item.coverUrl,
|
||||||
{ loadImage(item.coverUrl) },
|
{ loadImage(item.coverUrl) },
|
||||||
"Album Art",
|
Strings.albumArt(),
|
||||||
modifier = Modifier.height(70.dp).width(70.dp).clip(SpotiFlyerShapes.medium)
|
modifier = Modifier.height(70.dp).width(70.dp).clip(SpotiFlyerShapes.medium)
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp).height(60.dp).weight(1f), verticalArrangement = Arrangement.SpaceEvenly) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp).height(60.dp).weight(1f), verticalArrangement = Arrangement.SpaceEvenly) {
|
||||||
@ -481,12 +507,12 @@ fun DownloadRecordItem(
|
|||||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
|
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
|
||||||
) {
|
) {
|
||||||
Text(item.type, fontSize = 13.sp, color = colorOffWhite)
|
Text(item.type, fontSize = 13.sp, color = colorOffWhite)
|
||||||
Text("Tracks: ${item.totalFiles}", fontSize = 13.sp, color = colorOffWhite)
|
Text("${Strings.tracks()}: ${item.totalFiles}", fontSize = 13.sp, color = colorOffWhite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Image(
|
Image(
|
||||||
ShareImage(),
|
ShareImage(),
|
||||||
"Research",
|
Strings.reSearch(),
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
// if(!isOnline(ctx)) showDialog("Check Your Internet Connection") else
|
// if(!isOnline(ctx)) showDialog("Check Your Internet Connection") else
|
||||||
@ -504,7 +530,7 @@ fun HomeCategoryTabIndicator(
|
|||||||
) {
|
) {
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier.padding(horizontal = 24.dp)
|
modifier.padding(horizontal = 24.dp)
|
||||||
.height(4.dp)
|
.height(3.dp)
|
||||||
.background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100))
|
.background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -56,9 +56,10 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.arkivanov.decompose.extensions.compose.jetbrains.Children
|
import com.arkivanov.decompose.extensions.compose.jetbrains.Children
|
||||||
import com.arkivanov.decompose.extensions.compose.jetbrains.animation.child.crossfadeScale
|
import com.arkivanov.decompose.extensions.compose.jetbrains.animation.child.crossfadeScale
|
||||||
import com.arkivanov.decompose.extensions.compose.jetbrains.asState
|
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot
|
import com.shabinder.common.root.SpotiFlyerRoot
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot.Child
|
import com.shabinder.common.root.SpotiFlyerRoot.Child
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
import com.shabinder.common.uikit.splash.Splash
|
import com.shabinder.common.uikit.splash.Splash
|
||||||
import com.shabinder.common.uikit.splash.SplashState
|
import com.shabinder.common.uikit.splash.SplashState
|
||||||
import com.shabinder.common.uikit.utils.verticalGradientScrim
|
import com.shabinder.common.uikit.utils.verticalGradientScrim
|
||||||
@ -125,7 +126,7 @@ fun MainScreen(modifier: Modifier = Modifier, alpha: Float, topPadding: Dp = 0.d
|
|||||||
).then(modifier)
|
).then(modifier)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val activeComponent = component.routerState.asState()
|
val activeComponent = component.routerState.subscribeAsState()
|
||||||
val callBacks = component.callBacks
|
val callBacks = component.callBacks
|
||||||
AppBar(
|
AppBar(
|
||||||
backgroundColor = appBarColor,
|
backgroundColor = appBarColor,
|
||||||
@ -163,7 +164,7 @@ fun AppBar(
|
|||||||
AnimatedVisibility(isBackButtonVisible) {
|
AnimatedVisibility(isBackButtonVisible) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Rounded.ArrowBackIosNew,
|
Icons.Rounded.ArrowBackIosNew,
|
||||||
contentDescription = "Back Button",
|
contentDescription = Strings.backButton(),
|
||||||
modifier = Modifier.clickable { onBackPressed() },
|
modifier = Modifier.clickable { onBackPressed() },
|
||||||
tint = Color.LightGray
|
tint = Color.LightGray
|
||||||
)
|
)
|
||||||
@ -171,12 +172,12 @@ fun AppBar(
|
|||||||
}
|
}
|
||||||
Image(
|
Image(
|
||||||
SpotiFlyerLogo(),
|
SpotiFlyerLogo(),
|
||||||
"SpotiFlyer Logo",
|
Strings.spotiflyerLogo(),
|
||||||
Modifier.size(32.dp),
|
Modifier.size(32.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "SpotiFlyer",
|
text = Strings.title(),
|
||||||
style = appNameStyle
|
style = appNameStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -185,7 +186,7 @@ fun AppBar(
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = { setDownloadDirectory() }
|
onClick = { setDownloadDirectory() }
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Settings, "Preferences", tint = Color.Gray)
|
Icon(Icons.Filled.Settings, Strings.preferences(), tint = Color.Gray)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
@ -1 +1,33 @@
|
|||||||
package com.shabinder.common.uikit.dialogs
|
package com.shabinder.common.uikit.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.shabinder.common.uikit.DonationDialog
|
||||||
|
|
||||||
|
typealias DonationDialogCallBacks = Triple<openAction,dismissAction,snoozeAction>
|
||||||
|
private typealias openAction = () -> Unit
|
||||||
|
private typealias dismissAction = () -> Unit
|
||||||
|
private typealias snoozeAction = () -> Unit
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DonationDialogComponent(onDismissExtra: () -> Unit): DonationDialogCallBacks {
|
||||||
|
var isDonationDialogVisible by remember { mutableStateOf(false) }
|
||||||
|
DonationDialog(
|
||||||
|
isDonationDialogVisible,
|
||||||
|
onSnooze = { isDonationDialogVisible = false },
|
||||||
|
onDismiss = {
|
||||||
|
isDonationDialogVisible = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val openDonationDialog = { isDonationDialogVisible = true }
|
||||||
|
val snoozeDonationDialog = { isDonationDialogVisible = false }
|
||||||
|
val dismissDonationDialog = {
|
||||||
|
onDismissExtra()
|
||||||
|
isDonationDialogVisible = false
|
||||||
|
}
|
||||||
|
return DonationDialogCallBacks(openDonationDialog,dismissDonationDialog,snoozeDonationDialog)
|
||||||
|
}
|
@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
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.shabinder.common.translations.Strings
|
||||||
import com.shabinder.common.uikit.HeartIcon
|
import com.shabinder.common.uikit.HeartIcon
|
||||||
import com.shabinder.common.uikit.SpotiFlyerLogo
|
import com.shabinder.common.uikit.SpotiFlyerLogo
|
||||||
import com.shabinder.common.uikit.SpotiFlyerTypography
|
import com.shabinder.common.uikit.SpotiFlyerTypography
|
||||||
@ -55,7 +56,7 @@ fun Splash(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
|
|||||||
delay(SplashWaitTime)
|
delay(SplashWaitTime)
|
||||||
currentOnTimeout()
|
currentOnTimeout()
|
||||||
}
|
}
|
||||||
Image(SpotiFlyerLogo(), "SpotiFlyer Logo")
|
Image(SpotiFlyerLogo(), Strings.spotiflyerLogo())
|
||||||
MadeInIndia(Modifier.align(Alignment.BottomCenter))
|
MadeInIndia(Modifier.align(Alignment.BottomCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,21 +74,21 @@ fun MadeInIndia(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Made with ",
|
text = "${Strings.madeWith()} ",
|
||||||
color = colorPrimary,
|
color = colorPrimary,
|
||||||
fontSize = 22.sp
|
fontSize = 22.sp
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.padding(start = 4.dp))
|
Spacer(modifier = Modifier.padding(start = 4.dp))
|
||||||
Icon(HeartIcon(), "Love", tint = Color.Unspecified)
|
Icon(HeartIcon(), Strings.love(), tint = Color.Unspecified)
|
||||||
Spacer(modifier = Modifier.padding(start = 4.dp))
|
Spacer(modifier = Modifier.padding(start = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = " in India",
|
text = " ${Strings.inIndia()}",
|
||||||
color = colorPrimary,
|
color = colorPrimary,
|
||||||
fontSize = 22.sp
|
fontSize = 22.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"by: Shabinder Singh",
|
Strings.byDeveloperName(),
|
||||||
style = SpotiFlyerTypography.h6,
|
style = SpotiFlyerTypography.h6,
|
||||||
color = colorAccent,
|
color = colorAccent,
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp
|
||||||
|
@ -20,9 +20,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.v1.Dialog
|
import androidx.compose.ui.window.v1.Dialog
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
|
||||||
@OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -42,7 +42,7 @@ actual fun DonationDialog(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Support Us",
|
Strings.supportUs(),
|
||||||
style = SpotiFlyerTypography.h5,
|
style = SpotiFlyerTypography.h5,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = colorAccent,
|
color = colorAccent,
|
||||||
@ -67,7 +67,7 @@ actual fun DonationDialog(
|
|||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "International Donations (Outside India).",
|
text = Strings.worldWideDonations(),
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -90,7 +90,7 @@ actual fun DonationDialog(
|
|||||||
style = SpotiFlyerTypography.h6
|
style = SpotiFlyerTypography.h6
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Indian Donations (UPI / PayTM / PhonePe / Cards).",
|
text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).",
|
||||||
style = SpotiFlyerTypography.subtitle2
|
style = SpotiFlyerTypography.subtitle2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import de.comahe.i18n4k.gradle.plugin.i18n4k
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* * Copyright (c) 2021 Shabinder Singh
|
* * Copyright (c) 2021 Shabinder Singh
|
||||||
* * This program is free software: you can redistribute it and/or modify
|
* * This program is free software: you can redistribute it and/or modify
|
||||||
@ -20,11 +22,18 @@ plugins {
|
|||||||
id("multiplatform-setup-test")
|
id("multiplatform-setup-test")
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
kotlin("plugin.serialization")
|
kotlin("plugin.serialization")
|
||||||
|
id("de.comahe.i18n4k")
|
||||||
}
|
}
|
||||||
|
|
||||||
val statelyVersion = "1.1.7"
|
val statelyVersion = "1.1.7"
|
||||||
val statelyIsoVersion = "1.1.7-a1"
|
val statelyIsoVersion = "1.1.7-a1"
|
||||||
|
|
||||||
|
i18n4k {
|
||||||
|
inputDirectory = "../../translations"
|
||||||
|
packageName = "com.shabinder.common.translations"
|
||||||
|
// sourceCodeLocales = listOf("en", "de")
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
sourceSets {
|
sourceSets {
|
||||||
/*
|
/*
|
||||||
@ -44,6 +53,8 @@ kotlin {
|
|||||||
implementation("co.touchlab:stately-concurrency:$statelyVersion")
|
implementation("co.touchlab:stately-concurrency:$statelyVersion")
|
||||||
implementation("co.touchlab:stately-isolate:$statelyIsoVersion")
|
implementation("co.touchlab:stately-isolate:$statelyIsoVersion")
|
||||||
implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion")
|
implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion")
|
||||||
|
implementation(Extras.youtubeDownloader)
|
||||||
|
api(Internationalization.dep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidMain {
|
androidMain {
|
||||||
|
@ -14,7 +14,7 @@ actual interface PlatformActions {
|
|||||||
|
|
||||||
fun addToLibrary(path: String)
|
fun addToLibrary(path: String)
|
||||||
|
|
||||||
fun sendTracksToService(array: ArrayList<TrackDetails>)
|
fun sendTracksToService(array: List<TrackDetails>)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual val StubPlatformActions = object : PlatformActions {
|
actual val StubPlatformActions = object : PlatformActions {
|
||||||
@ -24,5 +24,5 @@ actual val StubPlatformActions = object : PlatformActions {
|
|||||||
|
|
||||||
override fun addToLibrary(path: String) {}
|
override fun addToLibrary(path: String) {}
|
||||||
|
|
||||||
override fun sendTracksToService(array: ArrayList<TrackDetails>) {}
|
override fun sendTracksToService(array: List<TrackDetails>) {}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
package com.shabinder.common
|
||||||
|
|
||||||
|
fun <T: Any?> T?.requireNotNull() : T = requireNotNull(this)
|
@ -16,6 +16,9 @@
|
|||||||
|
|
||||||
package com.shabinder.common.models
|
package com.shabinder.common.models
|
||||||
|
|
||||||
|
import io.github.shabinder.TargetPlatforms
|
||||||
|
import io.github.shabinder.activePlatform
|
||||||
|
|
||||||
sealed class CorsProxy(open val url: String) {
|
sealed class CorsProxy(open val url: String) {
|
||||||
data class SelfHostedCorsProxy(override val url: String = "https://cors.spotiflyer.ml/cors/" /*"https://spotiflyer.azurewebsites.net/"*/) : CorsProxy(url)
|
data class SelfHostedCorsProxy(override val url: String = "https://cors.spotiflyer.ml/cors/" /*"https://spotiflyer.azurewebsites.net/"*/) : CorsProxy(url)
|
||||||
data class PublicProxyWithExtension(override val url: String = "https://cors.bridged.cc/") : CorsProxy(url)
|
data class PublicProxyWithExtension(override val url: String = "https://cors.bridged.cc/") : CorsProxy(url)
|
||||||
@ -45,3 +48,5 @@ sealed class CorsProxy(open val url: String) {
|
|||||||
* Default Self Hosted, However ask user to use extension if possible.
|
* Default Self Hosted, However ask user to use extension if possible.
|
||||||
* */
|
* */
|
||||||
var corsProxy: CorsProxy = CorsProxy.SelfHostedCorsProxy()
|
var corsProxy: CorsProxy = CorsProxy.SelfHostedCorsProxy()
|
||||||
|
|
||||||
|
val corsApi get() = if (activePlatform is TargetPlatforms.Js) corsProxy.url else ""
|
@ -49,5 +49,5 @@ sealed class DownloadStatus : Parcelable {
|
|||||||
@Parcelize object Queued : DownloadStatus()
|
@Parcelize object Queued : DownloadStatus()
|
||||||
@Parcelize object NotDownloaded : DownloadStatus()
|
@Parcelize object NotDownloaded : DownloadStatus()
|
||||||
@Parcelize object Converting : DownloadStatus()
|
@Parcelize object Converting : DownloadStatus()
|
||||||
@Parcelize object Failed : DownloadStatus()
|
@Parcelize data class Failed(val error: Throwable) : DownloadStatus()
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
package com.shabinder.common.models
|
||||||
|
|
||||||
|
import com.shabinder.common.translations.Strings
|
||||||
|
|
||||||
|
sealed class SpotiFlyerException(override val message: String): Exception(message) {
|
||||||
|
|
||||||
|
data class FeatureNotImplementedYet(override val message: String = Strings.featureUnImplemented()): SpotiFlyerException(message)
|
||||||
|
data class NoInternetException(override val message: String = Strings.checkInternetConnection()): SpotiFlyerException(message)
|
||||||
|
|
||||||
|
data class MP3ConversionFailed(
|
||||||
|
val extraInfo:String? = null,
|
||||||
|
override val message: String = "${Strings.mp3ConverterBusy()} \nCAUSE:$extraInfo"
|
||||||
|
): SpotiFlyerException(message)
|
||||||
|
|
||||||
|
data class UnknownReason(
|
||||||
|
val exception: Throwable? = null,
|
||||||
|
override val message: String = Strings.unknownError()
|
||||||
|
): SpotiFlyerException(message)
|
||||||
|
|
||||||
|
data class NoMatchFound(
|
||||||
|
val trackName: String? = null,
|
||||||
|
override val message: String = "$trackName : ${Strings.noMatchFound()}"
|
||||||
|
): SpotiFlyerException(message)
|
||||||
|
|
||||||
|
data class YoutubeLinkNotFound(
|
||||||
|
val videoID: String? = null,
|
||||||
|
override val message: String = "${Strings.noLinkFound()}: $videoID"
|
||||||
|
): SpotiFlyerException(message)
|
||||||
|
|
||||||
|
data class DownloadLinkFetchFailed(
|
||||||
|
val trackName: String,
|
||||||
|
val jioSaavnError: Throwable,
|
||||||
|
val ytMusicError: Throwable,
|
||||||
|
override val message: String = "${Strings.noLinkFound()}: $trackName," +
|
||||||
|
" \n JioSaavn Error's StackTrace: ${jioSaavnError.stackTraceToString()} \n " +
|
||||||
|
" \n YtMusic Error's StackTrace: ${ytMusicError.stackTraceToString()} \n "
|
||||||
|
): SpotiFlyerException(message)
|
||||||
|
|
||||||
|
data class LinkInvalid(
|
||||||
|
val link: String? = null,
|
||||||
|
override val message: String = "${Strings.linkNotValid()}\n ${link ?: ""}"
|
||||||
|
): SpotiFlyerException(message)
|
||||||
|
}
|
@ -0,0 +1,207 @@
|
|||||||
|
package com.shabinder.common.models.event
|
||||||
|
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
inline fun <reified X> Event<*, *>.getAs() = when (this) {
|
||||||
|
is Event.Success -> value as? X
|
||||||
|
is Event.Failure -> error as? X
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?> Event<V, *>.success(f: (V) -> Unit) = fold(f, {})
|
||||||
|
|
||||||
|
inline fun <E : Throwable> Event<*, E>.failure(f: (E) -> Unit) = fold({}, f)
|
||||||
|
|
||||||
|
infix fun <V : Any?, E : Throwable> Event<V, E>.or(fallback: V) = when (this) {
|
||||||
|
is Event.Success -> this
|
||||||
|
else -> Event.Success(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline infix fun <V : Any?, E : Throwable> Event<V, E>.getOrElse(fallback: (E) -> V): V {
|
||||||
|
return when (this) {
|
||||||
|
is Event.Success -> value
|
||||||
|
is Event.Failure -> fallback(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <V : Any?, E : Throwable> Event<V, E>.getOrNull(): V? {
|
||||||
|
return when (this) {
|
||||||
|
is Event.Success -> value
|
||||||
|
is Event.Failure -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <V : Any?, E : Throwable> Event<V, E>.getThrowableOrNull(): E? {
|
||||||
|
return when (this) {
|
||||||
|
is Event.Success -> null
|
||||||
|
is Event.Failure -> error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, E : Throwable, U : Any?, F : Throwable> Event<V, E>.mapEither(
|
||||||
|
success: (V) -> U,
|
||||||
|
failure: (E) -> F
|
||||||
|
): Event<U, F> {
|
||||||
|
return when (this) {
|
||||||
|
is Event.Success -> Event.success(success(value))
|
||||||
|
is Event.Failure -> Event.error(failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, U : Any?, reified E : Throwable> Event<V, E>.map(transform: (V) -> U): Event<U, E> = try {
|
||||||
|
when (this) {
|
||||||
|
is Event.Success -> Event.Success(transform(value))
|
||||||
|
is Event.Failure -> Event.Failure(error)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
when (ex) {
|
||||||
|
is E -> Event.error(ex)
|
||||||
|
else -> throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, U : Any?, reified E : Throwable> Event<V, E>.flatMap(transform: (V) -> Event<U, E>): Event<U, E> =
|
||||||
|
try {
|
||||||
|
when (this) {
|
||||||
|
is Event.Success -> transform(value)
|
||||||
|
is Event.Failure -> Event.Failure(error)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
when (ex) {
|
||||||
|
is E -> Event.error(ex)
|
||||||
|
else -> throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, E : Throwable, E2 : Throwable> Event<V, E>.mapError(transform: (E) -> E2) = when (this) {
|
||||||
|
is Event.Success -> Event.Success(value)
|
||||||
|
is Event.Failure -> Event.Failure(transform(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, E : Throwable, E2 : Throwable> Event<V, E>.flatMapError(transform: (E) -> Event<V, E2>) =
|
||||||
|
when (this) {
|
||||||
|
is Event.Success -> Event.Success(value)
|
||||||
|
is Event.Failure -> transform(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, E : Throwable> Event<V, E>.onError(f: (E) -> Unit) = when (this) {
|
||||||
|
is Event.Success -> Event.Success(value)
|
||||||
|
is Event.Failure -> {
|
||||||
|
f(error)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, E : Throwable> Event<V, E>.onSuccess(f: (V) -> Unit): Event<V, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Event.Success -> {
|
||||||
|
f(value)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
is Event.Failure -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, E : Throwable> Event<V, E>.any(predicate: (V) -> Boolean): Boolean = try {
|
||||||
|
when (this) {
|
||||||
|
is Event.Success -> predicate(value)
|
||||||
|
is Event.Failure -> false
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V : Any?, U : Any?> Event<V, *>.fanout(other: () -> Event<U, *>): Event<Pair<V, U>, *> =
|
||||||
|
flatMap { outer -> other().map { outer to it } }
|
||||||
|
|
||||||
|
inline fun <V : Any?, reified E : Throwable> List<Event<V, E>>.lift(): Event<List<V>, E> = fold(
|
||||||
|
Event.success(
|
||||||
|
mutableListOf<V>()
|
||||||
|
) as Event<MutableList<V>, E>
|
||||||
|
) { acc, Event ->
|
||||||
|
acc.flatMap { combine ->
|
||||||
|
Event.map { combine.apply { add(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <V, E : Throwable> Event<V, E>.unwrap(failure: (E) -> Nothing): V =
|
||||||
|
apply { component2()?.let(failure) }.component1()!!
|
||||||
|
|
||||||
|
inline fun <V, E : Throwable> Event<V, E>.unwrapError(success: (V) -> Nothing): E =
|
||||||
|
apply { component1()?.let(success) }.component2()!!
|
||||||
|
|
||||||
|
|
||||||
|
sealed class Event<out V : Any?, out E : Throwable>: ReadOnlyProperty<Any?, V> {
|
||||||
|
|
||||||
|
open operator fun component1(): V? = null
|
||||||
|
open operator fun component2(): E? = null
|
||||||
|
|
||||||
|
inline fun <X> fold(success: (V) -> X, failure: (E) -> X): X = when (this) {
|
||||||
|
is Success -> success(this.value)
|
||||||
|
is Failure -> failure(this.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract val value: V
|
||||||
|
|
||||||
|
class Success<out V : Any?>(override val value: V) : Event<V, Nothing>() {
|
||||||
|
override fun component1(): V? = value
|
||||||
|
|
||||||
|
override fun toString() = "[Success: $value]"
|
||||||
|
|
||||||
|
override fun hashCode(): Int = value.hashCode()
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
return other is Success<*> && value == other.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Any?, property: KProperty<*>): V = value
|
||||||
|
}
|
||||||
|
|
||||||
|
class Failure<out E : Throwable>(val error: E) : Event<Nothing, E>() {
|
||||||
|
override fun component2(): E = error
|
||||||
|
|
||||||
|
override val value: Nothing get() = throw error
|
||||||
|
|
||||||
|
fun getThrowable(): E = error
|
||||||
|
|
||||||
|
override fun toString() = "[Failure: $error]"
|
||||||
|
|
||||||
|
override fun hashCode(): Int = error.hashCode()
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
return other is Failure<*> && error == other.error
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Any?, property: KProperty<*>): Nothing = value
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Factory methods
|
||||||
|
fun <E : Throwable> error(ex: E) = Failure(ex)
|
||||||
|
|
||||||
|
fun <V : Any?> success(v: V) = Success(v)
|
||||||
|
|
||||||
|
inline fun <V : Any?> of(
|
||||||
|
value: V?,
|
||||||
|
fail: (() -> Throwable) = { Throwable() }
|
||||||
|
): Event<V, Throwable> =
|
||||||
|
value?.let { success(it) } ?: error(fail())
|
||||||
|
|
||||||
|
inline fun <V : Any?, reified E : Throwable> of(crossinline f: () -> V): Event<V, E> = try {
|
||||||
|
success(f())
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
when (ex) {
|
||||||
|
is E -> error(ex)
|
||||||
|
else -> throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline operator fun <V : Any?> invoke(crossinline f: () -> V): Event<V, Throwable> = try {
|
||||||
|
success(f())
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
error(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package com.shabinder.common.models.event
|
||||||
|
|
||||||
|
inline fun <V> runCatching(block: () -> V): Event<V, Throwable> {
|
||||||
|
return try {
|
||||||
|
Event.success(block())
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Event.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline infix fun <T, V> T.runCatching(block: T.() -> V): Event<V, Throwable> {
|
||||||
|
return try {
|
||||||
|
Event.success(block())
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Event.error(e)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package com.shabinder.common.models.event
|
||||||
|
|
||||||
|
class Validation<out E : Throwable>(vararg resultSequence: Event<*, E>) {
|
||||||
|
|
||||||
|
val failures: List<E> = resultSequence.filterIsInstance<Event.Failure<E>>().map { it.getThrowable() }
|
||||||
|
|
||||||
|
val hasFailure = failures.isNotEmpty()
|
||||||
|
}
|
@ -0,0 +1,174 @@
|
|||||||
|
package com.shabinder.common.models.event.coroutines
|
||||||
|
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
inline fun <reified X> SuspendableEvent<*, *>.getAs() = when (this) {
|
||||||
|
is SuspendableEvent.Success -> value as? X
|
||||||
|
is SuspendableEvent.Failure -> error as? X
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?> SuspendableEvent<V, *>.success(noinline f: suspend (V) -> Unit) = fold(f, {})
|
||||||
|
|
||||||
|
suspend inline fun <E : Throwable> SuspendableEvent<*, E>.failure(noinline f: suspend (E) -> Unit) = fold({}, f)
|
||||||
|
|
||||||
|
infix fun <V : Any?, E : Throwable> SuspendableEvent<V, E>.or(fallback: V) = when (this) {
|
||||||
|
is SuspendableEvent.Success -> this
|
||||||
|
else -> SuspendableEvent.Success(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline infix fun <V : Any?, E : Throwable> SuspendableEvent<V, E>.getOrElse(crossinline fallback:suspend (E) -> V): V {
|
||||||
|
return when (this) {
|
||||||
|
is SuspendableEvent.Success -> value
|
||||||
|
is SuspendableEvent.Failure -> fallback(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <V : Any?, E : Throwable> SuspendableEvent<V, E>.getOrNull(): V? {
|
||||||
|
return when (this) {
|
||||||
|
is SuspendableEvent.Success -> value
|
||||||
|
is SuspendableEvent.Failure -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, U : Any?, E : Throwable> SuspendableEvent<V, E>.map(
|
||||||
|
crossinline transform: suspend (V) -> U
|
||||||
|
): SuspendableEvent<U, E> = try {
|
||||||
|
when (this) {
|
||||||
|
is SuspendableEvent.Success -> SuspendableEvent.Success(transform(value))
|
||||||
|
is SuspendableEvent.Failure -> SuspendableEvent.Failure(error)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
SuspendableEvent.error(ex as E)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, U : Any?, E : Throwable> SuspendableEvent<V, E>.flatMap(
|
||||||
|
crossinline transform: suspend (V) -> SuspendableEvent<U, E>
|
||||||
|
): SuspendableEvent<U, E> = try {
|
||||||
|
when (this) {
|
||||||
|
is SuspendableEvent.Success -> transform(value)
|
||||||
|
is SuspendableEvent.Failure -> SuspendableEvent.Failure(error)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
SuspendableEvent.error(ex as E)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, E : Throwable, E2 : Throwable> SuspendableEvent<V, E>.mapError(
|
||||||
|
crossinline transform: suspend (E) -> E2
|
||||||
|
) = try {
|
||||||
|
when (this) {
|
||||||
|
is SuspendableEvent.Success -> SuspendableEvent.Success<V, E2>(value)
|
||||||
|
is SuspendableEvent.Failure -> SuspendableEvent.Failure<V, E2>(transform(error))
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
SuspendableEvent.error(ex as E)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, E : Throwable, E2 : Throwable> SuspendableEvent<V, E>.flatMapError(
|
||||||
|
crossinline transform: suspend (E) -> SuspendableEvent<V, E2>
|
||||||
|
) = try {
|
||||||
|
when (this) {
|
||||||
|
is SuspendableEvent.Success -> SuspendableEvent.Success(value)
|
||||||
|
is SuspendableEvent.Failure -> transform(error)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
SuspendableEvent.error(ex as E)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, E : Throwable> SuspendableEvent<V, E>.any(
|
||||||
|
crossinline predicate: suspend (V) -> Boolean
|
||||||
|
): Boolean = try {
|
||||||
|
when (this) {
|
||||||
|
is SuspendableEvent.Success -> predicate(value)
|
||||||
|
is SuspendableEvent.Failure -> false
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, U : Any> SuspendableEvent<V, *>.fanout(
|
||||||
|
crossinline other: suspend () -> SuspendableEvent<U, *>
|
||||||
|
): SuspendableEvent<Pair<V, U>, *> =
|
||||||
|
flatMap { outer -> other().map { outer to it } }
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun <V : Any?, E : Throwable> List<SuspendableEvent<V, E>>.lift(): SuspendableEvent<List<V>, E> = fold(
|
||||||
|
SuspendableEvent.Success<MutableList<V>, E>(mutableListOf<V>()) as SuspendableEvent<MutableList<V>, E>
|
||||||
|
) { acc, result ->
|
||||||
|
acc.flatMap { combine ->
|
||||||
|
result.map { combine.apply { add(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SuspendableEvent<out V : Any?, out E : Throwable>: ReadOnlyProperty<Any?,V> {
|
||||||
|
|
||||||
|
abstract operator fun component1(): V?
|
||||||
|
abstract operator fun component2(): E?
|
||||||
|
|
||||||
|
suspend inline fun <X> fold(noinline success: suspend (V) -> X, noinline failure: suspend (E) -> X): X {
|
||||||
|
return when (this) {
|
||||||
|
is Success -> success(this.value)
|
||||||
|
is Failure -> failure(this.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract val value: V
|
||||||
|
|
||||||
|
class Success<out V : Any?, out E : Throwable>(override val value: V) : SuspendableEvent<V, E>() {
|
||||||
|
override fun component1(): V? = value
|
||||||
|
override fun component2(): E? = null
|
||||||
|
|
||||||
|
override fun toString() = "[Success: $value]"
|
||||||
|
|
||||||
|
override fun hashCode(): Int = value.hashCode()
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
return other is Success<*, *> && value == other.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Any?, property: KProperty<*>): V = value
|
||||||
|
}
|
||||||
|
|
||||||
|
class Failure<out V : Any?, out E : Throwable>(val error: E) : SuspendableEvent<V, E>() {
|
||||||
|
override fun component1(): V? = null
|
||||||
|
override fun component2(): E? = error
|
||||||
|
|
||||||
|
override val value: V get() = throw error
|
||||||
|
|
||||||
|
fun getThrowable(): E = error
|
||||||
|
|
||||||
|
override fun toString() = "[Failure: $error]"
|
||||||
|
|
||||||
|
override fun hashCode(): Int = error.hashCode()
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
return other is Failure<*, *> && error == other.error
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Any?, property: KProperty<*>): V = value
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Factory methods
|
||||||
|
fun <E : Throwable> error(ex: E) = Failure<Nothing, E>(ex)
|
||||||
|
|
||||||
|
inline fun <V : Any?> of(value: V?,crossinline fail: (() -> Throwable) = { Throwable() }): SuspendableEvent<V, Throwable> {
|
||||||
|
return value?.let { Success<V, Nothing>(it) } ?: error(fail())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <V : Any?, E : Throwable> of(
|
||||||
|
crossinline block: suspend () -> V
|
||||||
|
): SuspendableEvent<V, E> = try {
|
||||||
|
Success(block())
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Failure(ex as E)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline operator fun <V : Any?> invoke(
|
||||||
|
crossinline block: suspend () -> V
|
||||||
|
): SuspendableEvent<V, Throwable> = of(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.shabinder.common.models.event.coroutines
|
||||||
|
|
||||||
|
class SuspendedValidation<out E : Throwable>(vararg resultSequence: SuspendableEvent<*, E>) {
|
||||||
|
|
||||||
|
val failures: List<E> = resultSequence.filterIsInstance<SuspendableEvent.Failure<*, E>>().map { it.getThrowable() }
|
||||||
|
|
||||||
|
val hasFailure = failures.isNotEmpty()
|
||||||
|
|
||||||
|
}
|
@ -32,7 +32,7 @@ kotlin {
|
|||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
implementation("org.jetbrains.kotlinx:atomicfu:0.16.1")
|
implementation("org.jetbrains.kotlinx:atomicfu:0.16.1")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
|
||||||
implementation("com.russhwolf:multiplatform-settings-no-arg:0.7.7")
|
api(MultiPlatformSettings.dep)
|
||||||
implementation(Extras.youtubeDownloader)
|
implementation(Extras.youtubeDownloader)
|
||||||
implementation(Extras.fuzzyWuzzy)
|
implementation(Extras.fuzzyWuzzy)
|
||||||
implementation(MVIKotlin.rx)
|
implementation(MVIKotlin.rx)
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import com.shabinder.common.models.AllPlatforms
|
|
||||||
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.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@ -25,9 +24,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
// IO-Dispatcher
|
// IO-Dispatcher
|
||||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
// Current Platform Info
|
|
||||||
actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm
|
|
||||||
|
|
||||||
actual suspend fun downloadTracks(
|
actual suspend fun downloadTracks(
|
||||||
list: List<TrackDetails>,
|
list: List<TrackDetails>,
|
||||||
fetcher: FetchPlatformQueryResult,
|
fetcher: FetchPlatformQueryResult,
|
||||||
|
@ -22,8 +22,8 @@ import android.os.Environment
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.mpatric.mp3agic.Mp3File
|
import com.mpatric.mp3agic.Mp3File
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.di.utils.ParallelExecutor
|
import com.shabinder.common.di.utils.ParallelExecutor
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
@ -43,7 +43,7 @@ import java.net.URL
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
actual class Dir actual constructor(
|
actual class Dir actual constructor(
|
||||||
private val logger: Kermit,
|
private val logger: Kermit,
|
||||||
settingsPref: Settings,
|
private val preferenceManager: PreferenceManager,
|
||||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||||
) {
|
) {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@ -54,7 +54,7 @@ actual class Dir actual constructor(
|
|||||||
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
|
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
|
||||||
|
|
||||||
// fun call in order to always access Updated Value
|
// fun call in order to always access Updated Value
|
||||||
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) +
|
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
|
||||||
File.separator + "SpotiFlyer" + File.separator
|
File.separator + "SpotiFlyer" + File.separator
|
||||||
|
|
||||||
actual fun isPresent(path: String): Boolean = File(path).exists()
|
actual fun isPresent(path: String): Boolean = File(path).exists()
|
||||||
@ -202,5 +202,4 @@ actual class Dir actual constructor(
|
|||||||
private val parallelExecutor = ParallelExecutor(Dispatchers.IO)
|
private val parallelExecutor = ParallelExecutor(Dispatchers.IO)
|
||||||
|
|
||||||
actual val db: Database? = spotiFlyerDatabase.instance
|
actual val db: Database? = spotiFlyerDatabase.instance
|
||||||
actual val settings: Settings = settingsPref
|
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
package com.shabinder.common.di.saavn
|
package com.shabinder.common.di.providers.requests.saavn
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import io.ktor.util.InternalAPI
|
import io.ktor.util.*
|
||||||
import io.ktor.util.decodeBase64Bytes
|
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
@ -1,346 +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.worker
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import co.touchlab.kermit.Kermit
|
|
||||||
import com.shabinder.common.di.Dir
|
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
|
||||||
import com.shabinder.common.di.R
|
|
||||||
import com.shabinder.common.di.downloadFile
|
|
||||||
import com.shabinder.common.di.utils.ParallelExecutor
|
|
||||||
import com.shabinder.common.models.DownloadResult
|
|
||||||
import com.shabinder.common.models.DownloadStatus
|
|
||||||
import com.shabinder.common.models.Status
|
|
||||||
import com.shabinder.common.models.TrackDetails
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.android.ext.android.inject
|
|
||||||
import java.io.File
|
|
||||||
import kotlin.coroutines.CoroutineContext
|
|
||||||
|
|
||||||
class ForegroundService : Service(), CoroutineScope {
|
|
||||||
|
|
||||||
private val tag: String = "Foreground Service"
|
|
||||||
private val channelId = "ForegroundDownloaderService"
|
|
||||||
private val notificationId = 101
|
|
||||||
private var total = 0 // Total Downloads Requested
|
|
||||||
private var converted = 0 // Total Files Converted
|
|
||||||
private var downloaded = 0 // Total Files downloaded
|
|
||||||
private var failed = 0 // Total Files failed
|
|
||||||
private val isFinished get() = converted + failed == total
|
|
||||||
private var isSingleDownload = false
|
|
||||||
|
|
||||||
private lateinit var serviceJob: Job
|
|
||||||
override val coroutineContext: CoroutineContext
|
|
||||||
get() = serviceJob + Dispatchers.IO
|
|
||||||
|
|
||||||
private val allTracksStatus = hashMapOf<String, DownloadStatus>()
|
|
||||||
private var messageList = mutableListOf("", "", "", "", "")
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
|
||||||
private var isServiceStarted = false
|
|
||||||
private lateinit var cancelIntent: PendingIntent
|
|
||||||
|
|
||||||
private lateinit var downloadManager: DownloadManager
|
|
||||||
private lateinit var downloadService: ParallelExecutor
|
|
||||||
private val ytDownloader get() = fetcher.youtubeProvider.ytDownloader
|
|
||||||
private val fetcher: FetchPlatformQueryResult by inject()
|
|
||||||
private val logger: Kermit by inject()
|
|
||||||
private val dir: Dir by inject()
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? = null
|
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedImmutableFlag")
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
serviceJob = SupervisorJob()
|
|
||||||
downloadService = ParallelExecutor(Dispatchers.IO)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
createNotificationChannel(channelId, "Downloader Service")
|
|
||||||
}
|
|
||||||
val intent = Intent(
|
|
||||||
this,
|
|
||||||
ForegroundService::class.java
|
|
||||||
).apply { action = "kill" }
|
|
||||||
cancelIntent = PendingIntent.getService(this, 0, intent, FLAG_CANCEL_CURRENT)
|
|
||||||
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("WakelockTimeout")
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
// Send a notification that service is started
|
|
||||||
Log.i(tag, "Foreground Service Started.")
|
|
||||||
startForeground(notificationId, getNotification())
|
|
||||||
|
|
||||||
intent?.let {
|
|
||||||
when (it.action) {
|
|
||||||
"kill" -> killService()
|
|
||||||
"query" -> {
|
|
||||||
val response = Intent().apply {
|
|
||||||
action = "query_result"
|
|
||||||
synchronized(allTracksStatus) {
|
|
||||||
putExtra("tracks", allTracksStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sendBroadcast(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadObjects: ArrayList<TrackDetails>? = (
|
|
||||||
it.getParcelableArrayListExtra("object") ?: it.extras?.getParcelableArrayList(
|
|
||||||
"object"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
downloadObjects?.let { list ->
|
|
||||||
downloadObjects.size.let { size ->
|
|
||||||
total += size
|
|
||||||
isSingleDownload = (size == 1)
|
|
||||||
}
|
|
||||||
list.forEach { track ->
|
|
||||||
allTracksStatus[track.title] = DownloadStatus.Queued
|
|
||||||
}
|
|
||||||
updateNotification()
|
|
||||||
downloadAllTracks(list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Wake locks and misc tasks from here :
|
|
||||||
return if (isServiceStarted) {
|
|
||||||
// Service Already Started
|
|
||||||
START_STICKY
|
|
||||||
} else {
|
|
||||||
isServiceStarted = true
|
|
||||||
Log.i(tag, "Starting the foreground service task")
|
|
||||||
wakeLock =
|
|
||||||
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
|
||||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply {
|
|
||||||
acquire()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
START_STICKY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function To Download All Tracks Available in a List
|
|
||||||
**/
|
|
||||||
private fun downloadAllTracks(trackList: List<TrackDetails>) {
|
|
||||||
trackList.forEach {
|
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
downloadService.execute {
|
|
||||||
val url = fetcher.findMp3DownloadLink(it)
|
|
||||||
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL
|
|
||||||
enqueueDownload(url, it)
|
|
||||||
} else {
|
|
||||||
sendTrackBroadcast(Status.FAILED.name, it)
|
|
||||||
failed++
|
|
||||||
updateNotification()
|
|
||||||
allTracksStatus[it.title] = DownloadStatus.Failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun enqueueDownload(url: String, track: TrackDetails) {
|
|
||||||
// Initiating Download
|
|
||||||
addToNotification("Downloading ${track.title}")
|
|
||||||
logger.d(tag) { "${track.title} Download Started" }
|
|
||||||
allTracksStatus[track.title] = DownloadStatus.Downloading()
|
|
||||||
sendTrackBroadcast(Status.DOWNLOADING.name, track)
|
|
||||||
|
|
||||||
// Enqueueing Download
|
|
||||||
downloadFile(url).collect {
|
|
||||||
when (it) {
|
|
||||||
is DownloadResult.Error -> {
|
|
||||||
launch {
|
|
||||||
logger.d(tag) { it.message }
|
|
||||||
removeFromNotification("Downloading ${track.title}")
|
|
||||||
failed++
|
|
||||||
updateNotification()
|
|
||||||
sendTrackBroadcast(Status.FAILED.name, track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is DownloadResult.Progress -> {
|
|
||||||
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
|
|
||||||
logger.d(tag) { "${track.title} Progress: ${it.progress} %" }
|
|
||||||
|
|
||||||
val intent = Intent().apply {
|
|
||||||
action = "Progress"
|
|
||||||
putExtra("progress", it.progress)
|
|
||||||
putExtra("track", track)
|
|
||||||
}
|
|
||||||
sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
is DownloadResult.Success -> {
|
|
||||||
try {
|
|
||||||
// Save File and Embed Metadata
|
|
||||||
val job = launch(Dispatchers.Default) { dir.saveFileWithMetadata(it.byteArray, track) {} }
|
|
||||||
allTracksStatus[track.title] = DownloadStatus.Converting
|
|
||||||
sendTrackBroadcast("Converting", track)
|
|
||||||
addToNotification("Processing ${track.title}")
|
|
||||||
job.invokeOnCompletion {
|
|
||||||
converted++
|
|
||||||
allTracksStatus[track.title] = DownloadStatus.Downloaded
|
|
||||||
sendTrackBroadcast(Status.COMPLETED.name, track)
|
|
||||||
removeFromNotification("Processing ${track.title}")
|
|
||||||
}
|
|
||||||
logger.d(tag) { "${track.title} Download Completed" }
|
|
||||||
downloaded++
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Download Failed
|
|
||||||
logger.d(tag) { "${track.title} Download Failed! Error:Fetch!!!!" }
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
removeFromNotification("Downloading ${track.title}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
|
||||||
logger.d(tag) { "Releasing Wake Lock" }
|
|
||||||
try {
|
|
||||||
wakeLock?.let {
|
|
||||||
if (it.isHeld) {
|
|
||||||
it.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.d(tag) { "Service stopped without being started: ${e.message}" }
|
|
||||||
}
|
|
||||||
isServiceStarted = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("SameParameterValue")
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun createNotificationChannel(channelId: String, channelName: String) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
channelId,
|
|
||||||
channelName, NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
)
|
|
||||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
||||||
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
service.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Time To Wrap UP
|
|
||||||
* - `Clean Up` and `Stop this Foreground Service`
|
|
||||||
* */
|
|
||||||
private fun killService() {
|
|
||||||
launch {
|
|
||||||
logger.d(tag) { "Killing Self" }
|
|
||||||
messageList = mutableListOf("Cleaning And Exiting", "", "", "", "")
|
|
||||||
downloadService.close()
|
|
||||||
updateNotification()
|
|
||||||
cleanFiles(File(dir.defaultDir()), logger)
|
|
||||||
// TODO cleanFiles(File(dir.imageCacheDir()))
|
|
||||||
messageList = mutableListOf("", "", "", "", "")
|
|
||||||
releaseWakeLock()
|
|
||||||
serviceJob.cancel()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
stopForeground(true)
|
|
||||||
stopSelf()
|
|
||||||
} else {
|
|
||||||
stopSelf() // System will automatically close it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
if (isFinished) {
|
|
||||||
killService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
if (isFinished) {
|
|
||||||
killService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Create A New Notification with all the updated data
|
|
||||||
* */
|
|
||||||
private fun getNotification(): Notification = NotificationCompat.Builder(this, channelId).run {
|
|
||||||
setSmallIcon(R.drawable.ic_download_arrow)
|
|
||||||
setContentTitle("Total: $total Completed:$converted Failed:$failed")
|
|
||||||
setSilent(true)
|
|
||||||
setStyle(
|
|
||||||
NotificationCompat.InboxStyle().run {
|
|
||||||
addLine(messageList[messageList.size - 1])
|
|
||||||
addLine(messageList[messageList.size - 2])
|
|
||||||
addLine(messageList[messageList.size - 3])
|
|
||||||
addLine(messageList[messageList.size - 4])
|
|
||||||
addLine(messageList[messageList.size - 5])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
addAction(R.drawable.ic_round_cancel_24, "Exit", cancelIntent)
|
|
||||||
build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addToNotification(message: String) {
|
|
||||||
messageList.add(message)
|
|
||||||
updateNotification()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeFromNotification(message: String) {
|
|
||||||
messageList.remove(message)
|
|
||||||
updateNotification()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the method that can be called to update the Notification
|
|
||||||
*/
|
|
||||||
private fun updateNotification() {
|
|
||||||
val mNotificationManager: NotificationManager =
|
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
mNotificationManager.notify(notificationId, getNotification())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendTrackBroadcast(action: String, track: TrackDetails) {
|
|
||||||
val intent = Intent().apply {
|
|
||||||
setAction(action)
|
|
||||||
putExtra("track", track)
|
|
||||||
}
|
|
||||||
this@ForegroundService.sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
}
|
|
@ -20,21 +20,13 @@ import co.touchlab.kermit.Kermit
|
|||||||
import com.russhwolf.settings.Settings
|
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.database.getLogger
|
||||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.di.providers.GaanaProvider
|
import com.shabinder.common.di.providers.providersModule
|
||||||
import com.shabinder.common.di.providers.SaavnProvider
|
import io.ktor.client.*
|
||||||
import com.shabinder.common.di.providers.SpotifyProvider
|
import io.ktor.client.features.*
|
||||||
import com.shabinder.common.di.providers.YoutubeMp3
|
import io.ktor.client.features.json.*
|
||||||
import com.shabinder.common.di.providers.YoutubeMusic
|
import io.ktor.client.features.json.serializer.*
|
||||||
import com.shabinder.common.di.providers.YoutubeProvider
|
import io.ktor.client.features.logging.*
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.features.HttpTimeout
|
|
||||||
import io.ktor.client.features.json.JsonFeature
|
|
||||||
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
|
||||||
import io.ktor.client.features.logging.DEFAULT
|
|
||||||
import io.ktor.client.features.logging.LogLevel
|
|
||||||
import io.ktor.client.features.logging.Logger
|
|
||||||
import io.ktor.client.features.logging.Logging
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.dsl.KoinAppDeclaration
|
import org.koin.dsl.KoinAppDeclaration
|
||||||
@ -45,7 +37,11 @@ import kotlin.native.concurrent.ThreadLocal
|
|||||||
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
|
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
|
||||||
startKoin {
|
startKoin {
|
||||||
appDeclaration()
|
appDeclaration()
|
||||||
modules(commonModule(enableNetworkLogs = enableNetworkLogs), databaseModule())
|
modules(
|
||||||
|
commonModule(enableNetworkLogs = enableNetworkLogs),
|
||||||
|
providersModule(),
|
||||||
|
databaseModule()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by IOS
|
// Called by IOS
|
||||||
@ -55,16 +51,9 @@ fun commonModule(enableNetworkLogs: Boolean) = module {
|
|||||||
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
|
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
|
||||||
single { Dir(get(), get(), get()) }
|
single { Dir(get(), get(), get()) }
|
||||||
single { Settings() }
|
single { Settings() }
|
||||||
|
single { PreferenceManager(get()) }
|
||||||
single { Kermit(getLogger()) }
|
single { Kermit(getLogger()) }
|
||||||
single { TokenStore(get(), get()) }
|
single { TokenStore(get(), get()) }
|
||||||
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(), get()) }
|
|
||||||
single { YoutubeMusic(get(), get(), get(), get(), get()) }
|
|
||||||
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ThreadLocal
|
@ThreadLocal
|
||||||
|
@ -17,33 +17,25 @@
|
|||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.di.utils.removeIllegalChars
|
import com.shabinder.common.di.utils.removeIllegalChars
|
||||||
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.database.Database
|
import com.shabinder.database.Database
|
||||||
import io.ktor.client.request.HttpRequestBuilder
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.client.statement.HttpStatement
|
import io.ktor.http.*
|
||||||
import io.ktor.http.contentLength
|
|
||||||
import io.ktor.http.isSuccess
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
const val DirKey = "downloadDir"
|
|
||||||
const val AnalyticsKey = "analytics"
|
|
||||||
const val FirstLaunch = "firstLaunch"
|
|
||||||
const val DonationInterval = "donationInterval"
|
|
||||||
|
|
||||||
expect class Dir(
|
expect class Dir(
|
||||||
logger: Kermit,
|
logger: Kermit,
|
||||||
settingsPref: Settings,
|
preferenceManager: PreferenceManager,
|
||||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||||
) {
|
) {
|
||||||
val db: Database?
|
val db: Database?
|
||||||
val settings: Settings
|
|
||||||
fun isPresent(path: String): Boolean
|
fun isPresent(path: String): Boolean
|
||||||
fun fileSeparator(): String
|
fun fileSeparator(): String
|
||||||
fun defaultDir(): String
|
fun defaultDir(): String
|
||||||
@ -56,22 +48,6 @@ expect class Dir(
|
|||||||
fun addToLibrary(path: String)
|
fun addToLibrary(path: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
val Dir.isAnalyticsEnabled get() = settings.getBooleanOrNull(AnalyticsKey) ?: false
|
|
||||||
fun Dir.toggleAnalytics(enabled: Boolean) = settings.putBoolean(AnalyticsKey, enabled)
|
|
||||||
|
|
||||||
fun Dir.setDownloadDirectory(newBasePath: String) = settings.putString(DirKey, newBasePath)
|
|
||||||
|
|
||||||
val Dir.getDonationOffset: Int get() = (settings.getIntOrNull(DonationInterval) ?: 3).also {
|
|
||||||
// Min. Donation Asking Interval is `3`
|
|
||||||
if (it < 3) setDonationOffset(3) else setDonationOffset(it - 1)
|
|
||||||
}
|
|
||||||
fun Dir.setDonationOffset(offset: Int = 5) = settings.putInt(DonationInterval, offset)
|
|
||||||
|
|
||||||
val Dir.isFirstLaunch get() = settings.getBooleanOrNull(FirstLaunch) ?: true
|
|
||||||
fun Dir.firstLaunchDone() {
|
|
||||||
settings.putBoolean(FirstLaunch, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Call this function at startup!
|
* Call this function at startup!
|
||||||
* */
|
* */
|
||||||
@ -105,7 +81,7 @@ suspend fun downloadFile(url: String): Flow<DownloadResult> {
|
|||||||
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, 250000)
|
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))
|
||||||
|
@ -16,9 +16,8 @@
|
|||||||
|
|
||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import com.shabinder.common.models.AllPlatforms
|
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import io.ktor.client.request.head
|
import io.ktor.client.request.*
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -34,10 +33,6 @@ expect suspend fun downloadTracks(
|
|||||||
@SharedImmutable
|
@SharedImmutable
|
||||||
expect val dispatcherIO: CoroutineDispatcher
|
expect val dispatcherIO: CoroutineDispatcher
|
||||||
|
|
||||||
// Current Platform Info
|
|
||||||
@SharedImmutable
|
|
||||||
expect val currentPlatform: AllPlatforms
|
|
||||||
|
|
||||||
suspend fun isInternetAccessible(): Boolean {
|
suspend fun isInternetAccessible(): Boolean {
|
||||||
return withContext(dispatcherIO) {
|
return withContext(dispatcherIO) {
|
||||||
try {
|
try {
|
||||||
|
@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.database.DownloadRecordDatabaseQueries
|
import com.shabinder.common.database.DownloadRecordDatabaseQueries
|
||||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
|
||||||
import com.shabinder.common.di.providers.GaanaProvider
|
import com.shabinder.common.di.providers.GaanaProvider
|
||||||
import com.shabinder.common.di.providers.SaavnProvider
|
import com.shabinder.common.di.providers.SaavnProvider
|
||||||
import com.shabinder.common.di.providers.SpotifyProvider
|
import com.shabinder.common.di.providers.SpotifyProvider
|
||||||
@ -25,26 +25,37 @@ import com.shabinder.common.di.providers.YoutubeMp3
|
|||||||
import com.shabinder.common.di.providers.YoutubeMusic
|
import com.shabinder.common.di.providers.YoutubeMusic
|
||||||
import com.shabinder.common.di.providers.YoutubeProvider
|
import com.shabinder.common.di.providers.YoutubeProvider
|
||||||
import com.shabinder.common.di.providers.get
|
import com.shabinder.common.di.providers.get
|
||||||
|
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||||
import com.shabinder.common.models.PlatformQueryResult
|
import com.shabinder.common.models.PlatformQueryResult
|
||||||
|
import com.shabinder.common.models.SpotiFlyerException
|
||||||
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.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.models.spotify.Source
|
||||||
|
import com.shabinder.common.requireNotNull
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class FetchPlatformQueryResult(
|
class FetchPlatformQueryResult(
|
||||||
private val gaanaProvider: GaanaProvider,
|
private val gaanaProvider: GaanaProvider,
|
||||||
val spotifyProvider: SpotifyProvider,
|
private val spotifyProvider: SpotifyProvider,
|
||||||
val youtubeProvider: YoutubeProvider,
|
private val youtubeProvider: YoutubeProvider,
|
||||||
private val saavnProvider: SaavnProvider,
|
private val saavnProvider: SaavnProvider,
|
||||||
val youtubeMusic: YoutubeMusic,
|
private val youtubeMusic: YoutubeMusic,
|
||||||
val youtubeMp3: YoutubeMp3,
|
private val youtubeMp3: YoutubeMp3,
|
||||||
val audioToMp3: AudioToMp3,
|
private val audioToMp3: AudioToMp3,
|
||||||
val dir: Dir
|
val dir: Dir,
|
||||||
|
val logger: Kermit
|
||||||
) {
|
) {
|
||||||
private val db: DownloadRecordDatabaseQueries?
|
private val db: DownloadRecordDatabaseQueries?
|
||||||
get() = dir.db?.downloadRecordDatabaseQueries
|
get() = dir.db?.downloadRecordDatabaseQueries
|
||||||
|
|
||||||
suspend fun query(link: String): PlatformQueryResult? {
|
suspend fun authenticateSpotifyClient() = spotifyProvider.authenticateSpotifyClient()
|
||||||
|
|
||||||
|
suspend fun query(link: String): SuspendableEvent<PlatformQueryResult,Throwable> {
|
||||||
val result = when {
|
val result = when {
|
||||||
// SPOTIFY
|
// SPOTIFY
|
||||||
link.contains("spotify", true) ->
|
link.contains("spotify", true) ->
|
||||||
@ -63,13 +74,13 @@ class FetchPlatformQueryResult(
|
|||||||
gaanaProvider.query(link)
|
gaanaProvider.query(link)
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
null
|
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result != null) {
|
result.success {
|
||||||
addToDatabaseAsync(
|
addToDatabaseAsync(
|
||||||
link,
|
link,
|
||||||
result.copy() // Send a copy in order to not to freeze Result itself
|
it.copy() // Send a copy in order to not to freeze Result itself
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@ -79,35 +90,55 @@ class FetchPlatformQueryResult(
|
|||||||
// 2) If Not found try finding on Youtube Music
|
// 2) If Not found try finding on Youtube Music
|
||||||
suspend fun findMp3DownloadLink(
|
suspend fun findMp3DownloadLink(
|
||||||
track: TrackDetails
|
track: TrackDetails
|
||||||
): String? =
|
): SuspendableEvent<String,Throwable> =
|
||||||
if (track.videoID != null) {
|
if (track.videoID != null) {
|
||||||
// We Already have VideoID
|
// We Already have VideoID
|
||||||
when (track.source) {
|
when (track.source) {
|
||||||
Source.JioSaavn -> {
|
Source.JioSaavn -> {
|
||||||
saavnProvider.getSongFromID(track.videoID!!).media_url?.let { m4aLink ->
|
saavnProvider.getSongFromID(track.videoID.requireNotNull()).flatMap { song ->
|
||||||
audioToMp3.convertToMp3(m4aLink)
|
song.media_url?.let { audioToMp3.convertToMp3(it) } ?: findHighestQualityMp3Link(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Source.YouTube -> {
|
Source.YouTube -> {
|
||||||
youtubeMp3.getMp3DownloadLink(track.videoID!!)
|
youtubeMp3.getMp3DownloadLink(track.videoID.requireNotNull()).flatMapError {
|
||||||
?: youtubeProvider.ytDownloader?.getVideo(track.videoID!!)?.get()?.url?.let { m4aLink ->
|
youtubeProvider.ytDownloader.getVideo(track.videoID!!).get()?.url?.let { m4aLink ->
|
||||||
audioToMp3.convertToMp3(m4aLink)
|
audioToMp3.convertToMp3(m4aLink)
|
||||||
}
|
} ?: throw SpotiFlyerException.YoutubeLinkNotFound(track.videoID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
null/* Do Nothing, We should never reach here for now*/
|
/*We should never reach here for now*/
|
||||||
|
findHighestQualityMp3Link(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// First Try Getting A Link From JioSaavn
|
findHighestQualityMp3Link(track)
|
||||||
saavnProvider.findSongDownloadURL(
|
|
||||||
trackName = track.title,
|
|
||||||
trackArtists = track.artists
|
|
||||||
)
|
|
||||||
// Lets Try Fetching Now From Youtube Music
|
|
||||||
?: youtubeMusic.findSongDownloadURL(track)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun findHighestQualityMp3Link(
|
||||||
|
track: TrackDetails
|
||||||
|
):SuspendableEvent<String,Throwable> {
|
||||||
|
// Try Fetching Track from Jio Saavn
|
||||||
|
return saavnProvider.findMp3SongDownloadURL(
|
||||||
|
trackName = track.title,
|
||||||
|
trackArtists = track.artists
|
||||||
|
).flatMapError { saavnError ->
|
||||||
|
logger.e { "Fetching From Saavn Failed: \n${saavnError.stackTraceToString()}" }
|
||||||
|
// Saavn Failed, Lets Try Fetching Now From Youtube Music
|
||||||
|
youtubeMusic.findMp3SongDownloadURLYT(track).flatMapError { ytMusicError ->
|
||||||
|
// If Both Failed Bubble the Exception Up with both StackTraces
|
||||||
|
SuspendableEvent.error(
|
||||||
|
SpotiFlyerException.DownloadLinkFetchFailed(
|
||||||
|
trackName = track.title,
|
||||||
|
jioSaavnError = saavnError,
|
||||||
|
ytMusicError = ytMusicError
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) {
|
private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) {
|
||||||
GlobalScope.launch(dispatcherIO) {
|
GlobalScope.launch(dispatcherIO) {
|
||||||
db?.add(
|
db?.add(
|
||||||
|
@ -18,7 +18,7 @@ package com.shabinder.common.di
|
|||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.database.TokenDBQueries
|
import com.shabinder.common.database.TokenDBQueries
|
||||||
import com.shabinder.common.di.spotify.authenticateSpotify
|
import com.shabinder.common.di.providers.requests.spotify.authenticateSpotify
|
||||||
import com.shabinder.common.models.spotify.TokenData
|
import com.shabinder.common.models.spotify.TokenData
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -43,7 +43,7 @@ class TokenStore(
|
|||||||
logger.d { "System Time:${Clock.System.now().epochSeconds} , Token Expiry:${token?.expiry}" }
|
logger.d { "System Time:${Clock.System.now().epochSeconds} , Token Expiry:${token?.expiry}" }
|
||||||
if (Clock.System.now().epochSeconds > token?.expiry ?: 0 || token == null) {
|
if (Clock.System.now().epochSeconds > token?.expiry ?: 0 || token == null) {
|
||||||
logger.d { "Requesting New Token" }
|
logger.d { "Requesting New Token" }
|
||||||
token = authenticateSpotify()
|
token = authenticateSpotify().component1()
|
||||||
GlobalScope.launch { token?.access_token?.let { save(token) } }
|
GlobalScope.launch { token?.access_token?.let { save(token) } }
|
||||||
}
|
}
|
||||||
return token
|
return token
|
||||||
|
@ -0,0 +1,35 @@
|
|||||||
|
package com.shabinder.common.di.preference
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
|
||||||
|
class PreferenceManager(settings: Settings): Settings by settings {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DirKey = "downloadDir"
|
||||||
|
const val AnalyticsKey = "analytics"
|
||||||
|
const val FirstLaunch = "firstLaunch"
|
||||||
|
const val DonationInterval = "donationInterval"
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ANALYTICS */
|
||||||
|
val isAnalyticsEnabled get() = getBooleanOrNull(AnalyticsKey) ?: false
|
||||||
|
fun toggleAnalytics(enabled: Boolean) = putBoolean(AnalyticsKey, enabled)
|
||||||
|
|
||||||
|
|
||||||
|
/* DOWNLOAD DIRECTORY */
|
||||||
|
val downloadDir get() = getStringOrNull(DirKey)
|
||||||
|
fun setDownloadDirectory(newBasePath: String) = putString(DirKey, newBasePath)
|
||||||
|
|
||||||
|
|
||||||
|
/* OFFSET FOR WHEN TO ASK FOR SUPPORT */
|
||||||
|
val getDonationOffset: Int get() = (getIntOrNull(DonationInterval) ?: 3).also {
|
||||||
|
// Min. Donation Asking Interval is `3`
|
||||||
|
if (it < 3) setDonationOffset(3) else setDonationOffset(it - 1)
|
||||||
|
}
|
||||||
|
fun setDonationOffset(offset: Int = 5) = putInt(DonationInterval, offset)
|
||||||
|
|
||||||
|
|
||||||
|
/* TO CHECK IF THIS IS APP's FIRST LAUNCH */
|
||||||
|
val isFirstLaunch get() = getBooleanOrNull(FirstLaunch) ?: true
|
||||||
|
fun firstLaunchDone() = putBoolean(FirstLaunch, false)
|
||||||
|
}
|
@ -19,13 +19,15 @@ package com.shabinder.common.di.providers
|
|||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.di.Dir
|
||||||
import com.shabinder.common.di.finalOutputDir
|
import com.shabinder.common.di.finalOutputDir
|
||||||
import com.shabinder.common.di.gaana.GaanaRequests
|
import com.shabinder.common.di.providers.requests.gaana.GaanaRequests
|
||||||
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.SpotiFlyerException
|
||||||
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.gaana.GaanaTrack
|
import com.shabinder.common.models.gaana.GaanaTrack
|
||||||
import com.shabinder.common.models.spotify.Source
|
import com.shabinder.common.models.spotify.Source
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
|
|
||||||
class GaanaProvider(
|
class GaanaProvider(
|
||||||
override val httpClient: HttpClient,
|
override val httpClient: HttpClient,
|
||||||
@ -35,7 +37,7 @@ class GaanaProvider(
|
|||||||
|
|
||||||
private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg"
|
private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg"
|
||||||
|
|
||||||
suspend fun query(fullLink: String): PlatformQueryResult? {
|
suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
|
||||||
// Link Schema: https://gaana.com/type/link
|
// Link Schema: https://gaana.com/type/link
|
||||||
val gaanaLink = fullLink.substringAfter("gaana.com/")
|
val gaanaLink = fullLink.substringAfter("gaana.com/")
|
||||||
|
|
||||||
@ -44,17 +46,13 @@ class GaanaProvider(
|
|||||||
|
|
||||||
// Error
|
// Error
|
||||||
if (type == "Error" || link == "Error") {
|
if (type == "Error" || link == "Error") {
|
||||||
return null
|
throw SpotiFlyerException.LinkInvalid()
|
||||||
}
|
|
||||||
return try {
|
|
||||||
gaanaSearch(
|
|
||||||
type,
|
|
||||||
link
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gaanaSearch(
|
||||||
|
type,
|
||||||
|
link
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun gaanaSearch(
|
private suspend fun gaanaSearch(
|
||||||
@ -137,6 +135,7 @@ class GaanaProvider(
|
|||||||
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
|
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
|
||||||
return if (dir.isPresent(
|
return if (dir.isPresent(
|
||||||
dir.finalOutputDir(
|
dir.finalOutputDir(
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
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()) }
|
||||||
|
}
|
@ -2,16 +2,18 @@ package com.shabinder.common.di.providers
|
|||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.di.Dir
|
||||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
|
||||||
import com.shabinder.common.di.finalOutputDir
|
import com.shabinder.common.di.finalOutputDir
|
||||||
import com.shabinder.common.di.saavn.JioSaavnRequests
|
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||||
|
import com.shabinder.common.di.providers.requests.saavn.JioSaavnRequests
|
||||||
import com.shabinder.common.di.utils.removeIllegalChars
|
import com.shabinder.common.di.utils.removeIllegalChars
|
||||||
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.SpotiFlyerException
|
||||||
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.saavn.SaavnSong
|
import com.shabinder.common.models.saavn.SaavnSong
|
||||||
import com.shabinder.common.models.spotify.Source
|
import com.shabinder.common.models.spotify.Source
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
|
|
||||||
class SaavnProvider(
|
class SaavnProvider(
|
||||||
override val httpClient: HttpClient,
|
override val httpClient: HttpClient,
|
||||||
@ -20,19 +22,18 @@ class SaavnProvider(
|
|||||||
private val dir: Dir,
|
private val dir: Dir,
|
||||||
) : JioSaavnRequests {
|
) : JioSaavnRequests {
|
||||||
|
|
||||||
suspend fun query(fullLink: String): PlatformQueryResult {
|
suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult, Throwable> = SuspendableEvent {
|
||||||
val result = PlatformQueryResult(
|
PlatformQueryResult(
|
||||||
folderType = "",
|
folderType = "",
|
||||||
subFolder = "",
|
subFolder = "",
|
||||||
title = "",
|
title = "",
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
trackList = listOf(),
|
trackList = listOf(),
|
||||||
Source.JioSaavn
|
Source.JioSaavn
|
||||||
)
|
).apply {
|
||||||
with(result) {
|
|
||||||
when (fullLink.substringAfter("saavn.com/").substringBefore("/")) {
|
when (fullLink.substringAfter("saavn.com/").substringBefore("/")) {
|
||||||
"song" -> {
|
"song" -> {
|
||||||
getSong(fullLink).let {
|
getSong(fullLink).value.let {
|
||||||
folderType = "Tracks"
|
folderType = "Tracks"
|
||||||
subFolder = ""
|
subFolder = ""
|
||||||
trackList = listOf(it).toTrackDetails(folderType, subFolder)
|
trackList = listOf(it).toTrackDetails(folderType, subFolder)
|
||||||
@ -41,7 +42,7 @@ class SaavnProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"album" -> {
|
"album" -> {
|
||||||
getAlbum(fullLink)?.let {
|
getAlbum(fullLink).value.let {
|
||||||
folderType = "Albums"
|
folderType = "Albums"
|
||||||
subFolder = removeIllegalChars(it.title)
|
subFolder = removeIllegalChars(it.title)
|
||||||
trackList = it.songs.toTrackDetails(folderType, subFolder)
|
trackList = it.songs.toTrackDetails(folderType, subFolder)
|
||||||
@ -50,7 +51,7 @@ class SaavnProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"featured" -> { // Playlist
|
"featured" -> { // Playlist
|
||||||
getPlaylist(fullLink)?.let {
|
getPlaylist(fullLink).value.let {
|
||||||
folderType = "Playlists"
|
folderType = "Playlists"
|
||||||
subFolder = removeIllegalChars(it.listname)
|
subFolder = removeIllegalChars(it.listname)
|
||||||
trackList = it.songs.toTrackDetails(folderType, subFolder)
|
trackList = it.songs.toTrackDetails(folderType, subFolder)
|
||||||
@ -59,12 +60,10 @@ class SaavnProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Handle Error
|
throw SpotiFlyerException.LinkInvalid(fullLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<SaavnSong>.toTrackDetails(type: String, subFolder: String): List<TrackDetails> = this.map {
|
private fun List<SaavnSong>.toTrackDetails(type: String, subFolder: String): List<TrackDetails> = this.map {
|
||||||
|
@ -22,22 +22,24 @@ import com.shabinder.common.di.TokenStore
|
|||||||
import com.shabinder.common.di.createHttpClient
|
import com.shabinder.common.di.createHttpClient
|
||||||
import com.shabinder.common.di.finalOutputDir
|
import com.shabinder.common.di.finalOutputDir
|
||||||
import com.shabinder.common.di.globalJson
|
import com.shabinder.common.di.globalJson
|
||||||
import com.shabinder.common.di.spotify.SpotifyRequests
|
import com.shabinder.common.di.providers.requests.spotify.SpotifyRequests
|
||||||
import com.shabinder.common.di.spotify.authenticateSpotify
|
import com.shabinder.common.di.providers.requests.spotify.authenticateSpotify
|
||||||
import com.shabinder.common.models.DownloadStatus
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.NativeAtomicReference
|
import com.shabinder.common.models.NativeAtomicReference
|
||||||
import com.shabinder.common.models.PlatformQueryResult
|
import com.shabinder.common.models.PlatformQueryResult
|
||||||
|
import com.shabinder.common.models.SpotiFlyerException
|
||||||
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.spotify.Album
|
import com.shabinder.common.models.spotify.Album
|
||||||
import com.shabinder.common.models.spotify.Image
|
import com.shabinder.common.models.spotify.Image
|
||||||
import com.shabinder.common.models.spotify.PlaylistTrack
|
import com.shabinder.common.models.spotify.PlaylistTrack
|
||||||
import com.shabinder.common.models.spotify.Source
|
import com.shabinder.common.models.spotify.Source
|
||||||
import com.shabinder.common.models.spotify.Track
|
import com.shabinder.common.models.spotify.Track
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.features.defaultRequest
|
import io.ktor.client.features.*
|
||||||
import io.ktor.client.features.json.JsonFeature
|
import io.ktor.client.features.json.*
|
||||||
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
import io.ktor.client.features.json.serializer.*
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.*
|
||||||
|
|
||||||
class SpotifyProvider(
|
class SpotifyProvider(
|
||||||
private val tokenStore: TokenStore,
|
private val tokenStore: TokenStore,
|
||||||
@ -46,9 +48,9 @@ class SpotifyProvider(
|
|||||||
) : SpotifyRequests {
|
) : SpotifyRequests {
|
||||||
|
|
||||||
override suspend fun authenticateSpotifyClient(override: Boolean) {
|
override suspend fun authenticateSpotifyClient(override: Boolean) {
|
||||||
val token = if (override) authenticateSpotify() else tokenStore.getToken()
|
val token = if (override) authenticateSpotify().component1() else tokenStore.getToken()
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
logger.d { "Please Check your Network Connection" }
|
logger.d { "Spotify Auth Failed: Please Check your Network Connection" }
|
||||||
} else {
|
} else {
|
||||||
logger.d { "Spotify Provider Created with $token" }
|
logger.d { "Spotify Provider Created with $token" }
|
||||||
HttpClient {
|
HttpClient {
|
||||||
@ -64,7 +66,7 @@ class SpotifyProvider(
|
|||||||
|
|
||||||
override val httpClientRef = NativeAtomicReference(createHttpClient(true))
|
override val httpClientRef = NativeAtomicReference(createHttpClient(true))
|
||||||
|
|
||||||
suspend fun query(fullLink: String): PlatformQueryResult? {
|
suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult, Throwable> = SuspendableEvent {
|
||||||
|
|
||||||
var spotifyLink =
|
var spotifyLink =
|
||||||
"https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim()
|
"https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim()
|
||||||
@ -78,15 +80,16 @@ class SpotifyProvider(
|
|||||||
val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/')
|
val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/')
|
||||||
|
|
||||||
if (type == "Error" || link == "Error") {
|
if (type == "Error" || link == "Error") {
|
||||||
return null
|
throw SpotiFlyerException.LinkInvalid(fullLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == "episode" || type == "show") {
|
if (type == "episode" || type == "show") {
|
||||||
// TODO Implementation
|
throw SpotiFlyerException.FeatureNotImplementedYet(
|
||||||
return null
|
"Support for Spotify's ${type.uppercase()} isn't implemented yet"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
try {
|
||||||
spotifySearch(
|
spotifySearch(
|
||||||
type,
|
type,
|
||||||
link
|
link
|
||||||
@ -95,16 +98,11 @@ class SpotifyProvider(
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
// Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions
|
// Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions
|
||||||
authenticateSpotifyClient(true)
|
authenticateSpotifyClient(true)
|
||||||
// Retry Search
|
|
||||||
try {
|
spotifySearch(
|
||||||
spotifySearch(
|
type,
|
||||||
type,
|
link
|
||||||
link
|
)
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,15 +110,14 @@ class SpotifyProvider(
|
|||||||
type: String,
|
type: String,
|
||||||
link: String
|
link: String
|
||||||
): PlatformQueryResult {
|
): PlatformQueryResult {
|
||||||
val result = PlatformQueryResult(
|
return PlatformQueryResult(
|
||||||
folderType = "",
|
folderType = "",
|
||||||
subFolder = "",
|
subFolder = "",
|
||||||
title = "",
|
title = "",
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
trackList = listOf(),
|
trackList = listOf(),
|
||||||
Source.Spotify
|
Source.Spotify
|
||||||
)
|
).apply {
|
||||||
with(result) {
|
|
||||||
when (type) {
|
when (type) {
|
||||||
"track" -> {
|
"track" -> {
|
||||||
getTrack(link).also {
|
getTrack(link).also {
|
||||||
@ -186,15 +183,16 @@ class SpotifyProvider(
|
|||||||
coverUrl = playlistObject.images?.firstOrNull()?.url.toString()
|
coverUrl = playlistObject.images?.firstOrNull()?.url.toString()
|
||||||
}
|
}
|
||||||
"episode" -> { // TODO
|
"episode" -> { // TODO
|
||||||
|
throw SpotiFlyerException.FeatureNotImplementedYet()
|
||||||
}
|
}
|
||||||
"show" -> { // TODO
|
"show" -> { // TODO
|
||||||
|
throw SpotiFlyerException.FeatureNotImplementedYet()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// TODO Handle Error
|
throw SpotiFlyerException.LinkInvalid("Provide: Spotify, Type:$type -> Link:$link")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -17,28 +17,27 @@
|
|||||||
package com.shabinder.common.di.providers
|
package com.shabinder.common.di.providers
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.di.providers.requests.youtubeMp3.Yt1sMp3
|
||||||
import com.shabinder.common.di.currentPlatform
|
import com.shabinder.common.models.corsApi
|
||||||
import com.shabinder.common.di.youtubeMp3.Yt1sMp3
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
import com.shabinder.common.models.AllPlatforms
|
import com.shabinder.common.models.event.coroutines.map
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
|
||||||
class YoutubeMp3(
|
interface YoutubeMp3: Yt1sMp3 {
|
||||||
override val httpClient: HttpClient,
|
|
||||||
override val logger: Kermit,
|
companion object {
|
||||||
private val dir: Dir,
|
operator fun invoke(
|
||||||
) : Yt1sMp3 {
|
client: HttpClient,
|
||||||
suspend fun getMp3DownloadLink(videoID: String): String? = try {
|
logger: Kermit
|
||||||
logger.i { "Youtube MP3 Link Fetching!" }
|
): YoutubeMp3 {
|
||||||
getLinkFromYt1sMp3(videoID)?.let {
|
return object : YoutubeMp3 {
|
||||||
logger.i { "Download Link: $it" }
|
override val httpClient: HttpClient = client
|
||||||
if (currentPlatform is AllPlatforms.Js/* && corsProxy !is CorsProxy.PublicProxyWithExtension*/)
|
override val logger: Kermit = logger
|
||||||
"https://cors.spotiflyer.ml/cors/$it"
|
}
|
||||||
// "https://spotiflyer.azurewebsites.net/$it" // Data OUT Limit issue
|
|
||||||
else it
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
e.printStackTrace()
|
|
||||||
null
|
suspend fun getMp3DownloadLink(videoID: String): SuspendableEvent<String,Throwable> = getLinkFromYt1sMp3(videoID).map {
|
||||||
|
corsApi + it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,16 +17,19 @@
|
|||||||
package com.shabinder.common.di.providers
|
package com.shabinder.common.di.providers
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||||
import com.shabinder.common.di.gaana.corsApi
|
import com.shabinder.common.models.SpotiFlyerException
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.common.models.YoutubeTrack
|
import com.shabinder.common.models.YoutubeTrack
|
||||||
|
import com.shabinder.common.models.corsApi
|
||||||
|
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.map
|
||||||
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
|
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.request.headers
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.post
|
import io.ktor.http.*
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.contentType
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.buildJsonArray
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
@ -37,196 +40,197 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import kotlinx.serialization.json.putJsonObject
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import kotlin.collections.set
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
class YoutubeMusic constructor(
|
class YoutubeMusic constructor(
|
||||||
private val logger: Kermit,
|
private val logger: Kermit,
|
||||||
private val httpClient: HttpClient,
|
private val httpClient: HttpClient,
|
||||||
private val youtubeMp3: YoutubeMp3,
|
|
||||||
private val youtubeProvider: YoutubeProvider,
|
private val youtubeProvider: YoutubeProvider,
|
||||||
|
private val youtubeMp3: YoutubeMp3,
|
||||||
private val audioToMp3: AudioToMp3
|
private val audioToMp3: AudioToMp3
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
|
const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
|
||||||
const val tag = "YT Music"
|
const val tag = "YT Music"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findSongDownloadURL(
|
// Get Downloadable Link
|
||||||
|
suspend fun findMp3SongDownloadURLYT(
|
||||||
trackDetails: TrackDetails
|
trackDetails: TrackDetails
|
||||||
): String? {
|
): SuspendableEvent<String, Throwable> {
|
||||||
val bestMatchVideoID = getYTIDBestMatch(trackDetails)
|
return getYTIDBestMatch(trackDetails).flatMap { videoID ->
|
||||||
return bestMatchVideoID?.let { videoID ->
|
// 1 Try getting Link from Yt1s
|
||||||
youtubeMp3.getMp3DownloadLink(videoID) ?: youtubeProvider.ytDownloader?.getVideo(videoID)?.get()?.url?.let { m4aLink ->
|
youtubeMp3.getMp3DownloadLink(videoID).flatMapError {
|
||||||
audioToMp3.convertToMp3(
|
// 2 if Yt1s failed , Extract Manually
|
||||||
m4aLink
|
youtubeProvider.ytDownloader.getVideo(videoID).get()?.url?.let { m4aLink ->
|
||||||
|
audioToMp3.convertToMp3(m4aLink)
|
||||||
|
} ?: throw SpotiFlyerException.YoutubeLinkNotFound(
|
||||||
|
videoID,
|
||||||
|
message = "Caught Following Errors While Finding Downloadable Link for $videoID : \n${it.stackTraceToString()}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getYTIDBestMatch(
|
private suspend fun getYTIDBestMatch(
|
||||||
trackDetails: TrackDetails
|
trackDetails: TrackDetails
|
||||||
): String? {
|
):SuspendableEvent<String,Throwable> =
|
||||||
return try {
|
getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}").map { matchList ->
|
||||||
sortByBestMatch(
|
sortByBestMatch(
|
||||||
getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}"),
|
matchList,
|
||||||
trackName = trackDetails.title,
|
trackName = trackDetails.title,
|
||||||
trackArtists = trackDetails.artists,
|
trackArtists = trackDetails.artists,
|
||||||
trackDurationSec = trackDetails.durationSec
|
trackDurationSec = trackDetails.durationSec
|
||||||
).keys.firstOrNull()
|
).keys.firstOrNull() ?: throw SpotiFlyerException.NoMatchFound(trackDetails.title)
|
||||||
} catch (e: Exception) {
|
|
||||||
// All Internet/Client Related Errors
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
private suspend fun getYTTracks(query: String): List<YoutubeTrack> {
|
|
||||||
val youtubeTracks = mutableListOf<YoutubeTrack>()
|
|
||||||
|
|
||||||
val responseObj = Json.parseToJsonElement(getYoutubeMusicResponse(query))
|
private suspend fun getYTTracks(query: String): SuspendableEvent<List<YoutubeTrack>,Throwable> =
|
||||||
logger.i { "Youtube Music Response Recieved" }
|
getYoutubeMusicResponse(query).map { youtubeResponseData ->
|
||||||
val contentBlocks = responseObj.jsonObject["contents"]
|
val youtubeTracks = mutableListOf<YoutubeTrack>()
|
||||||
?.jsonObject?.get("sectionListRenderer")
|
val responseObj = Json.parseToJsonElement(youtubeResponseData)
|
||||||
?.jsonObject?.get("contents")?.jsonArray
|
// logger.i { "Youtube Music Response Received" }
|
||||||
|
val contentBlocks = responseObj.jsonObject["contents"]
|
||||||
|
?.jsonObject?.get("sectionListRenderer")
|
||||||
|
?.jsonObject?.get("contents")?.jsonArray
|
||||||
|
|
||||||
val resultBlocks = mutableListOf<JsonArray>()
|
val resultBlocks = mutableListOf<JsonArray>()
|
||||||
if (contentBlocks != null) {
|
if (contentBlocks != null) {
|
||||||
for (cBlock in contentBlocks) {
|
for (cBlock in contentBlocks) {
|
||||||
/**
|
|
||||||
*Ignore user-suggestion
|
|
||||||
*The 'itemSectionRenderer' field is for user notices (stuff like - 'showing
|
|
||||||
*results for xyz, search for abc instead') we have no use for them, the for
|
|
||||||
*loop below if throw a keyError if we don't ignore them
|
|
||||||
*/
|
|
||||||
if (cBlock.jsonObject.containsKey("itemSectionRenderer")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray
|
|
||||||
?: listOf()
|
|
||||||
) {
|
|
||||||
/**
|
/**
|
||||||
* apparently content Blocks without an 'overlay' field don't have linkBlocks
|
*Ignore user-suggestion
|
||||||
* I have no clue what they are and why there even exist
|
*The 'itemSectionRenderer' field is for user notices (stuff like - 'showing
|
||||||
*
|
*results for xyz, search for abc instead') we have no use for them, the for
|
||||||
if(!contents.containsKey("overlay")){
|
*loop below if throw a keyError if we don't ignore them
|
||||||
println(contents)
|
*/
|
||||||
continue
|
if (cBlock.jsonObject.containsKey("itemSectionRenderer")) {
|
||||||
TODO check and correct
|
continue
|
||||||
}*/
|
}
|
||||||
|
|
||||||
val result = contents.jsonObject["musicResponsiveListItemRenderer"]
|
for (
|
||||||
?.jsonObject?.get("flexColumns")?.jsonArray
|
contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray
|
||||||
|
?: listOf()
|
||||||
// Add the linkBlock
|
) {
|
||||||
val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"]
|
/**
|
||||||
?.jsonObject?.get("overlay")
|
* apparently content Blocks without an 'overlay' field don't have linkBlocks
|
||||||
?.jsonObject?.get("musicItemThumbnailOverlayRenderer")
|
* I have no clue what they are and why there even exist
|
||||||
?.jsonObject?.get("content")
|
*
|
||||||
?.jsonObject?.get("musicPlayButtonRenderer")
|
if(!contents.containsKey("overlay")){
|
||||||
?.jsonObject?.get("playNavigationEndpoint")
|
println(contents)
|
||||||
|
continue
|
||||||
// detailsBlock is always a list, so we just append the linkBlock to it
|
TODO check and correct
|
||||||
// instead of carrying along all the other junk from "musicResponsiveListItemRenderer"
|
}*/
|
||||||
val finalResult = buildJsonArray {
|
|
||||||
result?.let { add(it) }
|
val result = contents.jsonObject["musicResponsiveListItemRenderer"]
|
||||||
linkBlock?.let { add(it) }
|
?.jsonObject?.get("flexColumns")?.jsonArray
|
||||||
|
|
||||||
|
// Add the linkBlock
|
||||||
|
val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"]
|
||||||
|
?.jsonObject?.get("overlay")
|
||||||
|
?.jsonObject?.get("musicItemThumbnailOverlayRenderer")
|
||||||
|
?.jsonObject?.get("content")
|
||||||
|
?.jsonObject?.get("musicPlayButtonRenderer")
|
||||||
|
?.jsonObject?.get("playNavigationEndpoint")
|
||||||
|
|
||||||
|
// detailsBlock is always a list, so we just append the linkBlock to it
|
||||||
|
// instead of carrying along all the other junk from "musicResponsiveListItemRenderer"
|
||||||
|
val finalResult = buildJsonArray {
|
||||||
|
result?.let { add(it) }
|
||||||
|
linkBlock?.let { add(it) }
|
||||||
|
}
|
||||||
|
resultBlocks.add(finalResult)
|
||||||
}
|
}
|
||||||
resultBlocks.add(finalResult)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* We only need results that are Songs or Videos, so we filter out the rest, since
|
/* We only need results that are Songs or Videos, so we filter out the rest, since
|
||||||
! Songs and Videos are supplied with different details, extracting all details from
|
! Songs and Videos are supplied with different details, extracting all details from
|
||||||
! both is just carrying on redundant data, so we also have to selectively extract
|
! both is just carrying on redundant data, so we also have to selectively extract
|
||||||
! relevant details. What you need to know to understand how we do that here:
|
! relevant details. What you need to know to understand how we do that here:
|
||||||
!
|
!
|
||||||
! Songs details are ALWAYS in the following order:
|
! Songs details are ALWAYS in the following order:
|
||||||
! 0 - Name
|
! 0 - Name
|
||||||
! 1 - Type (Song)
|
! 1 - Type (Song)
|
||||||
! 2 - com.shabinder.spotiflyer.models.gaana.Artist
|
! 2 - com.shabinder.spotiflyer.models.gaana.Artist
|
||||||
! 3 - Album
|
! 3 - Album
|
||||||
! 4 - Duration (mm:ss)
|
! 4 - Duration (mm:ss)
|
||||||
!
|
!
|
||||||
! Video details are ALWAYS in the following order:
|
! Video details are ALWAYS in the following order:
|
||||||
! 0 - Name
|
! 0 - Name
|
||||||
! 1 - Type (Video)
|
! 1 - Type (Video)
|
||||||
! 2 - Channel
|
! 2 - Channel
|
||||||
! 3 - Viewers
|
! 3 - Viewers
|
||||||
! 4 - Duration (hh:mm:ss)
|
! 4 - Duration (hh:mm:ss)
|
||||||
!
|
!
|
||||||
! We blindly gather all the details we get our hands on, then
|
! We blindly gather all the details we get our hands on, then
|
||||||
! cherry pick the details we need based on their index numbers,
|
! cherry pick the details we need based on their index numbers,
|
||||||
! we do so only if their Type is 'Song' or 'Video
|
! we do so only if their Type is 'Song' or 'Video
|
||||||
*/
|
|
||||||
|
|
||||||
for (result in resultBlocks) {
|
|
||||||
|
|
||||||
// Blindly gather available details
|
|
||||||
val availableDetails = mutableListOf<String>()
|
|
||||||
|
|
||||||
/*
|
|
||||||
Filter Out dummies here itself
|
|
||||||
! 'musicResponsiveListItemFlexColumnRenderer' should have more that one
|
|
||||||
! sub-block, if not its a dummy, why does the YTM response contain dummies?
|
|
||||||
! I have no clue. We skip these.
|
|
||||||
|
|
||||||
! Remember that we appended the linkBlock to result, treating that like the
|
|
||||||
! other constituents of a result block will lead to errors, hence the 'in
|
|
||||||
! result[:-1] ,i.e., skip last element in array '
|
|
||||||
*/
|
*/
|
||||||
for (detailArray in result.subList(0, result.size - 1)) {
|
|
||||||
for (detail in detailArray.jsonArray) {
|
|
||||||
if (detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0 < 2) continue
|
|
||||||
|
|
||||||
// if not a dummy, collect All Variables
|
for (result in resultBlocks) {
|
||||||
val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]
|
|
||||||
?.jsonObject?.get("text")
|
|
||||||
?.jsonObject?.get("runs")?.jsonArray ?: listOf()
|
|
||||||
|
|
||||||
for (d in details) {
|
// Blindly gather available details
|
||||||
d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let {
|
val availableDetails = mutableListOf<String>()
|
||||||
if (it != " • ") {
|
|
||||||
availableDetails.add(it)
|
/*
|
||||||
|
Filter Out dummies here itself
|
||||||
|
! 'musicResponsiveListItemFlexColumnRenderer' should have more that one
|
||||||
|
! sub-block, if not its a dummy, why does the YTM response contain dummies?
|
||||||
|
! I have no clue. We skip these.
|
||||||
|
|
||||||
|
! Remember that we appended the linkBlock to result, treating that like the
|
||||||
|
! other constituents of a result block will lead to errors, hence the 'in
|
||||||
|
! result[:-1] ,i.e., skip last element in array '
|
||||||
|
*/
|
||||||
|
for (detailArray in result.subList(0, result.size - 1)) {
|
||||||
|
for (detail in detailArray.jsonArray) {
|
||||||
|
if (detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0 < 2) continue
|
||||||
|
|
||||||
|
// if not a dummy, collect All Variables
|
||||||
|
val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]
|
||||||
|
?.jsonObject?.get("text")
|
||||||
|
?.jsonObject?.get("runs")?.jsonArray ?: listOf()
|
||||||
|
|
||||||
|
for (d in details) {
|
||||||
|
d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let {
|
||||||
|
if (it != " • ") {
|
||||||
|
availableDetails.add(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// logger.d("YT Music details"){availableDetails.toString()}
|
||||||
// logger.d("YT Music details"){availableDetails.toString()}
|
|
||||||
/*
|
|
||||||
! Filter Out non-Song/Video results and incomplete results here itself
|
|
||||||
! From what we know about detail order, note that [1] - indicate result type
|
|
||||||
*/
|
|
||||||
if (availableDetails.size == 5 && availableDetails[1] in listOf("Song", "Video")) {
|
|
||||||
|
|
||||||
// skip if result is in hours instead of minutes (no song is that long)
|
|
||||||
if (availableDetails[4].split(':').size != 2) continue
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
! grab Video ID
|
! Filter Out non-Song/Video results and incomplete results here itself
|
||||||
! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...]
|
! From what we know about detail order, note that [1] - indicate result type
|
||||||
! so hardcoding the dict keys for data look up is an ardours process, since
|
|
||||||
! the sub-block pattern is fixed even though the key isn't, we just
|
|
||||||
! reference the dict keys by index
|
|
||||||
*/
|
*/
|
||||||
|
if (availableDetails.size == 5 && availableDetails[1] in listOf("Song", "Video")) {
|
||||||
|
|
||||||
val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content
|
// skip if result is in hours instead of minutes (no song is that long)
|
||||||
val ytTrack = YoutubeTrack(
|
if (availableDetails[4].split(':').size != 2) continue
|
||||||
name = availableDetails[0],
|
|
||||||
type = availableDetails[1],
|
/*
|
||||||
artist = availableDetails[2],
|
! grab Video ID
|
||||||
duration = availableDetails[4],
|
! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...]
|
||||||
videoId = videoId
|
! so hardcoding the dict keys for data look up is an ardours process, since
|
||||||
)
|
! the sub-block pattern is fixed even though the key isn't, we just
|
||||||
youtubeTracks.add(ytTrack)
|
! reference the dict keys by index
|
||||||
|
*/
|
||||||
|
|
||||||
|
val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content
|
||||||
|
val ytTrack = YoutubeTrack(
|
||||||
|
name = availableDetails[0],
|
||||||
|
type = availableDetails[1],
|
||||||
|
artist = availableDetails[2],
|
||||||
|
duration = availableDetails[4],
|
||||||
|
videoId = videoId
|
||||||
|
)
|
||||||
|
youtubeTracks.add(ytTrack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// logger.d {youtubeTracks.joinToString("\n")}
|
// logger.d {youtubeTracks.joinToString("\n")}
|
||||||
return youtubeTracks
|
youtubeTracks
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sortByBestMatch(
|
private fun sortByBestMatch(
|
||||||
@ -246,8 +250,8 @@ class YoutubeMusic constructor(
|
|||||||
// most song results on youtube go by $artist - $songName or artist1/artist2
|
// most song results on youtube go by $artist - $songName or artist1/artist2
|
||||||
var hasCommonWord = false
|
var hasCommonWord = false
|
||||||
|
|
||||||
val resultName = result.name?.toLowerCase()?.replace("-", " ")?.replace("/", " ") ?: ""
|
val resultName = result.name?.lowercase()?.replace("-", " ")?.replace("/", " ") ?: ""
|
||||||
val trackNameWords = trackName.toLowerCase().split(" ")
|
val trackNameWords = trackName.lowercase().split(" ")
|
||||||
|
|
||||||
for (nameWord in trackNameWords) {
|
for (nameWord in trackNameWords) {
|
||||||
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
|
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
|
||||||
@ -266,12 +270,12 @@ class YoutubeMusic constructor(
|
|||||||
|
|
||||||
if (result.type == "Song") {
|
if (result.type == "Song") {
|
||||||
for (artist in trackArtists) {
|
for (artist in trackArtists) {
|
||||||
if (FuzzySearch.ratio(artist.toLowerCase(), result.artist?.toLowerCase() ?: "") > 85)
|
if (FuzzySearch.ratio(artist.lowercase(), result.artist?.lowercase() ?: "") > 85)
|
||||||
artistMatchNumber++
|
artistMatchNumber++
|
||||||
}
|
}
|
||||||
} else { // i.e. is a Video
|
} else { // i.e. is a Video
|
||||||
for (artist in trackArtists) {
|
for (artist in trackArtists) {
|
||||||
if (FuzzySearch.partialRatio(artist.toLowerCase(), result.name?.toLowerCase() ?: "") > 85)
|
if (FuzzySearch.partialRatio(artist.lowercase(), result.name?.lowercase() ?: "") > 85)
|
||||||
artistMatchNumber++
|
artistMatchNumber++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -303,9 +307,8 @@ class YoutubeMusic constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getYoutubeMusicResponse(query: String): String {
|
private suspend fun getYoutubeMusicResponse(query: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
logger.i { "Fetching Youtube Music Response" }
|
httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") {
|
||||||
return httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") {
|
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
headers {
|
headers {
|
||||||
append("referer", "https://music.youtube.com/search")
|
append("referer", "https://music.youtube.com/search")
|
||||||
|
@ -22,7 +22,9 @@ import com.shabinder.common.di.finalOutputDir
|
|||||||
import com.shabinder.common.di.utils.removeIllegalChars
|
import com.shabinder.common.di.utils.removeIllegalChars
|
||||||
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.SpotiFlyerException
|
||||||
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.spotify.Source
|
import com.shabinder.common.models.spotify.Source
|
||||||
import io.github.shabinder.YoutubeDownloader
|
import io.github.shabinder.YoutubeDownloader
|
||||||
import io.github.shabinder.models.YoutubeVideo
|
import io.github.shabinder.models.YoutubeVideo
|
||||||
@ -49,7 +51,7 @@ class YoutubeProvider(
|
|||||||
private val sampleDomain2 = "youtube.com"
|
private val sampleDomain2 = "youtube.com"
|
||||||
private val sampleDomain3 = "youtu.be"
|
private val sampleDomain3 = "youtu.be"
|
||||||
|
|
||||||
suspend fun query(fullLink: String): PlatformQueryResult? {
|
suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult,Throwable> {
|
||||||
val link = fullLink.removePrefix("https://").removePrefix("http://")
|
val link = fullLink.removePrefix("https://").removePrefix("http://")
|
||||||
if (link.contains("playlist", true) || link.contains("list", true)) {
|
if (link.contains("playlist", true) || link.contains("list", true)) {
|
||||||
// Given Link is of a Playlist
|
// Given Link is of a Playlist
|
||||||
@ -77,74 +79,15 @@ class YoutubeProvider(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.d { "Your Youtube Link is not of a Video!!" }
|
logger.d { "Your Youtube Link is not of a Video!!" }
|
||||||
null
|
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(fullLink))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getYTPlaylist(
|
private suspend fun getYTPlaylist(
|
||||||
searchId: String
|
searchId: String
|
||||||
): PlatformQueryResult? {
|
): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
|
||||||
val result = PlatformQueryResult(
|
PlatformQueryResult(
|
||||||
folderType = "",
|
|
||||||
subFolder = "",
|
|
||||||
title = "",
|
|
||||||
coverUrl = "",
|
|
||||||
trackList = listOf(),
|
|
||||||
Source.YouTube
|
|
||||||
)
|
|
||||||
result.apply {
|
|
||||||
try {
|
|
||||||
val playlist = ytDownloader.getPlaylist(searchId)
|
|
||||||
val playlistDetails = playlist.details
|
|
||||||
val name = playlistDetails.title
|
|
||||||
subFolder = removeIllegalChars(name)
|
|
||||||
val videos = playlist.videos
|
|
||||||
|
|
||||||
coverUrl = "https://i.ytimg.com/vi/${
|
|
||||||
videos.firstOrNull()?.videoId
|
|
||||||
}/hqdefault.jpg"
|
|
||||||
title = name
|
|
||||||
|
|
||||||
trackList = videos.map {
|
|
||||||
TrackDetails(
|
|
||||||
title = it.title ?: "N/A",
|
|
||||||
artists = listOf(it.author ?: "N/A"),
|
|
||||||
durationSec = it.lengthSeconds,
|
|
||||||
albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg",
|
|
||||||
source = Source.YouTube,
|
|
||||||
albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg",
|
|
||||||
downloaded = if (dir.isPresent(
|
|
||||||
dir.finalOutputDir(
|
|
||||||
itemName = it.title ?: "N/A",
|
|
||||||
type = folderType,
|
|
||||||
subFolder = subFolder,
|
|
||||||
dir.defaultDir()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
DownloadStatus.Downloaded
|
|
||||||
else {
|
|
||||||
DownloadStatus.NotDownloaded
|
|
||||||
},
|
|
||||||
outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
|
|
||||||
videoID = it.videoId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
logger.d { "An Error Occurred While Processing!" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (result.title.isNotBlank()) result
|
|
||||||
else null
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DefaultLocale")
|
|
||||||
private suspend fun getYTTrack(
|
|
||||||
searchId: String,
|
|
||||||
): PlatformQueryResult? {
|
|
||||||
val result = PlatformQueryResult(
|
|
||||||
folderType = "",
|
folderType = "",
|
||||||
subFolder = "",
|
subFolder = "",
|
||||||
title = "",
|
title = "",
|
||||||
@ -152,47 +95,90 @@ class YoutubeProvider(
|
|||||||
trackList = listOf(),
|
trackList = listOf(),
|
||||||
Source.YouTube
|
Source.YouTube
|
||||||
).apply {
|
).apply {
|
||||||
try {
|
val playlist = ytDownloader.getPlaylist(searchId)
|
||||||
logger.i { searchId }
|
val playlistDetails = playlist.details
|
||||||
val video = ytDownloader.getVideo(searchId)
|
val name = playlistDetails.title
|
||||||
coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg"
|
subFolder = removeIllegalChars(name)
|
||||||
val detail = video.videoDetails
|
val videos = playlist.videos
|
||||||
val name = detail.title?.replace(detail.author?.uppercase() ?: "", "", true)
|
|
||||||
?: detail.title ?: ""
|
coverUrl = "https://i.ytimg.com/vi/${
|
||||||
// logger.i{ detail.toString() }
|
videos.firstOrNull()?.videoId
|
||||||
trackList = listOf(
|
}/hqdefault.jpg"
|
||||||
TrackDetails(
|
title = name
|
||||||
title = name,
|
|
||||||
artists = listOf(detail.author ?: "N/A"),
|
trackList = videos.map {
|
||||||
durationSec = detail.lengthSeconds,
|
TrackDetails(
|
||||||
albumArtPath = dir.imageCacheDir() + "$searchId.jpeg",
|
title = it.title ?: "N/A",
|
||||||
source = Source.YouTube,
|
artists = listOf(it.author ?: "N/A"),
|
||||||
albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg",
|
durationSec = it.lengthSeconds,
|
||||||
downloaded = if (dir.isPresent(
|
albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg",
|
||||||
dir.finalOutputDir(
|
source = Source.YouTube,
|
||||||
itemName = name,
|
albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg",
|
||||||
type = folderType,
|
downloaded = if (dir.isPresent(
|
||||||
subFolder = subFolder,
|
dir.finalOutputDir(
|
||||||
defaultDir = dir.defaultDir()
|
itemName = it.title ?: "N/A",
|
||||||
)
|
type = folderType,
|
||||||
|
subFolder = subFolder,
|
||||||
|
dir.defaultDir()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
DownloadStatus.Downloaded
|
|
||||||
else {
|
|
||||||
DownloadStatus.NotDownloaded
|
|
||||||
},
|
|
||||||
outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
|
|
||||||
videoID = searchId
|
|
||||||
)
|
)
|
||||||
|
DownloadStatus.Downloaded
|
||||||
|
else {
|
||||||
|
DownloadStatus.NotDownloaded
|
||||||
|
},
|
||||||
|
outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
|
||||||
|
videoID = it.videoId
|
||||||
)
|
)
|
||||||
title = name
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
logger.e { "An Error Occurred While Processing!,$searchId" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return if (result.title.isNotBlank()) result
|
}
|
||||||
else null
|
|
||||||
|
@Suppress("DefaultLocale")
|
||||||
|
private suspend fun getYTTrack(
|
||||||
|
searchId: String,
|
||||||
|
): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
|
||||||
|
PlatformQueryResult(
|
||||||
|
folderType = "",
|
||||||
|
subFolder = "",
|
||||||
|
title = "",
|
||||||
|
coverUrl = "",
|
||||||
|
trackList = listOf(),
|
||||||
|
Source.YouTube
|
||||||
|
).apply {
|
||||||
|
val video = ytDownloader.getVideo(searchId)
|
||||||
|
coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg"
|
||||||
|
val detail = video.videoDetails
|
||||||
|
val name = detail.title?.replace(detail.author?.uppercase() ?: "", "", true)
|
||||||
|
?: detail.title ?: ""
|
||||||
|
// logger.i{ detail.toString() }
|
||||||
|
trackList = listOf(
|
||||||
|
TrackDetails(
|
||||||
|
title = name,
|
||||||
|
artists = listOf(detail.author ?: "N/A"),
|
||||||
|
durationSec = detail.lengthSeconds,
|
||||||
|
albumArtPath = dir.imageCacheDir() + "$searchId.jpeg",
|
||||||
|
source = Source.YouTube,
|
||||||
|
albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg",
|
||||||
|
downloaded = if (dir.isPresent(
|
||||||
|
dir.finalOutputDir(
|
||||||
|
itemName = name,
|
||||||
|
type = folderType,
|
||||||
|
subFolder = subFolder,
|
||||||
|
defaultDir = dir.defaultDir()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DownloadStatus.Downloaded
|
||||||
|
else {
|
||||||
|
DownloadStatus.NotDownloaded
|
||||||
|
},
|
||||||
|
outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
|
||||||
|
videoID = searchId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
title = name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
package com.shabinder.common.di.audioToMp3
|
package com.shabinder.common.di.providers.requests.audioToMp3
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.models.AudioQuality
|
import com.shabinder.common.models.AudioQuality
|
||||||
import io.ktor.client.HttpClient
|
import com.shabinder.common.models.SpotiFlyerException
|
||||||
import io.ktor.client.request.forms.formData
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
import io.ktor.client.request.forms.submitFormWithBinaryData
|
import io.ktor.client.*
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.features.*
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.headers
|
import io.ktor.client.request.forms.*
|
||||||
import io.ktor.client.statement.HttpStatement
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.isSuccess
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
interface AudioToMp3 {
|
interface AudioToMp3 {
|
||||||
@ -32,9 +32,10 @@ interface AudioToMp3 {
|
|||||||
suspend fun convertToMp3(
|
suspend fun convertToMp3(
|
||||||
URL: String,
|
URL: String,
|
||||||
audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)),
|
audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)),
|
||||||
): String? {
|
): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
val activeHost = getHost() // ex - https://hostveryfast.onlineconverter.com/file/send
|
// Active Host ex - https://hostveryfast.onlineconverter.com/file/send
|
||||||
val jobLink = convertRequest(URL, activeHost, audioQuality) // ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
|
// Convert Job Request ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
|
||||||
|
var (activeHost,jobLink) = convertRequest(URL, audioQuality).value
|
||||||
|
|
||||||
// (jobStatus.contains("d")) == COMPLETION
|
// (jobStatus.contains("d")) == COMPLETION
|
||||||
var jobStatus: String
|
var jobStatus: String
|
||||||
@ -47,17 +48,23 @@ interface AudioToMp3 {
|
|||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
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--
|
retryCount--
|
||||||
logger.i("Job Status") { jobStatus }
|
logger.i("Job Status") { jobStatus }
|
||||||
if (!jobStatus.contains("d")) delay(400) // Add Delay , to give Server Time to process audio
|
if (!jobStatus.contains("d")) delay(600) // Add Delay , to give Server Time to process audio
|
||||||
} while (!jobStatus.contains("d", true) && retryCount != 0)
|
} while (!jobStatus.contains("d", true) && retryCount > 0)
|
||||||
|
|
||||||
return if (jobStatus.equals("d", true)) {
|
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
|
||||||
// Return MP3 Download Link
|
|
||||||
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
|
|
||||||
} else null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -66,11 +73,10 @@ interface AudioToMp3 {
|
|||||||
* */
|
* */
|
||||||
private suspend fun convertRequest(
|
private suspend fun convertRequest(
|
||||||
URL: String,
|
URL: String,
|
||||||
host: String? = null,
|
|
||||||
audioQuality: AudioQuality = AudioQuality.KBPS160,
|
audioQuality: AudioQuality = AudioQuality.KBPS160,
|
||||||
): String {
|
): SuspendableEvent<Pair<String,String>,Throwable> = SuspendableEvent {
|
||||||
val activeHost = host ?: getHost()
|
val activeHost by getHost()
|
||||||
val res = client.submitFormWithBinaryData<String>(
|
val convertJob = client.submitFormWithBinaryData<String>(
|
||||||
url = activeHost,
|
url = activeHost,
|
||||||
formData = formData {
|
formData = formData {
|
||||||
append("class", "audio")
|
append("class", "audio")
|
||||||
@ -87,28 +93,30 @@ interface AudioToMp3 {
|
|||||||
header("Referer", "https://www.onlineconverter.com/")
|
header("Referer", "https://www.onlineconverter.com/")
|
||||||
}
|
}
|
||||||
}.run {
|
}.run {
|
||||||
logger.d { this }
|
// logger.d { this }
|
||||||
dropLast(3) // last 3 are useless unicode char
|
dropLast(3) // last 3 are useless unicode char
|
||||||
}
|
}
|
||||||
|
|
||||||
val job = client.get<HttpStatement>(res) {
|
val job = client.get<HttpStatement>(convertJob) {
|
||||||
headers {
|
headers {
|
||||||
header("Host", "www.onlineconverter.com")
|
header("Host", "www.onlineconverter.com")
|
||||||
}
|
}
|
||||||
}.execute()
|
}.execute()
|
||||||
logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() }
|
logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() }
|
||||||
return res
|
|
||||||
|
Pair(activeHost,convertJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active Host free to process conversion
|
// Active Host free to process conversion
|
||||||
// ex - https://hostveryfast.onlineconverter.com/file/send
|
// ex - https://hostveryfast.onlineconverter.com/file/send
|
||||||
private suspend fun getHost(): String {
|
private suspend fun getHost(): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
return client.get<String>("https://www.onlineconverter.com/get/host") {
|
client.get<String>("https://www.onlineconverter.com/get/host") {
|
||||||
headers {
|
headers {
|
||||||
header("Host", "www.onlineconverter.com")
|
header("Host", "www.onlineconverter.com")
|
||||||
}
|
}
|
||||||
}.also { logger.i("Active Host") { it } }
|
}//.also { logger.i("Active Host") { it } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract full Domain from URL
|
// Extract full Domain from URL
|
||||||
// ex - hostveryfast.onlineconverter.com
|
// ex - hostveryfast.onlineconverter.com
|
||||||
private fun String.getHostDomain(): String {
|
private fun String.getHostDomain(): String {
|
@ -14,23 +14,17 @@
|
|||||||
* * 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.gaana
|
package com.shabinder.common.di.providers.requests.gaana
|
||||||
|
|
||||||
import com.shabinder.common.di.currentPlatform
|
import com.shabinder.common.models.corsApi
|
||||||
import com.shabinder.common.models.AllPlatforms
|
|
||||||
import com.shabinder.common.models.corsProxy
|
|
||||||
import com.shabinder.common.models.gaana.GaanaAlbum
|
import com.shabinder.common.models.gaana.GaanaAlbum
|
||||||
import com.shabinder.common.models.gaana.GaanaArtistDetails
|
import com.shabinder.common.models.gaana.GaanaArtistDetails
|
||||||
import com.shabinder.common.models.gaana.GaanaArtistTracks
|
import com.shabinder.common.models.gaana.GaanaArtistTracks
|
||||||
import com.shabinder.common.models.gaana.GaanaPlaylist
|
import com.shabinder.common.models.gaana.GaanaPlaylist
|
||||||
import com.shabinder.common.models.gaana.GaanaSong
|
import com.shabinder.common.models.gaana.GaanaSong
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.*
|
||||||
|
|
||||||
val corsApi get() = if (currentPlatform is AllPlatforms.Js) {
|
|
||||||
corsProxy.url
|
|
||||||
} // "https://spotiflyer-cors.azurewebsites.net/" //"https://spotiflyer-cors.herokuapp.com/"//"https://cors.bridged.cc/"
|
|
||||||
else ""
|
|
||||||
|
|
||||||
private const val TOKEN = "b2e6d7fbc136547a940516e9b77e5990"
|
private const val TOKEN = "b2e6d7fbc136547a940516e9b77e5990"
|
||||||
private val BASE_URL get() = "${corsApi}https://api.gaana.com"
|
private val BASE_URL get() = "${corsApi}https://api.gaana.com"
|
@ -1,21 +1,24 @@
|
|||||||
package com.shabinder.common.di.saavn
|
package com.shabinder.common.di.providers.requests.saavn
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
|
||||||
import com.shabinder.common.di.gaana.corsApi
|
|
||||||
import com.shabinder.common.di.globalJson
|
import com.shabinder.common.di.globalJson
|
||||||
|
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||||
|
import com.shabinder.common.models.corsApi
|
||||||
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
|
import com.shabinder.common.models.event.coroutines.map
|
||||||
|
import com.shabinder.common.models.event.coroutines.success
|
||||||
import com.shabinder.common.models.saavn.SaavnAlbum
|
import com.shabinder.common.models.saavn.SaavnAlbum
|
||||||
import com.shabinder.common.models.saavn.SaavnPlaylist
|
import com.shabinder.common.models.saavn.SaavnPlaylist
|
||||||
import com.shabinder.common.models.saavn.SaavnSearchResult
|
import com.shabinder.common.models.saavn.SaavnSearchResult
|
||||||
import com.shabinder.common.models.saavn.SaavnSong
|
import com.shabinder.common.models.saavn.SaavnSong
|
||||||
|
import com.shabinder.common.requireNotNull
|
||||||
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
|
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
|
||||||
import io.github.shabinder.utils.getBoolean
|
import io.github.shabinder.utils.getBoolean
|
||||||
import io.github.shabinder.utils.getJsonArray
|
import io.github.shabinder.utils.getJsonArray
|
||||||
import io.github.shabinder.utils.getJsonObject
|
import io.github.shabinder.utils.getJsonObject
|
||||||
import io.github.shabinder.utils.getString
|
import io.github.shabinder.utils.getString
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.features.ServerResponseException
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.get
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@ -24,6 +27,7 @@ import kotlinx.serialization.json.buildJsonArray
|
|||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlin.collections.set
|
||||||
|
|
||||||
interface JioSaavnRequests {
|
interface JioSaavnRequests {
|
||||||
|
|
||||||
@ -31,63 +35,64 @@ interface JioSaavnRequests {
|
|||||||
val httpClient: HttpClient
|
val httpClient: HttpClient
|
||||||
val logger: Kermit
|
val logger: Kermit
|
||||||
|
|
||||||
suspend fun findSongDownloadURL(
|
suspend fun findMp3SongDownloadURL(
|
||||||
trackName: String,
|
trackName: String,
|
||||||
trackArtists: List<String>,
|
trackArtists: List<String>,
|
||||||
): String? {
|
): SuspendableEvent<String,Throwable> = searchForSong(trackName).map { songs ->
|
||||||
val songs = searchForSong(trackName)
|
|
||||||
val bestMatches = sortByBestMatch(songs, trackName, trackArtists)
|
val bestMatches = sortByBestMatch(songs, trackName, trackArtists)
|
||||||
val m4aLink: String? = bestMatches.keys.firstOrNull()?.let {
|
|
||||||
getSongFromID(it).media_url
|
val m4aLink: String by getSongFromID(bestMatches.keys.first()).map { song ->
|
||||||
|
song.media_url.requireNotNull()
|
||||||
}
|
}
|
||||||
val mp3Link = m4aLink?.let { audioToMp3.convertToMp3(it) }
|
|
||||||
return mp3Link
|
val mp3Link by audioToMp3.convertToMp3(m4aLink)
|
||||||
|
|
||||||
|
mp3Link
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun searchForSong(
|
suspend fun searchForSong(
|
||||||
query: String,
|
query: String,
|
||||||
includeLyrics: Boolean = false
|
includeLyrics: Boolean = false
|
||||||
): List<SaavnSearchResult> {
|
): SuspendableEvent<List<SaavnSearchResult>,Throwable> = SuspendableEvent {
|
||||||
/*if (query.startsWith("http") && query.contains("saavn.com")) {
|
|
||||||
return listOf(getSong(query))
|
|
||||||
}*/
|
|
||||||
|
|
||||||
val searchURL = search_base_url + query
|
val searchURL = search_base_url + query
|
||||||
val results = mutableListOf<SaavnSearchResult>()
|
val results = mutableListOf<SaavnSearchResult>()
|
||||||
try {
|
|
||||||
(globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject).getJsonObject("songs").getJsonArray("data")?.forEach {
|
(globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject)
|
||||||
(it as? JsonObject)?.formatData()?.let { jsonObject ->
|
.getJsonObject("songs")
|
||||||
|
.getJsonArray("data").requireNotNull().forEach {
|
||||||
|
(it as JsonObject).formatData().let { jsonObject ->
|
||||||
results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject))
|
results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}catch (e: ServerResponseException) {}
|
|
||||||
return results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLyrics(ID: String): String? {
|
suspend fun getLyrics(ID: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
return try {
|
(Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject)
|
||||||
(Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject)
|
.getString("lyrics").requireNotNull()
|
||||||
.getString("lyrics")
|
|
||||||
}catch (e:Exception) { null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSong(
|
suspend fun getSong(
|
||||||
URL: String,
|
URL: String,
|
||||||
fetchLyrics: Boolean = false
|
fetchLyrics: Boolean = false
|
||||||
): SaavnSong {
|
): SuspendableEvent<SaavnSong,Throwable> = SuspendableEvent {
|
||||||
val id = getSongID(URL)
|
val id = getSongID(URL)
|
||||||
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
|
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
|
||||||
.formatData(fetchLyrics)
|
.formatData(fetchLyrics)
|
||||||
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
|
||||||
|
globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSongFromID(
|
suspend fun getSongFromID(
|
||||||
ID: String,
|
ID: String,
|
||||||
fetchLyrics: Boolean = false
|
fetchLyrics: Boolean = false
|
||||||
): SaavnSong {
|
): SuspendableEvent<SaavnSong,Throwable> = SuspendableEvent {
|
||||||
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject)
|
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject)
|
||||||
.formatData(fetchLyrics)
|
.formatData(fetchLyrics)
|
||||||
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
|
||||||
|
globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSongID(
|
private suspend fun getSongID(
|
||||||
@ -104,24 +109,19 @@ interface JioSaavnRequests {
|
|||||||
suspend fun getPlaylist(
|
suspend fun getPlaylist(
|
||||||
URL: String,
|
URL: String,
|
||||||
includeLyrics: Boolean = false
|
includeLyrics: Boolean = false
|
||||||
): SaavnPlaylist? {
|
): SuspendableEvent<SaavnPlaylist,Throwable> = SuspendableEvent {
|
||||||
return try {
|
globalJson.decodeFromJsonElement(
|
||||||
globalJson.decodeFromJsonElement(
|
SaavnPlaylist.serializer(),
|
||||||
SaavnPlaylist.serializer(),
|
(globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL).value)) as JsonObject)
|
||||||
(globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL))) as JsonObject)
|
.formatData(includeLyrics)
|
||||||
.formatData(includeLyrics)
|
)
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getPlaylistID(
|
private suspend fun getPlaylistID(
|
||||||
URL: String
|
URL: String
|
||||||
): String {
|
): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
val res = httpClient.get<String>(URL)
|
val res = httpClient.get<String>(URL)
|
||||||
return try {
|
try {
|
||||||
res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0]
|
res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0]
|
||||||
} catch (e: IndexOutOfBoundsException) {
|
} catch (e: IndexOutOfBoundsException) {
|
||||||
res.split("\"page_id\",\"")[1].split("\",\"")[0]
|
res.split("\"page_id\",\"")[1].split("\",\"")[0]
|
||||||
@ -131,24 +131,19 @@ interface JioSaavnRequests {
|
|||||||
suspend fun getAlbum(
|
suspend fun getAlbum(
|
||||||
URL: String,
|
URL: String,
|
||||||
includeLyrics: Boolean = false
|
includeLyrics: Boolean = false
|
||||||
): SaavnAlbum? {
|
): SuspendableEvent<SaavnAlbum,Throwable> = SuspendableEvent {
|
||||||
return try {
|
globalJson.decodeFromJsonElement(
|
||||||
globalJson.decodeFromJsonElement(
|
SaavnAlbum.serializer(),
|
||||||
SaavnAlbum.serializer(),
|
(globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL).value)) as JsonObject)
|
||||||
(globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL))) as JsonObject)
|
.formatData(includeLyrics)
|
||||||
.formatData(includeLyrics)
|
)
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getAlbumID(
|
private suspend fun getAlbumID(
|
||||||
URL: String
|
URL: String
|
||||||
): String {
|
): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
val res = httpClient.get<String>(URL)
|
val res = httpClient.get<String>(URL)
|
||||||
return try {
|
try {
|
||||||
res.split("\"album_id\":\"")[1].split('"')[0]
|
res.split("\"album_id\":\"")[1].split('"')[0]
|
||||||
} catch (e: IndexOutOfBoundsException) {
|
} catch (e: IndexOutOfBoundsException) {
|
||||||
res.split("\"page_id\",\"")[1].split("\",\"")[0]
|
res.split("\"page_id\",\"")[1].split("\",\"")[0]
|
||||||
@ -214,8 +209,10 @@ interface JioSaavnRequests {
|
|||||||
// Fetch Lyrics if Requested
|
// Fetch Lyrics if Requested
|
||||||
// Lyrics is HTML Based
|
// Lyrics is HTML Based
|
||||||
if (includeLyrics) {
|
if (includeLyrics) {
|
||||||
if (getBoolean("has_lyrics") == true) {
|
if (getBoolean("has_lyrics") == true && containsKey("id")) {
|
||||||
put("lyrics", getString("id")?.let { getLyrics(it) })
|
getLyrics(getString("id").requireNotNull()).success {
|
||||||
|
put("lyrics", it)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
put("lyrics", "")
|
put("lyrics", "")
|
||||||
}
|
}
|
||||||
@ -237,8 +234,8 @@ interface JioSaavnRequests {
|
|||||||
for (result in tracks) {
|
for (result in tracks) {
|
||||||
var hasCommonWord = false
|
var hasCommonWord = false
|
||||||
|
|
||||||
val resultName = result.title.toLowerCase().replace("/", " ")
|
val resultName = result.title.lowercase().replace("/", " ")
|
||||||
val trackNameWords = trackName.toLowerCase().split(" ")
|
val trackNameWords = trackName.lowercase().split(" ")
|
||||||
|
|
||||||
for (nameWord in trackNameWords) {
|
for (nameWord in trackNameWords) {
|
||||||
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
|
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
|
||||||
@ -258,11 +255,11 @@ interface JioSaavnRequests {
|
|||||||
// String Containing All Artist Names from JioSaavn Search Result
|
// String Containing All Artist Names from JioSaavn Search Result
|
||||||
val artistListString = mutableSetOf<String>().apply {
|
val artistListString = mutableSetOf<String>().apply {
|
||||||
result.more_info?.singers?.split(",")?.let { addAll(it) }
|
result.more_info?.singers?.split(",")?.let { addAll(it) }
|
||||||
result.more_info?.primary_artists?.toLowerCase()?.split(",")?.let { addAll(it) }
|
result.more_info?.primary_artists?.lowercase()?.split(",")?.let { addAll(it) }
|
||||||
}.joinToString(" , ")
|
}.joinToString(" , ")
|
||||||
|
|
||||||
for (artist in trackArtists) {
|
for (artist in trackArtists) {
|
||||||
if (FuzzySearch.partialRatio(artist.toLowerCase(), artistListString) > 85)
|
if (FuzzySearch.partialRatio(artist.lowercase(), artistListString) > 85)
|
||||||
artistMatchNumber++
|
artistMatchNumber++
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
|||||||
package com.shabinder.common.di.saavn
|
package com.shabinder.common.di.providers.requests.saavn
|
||||||
|
|
||||||
|
import com.shabinder.common.di.utils.unescape
|
||||||
|
|
||||||
expect suspend fun decryptURL(url: String): String
|
expect suspend fun decryptURL(url: String): String
|
||||||
|
|
@ -14,30 +14,29 @@
|
|||||||
* * 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.spotify
|
package com.shabinder.common.di.providers.requests.spotify
|
||||||
|
|
||||||
import com.shabinder.common.di.globalJson
|
import com.shabinder.common.di.globalJson
|
||||||
|
import com.shabinder.common.models.SpotiFlyerException
|
||||||
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
import com.shabinder.common.models.spotify.TokenData
|
import com.shabinder.common.models.spotify.TokenData
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.features.auth.Auth
|
import io.ktor.client.features.auth.*
|
||||||
import io.ktor.client.features.auth.providers.basic
|
import io.ktor.client.features.auth.providers.*
|
||||||
import io.ktor.client.features.json.JsonFeature
|
import io.ktor.client.features.json.*
|
||||||
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
import io.ktor.client.features.json.serializer.*
|
||||||
import io.ktor.client.request.forms.FormDataContent
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.forms.*
|
||||||
import io.ktor.http.Parameters
|
import io.ktor.http.*
|
||||||
import kotlin.native.concurrent.SharedImmutable
|
import kotlin.native.concurrent.SharedImmutable
|
||||||
|
|
||||||
suspend fun authenticateSpotify(): TokenData? {
|
suspend fun authenticateSpotify(): SuspendableEvent<TokenData,Throwable> = SuspendableEvent {
|
||||||
return try {
|
if (methods.value.isInternetAvailable) {
|
||||||
if (methods.value.isInternetAvailable) spotifyAuthClient.post("https://accounts.spotify.com/api/token") {
|
spotifyAuthClient.post("https://accounts.spotify.com/api/token") {
|
||||||
body = FormDataContent(Parameters.build { append("grant_type", "client_credentials") })
|
body = FormDataContent(Parameters.build { append("grant_type", "client_credentials") })
|
||||||
} else null
|
}
|
||||||
} catch (e: Exception) {
|
} else throw SpotiFlyerException.NoInternetException()
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SharedImmutable
|
@SharedImmutable
|
||||||
@ -48,9 +47,10 @@ private val spotifyAuthClient by lazy {
|
|||||||
|
|
||||||
install(Auth) {
|
install(Auth) {
|
||||||
basic {
|
basic {
|
||||||
sendWithoutRequest = true
|
sendWithoutRequest { true }
|
||||||
username = clientId
|
credentials {
|
||||||
password = clientSecret
|
BasicAuthCredentials(clientId, clientSecret)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
install(JsonFeature) {
|
install(JsonFeature) {
|
@ -14,16 +14,18 @@
|
|||||||
* * 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.spotify
|
package com.shabinder.common.di.providers.requests.spotify
|
||||||
|
|
||||||
import com.shabinder.common.di.gaana.corsApi
|
|
||||||
import com.shabinder.common.models.NativeAtomicReference
|
import com.shabinder.common.models.NativeAtomicReference
|
||||||
|
import com.shabinder.common.models.corsApi
|
||||||
import com.shabinder.common.models.spotify.Album
|
import com.shabinder.common.models.spotify.Album
|
||||||
import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack
|
import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack
|
||||||
import com.shabinder.common.models.spotify.Playlist
|
import com.shabinder.common.models.spotify.Playlist
|
||||||
import com.shabinder.common.models.spotify.Track
|
import com.shabinder.common.models.spotify.Track
|
||||||
import io.ktor.client.HttpClient
|
import io.github.shabinder.TargetPlatforms
|
||||||
import io.ktor.client.request.get
|
import io.github.shabinder.activePlatform
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
|
||||||
private val BASE_URL get() = "${corsApi}https://api.spotify.com/v1"
|
private val BASE_URL get() = "${corsApi}https://api.spotify.com/v1"
|
||||||
|
|
||||||
@ -32,7 +34,7 @@ interface SpotifyRequests {
|
|||||||
val httpClientRef: NativeAtomicReference<HttpClient>
|
val httpClientRef: NativeAtomicReference<HttpClient>
|
||||||
val httpClient: HttpClient get() = httpClientRef.value
|
val httpClient: HttpClient get() = httpClientRef.value
|
||||||
|
|
||||||
suspend fun authenticateSpotifyClient(override: Boolean = false)
|
suspend fun authenticateSpotifyClient(override: Boolean = activePlatform is TargetPlatforms.Js)
|
||||||
|
|
||||||
suspend fun getPlaylist(playlistID: String): Playlist {
|
suspend fun getPlaylist(playlistID: String): Playlist {
|
||||||
return httpClient.get("$BASE_URL/playlists/$playlistID")
|
return httpClient.get("$BASE_URL/playlists/$playlistID")
|
@ -14,14 +14,18 @@
|
|||||||
* * 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.youtubeMp3
|
package com.shabinder.common.di.providers.requests.youtubeMp3
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.shabinder.common.di.gaana.corsApi
|
import com.shabinder.common.models.corsApi
|
||||||
import io.ktor.client.HttpClient
|
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||||
import io.ktor.client.request.forms.FormDataContent
|
import com.shabinder.common.models.event.coroutines.flatMap
|
||||||
import io.ktor.client.request.post
|
import com.shabinder.common.models.event.coroutines.map
|
||||||
import io.ktor.http.Parameters
|
import com.shabinder.common.requireNotNull
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.request.forms.*
|
||||||
|
import io.ktor.http.*
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
@ -33,18 +37,23 @@ interface Yt1sMp3 {
|
|||||||
|
|
||||||
val httpClient: HttpClient
|
val httpClient: HttpClient
|
||||||
val logger: Kermit
|
val logger: Kermit
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Downloadable Mp3 Link for YT videoID.
|
* Downloadable Mp3 Link for YT videoID.
|
||||||
* */
|
* */
|
||||||
suspend fun getLinkFromYt1sMp3(videoID: String): String? =
|
suspend fun getLinkFromYt1sMp3(videoID: String): SuspendableEvent<String,Throwable> = getKey(videoID).flatMap { key ->
|
||||||
getConvertedMp3Link(videoID, getKey(videoID))?.get("dlink")?.jsonPrimitive?.toString()?.replace("\"", "")
|
getConvertedMp3Link(videoID, key).map {
|
||||||
|
it["dlink"].requireNotNull()
|
||||||
|
.jsonPrimitive.content.replace("\"", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* POST:https://yt1s.com/api/ajaxSearch/index
|
* POST:https://yt1s.com/api/ajaxSearch/index
|
||||||
* Body Form= q:yt video link ,vt:format=mp3
|
* Body Form= q:yt video link ,vt:format=mp3
|
||||||
* */
|
* */
|
||||||
private suspend fun getKey(videoID: String): String {
|
private suspend fun getKey(videoID: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||||
val response: JsonObject? = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") {
|
val response: JsonObject = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") {
|
||||||
body = FormDataContent(
|
body = FormDataContent(
|
||||||
Parameters.build {
|
Parameters.build {
|
||||||
append("q", "https://www.youtube.com/watch?v=$videoID")
|
append("q", "https://www.youtube.com/watch?v=$videoID")
|
||||||
@ -52,11 +61,12 @@ interface Yt1sMp3 {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return response?.get("kc")?.jsonPrimitive.toString()
|
|
||||||
|
response["kc"].requireNotNull().jsonPrimitive.content
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getConvertedMp3Link(videoID: String, key: String): JsonObject? {
|
private suspend fun getConvertedMp3Link(videoID: String, key: String): SuspendableEvent<JsonObject,Throwable> = SuspendableEvent {
|
||||||
return httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") {
|
httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") {
|
||||||
body = FormDataContent(
|
body = FormDataContent(
|
||||||
Parameters.build {
|
Parameters.build {
|
||||||
append("vid", videoID)
|
append("vid", videoID)
|
@ -1,4 +1,4 @@
|
|||||||
package com.shabinder.common.di.saavn
|
package com.shabinder.common.di.utils
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* JSON UTILS
|
* JSON UTILS
|
||||||
@ -6,7 +6,7 @@ package com.shabinder.common.di.saavn
|
|||||||
fun String.escape(): String {
|
fun String.escape(): String {
|
||||||
val output = StringBuilder()
|
val output = StringBuilder()
|
||||||
for (element in this) {
|
for (element in this) {
|
||||||
val chx = element.toInt()
|
val chx = element.code
|
||||||
if (chx != 0) {
|
if (chx != 0) {
|
||||||
when (element) {
|
when (element) {
|
||||||
'\n' -> {
|
'\n' -> {
|
||||||
@ -76,7 +76,7 @@ fun String.unescape(): String {
|
|||||||
/*if (!x.isLetterOrDigit()) {
|
/*if (!x.isLetterOrDigit()) {
|
||||||
throw RuntimeException("Bad character in unicode escape.")
|
throw RuntimeException("Bad character in unicode escape.")
|
||||||
}*/
|
}*/
|
||||||
hex.append(x.toLowerCase())
|
hex.append(x.lowercaseChar())
|
||||||
}
|
}
|
||||||
i += 4 // consume those four digits.
|
i += 4 // consume those four digits.
|
||||||
val code = hex.toString().toInt(16)
|
val code = hex.toString().toInt(16)
|
@ -22,7 +22,7 @@ package com.shabinder.common.di.utils
|
|||||||
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
|
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
|
||||||
|
|
||||||
import com.shabinder.common.di.dispatcherIO
|
import com.shabinder.common.di.dispatcherIO
|
||||||
import io.ktor.utils.io.core.Closeable
|
import io.ktor.utils.io.core.*
|
||||||
import kotlinx.atomicfu.atomic
|
import kotlinx.atomicfu.atomic
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@ -96,7 +96,7 @@ class ParallelExecutor(
|
|||||||
return
|
return
|
||||||
|
|
||||||
var change = expectedCount - actualCount
|
var change = expectedCount - actualCount
|
||||||
while (change > 0 && killQueue.poll() != null)
|
while (change > 0 && killQueue.tryReceive().getOrNull() != null)
|
||||||
change -= 1
|
change -= 1
|
||||||
|
|
||||||
if (change > 0)
|
if (change > 0)
|
||||||
@ -104,7 +104,7 @@ class ParallelExecutor(
|
|||||||
repeat(change) { launchProcessor() }
|
repeat(change) { launchProcessor() }
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
repeat(-change) { killQueue.offer(Unit) }
|
repeat(-change) { killQueue.trySend(Unit).isSuccess }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Operation<Result>(
|
private class Operation<Result>(
|
||||||
|
@ -17,9 +17,9 @@
|
|||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import com.shabinder.common.di.utils.ParallelExecutor
|
import com.shabinder.common.di.utils.ParallelExecutor
|
||||||
import com.shabinder.common.models.AllPlatforms
|
|
||||||
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.SpotiFlyerException
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -34,9 +34,6 @@ val DownloadScope = ParallelExecutor(Dispatchers.IO)
|
|||||||
// IO-Dispatcher
|
// IO-Dispatcher
|
||||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
// Current Platform Info
|
|
||||||
actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm
|
|
||||||
|
|
||||||
actual suspend fun downloadTracks(
|
actual suspend fun downloadTracks(
|
||||||
list: List<TrackDetails>,
|
list: List<TrackDetails>,
|
||||||
fetcher: FetchPlatformQueryResult,
|
fetcher: FetchPlatformQueryResult,
|
||||||
@ -44,41 +41,43 @@ actual suspend fun downloadTracks(
|
|||||||
) {
|
) {
|
||||||
list.forEach { trackDetails ->
|
list.forEach { trackDetails ->
|
||||||
DownloadScope.execute { // Send Download to Pool.
|
DownloadScope.execute { // Send Download to Pool.
|
||||||
val url = fetcher.findMp3DownloadLink(trackDetails)
|
fetcher.findMp3DownloadLink(trackDetails).fold(
|
||||||
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL
|
success = { url ->
|
||||||
downloadFile(url).collect {
|
downloadFile(url).collect {
|
||||||
when (it) {
|
when (it) {
|
||||||
is DownloadResult.Error -> {
|
is DownloadResult.Error -> {
|
||||||
DownloadProgressFlow.emit(
|
DownloadProgressFlow.emit(
|
||||||
DownloadProgressFlow.replayCache.getOrElse(
|
DownloadProgressFlow.replayCache.getOrElse(
|
||||||
0
|
0
|
||||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed) }
|
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause))) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is DownloadResult.Progress -> {
|
is DownloadResult.Progress -> {
|
||||||
DownloadProgressFlow.emit(
|
DownloadProgressFlow.emit(
|
||||||
DownloadProgressFlow.replayCache.getOrElse(
|
DownloadProgressFlow.replayCache.getOrElse(
|
||||||
0
|
0
|
||||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) }
|
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is DownloadResult.Success -> { // Todo clear map
|
is DownloadResult.Success -> { // Todo clear map
|
||||||
dir.saveFileWithMetadata(it.byteArray, trackDetails) {}
|
dir.saveFileWithMetadata(it.byteArray, trackDetails) {}
|
||||||
DownloadProgressFlow.emit(
|
DownloadProgressFlow.emit(
|
||||||
DownloadProgressFlow.replayCache.getOrElse(
|
DownloadProgressFlow.replayCache.getOrElse(
|
||||||
0
|
0
|
||||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloaded) }
|
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloaded) }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
failure = { error ->
|
||||||
|
DownloadProgressFlow.emit(
|
||||||
|
DownloadProgressFlow.replayCache.getOrElse(
|
||||||
|
0
|
||||||
|
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(error)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
)
|
||||||
DownloadProgressFlow.emit(
|
|
||||||
DownloadProgressFlow.replayCache.getOrElse(
|
|
||||||
0
|
|
||||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,8 +20,8 @@ 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.mpatric.mp3agic.Mp3File
|
import com.mpatric.mp3agic.Mp3File
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.database.Database
|
import com.shabinder.database.Database
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -40,7 +40,7 @@ import javax.imageio.ImageIO
|
|||||||
|
|
||||||
actual class Dir actual constructor(
|
actual class Dir actual constructor(
|
||||||
private val logger: Kermit,
|
private val logger: Kermit,
|
||||||
settingsPref: Settings,
|
private val preferenceManager: PreferenceManager,
|
||||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ actual class Dir actual constructor(
|
|||||||
|
|
||||||
private val defaultBaseDir = System.getProperty("user.home")
|
private val defaultBaseDir = System.getProperty("user.home")
|
||||||
|
|
||||||
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) + fileSeparator() +
|
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
|
||||||
"SpotiFlyer" + fileSeparator()
|
"SpotiFlyer" + fileSeparator()
|
||||||
|
|
||||||
actual fun isPresent(path: String): Boolean = File(path).exists()
|
actual fun isPresent(path: String): Boolean = File(path).exists()
|
||||||
@ -199,7 +199,6 @@ actual class Dir actual constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual val db: Database? = spotiFlyerDatabase.instance
|
actual val db: Database? = spotiFlyerDatabase.instance
|
||||||
actual val settings: Settings = settingsPref
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun BufferedImage.toImageBitmap() = Image.makeFromEncoded(
|
fun BufferedImage.toImageBitmap() = Image.makeFromEncoded(
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package com.shabinder.common.di.saavn
|
package com.shabinder.common.di.providers.requests.saavn
|
||||||
|
|
||||||
import io.ktor.util.InternalAPI
|
import io.ktor.util.*
|
||||||
import io.ktor.util.decodeBase64Bytes
|
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
@ -1,8 +1,8 @@
|
|||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.database.Database
|
import com.shabinder.database.Database
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@ -24,7 +24,7 @@ import platform.UIKit.UIImageJPEGRepresentation
|
|||||||
|
|
||||||
actual class Dir actual constructor(
|
actual class Dir actual constructor(
|
||||||
val logger: Kermit,
|
val logger: Kermit,
|
||||||
settingsPref: Settings,
|
private val preferenceManager: PreferenceManager,
|
||||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ actual class Dir actual constructor(
|
|||||||
private val defaultBaseDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!!.path!!
|
private val defaultBaseDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!!.path!!
|
||||||
|
|
||||||
// TODO Error Handling
|
// TODO Error Handling
|
||||||
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) +
|
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
|
||||||
fileSeparator() + "SpotiFlyer" + fileSeparator()
|
fileSeparator() + "SpotiFlyer" + fileSeparator()
|
||||||
|
|
||||||
private val defaultDirURL: NSURL by lazy {
|
private val defaultDirURL: NSURL by lazy {
|
||||||
@ -176,6 +176,5 @@ actual class Dir actual constructor(
|
|||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
actual val settings: Settings = settingsPref
|
|
||||||
actual val db: Database? = spotiFlyerDatabase.instance
|
actual val db: Database? = spotiFlyerDatabase.instance
|
||||||
}
|
}
|
||||||
|
@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import com.shabinder.common.models.AllPlatforms
|
|
||||||
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.SpotiFlyerException
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -34,9 +34,6 @@ val allTracksStatus: HashMap<String, DownloadStatus> = hashMapOf()
|
|||||||
// IO-Dispatcher
|
// IO-Dispatcher
|
||||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default
|
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
// Current Platform Info
|
|
||||||
actual val currentPlatform: AllPlatforms = AllPlatforms.Js
|
|
||||||
|
|
||||||
actual suspend fun downloadTracks(
|
actual suspend fun downloadTracks(
|
||||||
list: List<TrackDetails>,
|
list: List<TrackDetails>,
|
||||||
fetcher: FetchPlatformQueryResult,
|
fetcher: FetchPlatformQueryResult,
|
||||||
@ -45,29 +42,31 @@ actual suspend fun downloadTracks(
|
|||||||
list.forEach { track ->
|
list.forEach { track ->
|
||||||
withContext(dispatcherIO) {
|
withContext(dispatcherIO) {
|
||||||
allTracksStatus[track.title] = DownloadStatus.Queued
|
allTracksStatus[track.title] = DownloadStatus.Queued
|
||||||
val url = fetcher.findMp3DownloadLink(track)
|
fetcher.findMp3DownloadLink(track).fold(
|
||||||
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL
|
success = { url ->
|
||||||
downloadFile(url).collect {
|
downloadFile(url).collect {
|
||||||
when (it) {
|
when (it) {
|
||||||
is DownloadResult.Success -> {
|
is DownloadResult.Success -> {
|
||||||
println("Download Completed")
|
println("Download Completed")
|
||||||
dir.saveFileWithMetadata(it.byteArray, track) {}
|
dir.saveFileWithMetadata(it.byteArray, track) {}
|
||||||
}
|
}
|
||||||
is DownloadResult.Error -> {
|
is DownloadResult.Error -> {
|
||||||
allTracksStatus[track.title] = DownloadStatus.Failed
|
allTracksStatus[track.title] = DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause))
|
||||||
println("Download Error: ${track.title}")
|
println("Download Error: ${track.title}")
|
||||||
}
|
}
|
||||||
is DownloadResult.Progress -> {
|
is DownloadResult.Progress -> {
|
||||||
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
|
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
|
||||||
println("Download Progress: ${it.progress} : ${track.title}")
|
println("Download Progress: ${it.progress} : ${track.title}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
DownloadProgressFlow.emit(allTracksStatus)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
failure = { error ->
|
||||||
|
allTracksStatus[track.title] = DownloadStatus.Failed(error)
|
||||||
DownloadProgressFlow.emit(allTracksStatus)
|
DownloadProgressFlow.emit(allTracksStatus)
|
||||||
}
|
}
|
||||||
} else {
|
)
|
||||||
allTracksStatus[track.title] = DownloadStatus.Failed
|
|
||||||
DownloadProgressFlow.emit(allTracksStatus)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,13 @@
|
|||||||
package com.shabinder.common.di
|
package com.shabinder.common.di
|
||||||
|
|
||||||
import co.touchlab.kermit.Kermit
|
import co.touchlab.kermit.Kermit
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||||
import com.shabinder.common.di.gaana.corsApi
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.di.utils.removeIllegalChars
|
import com.shabinder.common.di.utils.removeIllegalChars
|
||||||
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.corsApi
|
||||||
import com.shabinder.database.Database
|
import com.shabinder.database.Database
|
||||||
import kotlinext.js.Object
|
import kotlinext.js.Object
|
||||||
import kotlinext.js.js
|
import kotlinext.js.js
|
||||||
@ -34,7 +34,7 @@ import org.w3c.dom.ImageBitmap
|
|||||||
|
|
||||||
actual class Dir actual constructor(
|
actual class Dir actual constructor(
|
||||||
private val logger: Kermit,
|
private val logger: Kermit,
|
||||||
settingsPref: Settings,
|
private val preferenceManager: PreferenceManager,
|
||||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||||
) {
|
) {
|
||||||
/*init {
|
/*init {
|
||||||
@ -116,7 +116,6 @@ actual class Dir actual constructor(
|
|||||||
private suspend fun freshImage(url: String): ImageBitmap? = null
|
private suspend fun freshImage(url: String): ImageBitmap? = null
|
||||||
|
|
||||||
actual val db: Database? = spotiFlyerDatabase.instance
|
actual val db: Database? = spotiFlyerDatabase.instance
|
||||||
actual val settings: Settings = settingsPref
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ByteArray.toArrayBuffer(): ArrayBuffer {
|
fun ByteArray.toArrayBuffer(): ArrayBuffer {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package com.shabinder.common.di.saavn
|
package com.shabinder.common.di.providers.requests.saavn
|
||||||
|
|
||||||
actual suspend fun decryptURL(url: String): String {
|
actual suspend fun decryptURL(url: String): String {
|
||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
@ -22,6 +22,7 @@ import com.arkivanov.mvikotlin.core.store.StoreFactory
|
|||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.di.Dir
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.di.Picture
|
||||||
|
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
|
||||||
@ -61,12 +62,13 @@ interface SpotiFlyerList {
|
|||||||
/*
|
/*
|
||||||
* Snooze Donation Dialog
|
* Snooze Donation Dialog
|
||||||
* */
|
* */
|
||||||
fun snoozeDonationDialog()
|
fun dismissDonationDialogSetOffset()
|
||||||
|
|
||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
val storeFactory: StoreFactory
|
val storeFactory: StoreFactory
|
||||||
val fetchQuery: FetchPlatformQueryResult
|
val fetchQuery: FetchPlatformQueryResult
|
||||||
val dir: Dir
|
val dir: Dir
|
||||||
|
val preferenceManager: PreferenceManager
|
||||||
val link: String
|
val link: String
|
||||||
val listOutput: Consumer<Output>
|
val listOutput: Consumer<Output>
|
||||||
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||||
@ -83,7 +85,7 @@ interface SpotiFlyerList {
|
|||||||
val queryResult: PlatformQueryResult? = null,
|
val queryResult: PlatformQueryResult? = null,
|
||||||
val link: String = "",
|
val link: String = "",
|
||||||
val trackList: List<TrackDetails> = emptyList(),
|
val trackList: List<TrackDetails> = emptyList(),
|
||||||
val errorOccurred: Exception? = null,
|
val errorOccurred: Throwable? = null,
|
||||||
val askForDonation: Boolean = false,
|
val askForDonation: Boolean = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -18,10 +18,10 @@ 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.shabinder.common.caching.Cache
|
import com.shabinder.common.caching.Cache
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.di.Picture
|
||||||
import com.shabinder.common.di.setDonationOffset
|
|
||||||
import com.shabinder.common.di.utils.asValue
|
import com.shabinder.common.di.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
|
||||||
@ -38,12 +38,16 @@ internal class SpotiFlyerListImpl(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
instanceKeeper.ensureNeverFrozen()
|
instanceKeeper.ensureNeverFrozen()
|
||||||
|
lifecycle.doOnResume {
|
||||||
|
onRefreshTracksStatuses()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val store =
|
private val store =
|
||||||
instanceKeeper.getStore {
|
instanceKeeper.getStore {
|
||||||
SpotiFlyerListStoreProvider(
|
SpotiFlyerListStoreProvider(
|
||||||
dir = this.dir,
|
dir = this.dir,
|
||||||
|
preferenceManager = preferenceManager,
|
||||||
storeFactory = storeFactory,
|
storeFactory = storeFactory,
|
||||||
fetchQuery = fetchQuery,
|
fetchQuery = fetchQuery,
|
||||||
downloadProgressFlow = downloadProgressFlow,
|
downloadProgressFlow = downloadProgressFlow,
|
||||||
@ -74,8 +78,8 @@ internal class SpotiFlyerListImpl(
|
|||||||
store.accept(Intent.RefreshTracksStatuses)
|
store.accept(Intent.RefreshTracksStatuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun snoozeDonationDialog() {
|
override fun dismissDonationDialogSetOffset() {
|
||||||
dir.setDonationOffset(offset = 10)
|
preferenceManager.setDonationOffset(offset = 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadImage(url: String, isCover: Boolean): Picture {
|
override suspend fun loadImage(url: String, isCover: Boolean): Picture {
|
||||||
|
@ -21,11 +21,10 @@ 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.core.store.StoreFactory
|
||||||
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
||||||
import com.shabinder.common.database.getLogger
|
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.di.Dir
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||||
import com.shabinder.common.di.downloadTracks
|
import com.shabinder.common.di.downloadTracks
|
||||||
import com.shabinder.common.di.getDonationOffset
|
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
|
||||||
@ -33,17 +32,16 @@ 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 kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
internal class SpotiFlyerListStoreProvider(
|
internal class SpotiFlyerListStoreProvider(
|
||||||
private val dir: Dir,
|
private val dir: Dir,
|
||||||
|
private val preferenceManager: PreferenceManager,
|
||||||
private val storeFactory: StoreFactory,
|
private val storeFactory: StoreFactory,
|
||||||
private val fetchQuery: FetchPlatformQueryResult,
|
private val fetchQuery: FetchPlatformQueryResult,
|
||||||
private val link: String,
|
private val link: String,
|
||||||
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||||
) {
|
) {
|
||||||
val logger = getLogger()
|
|
||||||
|
|
||||||
fun provide(): SpotiFlyerListStore =
|
fun provide(): SpotiFlyerListStore =
|
||||||
object :
|
object :
|
||||||
SpotiFlyerListStore,
|
SpotiFlyerListStore,
|
||||||
@ -59,8 +57,8 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
data class ResultFetched(val result: PlatformQueryResult, val trackList: List<TrackDetails>) : Result()
|
data class ResultFetched(val result: PlatformQueryResult, val trackList: List<TrackDetails>) : Result()
|
||||||
data class UpdateTrackList(val list: List<TrackDetails>) : Result()
|
data class UpdateTrackList(val list: List<TrackDetails>) : Result()
|
||||||
data class UpdateTrackItem(val item: TrackDetails) : Result()
|
data class UpdateTrackItem(val item: TrackDetails) : Result()
|
||||||
data class ErrorOccurred(val error: Exception) : Result()
|
data class ErrorOccurred(val error: Throwable) : Result()
|
||||||
data class AskForDonation(val isAllowed: Boolean) : Result()
|
data class AskForSupport(val isAllowed: Boolean) : Result()
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
||||||
@ -70,18 +68,18 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
|
|
||||||
dir.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
|
dir.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
|
||||||
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 = dir.getDonationOffset
|
val offset = preferenceManager.getDonationOffset
|
||||||
dispatch(
|
dispatch(
|
||||||
Result.AskForDonation(
|
Result.AskForSupport(
|
||||||
// Every 3rd Interval or After some offset
|
// Every 3rd Interval or After some offset
|
||||||
isAllowed = offset < 4 && (it % offset == 0L)
|
isAllowed = offset < 4 && (it % offset == 0L)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadProgressFlow.collectLatest { map ->
|
downloadProgressFlow.collect { map ->
|
||||||
logger.d(map.size.toString(), "ListStore: flow Updated")
|
// logger.d(map.size.toString(), "ListStore: flow Updated")
|
||||||
val updatedTrackList = getState().trackList.updateTracksStatuses(map)
|
val updatedTrackList = getState().trackList.updateTracksStatuses(map)
|
||||||
if (updatedTrackList.isNotEmpty()) dispatch(Result.UpdateTrackList(updatedTrackList))
|
if (updatedTrackList.isNotEmpty()) dispatch(Result.UpdateTrackList(updatedTrackList))
|
||||||
}
|
}
|
||||||
@ -90,19 +88,17 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
override suspend fun executeIntent(intent: Intent, getState: () -> State) {
|
override suspend fun executeIntent(intent: Intent, getState: () -> State) {
|
||||||
when (intent) {
|
when (intent) {
|
||||||
is Intent.SearchLink -> {
|
is Intent.SearchLink -> {
|
||||||
try {
|
val resp = fetchQuery.query(link)
|
||||||
val result = fetchQuery.query(link)
|
resp.fold(
|
||||||
if (result != null) {
|
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)
|
||||||
} else {
|
},
|
||||||
throw Exception("An Error Occurred, Check your Link / Connection")
|
failure = {
|
||||||
|
dispatch(Result.ErrorOccurred(it))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
)
|
||||||
e.printStackTrace()
|
|
||||||
dispatch(Result.ErrorOccurred(e))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is Intent.StartDownloadAll -> {
|
is Intent.StartDownloadAll -> {
|
||||||
@ -133,7 +129,7 @@ internal class SpotiFlyerListStoreProvider(
|
|||||||
is Result.UpdateTrackList -> copy(trackList = result.list)
|
is Result.UpdateTrackList -> copy(trackList = result.list)
|
||||||
is Result.UpdateTrackItem -> updateTrackItem(result.item)
|
is Result.UpdateTrackItem -> updateTrackItem(result.item)
|
||||||
is Result.ErrorOccurred -> copy(errorOccurred = result.error)
|
is Result.ErrorOccurred -> copy(errorOccurred = result.error)
|
||||||
is Result.AskForDonation -> copy(askForDonation = result.isAllowed)
|
is Result.AskForSupport -> copy(askForDonation = result.isAllowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun State.updateTrackItem(item: TrackDetails): State {
|
private fun State.updateTrackItem(item: TrackDetails): State {
|
||||||
|
@ -21,6 +21,7 @@ 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.di.Dir
|
||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.di.Picture
|
||||||
|
import com.shabinder.common.di.preference.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
|
||||||
@ -58,11 +59,14 @@ interface SpotiFlyerMain {
|
|||||||
* */
|
* */
|
||||||
suspend fun loadImage(url: String): Picture
|
suspend fun loadImage(url: String): Picture
|
||||||
|
|
||||||
|
fun dismissDonationDialogOffset()
|
||||||
|
|
||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
val mainOutput: Consumer<Output>
|
val mainOutput: Consumer<Output>
|
||||||
val storeFactory: StoreFactory
|
val storeFactory: StoreFactory
|
||||||
val database: Database?
|
val database: Database?
|
||||||
val dir: Dir
|
val dir: Dir
|
||||||
|
val preferenceManager: PreferenceManager
|
||||||
val mainAnalytics: Analytics
|
val mainAnalytics: Analytics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,10 @@ import com.shabinder.common.caching.Cache
|
|||||||
import com.shabinder.common.di.Picture
|
import com.shabinder.common.di.Picture
|
||||||
import com.shabinder.common.di.utils.asValue
|
import com.shabinder.common.di.utils.asValue
|
||||||
import com.shabinder.common.main.SpotiFlyerMain
|
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.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
|
||||||
@ -41,6 +44,7 @@ internal class SpotiFlyerMainImpl(
|
|||||||
private val store =
|
private val store =
|
||||||
instanceKeeper.getStore {
|
instanceKeeper.getStore {
|
||||||
SpotiFlyerMainStoreProvider(
|
SpotiFlyerMainStoreProvider(
|
||||||
|
preferenceManager = preferenceManager,
|
||||||
storeFactory = storeFactory,
|
storeFactory = storeFactory,
|
||||||
database = database,
|
database = database,
|
||||||
dir = dir
|
dir = dir
|
||||||
@ -78,4 +82,8 @@ internal class SpotiFlyerMainImpl(
|
|||||||
dir.loadImage(url, 150, 150)
|
dir.loadImage(url, 150, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun dismissDonationDialogOffset() {
|
||||||
|
preferenceManager.setDonationOffset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,8 +22,7 @@ import com.arkivanov.mvikotlin.core.store.Store
|
|||||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
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.Dir
|
||||||
import com.shabinder.common.di.isAnalyticsEnabled
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.di.toggleAnalytics
|
|
||||||
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
|
||||||
@ -39,6 +38,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
|
|
||||||
internal class SpotiFlyerMainStoreProvider(
|
internal class SpotiFlyerMainStoreProvider(
|
||||||
private val storeFactory: StoreFactory,
|
private val storeFactory: StoreFactory,
|
||||||
|
private val preferenceManager: PreferenceManager,
|
||||||
private val dir: Dir,
|
private val dir: Dir,
|
||||||
database: Database?
|
database: Database?
|
||||||
) {
|
) {
|
||||||
@ -76,7 +76,7 @@ internal class SpotiFlyerMainStoreProvider(
|
|||||||
|
|
||||||
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
||||||
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
||||||
dispatch(Result.ToggleAnalytics(dir.isAnalyticsEnabled))
|
dispatch(Result.ToggleAnalytics(preferenceManager.isAnalyticsEnabled))
|
||||||
updates?.collect {
|
updates?.collect {
|
||||||
dispatch(Result.ItemsLoaded(it))
|
dispatch(Result.ItemsLoaded(it))
|
||||||
}
|
}
|
||||||
@ -91,7 +91,7 @@ internal class SpotiFlyerMainStoreProvider(
|
|||||||
is Intent.SelectCategory -> dispatch(Result.CategoryChanged(intent.category))
|
is Intent.SelectCategory -> dispatch(Result.CategoryChanged(intent.category))
|
||||||
is Intent.ToggleAnalytics -> {
|
is Intent.ToggleAnalytics -> {
|
||||||
dispatch(Result.ToggleAnalytics(intent.enabled))
|
dispatch(Result.ToggleAnalytics(intent.enabled))
|
||||||
dir.toggleAnalytics(intent.enabled)
|
preferenceManager.toggleAnalytics(intent.enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,10 +14,22 @@
|
|||||||
* * 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.models
|
plugins {
|
||||||
|
id("android-setup")
|
||||||
sealed class AllPlatforms {
|
id("multiplatform-setup")
|
||||||
object Js : AllPlatforms()
|
id("multiplatform-setup-test")
|
||||||
object Jvm : AllPlatforms()
|
id("kotlin-parcelize")
|
||||||
object Native : AllPlatforms()
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain {
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":common:dependency-injection"))
|
||||||
|
implementation(project(":common:data-models"))
|
||||||
|
implementation(project(":common:database"))
|
||||||
|
implementation(SqlDelight.coroutineExtensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
18
common/preference/src/androidMain/AndroidManifest.xml
Normal file
18
common/preference/src/androidMain/AndroidManifest.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?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.preference"/>
|
@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* * 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.preference
|
||||||
|
|
||||||
|
import com.arkivanov.decompose.ComponentContext
|
||||||
|
import com.arkivanov.decompose.value.Value
|
||||||
|
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||||
|
import com.shabinder.common.di.Dir
|
||||||
|
import com.shabinder.common.di.Picture
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
|
import com.shabinder.common.models.AudioQuality
|
||||||
|
import com.shabinder.common.models.Consumer
|
||||||
|
import com.shabinder.common.preference.integration.SpotiFlyerPreferenceImpl
|
||||||
|
|
||||||
|
interface SpotiFlyerPreference {
|
||||||
|
|
||||||
|
val model: Value<State>
|
||||||
|
|
||||||
|
val analytics: Analytics
|
||||||
|
|
||||||
|
fun toggleAnalytics(enabled: Boolean)
|
||||||
|
|
||||||
|
fun setDownloadDirectory(newBasePath: String)
|
||||||
|
|
||||||
|
suspend fun loadImage(url: String): Picture
|
||||||
|
|
||||||
|
interface Dependencies {
|
||||||
|
val prefOutput: Consumer<Output>
|
||||||
|
val storeFactory: StoreFactory
|
||||||
|
val dir: Dir
|
||||||
|
val preferenceManager: PreferenceManager
|
||||||
|
val preferenceAnalytics: Analytics
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Analytics
|
||||||
|
|
||||||
|
sealed class Output {
|
||||||
|
object Finished : Output()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val preferredQuality: AudioQuality = AudioQuality.KBPS320,
|
||||||
|
val isAnalyticsEnabled: Boolean = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName") // Factory function
|
||||||
|
fun SpotiFlyerPreference(componentContext: ComponentContext, dependencies: SpotiFlyerPreference.Dependencies): SpotiFlyerPreference =
|
||||||
|
SpotiFlyerPreferenceImpl(componentContext, dependencies)
|
@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* * 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.preference.integration
|
||||||
|
|
||||||
|
import co.touchlab.stately.ensureNeverFrozen
|
||||||
|
import com.arkivanov.decompose.ComponentContext
|
||||||
|
import com.arkivanov.decompose.value.Value
|
||||||
|
import com.shabinder.common.caching.Cache
|
||||||
|
import com.shabinder.common.di.Picture
|
||||||
|
import com.shabinder.common.di.utils.asValue
|
||||||
|
import com.shabinder.common.preference.SpotiFlyerPreference
|
||||||
|
import com.shabinder.common.preference.SpotiFlyerPreference.Dependencies
|
||||||
|
import com.shabinder.common.preference.SpotiFlyerPreference.State
|
||||||
|
import com.shabinder.common.preference.store.SpotiFlyerPreferenceStore.Intent
|
||||||
|
import com.shabinder.common.preference.store.SpotiFlyerPreferenceStoreProvider
|
||||||
|
import com.shabinder.common.preference.store.getStore
|
||||||
|
|
||||||
|
internal class SpotiFlyerPreferenceImpl(
|
||||||
|
componentContext: ComponentContext,
|
||||||
|
dependencies: Dependencies
|
||||||
|
) : SpotiFlyerPreference, ComponentContext by componentContext, Dependencies by dependencies {
|
||||||
|
|
||||||
|
init {
|
||||||
|
instanceKeeper.ensureNeverFrozen()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val store =
|
||||||
|
instanceKeeper.getStore {
|
||||||
|
SpotiFlyerPreferenceStoreProvider(
|
||||||
|
storeFactory = storeFactory,
|
||||||
|
preferenceManager = preferenceManager
|
||||||
|
).provide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cache = Cache.Builder
|
||||||
|
.newBuilder()
|
||||||
|
.maximumCacheSize(10)
|
||||||
|
.build<String, Picture>()
|
||||||
|
|
||||||
|
override val model: Value<State> = store.asValue()
|
||||||
|
|
||||||
|
override val analytics = preferenceAnalytics
|
||||||
|
|
||||||
|
override fun toggleAnalytics(enabled: Boolean) {
|
||||||
|
store.accept(Intent.ToggleAnalytics(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setDownloadDirectory(newBasePath: String) {
|
||||||
|
preferenceManager.setDownloadDirectory(newBasePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadImage(url: String): Picture {
|
||||||
|
return cache.get(url) {
|
||||||
|
dir.loadImage(url, 150, 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* * 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.preference.store
|
||||||
|
|
||||||
|
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
|
||||||
|
import com.arkivanov.decompose.instancekeeper.getOrCreate
|
||||||
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
|
|
||||||
|
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =
|
||||||
|
getOrCreate(key) { StoreHolder(factory()) }
|
||||||
|
.store
|
||||||
|
|
||||||
|
inline fun <reified T :
|
||||||
|
Store<*, *, *>> InstanceKeeper.getStore(noinline factory: () -> T): T =
|
||||||
|
getStore(T::class, factory)
|
||||||
|
|
||||||
|
private class StoreHolder<T : Store<*, *, *>>(
|
||||||
|
val store: T
|
||||||
|
) : InstanceKeeper.Instance {
|
||||||
|
override fun onDestroy() {
|
||||||
|
store.dispose()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* * 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.preference.store
|
||||||
|
|
||||||
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
|
import com.shabinder.common.preference.SpotiFlyerPreference
|
||||||
|
|
||||||
|
internal interface SpotiFlyerPreferenceStore : Store<SpotiFlyerPreferenceStore.Intent, SpotiFlyerPreference.State, Nothing> {
|
||||||
|
sealed class Intent {
|
||||||
|
data class OpenPlatform(val platformID: String, val platformLink: String) : Intent()
|
||||||
|
data class ToggleAnalytics(val enabled: Boolean) : Intent()
|
||||||
|
object GiveDonation : Intent()
|
||||||
|
object ShareApp : Intent()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* * 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.preference.store
|
||||||
|
|
||||||
|
import com.arkivanov.mvikotlin.core.store.Reducer
|
||||||
|
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||||
|
import com.arkivanov.mvikotlin.core.store.Store
|
||||||
|
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||||
|
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
|
import com.shabinder.common.models.methods
|
||||||
|
import com.shabinder.common.preference.SpotiFlyerPreference.State
|
||||||
|
import com.shabinder.common.preference.store.SpotiFlyerPreferenceStore.Intent
|
||||||
|
|
||||||
|
internal class SpotiFlyerPreferenceStoreProvider(
|
||||||
|
private val storeFactory: StoreFactory,
|
||||||
|
private val preferenceManager: PreferenceManager
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun provide(): SpotiFlyerPreferenceStore =
|
||||||
|
object :
|
||||||
|
SpotiFlyerPreferenceStore,
|
||||||
|
Store<Intent, State, Nothing> by storeFactory.create(
|
||||||
|
name = "SpotiFlyerPreferenceStore",
|
||||||
|
initialState = State(),
|
||||||
|
bootstrapper = SimpleBootstrapper(Unit),
|
||||||
|
executorFactory = ::ExecutorImpl,
|
||||||
|
reducer = ReducerImpl
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private sealed class Result {
|
||||||
|
data class ToggleAnalytics(val isEnabled: Boolean) : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
||||||
|
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
||||||
|
dispatch(Result.ToggleAnalytics(preferenceManager.isAnalyticsEnabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun executeIntent(intent: Intent, getState: () -> State) {
|
||||||
|
when (intent) {
|
||||||
|
is Intent.OpenPlatform -> methods.value.openPlatform(intent.platformID, intent.platformLink)
|
||||||
|
is Intent.GiveDonation -> methods.value.giveDonation()
|
||||||
|
is Intent.ShareApp -> methods.value.shareApp()
|
||||||
|
is Intent.ToggleAnalytics -> {
|
||||||
|
dispatch(Result.ToggleAnalytics(intent.enabled))
|
||||||
|
preferenceManager.toggleAnalytics(intent.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ReducerImpl : Reducer<State, Result> {
|
||||||
|
override fun State.reduce(result: Result): State =
|
||||||
|
when (result) {
|
||||||
|
is Result.ToggleAnalytics -> copy(isAnalyticsEnabled = result.isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,6 +22,7 @@ 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.di.Dir
|
||||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.list.SpotiFlyerList
|
import com.shabinder.common.list.SpotiFlyerList
|
||||||
import com.shabinder.common.main.SpotiFlyerMain
|
import com.shabinder.common.main.SpotiFlyerMain
|
||||||
import com.shabinder.common.models.Actions
|
import com.shabinder.common.models.Actions
|
||||||
@ -49,9 +50,10 @@ interface SpotiFlyerRoot {
|
|||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
val storeFactory: StoreFactory
|
val storeFactory: StoreFactory
|
||||||
val database: Database?
|
val database: Database?
|
||||||
val fetchPlatformQueryResult: FetchPlatformQueryResult
|
val fetchQuery: FetchPlatformQueryResult
|
||||||
val directories: Dir
|
val dir: Dir
|
||||||
val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
val preferenceManager: PreferenceManager
|
||||||
|
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||||
val actions: Actions
|
val actions: Actions
|
||||||
val analytics: Analytics
|
val analytics: Analytics
|
||||||
}
|
}
|
||||||
|
@ -27,13 +27,10 @@ import com.arkivanov.decompose.router
|
|||||||
import com.arkivanov.decompose.statekeeper.Parcelable
|
import com.arkivanov.decompose.statekeeper.Parcelable
|
||||||
import com.arkivanov.decompose.statekeeper.Parcelize
|
import com.arkivanov.decompose.statekeeper.Parcelize
|
||||||
import com.arkivanov.decompose.value.Value
|
import com.arkivanov.decompose.value.Value
|
||||||
import com.shabinder.common.di.Dir
|
import com.shabinder.common.di.dispatcherIO
|
||||||
import com.shabinder.common.di.currentPlatform
|
|
||||||
import com.shabinder.common.di.providers.SpotifyProvider
|
|
||||||
import com.shabinder.common.list.SpotiFlyerList
|
import com.shabinder.common.list.SpotiFlyerList
|
||||||
import com.shabinder.common.main.SpotiFlyerMain
|
import com.shabinder.common.main.SpotiFlyerMain
|
||||||
import com.shabinder.common.models.Actions
|
import com.shabinder.common.models.Actions
|
||||||
import com.shabinder.common.models.AllPlatforms
|
|
||||||
import com.shabinder.common.models.Consumer
|
import com.shabinder.common.models.Consumer
|
||||||
import com.shabinder.common.models.methods
|
import com.shabinder.common.models.methods
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot
|
import com.shabinder.common.root.SpotiFlyerRoot
|
||||||
@ -41,7 +38,7 @@ import com.shabinder.common.root.SpotiFlyerRoot.Analytics
|
|||||||
import com.shabinder.common.root.SpotiFlyerRoot.Child
|
import com.shabinder.common.root.SpotiFlyerRoot.Child
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot.Dependencies
|
import com.shabinder.common.root.SpotiFlyerRoot.Dependencies
|
||||||
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -79,11 +76,8 @@ internal class SpotiFlyerRootImpl(
|
|||||||
) {
|
) {
|
||||||
instanceKeeper.ensureNeverFrozen()
|
instanceKeeper.ensureNeverFrozen()
|
||||||
methods.value = dependencies.actions.freeze()
|
methods.value = dependencies.actions.freeze()
|
||||||
/*Authenticate Spotify Client*/
|
/*Init App Launch & Authenticate Spotify Client*/
|
||||||
authenticateSpotify(
|
initAppLaunchAndAuthenticateSpotify(dependencies.fetchQuery::authenticateSpotifyClient)
|
||||||
dependencies.fetchPlatformQueryResult.spotifyProvider,
|
|
||||||
currentPlatform is AllPlatforms.Js
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val router =
|
private val router =
|
||||||
@ -134,11 +128,12 @@ internal class SpotiFlyerRootImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun authenticateSpotify(spotifyProvider: SpotifyProvider, override: Boolean) {
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
GlobalScope.launch(Dispatchers.Default) {
|
private fun initAppLaunchAndAuthenticateSpotify(authenticator: suspend () -> Unit) {
|
||||||
|
GlobalScope.launch(dispatcherIO) {
|
||||||
analytics.appLaunchEvent()
|
analytics.appLaunchEvent()
|
||||||
/*Authenticate Spotify Client*/
|
/*Authenticate Spotify Client*/
|
||||||
spotifyProvider.authenticateSpotifyClient(override)
|
authenticator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,10 +151,7 @@ private fun spotiFlyerMain(componentContext: ComponentContext, output: Consumer<
|
|||||||
componentContext = componentContext,
|
componentContext = componentContext,
|
||||||
dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies {
|
dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies {
|
||||||
override val mainOutput: Consumer<SpotiFlyerMain.Output> = output
|
override val mainOutput: Consumer<SpotiFlyerMain.Output> = output
|
||||||
override val dir: Dir = directories
|
override val mainAnalytics = object : SpotiFlyerMain.Analytics , Analytics by analytics {}
|
||||||
override val mainAnalytics = object : SpotiFlyerMain.Analytics {
|
|
||||||
override fun donationDialogVisit() = analytics.donationDialogVisit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -167,11 +159,8 @@ private fun spotiFlyerList(componentContext: ComponentContext, link: String, out
|
|||||||
SpotiFlyerList(
|
SpotiFlyerList(
|
||||||
componentContext = componentContext,
|
componentContext = componentContext,
|
||||||
dependencies = object : SpotiFlyerList.Dependencies, Dependencies by dependencies {
|
dependencies = object : SpotiFlyerList.Dependencies, Dependencies by dependencies {
|
||||||
override val fetchQuery = fetchPlatformQueryResult
|
|
||||||
override val dir: Dir = directories
|
|
||||||
override val link: String = link
|
override val link: String = link
|
||||||
override val listOutput: Consumer<SpotiFlyerList.Output> = output
|
override val listOutput: Consumer<SpotiFlyerList.Output> = output
|
||||||
override val downloadProgressFlow = downloadProgressReport
|
override val listAnalytics = object : SpotiFlyerList.Analytics, Analytics by analytics {}
|
||||||
override val listAnalytics = object : SpotiFlyerList.Analytics {}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
59
console-app/build.gradle.kts
Normal file
59
console-app/build.gradle.kts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm")// version "1.4.32"
|
||||||
|
kotlin("plugin.serialization")
|
||||||
|
id("ktlint-setup")
|
||||||
|
id("com.jakewharton.mosaic")
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.shabinder"
|
||||||
|
version = Versions.versionCode
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("MainKt")
|
||||||
|
applicationName = "spotiflyer-console-app"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(Koin.core)
|
||||||
|
implementation(project(":common:database"))
|
||||||
|
implementation(project(":common:data-models"))
|
||||||
|
implementation(project(":common:dependency-injection"))
|
||||||
|
implementation(project(":common:root"))
|
||||||
|
implementation(project(":common:main"))
|
||||||
|
implementation(project(":common:list"))
|
||||||
|
implementation(project(":common:list"))
|
||||||
|
|
||||||
|
|
||||||
|
// Decompose
|
||||||
|
implementation(Decompose.decompose)
|
||||||
|
implementation(Decompose.extensionsCompose)
|
||||||
|
|
||||||
|
// MVI
|
||||||
|
implementation(MVIKotlin.mvikotlin)
|
||||||
|
implementation(MVIKotlin.mvikotlinMain)
|
||||||
|
|
||||||
|
// Koin
|
||||||
|
implementation(Koin.core)
|
||||||
|
|
||||||
|
// Matomo
|
||||||
|
implementation("org.piwik.java.tracking:matomo-java-tracker:1.6")
|
||||||
|
|
||||||
|
implementation(Ktor.slf4j)
|
||||||
|
implementation(Ktor.clientCore)
|
||||||
|
implementation(Ktor.clientJson)
|
||||||
|
implementation(Ktor.clientApache)
|
||||||
|
implementation(Ktor.clientLogging)
|
||||||
|
implementation(Ktor.clientSerialization)
|
||||||
|
implementation(Serialization.json)
|
||||||
|
// testDeps
|
||||||
|
testImplementation(kotlin("test-junit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnit()
|
||||||
|
}
|
29
console-app/src/main/java/common/Common.kt
Normal file
29
console-app/src/main/java/common/Common.kt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
@file:Suppress("FunctionName")
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.features.HttpTimeout
|
||||||
|
import io.ktor.client.features.json.JsonFeature
|
||||||
|
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
||||||
|
import io.ktor.client.features.logging.DEFAULT
|
||||||
|
import io.ktor.client.features.logging.LogLevel
|
||||||
|
import io.ktor.client.features.logging.Logger
|
||||||
|
import io.ktor.client.features.logging.Logging
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
internal val client = HttpClient {
|
||||||
|
install(HttpTimeout)
|
||||||
|
install(JsonFeature) {
|
||||||
|
serializer = KotlinxSerializer(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
install(Logging) {
|
||||||
|
logger = Logger.DEFAULT
|
||||||
|
level = LogLevel.INFO
|
||||||
|
}
|
||||||
|
}
|
29
console-app/src/main/java/common/Parameters.kt
Normal file
29
console-app/src/main/java/common/Parameters.kt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import utils.byOptionalProperty
|
||||||
|
import utils.byProperty
|
||||||
|
|
||||||
|
internal data class Parameters(
|
||||||
|
val githubToken: String,
|
||||||
|
val ownerName: String,
|
||||||
|
val repoName: String,
|
||||||
|
val branchName: String,
|
||||||
|
val filePath: String,
|
||||||
|
val imageDescription: String,
|
||||||
|
val commitMessage: String,
|
||||||
|
val tagName: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun initParameters() = Parameters(
|
||||||
|
githubToken = "GH_TOKEN".byProperty,
|
||||||
|
ownerName = "OWNER_NAME".byProperty,
|
||||||
|
repoName = "REPO_NAME".byProperty,
|
||||||
|
branchName = "BRANCH_NAME".byOptionalProperty ?: "main",
|
||||||
|
filePath = "FILE_PATH".byOptionalProperty ?: "README.md",
|
||||||
|
imageDescription = "IMAGE_DESCRIPTION".byOptionalProperty ?: "IMAGE",
|
||||||
|
commitMessage = "COMMIT_MESSAGE".byOptionalProperty ?: "HTML-TO-IMAGE Update",
|
||||||
|
tagName = "TAG_NAME".byOptionalProperty ?: "HTI"
|
||||||
|
// hctiKey = "HCTI_KEY".analytics_html_img.getByProperty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
20
console-app/src/main/java/main.kt
Normal file
20
console-app/src/main/java/main.kt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.jakewharton.mosaic.Text
|
||||||
|
import com.jakewharton.mosaic.runMosaic
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
fun main(/*args: Array<String>*/) = runMosaic {
|
||||||
|
// TODO https://github.com/JakeWharton/mosaic/issues/3
|
||||||
|
var count by mutableStateOf(0)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Text("The count is: $count")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in 1..20) {
|
||||||
|
delay(250)
|
||||||
|
count = i
|
||||||
|
}
|
||||||
|
}
|
17
console-app/src/main/java/utils/Exceptions.kt
Normal file
17
console-app/src/main/java/utils/Exceptions.kt
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
@file:Suppress("ClassName")
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
data class ENV_KEY_MISSING(
|
||||||
|
val keyName: String,
|
||||||
|
override val message: String? = "$keyName was not found, please check your ENV variables"
|
||||||
|
) : Exception(message)
|
||||||
|
|
||||||
|
data class HCTI_URL_RESPONSE_ERROR(
|
||||||
|
val response: String,
|
||||||
|
override val message: String? = "Server Error, We Recieved this Resp: $response"
|
||||||
|
) : Exception(message)
|
||||||
|
|
||||||
|
data class RETRY_LIMIT_EXHAUSTED(
|
||||||
|
override val message: String? = "RETRY LIMIT EXHAUSTED!"
|
||||||
|
) : Exception(message)
|
9
console-app/src/main/java/utils/Ext.kt
Normal file
9
console-app/src/main/java/utils/Ext.kt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
val String.byProperty: String get() = System.getenv(this)
|
||||||
|
?: throw (ENV_KEY_MISSING(this))
|
||||||
|
|
||||||
|
val String.byOptionalProperty: String? get() = System.getenv(this)
|
||||||
|
|
||||||
|
fun debug(message: String) = println("\n::debug::$message")
|
||||||
|
fun debug(tag: String, message: String) = println("\n::debug::$tag:\n$message")
|
6
console-app/src/main/java/utils/TestClass.kt
Normal file
6
console-app/src/main/java/utils/TestClass.kt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
// Test Class- at development Time
|
||||||
|
fun main(): Unit = runBlocking {}
|
@ -38,8 +38,8 @@ kotlin {
|
|||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(project(":common:database"))
|
implementation(project(":common:database"))
|
||||||
implementation(project(":common:dependency-injection"))
|
implementation(project(":common:dependency-injection"))
|
||||||
implementation(project(":common:compose"))
|
|
||||||
implementation(project(":common:data-models"))
|
implementation(project(":common:data-models"))
|
||||||
|
implementation(project(":common:compose"))
|
||||||
implementation(project(":common:root"))
|
implementation(project(":common:root"))
|
||||||
|
|
||||||
// Decompose
|
// Decompose
|
||||||
|
@ -27,12 +27,21 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponen
|
|||||||
import com.arkivanov.mvikotlin.core.lifecycle.LifecycleRegistry
|
import com.arkivanov.mvikotlin.core.lifecycle.LifecycleRegistry
|
||||||
import com.arkivanov.mvikotlin.core.lifecycle.resume
|
import com.arkivanov.mvikotlin.core.lifecycle.resume
|
||||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||||
import com.shabinder.common.di.*
|
import com.shabinder.common.di.Dir
|
||||||
|
import com.shabinder.common.di.DownloadProgressFlow
|
||||||
|
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||||
|
import com.shabinder.common.di.initKoin
|
||||||
|
import com.shabinder.common.di.isInternetAccessible
|
||||||
|
import com.shabinder.common.di.preference.PreferenceManager
|
||||||
import com.shabinder.common.models.Actions
|
import com.shabinder.common.models.Actions
|
||||||
import com.shabinder.common.models.PlatformActions
|
import com.shabinder.common.models.PlatformActions
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.common.root.SpotiFlyerRoot
|
import com.shabinder.common.root.SpotiFlyerRoot
|
||||||
import com.shabinder.common.uikit.*
|
import com.shabinder.common.uikit.SpotiFlyerColors
|
||||||
|
import com.shabinder.common.uikit.SpotiFlyerRootContent
|
||||||
|
import com.shabinder.common.uikit.SpotiFlyerShapes
|
||||||
|
import com.shabinder.common.uikit.SpotiFlyerTypography
|
||||||
|
import com.shabinder.common.uikit.colorOffWhite
|
||||||
import com.shabinder.database.Database
|
import com.shabinder.database.Database
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.piwik.java.tracking.PiwikTracker
|
import org.piwik.java.tracking.PiwikTracker
|
||||||
@ -79,10 +88,11 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
|
|||||||
componentContext = componentContext,
|
componentContext = componentContext,
|
||||||
dependencies = object : SpotiFlyerRoot.Dependencies {
|
dependencies = object : SpotiFlyerRoot.Dependencies {
|
||||||
override val storeFactory = DefaultStoreFactory
|
override val storeFactory = DefaultStoreFactory
|
||||||
override val fetchPlatformQueryResult: FetchPlatformQueryResult = koin.get()
|
override val fetchQuery: FetchPlatformQueryResult = koin.get()
|
||||||
override val directories: Dir = koin.get()
|
override val dir: Dir = koin.get()
|
||||||
override val database: Database? = directories.db
|
override val database: Database? = dir.db
|
||||||
override val downloadProgressReport = DownloadProgressFlow
|
override val preferenceManager: PreferenceManager = koin.get()
|
||||||
|
override val downloadProgressFlow = DownloadProgressFlow
|
||||||
override val actions: Actions = object: Actions {
|
override val actions: Actions = object: Actions {
|
||||||
override val platformActions = object : PlatformActions {}
|
override val platformActions = object : PlatformActions {}
|
||||||
|
|
||||||
@ -100,7 +110,7 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
|
|||||||
APPROVE_OPTION -> {
|
APPROVE_OPTION -> {
|
||||||
val directory = fileChooser.selectedFile
|
val directory = fileChooser.selectedFile
|
||||||
if(directory.canWrite()){
|
if(directory.canWrite()){
|
||||||
directories.setDownloadDirectory(directory.absolutePath)
|
preferenceManager.setDownloadDirectory(directory.absolutePath)
|
||||||
showPopUpMessage("Set New Download Directory:\n${directory.absolutePath}")
|
showPopUpMessage("Set New Download Directory:\n${directory.absolutePath}")
|
||||||
} else {
|
} else {
|
||||||
showPopUpMessage("Cant Write to Selected Directory!")
|
showPopUpMessage("Cant Write to Selected Directory!")
|
||||||
@ -137,10 +147,10 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
|
|||||||
}
|
}
|
||||||
override val analytics = object: SpotiFlyerRoot.Analytics {
|
override val analytics = object: SpotiFlyerRoot.Analytics {
|
||||||
override fun appLaunchEvent() {
|
override fun appLaunchEvent() {
|
||||||
if(directories.isFirstLaunch) {
|
if(preferenceManager.isFirstLaunch) {
|
||||||
// Enable Analytics on First Launch
|
// Enable Analytics on First Launch
|
||||||
directories.toggleAnalytics(true)
|
preferenceManager.toggleAnalytics(true)
|
||||||
directories.firstLaunchDone()
|
preferenceManager.firstLaunchDone()
|
||||||
}
|
}
|
||||||
tracker.trackAsync {
|
tracker.trackAsync {
|
||||||
eventName = "App Launch"
|
eventName = "App Launch"
|
||||||
|
@ -22,10 +22,20 @@ include(
|
|||||||
":common:root",
|
":common:root",
|
||||||
":common:main",
|
":common:main",
|
||||||
":common:list",
|
":common:list",
|
||||||
|
":common:preference",
|
||||||
":common:data-models",
|
":common:data-models",
|
||||||
":common:dependency-injection",
|
":common:dependency-injection",
|
||||||
":android",
|
":android",
|
||||||
":desktop",
|
":desktop",
|
||||||
":web-app",
|
":web-app",
|
||||||
|
":console-app",
|
||||||
":maintenance-tasks"
|
":maintenance-tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
includeBuild("mosaic/mosaic") {
|
||||||
|
dependencySubstitution {
|
||||||
|
substitute(module("com.jakewharton.mosaic:mosaic-gradle-plugin")).with(project(":mosaic-gradle-plugin"))
|
||||||
|
substitute(module("com.jakewharton.mosaic:mosaic-runtime")).with(project(":mosaic-runtime"))
|
||||||
|
substitute(module("com.jakewharton.mosaic:compose-compiler")).with(project(":compose:compiler"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
75
translations/Strings_en.properties
Normal file
75
translations/Strings_en.properties
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
title = SpotiFlyer
|
||||||
|
about = About
|
||||||
|
history = History
|
||||||
|
donate = Donate
|
||||||
|
preferences = Preferences
|
||||||
|
search = Search
|
||||||
|
supportedPlatforms = Supported Platforms
|
||||||
|
supportDevelopment = Support Development
|
||||||
|
openProjectRepo = Open Project Repo
|
||||||
|
starOrForkProject = Star / Fork the project on Github.
|
||||||
|
help = Help
|
||||||
|
translate = Translate
|
||||||
|
helpTranslateDescription = Help us translate this app in your local language.
|
||||||
|
supportDeveloper = Support Developer
|
||||||
|
donateDescription = If you think I deserve to get paid for my work, you can support me here.
|
||||||
|
share = Share
|
||||||
|
shareDescription = Share this app with your friends and family.
|
||||||
|
status = Status
|
||||||
|
analytics = Analytics
|
||||||
|
analyticsDescription = Your Data is Anonymized and never shared with 3rd party service.
|
||||||
|
noHistoryAvailable = No History Available
|
||||||
|
cleaningAndExiting = Cleaning And Exiting
|
||||||
|
total = Total
|
||||||
|
completed = Completed
|
||||||
|
failed = Failed
|
||||||
|
exit = Exit
|
||||||
|
downloading = Downloading
|
||||||
|
processing = Processing
|
||||||
|
queued = Queued
|
||||||
|
|
||||||
|
acraNotificationTitle = OOPS, SpotiFlyer Crashed
|
||||||
|
acraNotificationText = Please Send Crash Report to App Developers, So this unfortunate event may not happen again.
|
||||||
|
|
||||||
|
albumArt = Album Art
|
||||||
|
tracks = Tracks
|
||||||
|
coverImage = Cover Image
|
||||||
|
reSearch = Re-Search
|
||||||
|
loading = Loading
|
||||||
|
downloadAll = Download All
|
||||||
|
button = Button
|
||||||
|
errorOccurred = An Error Occurred, Check your Link / Connection
|
||||||
|
downloadDone = Download Done
|
||||||
|
downloadError = Error! Cant Download this track
|
||||||
|
downloadStart = Start Download
|
||||||
|
supportUs = We Need Your Support!
|
||||||
|
donation = Donation
|
||||||
|
worldWideDonations = World Wide Donations
|
||||||
|
indianDonations = Indian Donations Only
|
||||||
|
dismiss = Dismiss
|
||||||
|
remindLater = Remind Later
|
||||||
|
|
||||||
|
# Exceptions
|
||||||
|
mp3ConverterBusy = MP3 Converter unreachable, probably BUSY !
|
||||||
|
unknownError = Unknown Error
|
||||||
|
noMatchFound = NO Match Found!
|
||||||
|
noLinkFound = No Downloadable link found
|
||||||
|
linkNotValid = Entered Link is NOT Valid!
|
||||||
|
checkInternetConnection = Check Your Internet Connection
|
||||||
|
featureUnImplemented = Feature not yet implemented.
|
||||||
|
|
||||||
|
minute = min
|
||||||
|
second = sec
|
||||||
|
|
||||||
|
spotiflyerLogo = SpotiFlyer Logo
|
||||||
|
backButton = Back Button
|
||||||
|
infoTab = Info Tab
|
||||||
|
historyTab = History Tab
|
||||||
|
linkTextBox = Link Text Box
|
||||||
|
pasteLinkHere = Paste Link Here...
|
||||||
|
enterALink = Enter A Link!
|
||||||
|
madeWith = Made with
|
||||||
|
love = Love
|
||||||
|
inIndia = in India
|
||||||
|
open = Open
|
||||||
|
byDeveloperName = by: Shabinder Singh
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user