mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-11-22 01:04:31 +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"]
|
||||
path = 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)
|
||||
|
||||
// Extras
|
||||
Extras.Android.apply {
|
||||
with(Extras.Android) {
|
||||
implementation(Acra.notification)
|
||||
implementation(Acra.http)
|
||||
implementation(appUpdator)
|
||||
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("dev.icerock.moko:parcelize:0.7.0")
|
||||
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
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
@ -72,6 +72,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service android:name="com.shabinder.common.di.worker.ForegroundService"/>
|
||||
<service android:name=".service.ForegroundService"/>
|
||||
</application>
|
||||
</manifest>
|
@ -19,6 +19,7 @@ package com.shabinder.spotiflyer
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.shabinder.common.di.initKoin
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.spotiflyer.di.appModule
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.config.notification
|
||||
@ -77,10 +78,10 @@ class App: Application(), KoinComponent {
|
||||
* Obeying `F-Droid Inclusion Privacy Rules`
|
||||
* */
|
||||
notification {
|
||||
title = getString(R.string.acra_notification_title)
|
||||
text = getString(R.string.acra_notification_text)
|
||||
channelName = getString(R.string.acra_notification_channel)
|
||||
channelDescription = getString(R.string.acra_notification_channel_desc)
|
||||
title = Strings.acraNotificationTitle()
|
||||
text = Strings.acraNotificationText()
|
||||
channelName = "SpotiFlyer_Crashlytics"
|
||||
channelDescription = "Notification Channel to send Spotiflyer Crashes."
|
||||
sendOnClick = true
|
||||
}
|
||||
// Send Crash Report to self hosted Acrarium (FOSS)
|
||||
|
@ -17,15 +17,16 @@
|
||||
package com.shabinder.spotiflyer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
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.statusBarsPadding
|
||||
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.DownloadStatus
|
||||
import com.shabinder.common.models.PlatformActions
|
||||
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.methods
|
||||
import com.shabinder.common.root.SpotiFlyerRoot
|
||||
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
|
||||
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
||||
import com.shabinder.common.uikit.*
|
||||
import com.shabinder.spotiflyer.service.ForegroundService
|
||||
import com.shabinder.spotiflyer.ui.AnalyticsDialog
|
||||
import com.shabinder.spotiflyer.ui.NetworkDialog
|
||||
import com.shabinder.spotiflyer.ui.PermissionDialog
|
||||
@ -78,14 +79,20 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private val fetcher: FetchPlatformQueryResult by inject()
|
||||
private val dir: Dir by inject()
|
||||
private val preferenceManager: PreferenceManager by inject()
|
||||
private lateinit var root: SpotiFlyerRoot
|
||||
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks
|
||||
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
|
||||
private var permissionGranted = mutableStateOf(true)
|
||||
private lateinit var updateUIReceiver: BroadcastReceiver
|
||||
private lateinit var queryReceiver: BroadcastReceiver
|
||||
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
|
||||
private val tracker get() = (application as App).tracker
|
||||
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
|
||||
|
||||
// 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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@ -124,18 +131,18 @@ class MainActivity : ComponentActivity() {
|
||||
AnalyticsDialog(
|
||||
askForAnalyticsPermission,
|
||||
enableAnalytics = {
|
||||
dir.toggleAnalytics(true)
|
||||
dir.firstLaunchDone()
|
||||
preferenceManager.toggleAnalytics(true)
|
||||
preferenceManager.firstLaunchDone()
|
||||
},
|
||||
dismissDialog = {
|
||||
askForAnalyticsPermission = false
|
||||
dir.firstLaunchDone()
|
||||
preferenceManager.firstLaunchDone()
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(view) {
|
||||
permissionGranted.value = checkPermissions()
|
||||
if(dir.isFirstLaunch) {
|
||||
if(preferenceManager.isFirstLaunch) {
|
||||
delay(2500)
|
||||
// Ask For Analytics Permission on first Dialog
|
||||
askForAnalyticsPermission = true
|
||||
@ -149,63 +156,79 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun initialise() {
|
||||
val isGithubRelease = checkAppSignature(this).also {
|
||||
Log.i("SpotiFlyer Github Rel.:",it.toString())
|
||||
}
|
||||
val isGithubRelease = checkAppSignature(this)
|
||||
/*
|
||||
* Only Send an `Update Notification` on Github Release Builds
|
||||
* and Track Downloads for all other releases like F-Droid,
|
||||
* for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer
|
||||
* */
|
||||
if(isGithubRelease) { checkIfLatestVersion() }
|
||||
if(dir.isAnalyticsEnabled && !isGithubRelease) {
|
||||
if(preferenceManager.isAnalyticsEnabled && !isGithubRelease) {
|
||||
// Download/App Install Event for F-Droid builds
|
||||
TrackHelper.track().download().with(tracker)
|
||||
}
|
||||
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
|
||||
private fun isInternetAvailableState(): State<Boolean?> {
|
||||
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) {
|
||||
android.widget.Toast.makeText(
|
||||
applicationContext,
|
||||
@ -225,9 +248,10 @@ class MainActivity : ComponentActivity() {
|
||||
dependencies = object : SpotiFlyerRoot.Dependencies{
|
||||
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory)
|
||||
override val database = this@MainActivity.dir.db
|
||||
override val fetchPlatformQueryResult = this@MainActivity.fetcher
|
||||
override val directories: Dir = this@MainActivity.dir
|
||||
override val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
|
||||
override val fetchQuery = this@MainActivity.fetcher
|
||||
override val dir: Dir = this@MainActivity.dir
|
||||
override val preferenceManager = this@MainActivity.preferenceManager
|
||||
override val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
|
||||
override val actions = object: Actions {
|
||||
|
||||
override val platformActions = object : PlatformActions {
|
||||
@ -243,12 +267,9 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
override fun sendTracksToService(array: ArrayList<TrackDetails>) {
|
||||
for (list in array.chunked(50)) {
|
||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||
serviceIntent.putParcelableArrayListExtra("object", list as ArrayList)
|
||||
ContextCompat.startForegroundService(this@MainActivity, serviceIntent)
|
||||
}
|
||||
override fun sendTracksToService(array: List<TrackDetails>) {
|
||||
if (foregroundService == null) initForegroundService()
|
||||
foregroundService?.downloadAllTracks(array)
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,12 +277,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun setDownloadDirectoryAction() = setUpOnPrefClickListener()
|
||||
|
||||
override fun queryActiveTracks() {
|
||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java).apply {
|
||||
action = "query"
|
||||
}
|
||||
ContextCompat.startForegroundService(this@MainActivity, serviceIntent)
|
||||
}
|
||||
override fun queryActiveTracks() = this@MainActivity.queryActiveTracks()
|
||||
|
||||
override fun giveDonation() {
|
||||
openPlatform("",platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button")
|
||||
@ -303,7 +319,7 @@ class MainActivity : ComponentActivity() {
|
||||
* */
|
||||
override val analytics = object: Analytics {
|
||||
override fun appLaunchEvent() {
|
||||
if(dir.isAnalyticsEnabled){
|
||||
if(preferenceManager.isAnalyticsEnabled){
|
||||
TrackHelper.track()
|
||||
.event("events","App_Launch")
|
||||
.name("App Launch").with(tracker)
|
||||
@ -311,7 +327,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun homeScreenVisit() {
|
||||
if(dir.isAnalyticsEnabled){
|
||||
if(preferenceManager.isAnalyticsEnabled){
|
||||
// HomeScreen Visit Event
|
||||
TrackHelper.track().screen("/main_activity/home_screen")
|
||||
.title("HomeScreen").with(tracker)
|
||||
@ -319,7 +335,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun listScreenVisit() {
|
||||
if(dir.isAnalyticsEnabled){
|
||||
if(preferenceManager.isAnalyticsEnabled){
|
||||
// ListScreen Visit Event
|
||||
TrackHelper.track().screen("/main_activity/list_screen")
|
||||
.title("ListScreen").with(tracker)
|
||||
@ -327,7 +343,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun donationDialogVisit() {
|
||||
if (dir.isAnalyticsEnabled) {
|
||||
if (preferenceManager.isAnalyticsEnabled) {
|
||||
// Donation Dialog Open Event
|
||||
TrackHelper.track().screen("/main_activity/donation_dialog")
|
||||
.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")
|
||||
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?) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntentFromExternalActivity(intent)
|
||||
@ -451,6 +445,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unbindService()
|
||||
}
|
||||
|
||||
companion object {
|
||||
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
|
||||
|
||||
/**
|
||||
* Cleaning All Residual Files except Mp3 Files
|
||||
**/
|
||||
fun cleanFiles(dir: File, logger: Kermit) {
|
||||
fun cleanFiles(dir: File) {
|
||||
try {
|
||||
logger.d("File Cleaning") { "Starting Cleaning in ${dir.path} " }
|
||||
Log.d("File Cleaning","Starting Cleaning in ${dir.path} ")
|
||||
val fList = dir.listFiles()
|
||||
fList?.let {
|
||||
for (file in fList) {
|
||||
if (file.isDirectory) {
|
||||
cleanFiles(file, logger)
|
||||
cleanFiles(file)
|
||||
} else if (file.isFile) {
|
||||
if (file.path.toString().substringAfterLast(".") != "mp3") {
|
||||
logger.d("Files Cleaning") { "Cleaning ${file.path}" }
|
||||
Log.d("Files Cleaning","Cleaning ${file.path}")
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
@ -24,3 +24,4 @@ fun cleanFiles(dir: File, logger: Kermit) {
|
||||
}
|
||||
} 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 {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
useIR = true
|
||||
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
|
||||
}
|
||||
}
|
||||
afterEvaluate {
|
||||
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 {
|
||||
implementation("com.android.tools.build:gradle:4.1.1")
|
||||
implementation("org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktLint}")
|
||||
implementation(JetBrains.Compose.gradlePlugin)
|
||||
implementation(JetBrains.Kotlin.gradlePlugin)
|
||||
implementation(JetBrains.Kotlin.serialization)
|
||||
implementation(SqlDelight.gradlePlugin)
|
||||
implementation("org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktLint}")
|
||||
implementation("de.comahe.i18n4k:i18n4k-gradle-plugin:0.1.1")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
@ -49,7 +49,7 @@ object Versions {
|
||||
const val minSdkVersion = 21
|
||||
const val compileSdkVersion = 29
|
||||
const val targetSdkVersion = 29
|
||||
const val androidLifecycle = "2.3.0"
|
||||
const val androidxLifecycle = "2.3.1"
|
||||
}
|
||||
|
||||
object HostOS {
|
||||
@ -60,6 +60,10 @@ object HostOS {
|
||||
val isLinux = hostOs.startsWith("Linux",true)
|
||||
}
|
||||
|
||||
object MultiPlatformSettings {
|
||||
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7"
|
||||
}
|
||||
|
||||
object Koin {
|
||||
val core = "io.insert-koin:koin-core:${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}"
|
||||
}
|
||||
|
||||
object Internationalization {
|
||||
const val dep = "de.comahe.i18n4k:i18n4k-core:0.1.1"
|
||||
}
|
||||
|
||||
object Extras {
|
||||
const val youtubeDownloader = "io.github.shabinder:youtube-api-dl:1.2"
|
||||
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.FontWeight
|
||||
import com.shabinder.common.database.R
|
||||
import com.shabinder.common.translations.Strings
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
actual fun montserratFont() = FontFamily(
|
||||
@ -43,7 +44,7 @@ actual fun pristineFont() = FontFamily(
|
||||
actual fun DownloadImageTick() {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_tick),
|
||||
"Download Done"
|
||||
Strings.downloadDone()
|
||||
)
|
||||
}
|
||||
|
||||
@ -51,7 +52,7 @@ actual fun DownloadImageTick() {
|
||||
actual fun DownloadImageError() {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_error),
|
||||
"Error! Cant Download this track"
|
||||
Strings.downloadError()
|
||||
)
|
||||
}
|
||||
|
||||
@ -59,7 +60,7 @@ actual fun DownloadImageError() {
|
||||
actual fun DownloadImageArrow(modifier: Modifier) {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_arrow),
|
||||
"Start Download",
|
||||
Strings.downloadStart(),
|
||||
modifier
|
||||
)
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.common.translations.Strings
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
@ -44,7 +45,7 @@ actual fun DonationDialog(
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"We Need Your Support!",
|
||||
Strings.supportUs(),
|
||||
style = SpotiFlyerTypography.h5,
|
||||
textAlign = TextAlign.Center,
|
||||
color = colorAccent,
|
||||
@ -69,7 +70,7 @@ actual fun DonationDialog(
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Worldwide Donations",
|
||||
text = Strings.worldWideDonations(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -92,7 +93,7 @@ actual fun DonationDialog(
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "International Donations (Outside India).",
|
||||
text = Strings.worldWideDonations(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -115,7 +116,7 @@ actual fun DonationDialog(
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Indian Donations (UPI / PayTM / PhonePe / Cards).",
|
||||
text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).",
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -126,11 +127,11 @@ actual fun DonationDialog(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth()
|
||||
) {
|
||||
OutlinedButton(onClick = onSnooze) {
|
||||
Text("Dismiss.")
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text(Strings.dismiss())
|
||||
}
|
||||
TextButton(onClick = onDismiss, colors = ButtonDefaults.buttonColors()) {
|
||||
Text("Remind Later!")
|
||||
TextButton(onClick = onSnooze, colors = ButtonDefaults.buttonColors()) {
|
||||
Text(Strings.remindLater())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.Color
|
||||
val colorPrimary = Color(0xFFFC5C7D)
|
||||
val colorPrimaryDark = Color(0xFFCE1CFF)
|
||||
val colorAccent = Color(0xFF9AB3FF)
|
||||
val colorAccentVariant = Color(0xFF3457D5)
|
||||
val colorRedError = Color(0xFFFF9494)
|
||||
val colorSuccessGreen = Color(0xFF59C351)
|
||||
val darkBackgroundColor = Color(0xFF000000)
|
||||
|
@ -17,12 +17,29 @@
|
||||
package com.shabinder.common.uikit
|
||||
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
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.Modifier
|
||||
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.TrackDetails
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.common.uikit.dialogs.DonationDialogComponent
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@ -49,10 +68,11 @@ fun SpotiFlyerListContent(
|
||||
LaunchedEffect(model.errorOccurred) {
|
||||
/*Handle if Any Exception Occurred*/
|
||||
model.errorOccurred?.let {
|
||||
methods.value.showPopUpMessage(it.message ?: "An Error Occurred, Check your Link / Connection")
|
||||
methods.value.showPopUpMessage(it.message ?: Strings.errorOccurred())
|
||||
component.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
val result = model.queryResult
|
||||
if (result == null) {
|
||||
@ -60,7 +80,7 @@ fun SpotiFlyerListContent(
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier.padding(8.dp))
|
||||
Text("Loading..", style = appNameStyle, color = colorPrimary)
|
||||
Text("${Strings.loading()}...", style = appNameStyle, color = colorPrimary)
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -83,25 +103,19 @@ fun SpotiFlyerListContent(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// Donation Dialog Visibility
|
||||
var visibilty by remember { mutableStateOf(false) }
|
||||
DonationDialog(
|
||||
isVisible = visibilty,
|
||||
onDismiss = {
|
||||
visibilty = false
|
||||
},
|
||||
onSnooze = {
|
||||
visibilty = false
|
||||
component.snoozeDonationDialog()
|
||||
}
|
||||
)
|
||||
val (openDonationDialog,dismissDonationDialog,snoozeDonationDialog) = DonationDialogComponent {
|
||||
component.dismissDonationDialogSetOffset()
|
||||
}
|
||||
|
||||
DownloadAllButton(
|
||||
onClick = {
|
||||
component.onDownloadAllClicked(model.trackList)
|
||||
// Check If we are allowed to show donation Dialog
|
||||
if (model.askForDonation) {
|
||||
// Show Donation Dialog
|
||||
visibilty = true
|
||||
openDonationDialog()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 24.dp).align(Alignment.BottomCenter)
|
||||
@ -129,7 +143,7 @@ fun TrackCard(
|
||||
ImageLoad(
|
||||
track.albumArtURL,
|
||||
{ loadImage() },
|
||||
"Album Art",
|
||||
Strings.albumArt(),
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
.height(70.dp)
|
||||
@ -143,7 +157,7 @@ fun TrackCard(
|
||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
|
||||
) {
|
||||
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) {
|
||||
@ -189,7 +203,7 @@ fun CoverImage(
|
||||
ImageLoad(
|
||||
coverURL,
|
||||
{ loadImage(coverURL, true) },
|
||||
"Cover Image",
|
||||
Strings.coverImage(),
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.width(190.dp)
|
||||
@ -212,9 +226,9 @@ fun CoverImage(
|
||||
@Composable
|
||||
fun DownloadAllButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text("Download All") },
|
||||
text = { Text(Strings.downloadAll()) },
|
||||
onClick = onClick,
|
||||
icon = { Icon(DownloadAllImage(), "Download All Button", tint = Color(0xFF000000)) },
|
||||
icon = { Icon(DownloadAllImage(), Strings.downloadAll() + Strings.button(), tint = Color(0xFF000000)) },
|
||||
backgroundColor = colorAccent,
|
||||
modifier = modifier
|
||||
)
|
||||
|
@ -17,21 +17,54 @@
|
||||
package com.shabinder.common.uikit
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Text
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.material.TextFieldDefaults.textFieldColors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.rounded.CardGiftcard
|
||||
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.Modifier
|
||||
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.models.DownloadRecord
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.common.uikit.dialogs.DonationDialogComponent
|
||||
|
||||
@Composable
|
||||
fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
|
||||
val model by component.model.subscribeAsState()
|
||||
|
||||
val (openDonationDialog,_,_) = DonationDialogComponent {
|
||||
component.dismissDonationDialogOffset()
|
||||
}
|
||||
|
||||
Column {
|
||||
SearchPanel(
|
||||
model.link,
|
||||
@ -65,14 +104,17 @@ fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
|
||||
HomeTabBar(
|
||||
model.selectedCategory,
|
||||
HomeCategory.values(),
|
||||
component::selectCategory
|
||||
component::selectCategory,
|
||||
)
|
||||
|
||||
when (model.selectedCategory) {
|
||||
HomeCategory.About -> AboutColumn(
|
||||
analyticsEnabled = model.isAnalyticsEnabled,
|
||||
donationDialogOpenEvent = { component.analytics.donationDialogVisit() },
|
||||
toggleAnalytics = component::toggleAnalytics
|
||||
toggleAnalytics = component::toggleAnalytics,
|
||||
openDonationDialog = {
|
||||
component.analytics.donationDialogVisit()
|
||||
openDonationDialog()
|
||||
}
|
||||
)
|
||||
HomeCategory.History -> HistoryColumn(
|
||||
model.records.sortedByDescending { it.id },
|
||||
@ -98,6 +140,7 @@ fun HomeTabBar(
|
||||
}
|
||||
|
||||
TabRow(
|
||||
backgroundColor = transparent,
|
||||
selectedTabIndex = selectedIndex,
|
||||
indicator = indicator,
|
||||
modifier = modifier,
|
||||
@ -109,16 +152,16 @@ fun HomeTabBar(
|
||||
text = {
|
||||
Text(
|
||||
text = when (category) {
|
||||
HomeCategory.About -> "About"
|
||||
HomeCategory.History -> "History"
|
||||
HomeCategory.About -> Strings.about()
|
||||
HomeCategory.History -> Strings.history()
|
||||
},
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
when (category) {
|
||||
HomeCategory.About -> Icon(Icons.Outlined.Info, "Info Tab")
|
||||
HomeCategory.History -> Icon(Icons.Outlined.History, "History Tab")
|
||||
HomeCategory.About -> Icon(Icons.Outlined.Info, Strings.infoTab())
|
||||
HomeCategory.History -> Icon(Icons.Outlined.History, Strings.historyTab())
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -141,9 +184,9 @@ fun SearchPanel(
|
||||
value = link,
|
||||
onValueChange = updateLink,
|
||||
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,
|
||||
textStyle = TextStyle.Default.merge(TextStyle(fontSize = 18.sp, color = Color.White)),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
@ -170,7 +213,7 @@ fun SearchPanel(
|
||||
OutlinedButton(
|
||||
modifier = Modifier.padding(12.dp).wrapContentWidth(),
|
||||
onClick = {
|
||||
if (link.isBlank()) methods.value.showPopUpMessage("Enter A Link!")
|
||||
if (link.isBlank()) methods.value.showPopUpMessage(Strings.enterALink())
|
||||
else {
|
||||
// TODO if(!isOnline(ctx)) showPopUpMessage("Check Your Internet Connection") else
|
||||
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(
|
||||
modifier: Modifier = Modifier,
|
||||
analyticsEnabled:Boolean,
|
||||
donationDialogOpenEvent: () -> Unit,
|
||||
openDonationDialog: () -> Unit,
|
||||
toggleAnalytics: (enabled: Boolean) -> Unit
|
||||
) {
|
||||
|
||||
@ -209,7 +252,7 @@ fun AboutColumn(
|
||||
) {
|
||||
Column(modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = "Supported Platforms",
|
||||
text = Strings.supportedPlatforms(),
|
||||
style = SpotiFlyerTypography.body1,
|
||||
color = colorAccent
|
||||
)
|
||||
@ -217,7 +260,7 @@ fun AboutColumn(
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth()) {
|
||||
Icon(
|
||||
SpotifyLogo(),
|
||||
"Open Spotify",
|
||||
"${Strings.open()} Spotify",
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||
onClick = { methods.value.openPlatform("com.spotify.music", "http://open.spotify.com") }
|
||||
@ -226,7 +269,7 @@ fun AboutColumn(
|
||||
Spacer(modifier = modifier.padding(start = 16.dp))
|
||||
Icon(
|
||||
GaanaLogo(),
|
||||
"Open Gaana",
|
||||
"${Strings.open()} Gaana",
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||
onClick = { methods.value.openPlatform("com.gaana", "https://www.gaana.com") }
|
||||
@ -235,7 +278,7 @@ fun AboutColumn(
|
||||
Spacer(modifier = modifier.padding(start = 16.dp))
|
||||
Icon(
|
||||
SaavnLogo(),
|
||||
"Open Jio Saavn",
|
||||
"${Strings.open()} Jio Saavn",
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.clickable(
|
||||
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))
|
||||
Icon(
|
||||
YoutubeLogo(),
|
||||
"Open Youtube",
|
||||
"${Strings.open()} Youtube",
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||
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))
|
||||
Icon(
|
||||
YoutubeMusicLogo(),
|
||||
"Open Youtube Music",
|
||||
"${Strings.open()} Youtube Music",
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
|
||||
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)) {
|
||||
Text(
|
||||
text = "Support Development",
|
||||
text = Strings.supportDevelopment(),
|
||||
style = SpotiFlyerTypography.body1,
|
||||
color = colorAccent
|
||||
)
|
||||
@ -281,7 +324,7 @@ fun AboutColumn(
|
||||
)
|
||||
.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))
|
||||
Column {
|
||||
Text(
|
||||
@ -289,7 +332,7 @@ fun AboutColumn(
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Star / Fork the project on Github.",
|
||||
text = Strings.starOrForkProject(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -299,51 +342,34 @@ fun AboutColumn(
|
||||
.clickable(onClick = { methods.value.openPlatform("", "http://github.com/Shabinder/SpotiFlyer") }),
|
||||
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))
|
||||
Column {
|
||||
Text(
|
||||
text = "Translate",
|
||||
text = Strings.translate(),
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Help us translate this app in your local language.",
|
||||
text = Strings.helpTranslateDescription(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var isDonationDialogVisible by remember { mutableStateOf(false) }
|
||||
|
||||
DonationDialog(
|
||||
isDonationDialogVisible,
|
||||
onDismiss = {
|
||||
isDonationDialogVisible = false
|
||||
},
|
||||
onSnooze = {
|
||||
isDonationDialogVisible = false
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)
|
||||
.clickable(
|
||||
onClick = {
|
||||
isDonationDialogVisible = true
|
||||
donationDialogOpenEvent()
|
||||
}
|
||||
),
|
||||
.clickable(onClick = openDonationDialog),
|
||||
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))
|
||||
Column {
|
||||
Text(
|
||||
text = "Donate",
|
||||
text = Strings.donate(),
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
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.",
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
@ -358,15 +384,15 @@ fun AboutColumn(
|
||||
),
|
||||
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))
|
||||
Column {
|
||||
Text(
|
||||
text = "Share",
|
||||
text = Strings.share(),
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Share this app with your friends and family.",
|
||||
text = Strings.shareDescription(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -380,17 +406,17 @@ fun AboutColumn(
|
||||
),
|
||||
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))
|
||||
Column(
|
||||
Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Analytics",
|
||||
text = Strings.analytics(),
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Your Data is Anonymized and never shared with 3rd party service",
|
||||
text = Strings.analyticsDescription(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -421,10 +447,10 @@ fun HistoryColumn(
|
||||
if (it.isEmpty()) {
|
||||
Column(Modifier.padding(8.dp).fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Outlined.Info, "No History Available Yet", modifier = Modifier.size(80.dp),
|
||||
Icons.Outlined.Info, Strings.noHistoryAvailable(), modifier = Modifier.size(80.dp),
|
||||
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 {
|
||||
Box {
|
||||
@ -470,7 +496,7 @@ fun DownloadRecordItem(
|
||||
ImageLoad(
|
||||
item.coverUrl,
|
||||
{ loadImage(item.coverUrl) },
|
||||
"Album Art",
|
||||
Strings.albumArt(),
|
||||
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) {
|
||||
@ -481,12 +507,12 @@ fun DownloadRecordItem(
|
||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
|
||||
) {
|
||||
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(
|
||||
ShareImage(),
|
||||
"Research",
|
||||
Strings.reSearch(),
|
||||
modifier = Modifier.clickable(
|
||||
onClick = {
|
||||
// if(!isOnline(ctx)) showDialog("Check Your Internet Connection") else
|
||||
@ -504,7 +530,7 @@ fun HomeCategoryTabIndicator(
|
||||
) {
|
||||
Spacer(
|
||||
modifier.padding(horizontal = 24.dp)
|
||||
.height(4.dp)
|
||||
.height(3.dp)
|
||||
.background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100))
|
||||
)
|
||||
}
|
||||
|
@ -56,9 +56,10 @@ 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.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.Child
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.common.uikit.splash.Splash
|
||||
import com.shabinder.common.uikit.splash.SplashState
|
||||
import com.shabinder.common.uikit.utils.verticalGradientScrim
|
||||
@ -125,7 +126,7 @@ fun MainScreen(modifier: Modifier = Modifier, alpha: Float, topPadding: Dp = 0.d
|
||||
).then(modifier)
|
||||
) {
|
||||
|
||||
val activeComponent = component.routerState.asState()
|
||||
val activeComponent = component.routerState.subscribeAsState()
|
||||
val callBacks = component.callBacks
|
||||
AppBar(
|
||||
backgroundColor = appBarColor,
|
||||
@ -163,7 +164,7 @@ fun AppBar(
|
||||
AnimatedVisibility(isBackButtonVisible) {
|
||||
Icon(
|
||||
Icons.Rounded.ArrowBackIosNew,
|
||||
contentDescription = "Back Button",
|
||||
contentDescription = Strings.backButton(),
|
||||
modifier = Modifier.clickable { onBackPressed() },
|
||||
tint = Color.LightGray
|
||||
)
|
||||
@ -171,12 +172,12 @@ fun AppBar(
|
||||
}
|
||||
Image(
|
||||
SpotiFlyerLogo(),
|
||||
"SpotiFlyer Logo",
|
||||
Strings.spotiflyerLogo(),
|
||||
Modifier.size(32.dp),
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text = "SpotiFlyer",
|
||||
text = Strings.title(),
|
||||
style = appNameStyle
|
||||
)
|
||||
}
|
||||
@ -185,7 +186,7 @@ fun AppBar(
|
||||
IconButton(
|
||||
onClick = { setDownloadDirectory() }
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, "Preferences", tint = Color.Gray)
|
||||
Icon(Icons.Filled.Settings, Strings.preferences(), tint = Color.Gray)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
|
@ -1 +1,33 @@
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.shabinder.common.translations.Strings
|
||||
import com.shabinder.common.uikit.HeartIcon
|
||||
import com.shabinder.common.uikit.SpotiFlyerLogo
|
||||
import com.shabinder.common.uikit.SpotiFlyerTypography
|
||||
@ -55,7 +56,7 @@ fun Splash(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
|
||||
delay(SplashWaitTime)
|
||||
currentOnTimeout()
|
||||
}
|
||||
Image(SpotiFlyerLogo(), "SpotiFlyer Logo")
|
||||
Image(SpotiFlyerLogo(), Strings.spotiflyerLogo())
|
||||
MadeInIndia(Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
@ -73,21 +74,21 @@ fun MadeInIndia(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Made with ",
|
||||
text = "${Strings.madeWith()} ",
|
||||
color = colorPrimary,
|
||||
fontSize = 22.sp
|
||||
)
|
||||
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))
|
||||
Text(
|
||||
text = " in India",
|
||||
text = " ${Strings.inIndia()}",
|
||||
color = colorPrimary,
|
||||
fontSize = 22.sp
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"by: Shabinder Singh",
|
||||
Strings.byDeveloperName(),
|
||||
style = SpotiFlyerTypography.h6,
|
||||
color = colorAccent,
|
||||
fontSize = 14.sp
|
||||
|
@ -20,9 +20,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.v1.Dialog
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.common.translations.Strings
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@ -42,7 +42,7 @@ actual fun DonationDialog(
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Support Us",
|
||||
Strings.supportUs(),
|
||||
style = SpotiFlyerTypography.h5,
|
||||
textAlign = TextAlign.Center,
|
||||
color = colorAccent,
|
||||
@ -67,7 +67,7 @@ actual fun DonationDialog(
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "International Donations (Outside India).",
|
||||
text = Strings.worldWideDonations(),
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
@ -90,7 +90,7 @@ actual fun DonationDialog(
|
||||
style = SpotiFlyerTypography.h6
|
||||
)
|
||||
Text(
|
||||
text = "Indian Donations (UPI / PayTM / PhonePe / Cards).",
|
||||
text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).",
|
||||
style = SpotiFlyerTypography.subtitle2
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import de.comahe.i18n4k.gradle.plugin.i18n4k
|
||||
|
||||
/*
|
||||
* * Copyright (c) 2021 Shabinder Singh
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
@ -20,11 +22,18 @@ plugins {
|
||||
id("multiplatform-setup-test")
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization")
|
||||
id("de.comahe.i18n4k")
|
||||
}
|
||||
|
||||
val statelyVersion = "1.1.7"
|
||||
val statelyIsoVersion = "1.1.7-a1"
|
||||
|
||||
i18n4k {
|
||||
inputDirectory = "../../translations"
|
||||
packageName = "com.shabinder.common.translations"
|
||||
// sourceCodeLocales = listOf("en", "de")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
/*
|
||||
@ -44,6 +53,8 @@ kotlin {
|
||||
implementation("co.touchlab:stately-concurrency:$statelyVersion")
|
||||
implementation("co.touchlab:stately-isolate:$statelyIsoVersion")
|
||||
implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion")
|
||||
implementation(Extras.youtubeDownloader)
|
||||
api(Internationalization.dep)
|
||||
}
|
||||
}
|
||||
androidMain {
|
||||
|
@ -14,7 +14,7 @@ actual interface PlatformActions {
|
||||
|
||||
fun addToLibrary(path: String)
|
||||
|
||||
fun sendTracksToService(array: ArrayList<TrackDetails>)
|
||||
fun sendTracksToService(array: List<TrackDetails>)
|
||||
}
|
||||
|
||||
actual val StubPlatformActions = object : PlatformActions {
|
||||
@ -24,5 +24,5 @@ actual val StubPlatformActions = object : PlatformActions {
|
||||
|
||||
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
|
||||
|
||||
import io.github.shabinder.TargetPlatforms
|
||||
import io.github.shabinder.activePlatform
|
||||
|
||||
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 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.
|
||||
* */
|
||||
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 NotDownloaded : 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("org.jetbrains.kotlinx:atomicfu:0.16.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.fuzzyWuzzy)
|
||||
implementation(MVIKotlin.rx)
|
||||
|
@ -16,7 +16,6 @@
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import com.shabinder.common.models.AllPlatforms
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.methods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@ -25,9 +24,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
// IO-Dispatcher
|
||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
// Current Platform Info
|
||||
actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm
|
||||
|
||||
actual suspend fun downloadTracks(
|
||||
list: List<TrackDetails>,
|
||||
fetcher: FetchPlatformQueryResult,
|
||||
|
@ -22,8 +22,8 @@ import android.os.Environment
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.utils.ParallelExecutor
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.methods
|
||||
@ -43,7 +43,7 @@ import java.net.URL
|
||||
@Suppress("DEPRECATION")
|
||||
actual class Dir actual constructor(
|
||||
private val logger: Kermit,
|
||||
settingsPref: Settings,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
@ -54,7 +54,7 @@ actual class Dir actual constructor(
|
||||
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
|
||||
|
||||
// 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
|
||||
|
||||
actual fun isPresent(path: String): Boolean = File(path).exists()
|
||||
@ -202,5 +202,4 @@ actual class Dir actual constructor(
|
||||
private val parallelExecutor = ParallelExecutor(Dispatchers.IO)
|
||||
|
||||
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 io.ktor.util.InternalAPI
|
||||
import io.ktor.util.decodeBase64Bytes
|
||||
import io.ktor.util.*
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
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.shabinder.common.database.databaseModule
|
||||
import com.shabinder.common.database.getLogger
|
||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
||||
import com.shabinder.common.di.providers.GaanaProvider
|
||||
import com.shabinder.common.di.providers.SaavnProvider
|
||||
import com.shabinder.common.di.providers.SpotifyProvider
|
||||
import com.shabinder.common.di.providers.YoutubeMp3
|
||||
import com.shabinder.common.di.providers.YoutubeMusic
|
||||
import com.shabinder.common.di.providers.YoutubeProvider
|
||||
import 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 com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.providers.providersModule
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.features.logging.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.dsl.KoinAppDeclaration
|
||||
@ -45,7 +37,11 @@ import kotlin.native.concurrent.ThreadLocal
|
||||
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
|
||||
startKoin {
|
||||
appDeclaration()
|
||||
modules(commonModule(enableNetworkLogs = enableNetworkLogs), databaseModule())
|
||||
modules(
|
||||
commonModule(enableNetworkLogs = enableNetworkLogs),
|
||||
providersModule(),
|
||||
databaseModule()
|
||||
)
|
||||
}
|
||||
|
||||
// Called by IOS
|
||||
@ -55,16 +51,9 @@ fun commonModule(enableNetworkLogs: Boolean) = module {
|
||||
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
|
||||
single { Dir(get(), get(), get()) }
|
||||
single { Settings() }
|
||||
single { PreferenceManager(get()) }
|
||||
single { Kermit(getLogger()) }
|
||||
single { TokenStore(get(), get()) }
|
||||
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
|
||||
|
@ -17,33 +17,25 @@
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.di.utils.removeIllegalChars
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.database.Database
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val DirKey = "downloadDir"
|
||||
const val AnalyticsKey = "analytics"
|
||||
const val FirstLaunch = "firstLaunch"
|
||||
const val DonationInterval = "donationInterval"
|
||||
|
||||
expect class Dir(
|
||||
logger: Kermit,
|
||||
settingsPref: Settings,
|
||||
preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
val db: Database?
|
||||
val settings: Settings
|
||||
fun isPresent(path: String): Boolean
|
||||
fun fileSeparator(): String
|
||||
fun defaultDir(): String
|
||||
@ -56,22 +48,6 @@ expect class Dir(
|
||||
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!
|
||||
* */
|
||||
@ -105,7 +81,7 @@ suspend fun downloadFile(url: String): Flow<DownloadResult> {
|
||||
var offset = 0
|
||||
do {
|
||||
// Set Length optimally, after how many kb you want a progress update, now it 0.25mb
|
||||
val currentRead = response.content.readAvailable(data, offset, 250000)
|
||||
val currentRead = response.content.readAvailable(data, offset, 2_50_000)
|
||||
offset += currentRead
|
||||
val progress = (offset * 100f / data.size).roundToInt()
|
||||
emit(DownloadResult.Progress(progress))
|
||||
|
@ -16,9 +16,8 @@
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import com.shabinder.common.models.AllPlatforms
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import io.ktor.client.request.head
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -34,10 +33,6 @@ expect suspend fun downloadTracks(
|
||||
@SharedImmutable
|
||||
expect val dispatcherIO: CoroutineDispatcher
|
||||
|
||||
// Current Platform Info
|
||||
@SharedImmutable
|
||||
expect val currentPlatform: AllPlatforms
|
||||
|
||||
suspend fun isInternetAccessible(): Boolean {
|
||||
return withContext(dispatcherIO) {
|
||||
try {
|
||||
|
@ -16,8 +16,8 @@
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
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.SaavnProvider
|
||||
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.YoutubeProvider
|
||||
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.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.flatMap
|
||||
import com.shabinder.common.models.event.coroutines.flatMapError
|
||||
import com.shabinder.common.models.event.coroutines.success
|
||||
import com.shabinder.common.models.spotify.Source
|
||||
import com.shabinder.common.requireNotNull
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FetchPlatformQueryResult(
|
||||
private val gaanaProvider: GaanaProvider,
|
||||
val spotifyProvider: SpotifyProvider,
|
||||
val youtubeProvider: YoutubeProvider,
|
||||
private val spotifyProvider: SpotifyProvider,
|
||||
private val youtubeProvider: YoutubeProvider,
|
||||
private val saavnProvider: SaavnProvider,
|
||||
val youtubeMusic: YoutubeMusic,
|
||||
val youtubeMp3: YoutubeMp3,
|
||||
val audioToMp3: AudioToMp3,
|
||||
val dir: Dir
|
||||
private val youtubeMusic: YoutubeMusic,
|
||||
private val youtubeMp3: YoutubeMp3,
|
||||
private val audioToMp3: AudioToMp3,
|
||||
val dir: Dir,
|
||||
val logger: Kermit
|
||||
) {
|
||||
private val 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 {
|
||||
// SPOTIFY
|
||||
link.contains("spotify", true) ->
|
||||
@ -63,13 +74,13 @@ class FetchPlatformQueryResult(
|
||||
gaanaProvider.query(link)
|
||||
|
||||
else -> {
|
||||
null
|
||||
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link))
|
||||
}
|
||||
}
|
||||
if (result != null) {
|
||||
result.success {
|
||||
addToDatabaseAsync(
|
||||
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
|
||||
@ -79,35 +90,55 @@ class FetchPlatformQueryResult(
|
||||
// 2) If Not found try finding on Youtube Music
|
||||
suspend fun findMp3DownloadLink(
|
||||
track: TrackDetails
|
||||
): String? =
|
||||
): SuspendableEvent<String,Throwable> =
|
||||
if (track.videoID != null) {
|
||||
// We Already have VideoID
|
||||
when (track.source) {
|
||||
Source.JioSaavn -> {
|
||||
saavnProvider.getSongFromID(track.videoID!!).media_url?.let { m4aLink ->
|
||||
audioToMp3.convertToMp3(m4aLink)
|
||||
saavnProvider.getSongFromID(track.videoID.requireNotNull()).flatMap { song ->
|
||||
song.media_url?.let { audioToMp3.convertToMp3(it) } ?: findHighestQualityMp3Link(track)
|
||||
}
|
||||
}
|
||||
Source.YouTube -> {
|
||||
youtubeMp3.getMp3DownloadLink(track.videoID!!)
|
||||
?: youtubeProvider.ytDownloader?.getVideo(track.videoID!!)?.get()?.url?.let { m4aLink ->
|
||||
youtubeMp3.getMp3DownloadLink(track.videoID.requireNotNull()).flatMapError {
|
||||
youtubeProvider.ytDownloader.getVideo(track.videoID!!).get()?.url?.let { m4aLink ->
|
||||
audioToMp3.convertToMp3(m4aLink)
|
||||
}
|
||||
} ?: throw SpotiFlyerException.YoutubeLinkNotFound(track.videoID)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
null/* Do Nothing, We should never reach here for now*/
|
||||
/*We should never reach here for now*/
|
||||
findHighestQualityMp3Link(track)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First Try Getting A Link From JioSaavn
|
||||
saavnProvider.findSongDownloadURL(
|
||||
trackName = track.title,
|
||||
trackArtists = track.artists
|
||||
)
|
||||
// Lets Try Fetching Now From Youtube Music
|
||||
?: youtubeMusic.findSongDownloadURL(track)
|
||||
findHighestQualityMp3Link(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) {
|
||||
GlobalScope.launch(dispatcherIO) {
|
||||
db?.add(
|
||||
|
@ -18,7 +18,7 @@ package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
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 kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@ -43,7 +43,7 @@ class TokenStore(
|
||||
logger.d { "System Time:${Clock.System.now().epochSeconds} , Token Expiry:${token?.expiry}" }
|
||||
if (Clock.System.now().epochSeconds > token?.expiry ?: 0 || token == null) {
|
||||
logger.d { "Requesting New Token" }
|
||||
token = authenticateSpotify()
|
||||
token = authenticateSpotify().component1()
|
||||
GlobalScope.launch { token?.access_token?.let { save(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 com.shabinder.common.di.Dir
|
||||
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.PlatformQueryResult
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.gaana.GaanaTrack
|
||||
import com.shabinder.common.models.spotify.Source
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.*
|
||||
|
||||
class GaanaProvider(
|
||||
override val httpClient: HttpClient,
|
||||
@ -35,7 +37,7 @@ class GaanaProvider(
|
||||
|
||||
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
|
||||
val gaanaLink = fullLink.substringAfter("gaana.com/")
|
||||
|
||||
@ -44,17 +46,13 @@ class GaanaProvider(
|
||||
|
||||
// Error
|
||||
if (type == "Error" || link == "Error") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
gaanaSearch(
|
||||
type,
|
||||
link
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
throw SpotiFlyerException.LinkInvalid()
|
||||
}
|
||||
|
||||
gaanaSearch(
|
||||
type,
|
||||
link
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun gaanaSearch(
|
||||
@ -137,6 +135,7 @@ class GaanaProvider(
|
||||
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
||||
)
|
||||
}
|
||||
|
||||
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
|
||||
return if (dir.isPresent(
|
||||
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 com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
||||
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.models.DownloadStatus
|
||||
import com.shabinder.common.models.PlatformQueryResult
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.saavn.SaavnSong
|
||||
import com.shabinder.common.models.spotify.Source
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.*
|
||||
|
||||
class SaavnProvider(
|
||||
override val httpClient: HttpClient,
|
||||
@ -20,19 +22,18 @@ class SaavnProvider(
|
||||
private val dir: Dir,
|
||||
) : JioSaavnRequests {
|
||||
|
||||
suspend fun query(fullLink: String): PlatformQueryResult {
|
||||
val result = PlatformQueryResult(
|
||||
suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult, Throwable> = SuspendableEvent {
|
||||
PlatformQueryResult(
|
||||
folderType = "",
|
||||
subFolder = "",
|
||||
title = "",
|
||||
coverUrl = "",
|
||||
trackList = listOf(),
|
||||
Source.JioSaavn
|
||||
)
|
||||
with(result) {
|
||||
).apply {
|
||||
when (fullLink.substringAfter("saavn.com/").substringBefore("/")) {
|
||||
"song" -> {
|
||||
getSong(fullLink).let {
|
||||
getSong(fullLink).value.let {
|
||||
folderType = "Tracks"
|
||||
subFolder = ""
|
||||
trackList = listOf(it).toTrackDetails(folderType, subFolder)
|
||||
@ -41,7 +42,7 @@ class SaavnProvider(
|
||||
}
|
||||
}
|
||||
"album" -> {
|
||||
getAlbum(fullLink)?.let {
|
||||
getAlbum(fullLink).value.let {
|
||||
folderType = "Albums"
|
||||
subFolder = removeIllegalChars(it.title)
|
||||
trackList = it.songs.toTrackDetails(folderType, subFolder)
|
||||
@ -50,7 +51,7 @@ class SaavnProvider(
|
||||
}
|
||||
}
|
||||
"featured" -> { // Playlist
|
||||
getPlaylist(fullLink)?.let {
|
||||
getPlaylist(fullLink).value.let {
|
||||
folderType = "Playlists"
|
||||
subFolder = removeIllegalChars(it.listname)
|
||||
trackList = it.songs.toTrackDetails(folderType, subFolder)
|
||||
@ -59,12 +60,10 @@ class SaavnProvider(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Handle Error
|
||||
throw SpotiFlyerException.LinkInvalid(fullLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
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.finalOutputDir
|
||||
import com.shabinder.common.di.globalJson
|
||||
import com.shabinder.common.di.spotify.SpotifyRequests
|
||||
import com.shabinder.common.di.spotify.authenticateSpotify
|
||||
import com.shabinder.common.di.providers.requests.spotify.SpotifyRequests
|
||||
import com.shabinder.common.di.providers.requests.spotify.authenticateSpotify
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.NativeAtomicReference
|
||||
import com.shabinder.common.models.PlatformQueryResult
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.spotify.Album
|
||||
import com.shabinder.common.models.spotify.Image
|
||||
import com.shabinder.common.models.spotify.PlaylistTrack
|
||||
import com.shabinder.common.models.spotify.Source
|
||||
import com.shabinder.common.models.spotify.Track
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.features.defaultRequest
|
||||
import io.ktor.client.features.json.JsonFeature
|
||||
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.request.*
|
||||
|
||||
class SpotifyProvider(
|
||||
private val tokenStore: TokenStore,
|
||||
@ -46,9 +48,9 @@ class SpotifyProvider(
|
||||
) : SpotifyRequests {
|
||||
|
||||
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) {
|
||||
logger.d { "Please Check your Network Connection" }
|
||||
logger.d { "Spotify Auth Failed: Please Check your Network Connection" }
|
||||
} else {
|
||||
logger.d { "Spotify Provider Created with $token" }
|
||||
HttpClient {
|
||||
@ -64,7 +66,7 @@ class SpotifyProvider(
|
||||
|
||||
override val httpClientRef = NativeAtomicReference(createHttpClient(true))
|
||||
|
||||
suspend fun query(fullLink: String): PlatformQueryResult? {
|
||||
suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult, Throwable> = SuspendableEvent {
|
||||
|
||||
var spotifyLink =
|
||||
"https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim()
|
||||
@ -78,15 +80,16 @@ class SpotifyProvider(
|
||||
val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/')
|
||||
|
||||
if (type == "Error" || link == "Error") {
|
||||
return null
|
||||
throw SpotiFlyerException.LinkInvalid(fullLink)
|
||||
}
|
||||
|
||||
if (type == "episode" || type == "show") {
|
||||
// TODO Implementation
|
||||
return null
|
||||
throw SpotiFlyerException.FeatureNotImplementedYet(
|
||||
"Support for Spotify's ${type.uppercase()} isn't implemented yet"
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
try {
|
||||
spotifySearch(
|
||||
type,
|
||||
link
|
||||
@ -95,16 +98,11 @@ class SpotifyProvider(
|
||||
e.printStackTrace()
|
||||
// Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions
|
||||
authenticateSpotifyClient(true)
|
||||
// Retry Search
|
||||
try {
|
||||
spotifySearch(
|
||||
type,
|
||||
link
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
spotifySearch(
|
||||
type,
|
||||
link
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,15 +110,14 @@ class SpotifyProvider(
|
||||
type: String,
|
||||
link: String
|
||||
): PlatformQueryResult {
|
||||
val result = PlatformQueryResult(
|
||||
return PlatformQueryResult(
|
||||
folderType = "",
|
||||
subFolder = "",
|
||||
title = "",
|
||||
coverUrl = "",
|
||||
trackList = listOf(),
|
||||
Source.Spotify
|
||||
)
|
||||
with(result) {
|
||||
).apply {
|
||||
when (type) {
|
||||
"track" -> {
|
||||
getTrack(link).also {
|
||||
@ -186,15 +183,16 @@ class SpotifyProvider(
|
||||
coverUrl = playlistObject.images?.firstOrNull()?.url.toString()
|
||||
}
|
||||
"episode" -> { // TODO
|
||||
throw SpotiFlyerException.FeatureNotImplementedYet()
|
||||
}
|
||||
"show" -> { // TODO
|
||||
throw SpotiFlyerException.FeatureNotImplementedYet()
|
||||
}
|
||||
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
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.currentPlatform
|
||||
import com.shabinder.common.di.youtubeMp3.Yt1sMp3
|
||||
import com.shabinder.common.models.AllPlatforms
|
||||
import com.shabinder.common.di.providers.requests.youtubeMp3.Yt1sMp3
|
||||
import com.shabinder.common.models.corsApi
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.event.coroutines.map
|
||||
import io.ktor.client.*
|
||||
|
||||
class YoutubeMp3(
|
||||
override val httpClient: HttpClient,
|
||||
override val logger: Kermit,
|
||||
private val dir: Dir,
|
||||
) : Yt1sMp3 {
|
||||
suspend fun getMp3DownloadLink(videoID: String): String? = try {
|
||||
logger.i { "Youtube MP3 Link Fetching!" }
|
||||
getLinkFromYt1sMp3(videoID)?.let {
|
||||
logger.i { "Download Link: $it" }
|
||||
if (currentPlatform is AllPlatforms.Js/* && corsProxy !is CorsProxy.PublicProxyWithExtension*/)
|
||||
"https://cors.spotiflyer.ml/cors/$it"
|
||||
// "https://spotiflyer.azurewebsites.net/$it" // Data OUT Limit issue
|
||||
else it
|
||||
interface YoutubeMp3: Yt1sMp3 {
|
||||
|
||||
companion object {
|
||||
operator fun invoke(
|
||||
client: HttpClient,
|
||||
logger: Kermit
|
||||
): YoutubeMp3 {
|
||||
return object : YoutubeMp3 {
|
||||
override val httpClient: HttpClient = client
|
||||
override val logger: Kermit = logger
|
||||
}
|
||||
}
|
||||
} 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
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.shabinder.common.di.audioToMp3.AudioToMp3
|
||||
import com.shabinder.common.di.gaana.corsApi
|
||||
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
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.ktor.client.HttpClient
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
@ -37,196 +40,197 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class YoutubeMusic constructor(
|
||||
private val logger: Kermit,
|
||||
private val httpClient: HttpClient,
|
||||
private val youtubeMp3: YoutubeMp3,
|
||||
private val youtubeProvider: YoutubeProvider,
|
||||
private val youtubeMp3: YoutubeMp3,
|
||||
private val audioToMp3: AudioToMp3
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
|
||||
const val tag = "YT Music"
|
||||
}
|
||||
|
||||
suspend fun findSongDownloadURL(
|
||||
// Get Downloadable Link
|
||||
suspend fun findMp3SongDownloadURLYT(
|
||||
trackDetails: TrackDetails
|
||||
): String? {
|
||||
val bestMatchVideoID = getYTIDBestMatch(trackDetails)
|
||||
return bestMatchVideoID?.let { videoID ->
|
||||
youtubeMp3.getMp3DownloadLink(videoID) ?: youtubeProvider.ytDownloader?.getVideo(videoID)?.get()?.url?.let { m4aLink ->
|
||||
audioToMp3.convertToMp3(
|
||||
m4aLink
|
||||
): SuspendableEvent<String, Throwable> {
|
||||
return getYTIDBestMatch(trackDetails).flatMap { videoID ->
|
||||
// 1 Try getting Link from Yt1s
|
||||
youtubeMp3.getMp3DownloadLink(videoID).flatMapError {
|
||||
// 2 if Yt1s failed , Extract Manually
|
||||
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
|
||||
): String? {
|
||||
return try {
|
||||
):SuspendableEvent<String,Throwable> =
|
||||
getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}").map { matchList ->
|
||||
sortByBestMatch(
|
||||
getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}"),
|
||||
matchList,
|
||||
trackName = trackDetails.title,
|
||||
trackArtists = trackDetails.artists,
|
||||
trackDurationSec = trackDetails.durationSec
|
||||
).keys.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
// All Internet/Client Related Errors
|
||||
e.printStackTrace()
|
||||
null
|
||||
).keys.firstOrNull() ?: throw SpotiFlyerException.NoMatchFound(trackDetails.title)
|
||||
}
|
||||
}
|
||||
private suspend fun getYTTracks(query: String): List<YoutubeTrack> {
|
||||
val youtubeTracks = mutableListOf<YoutubeTrack>()
|
||||
|
||||
val responseObj = Json.parseToJsonElement(getYoutubeMusicResponse(query))
|
||||
logger.i { "Youtube Music Response Recieved" }
|
||||
val contentBlocks = responseObj.jsonObject["contents"]
|
||||
?.jsonObject?.get("sectionListRenderer")
|
||||
?.jsonObject?.get("contents")?.jsonArray
|
||||
private suspend fun getYTTracks(query: String): SuspendableEvent<List<YoutubeTrack>,Throwable> =
|
||||
getYoutubeMusicResponse(query).map { youtubeResponseData ->
|
||||
val youtubeTracks = mutableListOf<YoutubeTrack>()
|
||||
val responseObj = Json.parseToJsonElement(youtubeResponseData)
|
||||
// logger.i { "Youtube Music Response Received" }
|
||||
val contentBlocks = responseObj.jsonObject["contents"]
|
||||
?.jsonObject?.get("sectionListRenderer")
|
||||
?.jsonObject?.get("contents")?.jsonArray
|
||||
|
||||
val resultBlocks = mutableListOf<JsonArray>()
|
||||
if (contentBlocks != null) {
|
||||
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()
|
||||
) {
|
||||
val resultBlocks = mutableListOf<JsonArray>()
|
||||
if (contentBlocks != null) {
|
||||
for (cBlock in contentBlocks) {
|
||||
/**
|
||||
* apparently content Blocks without an 'overlay' field don't have linkBlocks
|
||||
* I have no clue what they are and why there even exist
|
||||
*
|
||||
if(!contents.containsKey("overlay")){
|
||||
println(contents)
|
||||
continue
|
||||
TODO check and correct
|
||||
}*/
|
||||
|
||||
val result = contents.jsonObject["musicResponsiveListItemRenderer"]
|
||||
?.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) }
|
||||
*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
|
||||
* I have no clue what they are and why there even exist
|
||||
*
|
||||
if(!contents.containsKey("overlay")){
|
||||
println(contents)
|
||||
continue
|
||||
TODO check and correct
|
||||
}*/
|
||||
|
||||
val result = contents.jsonObject["musicResponsiveListItemRenderer"]
|
||||
?.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
|
||||
! 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
|
||||
! relevant details. What you need to know to understand how we do that here:
|
||||
!
|
||||
! Songs details are ALWAYS in the following order:
|
||||
! 0 - Name
|
||||
! 1 - Type (Song)
|
||||
! 2 - com.shabinder.spotiflyer.models.gaana.Artist
|
||||
! 3 - Album
|
||||
! 4 - Duration (mm:ss)
|
||||
!
|
||||
! Video details are ALWAYS in the following order:
|
||||
! 0 - Name
|
||||
! 1 - Type (Video)
|
||||
! 2 - Channel
|
||||
! 3 - Viewers
|
||||
! 4 - Duration (hh:mm:ss)
|
||||
!
|
||||
! We blindly gather all the details we get our hands on, then
|
||||
! cherry pick the details we need based on their index numbers,
|
||||
! 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 '
|
||||
/* 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
|
||||
! 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:
|
||||
!
|
||||
! Songs details are ALWAYS in the following order:
|
||||
! 0 - Name
|
||||
! 1 - Type (Song)
|
||||
! 2 - com.shabinder.spotiflyer.models.gaana.Artist
|
||||
! 3 - Album
|
||||
! 4 - Duration (mm:ss)
|
||||
!
|
||||
! Video details are ALWAYS in the following order:
|
||||
! 0 - Name
|
||||
! 1 - Type (Video)
|
||||
! 2 - Channel
|
||||
! 3 - Viewers
|
||||
! 4 - Duration (hh:mm:ss)
|
||||
!
|
||||
! We blindly gather all the details we get our hands on, then
|
||||
! cherry pick the details we need based on their index numbers,
|
||||
! we do so only if their Type is 'Song' or 'Video
|
||||
*/
|
||||
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 (result in resultBlocks) {
|
||||
|
||||
for (d in details) {
|
||||
d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
if (it != " • ") {
|
||||
availableDetails.add(it)
|
||||
// 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
|
||||
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()}
|
||||
/*
|
||||
! 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
|
||||
|
||||
// logger.d("YT Music details"){availableDetails.toString()}
|
||||
/*
|
||||
! grab Video ID
|
||||
! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...]
|
||||
! 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
|
||||
! 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")) {
|
||||
|
||||
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)
|
||||
// skip if result is in hours instead of minutes (no song is that long)
|
||||
if (availableDetails[4].split(':').size != 2) continue
|
||||
|
||||
/*
|
||||
! grab Video ID
|
||||
! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...]
|
||||
! 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
|
||||
*/
|
||||
|
||||
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")}
|
||||
return youtubeTracks
|
||||
youtubeTracks
|
||||
}
|
||||
|
||||
private fun sortByBestMatch(
|
||||
@ -246,8 +250,8 @@ class YoutubeMusic constructor(
|
||||
// most song results on youtube go by $artist - $songName or artist1/artist2
|
||||
var hasCommonWord = false
|
||||
|
||||
val resultName = result.name?.toLowerCase()?.replace("-", " ")?.replace("/", " ") ?: ""
|
||||
val trackNameWords = trackName.toLowerCase().split(" ")
|
||||
val resultName = result.name?.lowercase()?.replace("-", " ")?.replace("/", " ") ?: ""
|
||||
val trackNameWords = trackName.lowercase().split(" ")
|
||||
|
||||
for (nameWord in trackNameWords) {
|
||||
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
|
||||
@ -266,12 +270,12 @@ class YoutubeMusic constructor(
|
||||
|
||||
if (result.type == "Song") {
|
||||
for (artist in trackArtists) {
|
||||
if (FuzzySearch.ratio(artist.toLowerCase(), result.artist?.toLowerCase() ?: "") > 85)
|
||||
if (FuzzySearch.ratio(artist.lowercase(), result.artist?.lowercase() ?: "") > 85)
|
||||
artistMatchNumber++
|
||||
}
|
||||
} else { // i.e. is a Video
|
||||
for (artist in trackArtists) {
|
||||
if (FuzzySearch.partialRatio(artist.toLowerCase(), result.name?.toLowerCase() ?: "") > 85)
|
||||
if (FuzzySearch.partialRatio(artist.lowercase(), result.name?.lowercase() ?: "") > 85)
|
||||
artistMatchNumber++
|
||||
}
|
||||
}
|
||||
@ -303,9 +307,8 @@ class YoutubeMusic constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getYoutubeMusicResponse(query: String): String {
|
||||
logger.i { "Fetching Youtube Music Response" }
|
||||
return httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") {
|
||||
private suspend fun getYoutubeMusicResponse(query: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") {
|
||||
contentType(ContentType.Application.Json)
|
||||
headers {
|
||||
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.models.DownloadStatus
|
||||
import com.shabinder.common.models.PlatformQueryResult
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.spotify.Source
|
||||
import io.github.shabinder.YoutubeDownloader
|
||||
import io.github.shabinder.models.YoutubeVideo
|
||||
@ -49,7 +51,7 @@ class YoutubeProvider(
|
||||
private val sampleDomain2 = "youtube.com"
|
||||
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://")
|
||||
if (link.contains("playlist", true) || link.contains("list", true)) {
|
||||
// Given Link is of a Playlist
|
||||
@ -77,74 +79,15 @@ class YoutubeProvider(
|
||||
)
|
||||
} else {
|
||||
logger.d { "Your Youtube Link is not of a Video!!" }
|
||||
null
|
||||
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(fullLink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getYTPlaylist(
|
||||
searchId: String
|
||||
): PlatformQueryResult? {
|
||||
val result = 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(
|
||||
): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
|
||||
PlatformQueryResult(
|
||||
folderType = "",
|
||||
subFolder = "",
|
||||
title = "",
|
||||
@ -152,47 +95,90 @@ class YoutubeProvider(
|
||||
trackList = listOf(),
|
||||
Source.YouTube
|
||||
).apply {
|
||||
try {
|
||||
logger.i { searchId }
|
||||
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()
|
||||
)
|
||||
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(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 com.shabinder.common.models.AudioQuality
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.forms.submitFormWithBinaryData
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.http.isSuccess
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
interface AudioToMp3 {
|
||||
@ -32,9 +32,10 @@ interface AudioToMp3 {
|
||||
suspend fun convertToMp3(
|
||||
URL: String,
|
||||
audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)),
|
||||
): String? {
|
||||
val activeHost = getHost() // ex - https://hostveryfast.onlineconverter.com/file/send
|
||||
val jobLink = convertRequest(URL, activeHost, audioQuality) // ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
|
||||
): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
// Active Host ex - https://hostveryfast.onlineconverter.com/file/send
|
||||
// Convert Job Request ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
|
||||
var (activeHost,jobLink) = convertRequest(URL, audioQuality).value
|
||||
|
||||
// (jobStatus.contains("d")) == COMPLETION
|
||||
var jobStatus: String
|
||||
@ -47,17 +48,23 @@ interface AudioToMp3 {
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
if(e is ClientRequestException && e.response.status.value == 404) {
|
||||
// No Need to Retry, Host/Converter is Busy
|
||||
throw SpotiFlyerException.MP3ConversionFailed(e.message)
|
||||
}
|
||||
// Try Using New Host/Converter
|
||||
convertRequest(URL, audioQuality).value.also {
|
||||
activeHost = it.first
|
||||
jobLink = it.second
|
||||
}
|
||||
""
|
||||
}
|
||||
retryCount--
|
||||
logger.i("Job Status") { jobStatus }
|
||||
if (!jobStatus.contains("d")) delay(400) // Add Delay , to give Server Time to process audio
|
||||
} while (!jobStatus.contains("d", true) && retryCount != 0)
|
||||
if (!jobStatus.contains("d")) delay(600) // Add Delay , to give Server Time to process audio
|
||||
} while (!jobStatus.contains("d", true) && retryCount > 0)
|
||||
|
||||
return if (jobStatus.equals("d", true)) {
|
||||
// Return MP3 Download Link
|
||||
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
|
||||
} else null
|
||||
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
|
||||
}
|
||||
|
||||
/*
|
||||
@ -66,11 +73,10 @@ interface AudioToMp3 {
|
||||
* */
|
||||
private suspend fun convertRequest(
|
||||
URL: String,
|
||||
host: String? = null,
|
||||
audioQuality: AudioQuality = AudioQuality.KBPS160,
|
||||
): String {
|
||||
val activeHost = host ?: getHost()
|
||||
val res = client.submitFormWithBinaryData<String>(
|
||||
): SuspendableEvent<Pair<String,String>,Throwable> = SuspendableEvent {
|
||||
val activeHost by getHost()
|
||||
val convertJob = client.submitFormWithBinaryData<String>(
|
||||
url = activeHost,
|
||||
formData = formData {
|
||||
append("class", "audio")
|
||||
@ -87,28 +93,30 @@ interface AudioToMp3 {
|
||||
header("Referer", "https://www.onlineconverter.com/")
|
||||
}
|
||||
}.run {
|
||||
logger.d { this }
|
||||
// logger.d { this }
|
||||
dropLast(3) // last 3 are useless unicode char
|
||||
}
|
||||
|
||||
val job = client.get<HttpStatement>(res) {
|
||||
val job = client.get<HttpStatement>(convertJob) {
|
||||
headers {
|
||||
header("Host", "www.onlineconverter.com")
|
||||
}
|
||||
}.execute()
|
||||
logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() }
|
||||
return res
|
||||
|
||||
Pair(activeHost,convertJob)
|
||||
}
|
||||
|
||||
// Active Host free to process conversion
|
||||
// ex - https://hostveryfast.onlineconverter.com/file/send
|
||||
private suspend fun getHost(): String {
|
||||
return client.get<String>("https://www.onlineconverter.com/get/host") {
|
||||
private suspend fun getHost(): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
client.get<String>("https://www.onlineconverter.com/get/host") {
|
||||
headers {
|
||||
header("Host", "www.onlineconverter.com")
|
||||
}
|
||||
}.also { logger.i("Active Host") { it } }
|
||||
}//.also { logger.i("Active Host") { it } }
|
||||
}
|
||||
|
||||
// Extract full Domain from URL
|
||||
// ex - hostveryfast.onlineconverter.com
|
||||
private fun String.getHostDomain(): String {
|
@ -14,23 +14,17 @@
|
||||
* * 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.AllPlatforms
|
||||
import com.shabinder.common.models.corsProxy
|
||||
import com.shabinder.common.models.corsApi
|
||||
import com.shabinder.common.models.gaana.GaanaAlbum
|
||||
import com.shabinder.common.models.gaana.GaanaArtistDetails
|
||||
import com.shabinder.common.models.gaana.GaanaArtistTracks
|
||||
import com.shabinder.common.models.gaana.GaanaPlaylist
|
||||
import com.shabinder.common.models.gaana.GaanaSong
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.*
|
||||
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 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 com.shabinder.common.di.audioToMp3.AudioToMp3
|
||||
import com.shabinder.common.di.gaana.corsApi
|
||||
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.SaavnPlaylist
|
||||
import com.shabinder.common.models.saavn.SaavnSearchResult
|
||||
import com.shabinder.common.models.saavn.SaavnSong
|
||||
import com.shabinder.common.requireNotNull
|
||||
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
|
||||
import io.github.shabinder.utils.getBoolean
|
||||
import io.github.shabinder.utils.getJsonArray
|
||||
import io.github.shabinder.utils.getJsonObject
|
||||
import io.github.shabinder.utils.getString
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.features.ServerResponseException
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@ -24,6 +27,7 @@ import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.collections.set
|
||||
|
||||
interface JioSaavnRequests {
|
||||
|
||||
@ -31,63 +35,64 @@ interface JioSaavnRequests {
|
||||
val httpClient: HttpClient
|
||||
val logger: Kermit
|
||||
|
||||
suspend fun findSongDownloadURL(
|
||||
suspend fun findMp3SongDownloadURL(
|
||||
trackName: String,
|
||||
trackArtists: List<String>,
|
||||
): String? {
|
||||
val songs = searchForSong(trackName)
|
||||
): SuspendableEvent<String,Throwable> = searchForSong(trackName).map { songs ->
|
||||
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(
|
||||
query: String,
|
||||
includeLyrics: Boolean = false
|
||||
): List<SaavnSearchResult> {
|
||||
/*if (query.startsWith("http") && query.contains("saavn.com")) {
|
||||
return listOf(getSong(query))
|
||||
}*/
|
||||
): SuspendableEvent<List<SaavnSearchResult>,Throwable> = SuspendableEvent {
|
||||
|
||||
val searchURL = search_base_url + query
|
||||
val results = mutableListOf<SaavnSearchResult>()
|
||||
try {
|
||||
(globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject).getJsonObject("songs").getJsonArray("data")?.forEach {
|
||||
(it as? JsonObject)?.formatData()?.let { jsonObject ->
|
||||
|
||||
(globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject)
|
||||
.getJsonObject("songs")
|
||||
.getJsonArray("data").requireNotNull().forEach {
|
||||
(it as JsonObject).formatData().let { jsonObject ->
|
||||
results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject))
|
||||
}
|
||||
}
|
||||
}catch (e: ServerResponseException) {}
|
||||
return results
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
suspend fun getLyrics(ID: String): String? {
|
||||
return try {
|
||||
(Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject)
|
||||
.getString("lyrics")
|
||||
}catch (e:Exception) { null }
|
||||
suspend fun getLyrics(ID: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
(Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject)
|
||||
.getString("lyrics").requireNotNull()
|
||||
}
|
||||
|
||||
suspend fun getSong(
|
||||
URL: String,
|
||||
fetchLyrics: Boolean = false
|
||||
): SaavnSong {
|
||||
): SuspendableEvent<SaavnSong,Throwable> = SuspendableEvent {
|
||||
val id = getSongID(URL)
|
||||
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
|
||||
.formatData(fetchLyrics)
|
||||
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||
|
||||
globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||
}
|
||||
|
||||
suspend fun getSongFromID(
|
||||
ID: String,
|
||||
fetchLyrics: Boolean = false
|
||||
): SaavnSong {
|
||||
): SuspendableEvent<SaavnSong,Throwable> = SuspendableEvent {
|
||||
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject)
|
||||
.formatData(fetchLyrics)
|
||||
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||
|
||||
globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||
}
|
||||
|
||||
private suspend fun getSongID(
|
||||
@ -104,24 +109,19 @@ interface JioSaavnRequests {
|
||||
suspend fun getPlaylist(
|
||||
URL: String,
|
||||
includeLyrics: Boolean = false
|
||||
): SaavnPlaylist? {
|
||||
return try {
|
||||
globalJson.decodeFromJsonElement(
|
||||
SaavnPlaylist.serializer(),
|
||||
(globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL))) as JsonObject)
|
||||
.formatData(includeLyrics)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
): SuspendableEvent<SaavnPlaylist,Throwable> = SuspendableEvent {
|
||||
globalJson.decodeFromJsonElement(
|
||||
SaavnPlaylist.serializer(),
|
||||
(globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL).value)) as JsonObject)
|
||||
.formatData(includeLyrics)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getPlaylistID(
|
||||
URL: String
|
||||
): String {
|
||||
): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
val res = httpClient.get<String>(URL)
|
||||
return try {
|
||||
try {
|
||||
res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0]
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
res.split("\"page_id\",\"")[1].split("\",\"")[0]
|
||||
@ -131,24 +131,19 @@ interface JioSaavnRequests {
|
||||
suspend fun getAlbum(
|
||||
URL: String,
|
||||
includeLyrics: Boolean = false
|
||||
): SaavnAlbum? {
|
||||
return try {
|
||||
globalJson.decodeFromJsonElement(
|
||||
SaavnAlbum.serializer(),
|
||||
(globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL))) as JsonObject)
|
||||
.formatData(includeLyrics)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
): SuspendableEvent<SaavnAlbum,Throwable> = SuspendableEvent {
|
||||
globalJson.decodeFromJsonElement(
|
||||
SaavnAlbum.serializer(),
|
||||
(globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL).value)) as JsonObject)
|
||||
.formatData(includeLyrics)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getAlbumID(
|
||||
URL: String
|
||||
): String {
|
||||
): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
val res = httpClient.get<String>(URL)
|
||||
return try {
|
||||
try {
|
||||
res.split("\"album_id\":\"")[1].split('"')[0]
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
res.split("\"page_id\",\"")[1].split("\",\"")[0]
|
||||
@ -214,8 +209,10 @@ interface JioSaavnRequests {
|
||||
// Fetch Lyrics if Requested
|
||||
// Lyrics is HTML Based
|
||||
if (includeLyrics) {
|
||||
if (getBoolean("has_lyrics") == true) {
|
||||
put("lyrics", getString("id")?.let { getLyrics(it) })
|
||||
if (getBoolean("has_lyrics") == true && containsKey("id")) {
|
||||
getLyrics(getString("id").requireNotNull()).success {
|
||||
put("lyrics", it)
|
||||
}
|
||||
} else {
|
||||
put("lyrics", "")
|
||||
}
|
||||
@ -237,8 +234,8 @@ interface JioSaavnRequests {
|
||||
for (result in tracks) {
|
||||
var hasCommonWord = false
|
||||
|
||||
val resultName = result.title.toLowerCase().replace("/", " ")
|
||||
val trackNameWords = trackName.toLowerCase().split(" ")
|
||||
val resultName = result.title.lowercase().replace("/", " ")
|
||||
val trackNameWords = trackName.lowercase().split(" ")
|
||||
|
||||
for (nameWord in trackNameWords) {
|
||||
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
|
||||
val artistListString = mutableSetOf<String>().apply {
|
||||
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(" , ")
|
||||
|
||||
for (artist in trackArtists) {
|
||||
if (FuzzySearch.partialRatio(artist.toLowerCase(), artistListString) > 85)
|
||||
if (FuzzySearch.partialRatio(artist.lowercase(), artistListString) > 85)
|
||||
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
|
||||
|
@ -14,30 +14,29 @@
|
||||
* * 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.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.event.coroutines.SuspendableEvent
|
||||
import com.shabinder.common.models.methods
|
||||
import com.shabinder.common.models.spotify.TokenData
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.features.auth.Auth
|
||||
import io.ktor.client.features.auth.providers.basic
|
||||
import io.ktor.client.features.json.JsonFeature
|
||||
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.http.Parameters
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.features.auth.*
|
||||
import io.ktor.client.features.auth.providers.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import kotlin.native.concurrent.SharedImmutable
|
||||
|
||||
suspend fun authenticateSpotify(): TokenData? {
|
||||
return try {
|
||||
if (methods.value.isInternetAvailable) spotifyAuthClient.post("https://accounts.spotify.com/api/token") {
|
||||
suspend fun authenticateSpotify(): SuspendableEvent<TokenData,Throwable> = SuspendableEvent {
|
||||
if (methods.value.isInternetAvailable) {
|
||||
spotifyAuthClient.post("https://accounts.spotify.com/api/token") {
|
||||
body = FormDataContent(Parameters.build { append("grant_type", "client_credentials") })
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
} else throw SpotiFlyerException.NoInternetException()
|
||||
}
|
||||
|
||||
@SharedImmutable
|
||||
@ -48,9 +47,10 @@ private val spotifyAuthClient by lazy {
|
||||
|
||||
install(Auth) {
|
||||
basic {
|
||||
sendWithoutRequest = true
|
||||
username = clientId
|
||||
password = clientSecret
|
||||
sendWithoutRequest { true }
|
||||
credentials {
|
||||
BasicAuthCredentials(clientId, clientSecret)
|
||||
}
|
||||
}
|
||||
}
|
||||
install(JsonFeature) {
|
@ -14,16 +14,18 @@
|
||||
* * 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.corsApi
|
||||
import com.shabinder.common.models.spotify.Album
|
||||
import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack
|
||||
import com.shabinder.common.models.spotify.Playlist
|
||||
import com.shabinder.common.models.spotify.Track
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.github.shabinder.TargetPlatforms
|
||||
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"
|
||||
|
||||
@ -32,7 +34,7 @@ interface SpotifyRequests {
|
||||
val httpClientRef: NativeAtomicReference<HttpClient>
|
||||
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 {
|
||||
return httpClient.get("$BASE_URL/playlists/$playlistID")
|
@ -14,14 +14,18 @@
|
||||
* * 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 com.shabinder.common.di.gaana.corsApi
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.http.Parameters
|
||||
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.map
|
||||
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.jsonPrimitive
|
||||
|
||||
@ -33,18 +37,23 @@ interface Yt1sMp3 {
|
||||
|
||||
val httpClient: HttpClient
|
||||
val logger: Kermit
|
||||
|
||||
/*
|
||||
* Downloadable Mp3 Link for YT videoID.
|
||||
* */
|
||||
suspend fun getLinkFromYt1sMp3(videoID: String): String? =
|
||||
getConvertedMp3Link(videoID, getKey(videoID))?.get("dlink")?.jsonPrimitive?.toString()?.replace("\"", "")
|
||||
suspend fun getLinkFromYt1sMp3(videoID: String): SuspendableEvent<String,Throwable> = getKey(videoID).flatMap { key ->
|
||||
getConvertedMp3Link(videoID, key).map {
|
||||
it["dlink"].requireNotNull()
|
||||
.jsonPrimitive.content.replace("\"", "")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* POST:https://yt1s.com/api/ajaxSearch/index
|
||||
* Body Form= q:yt video link ,vt:format=mp3
|
||||
* */
|
||||
private suspend fun getKey(videoID: String): String {
|
||||
val response: JsonObject? = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") {
|
||||
private suspend fun getKey(videoID: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
|
||||
val response: JsonObject = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") {
|
||||
body = FormDataContent(
|
||||
Parameters.build {
|
||||
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? {
|
||||
return httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") {
|
||||
private suspend fun getConvertedMp3Link(videoID: String, key: String): SuspendableEvent<JsonObject,Throwable> = SuspendableEvent {
|
||||
httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") {
|
||||
body = FormDataContent(
|
||||
Parameters.build {
|
||||
append("vid", videoID)
|
@ -1,4 +1,4 @@
|
||||
package com.shabinder.common.di.saavn
|
||||
package com.shabinder.common.di.utils
|
||||
|
||||
/*
|
||||
* JSON UTILS
|
||||
@ -6,7 +6,7 @@ package com.shabinder.common.di.saavn
|
||||
fun String.escape(): String {
|
||||
val output = StringBuilder()
|
||||
for (element in this) {
|
||||
val chx = element.toInt()
|
||||
val chx = element.code
|
||||
if (chx != 0) {
|
||||
when (element) {
|
||||
'\n' -> {
|
||||
@ -76,7 +76,7 @@ fun String.unescape(): String {
|
||||
/*if (!x.isLetterOrDigit()) {
|
||||
throw RuntimeException("Bad character in unicode escape.")
|
||||
}*/
|
||||
hex.append(x.toLowerCase())
|
||||
hex.append(x.lowercaseChar())
|
||||
}
|
||||
i += 4 // consume those four digits.
|
||||
val code = hex.toString().toInt(16)
|
@ -22,7 +22,7 @@ package com.shabinder.common.di.utils
|
||||
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
|
||||
|
||||
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.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@ -96,7 +96,7 @@ class ParallelExecutor(
|
||||
return
|
||||
|
||||
var change = expectedCount - actualCount
|
||||
while (change > 0 && killQueue.poll() != null)
|
||||
while (change > 0 && killQueue.tryReceive().getOrNull() != null)
|
||||
change -= 1
|
||||
|
||||
if (change > 0)
|
||||
@ -104,7 +104,7 @@ class ParallelExecutor(
|
||||
repeat(change) { launchProcessor() }
|
||||
}
|
||||
else
|
||||
repeat(-change) { killQueue.offer(Unit) }
|
||||
repeat(-change) { killQueue.trySend(Unit).isSuccess }
|
||||
}
|
||||
|
||||
private class Operation<Result>(
|
||||
|
@ -17,9 +17,9 @@
|
||||
package com.shabinder.common.di
|
||||
|
||||
import com.shabinder.common.di.utils.ParallelExecutor
|
||||
import com.shabinder.common.models.AllPlatforms
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -34,9 +34,6 @@ val DownloadScope = ParallelExecutor(Dispatchers.IO)
|
||||
// IO-Dispatcher
|
||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
// Current Platform Info
|
||||
actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm
|
||||
|
||||
actual suspend fun downloadTracks(
|
||||
list: List<TrackDetails>,
|
||||
fetcher: FetchPlatformQueryResult,
|
||||
@ -44,41 +41,43 @@ actual suspend fun downloadTracks(
|
||||
) {
|
||||
list.forEach { trackDetails ->
|
||||
DownloadScope.execute { // Send Download to Pool.
|
||||
val url = fetcher.findMp3DownloadLink(trackDetails)
|
||||
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL
|
||||
downloadFile(url).collect {
|
||||
when (it) {
|
||||
is DownloadResult.Error -> {
|
||||
DownloadProgressFlow.emit(
|
||||
DownloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed) }
|
||||
)
|
||||
}
|
||||
is DownloadResult.Progress -> {
|
||||
DownloadProgressFlow.emit(
|
||||
DownloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) }
|
||||
)
|
||||
}
|
||||
is DownloadResult.Success -> { // Todo clear map
|
||||
dir.saveFileWithMetadata(it.byteArray, trackDetails) {}
|
||||
DownloadProgressFlow.emit(
|
||||
DownloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloaded) }
|
||||
)
|
||||
fetcher.findMp3DownloadLink(trackDetails).fold(
|
||||
success = { url ->
|
||||
downloadFile(url).collect {
|
||||
when (it) {
|
||||
is DownloadResult.Error -> {
|
||||
DownloadProgressFlow.emit(
|
||||
DownloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause))) }
|
||||
)
|
||||
}
|
||||
is DownloadResult.Progress -> {
|
||||
DownloadProgressFlow.emit(
|
||||
DownloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) }
|
||||
)
|
||||
}
|
||||
is DownloadResult.Success -> { // Todo clear map
|
||||
dir.saveFileWithMetadata(it.byteArray, trackDetails) {}
|
||||
DownloadProgressFlow.emit(
|
||||
DownloadProgressFlow.replayCache.getOrElse(
|
||||
0
|
||||
) { 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 co.touchlab.kermit.Kermit
|
||||
import com.mpatric.mp3agic.Mp3File
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.database.Database
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -40,7 +40,7 @@ import javax.imageio.ImageIO
|
||||
|
||||
actual class Dir actual constructor(
|
||||
private val logger: Kermit,
|
||||
settingsPref: Settings,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
|
||||
@ -55,7 +55,7 @@ actual class Dir actual constructor(
|
||||
|
||||
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()
|
||||
|
||||
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 settings: Settings = settingsPref
|
||||
}
|
||||
|
||||
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.decodeBase64Bytes
|
||||
import io.ktor.util.*
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKey
|
@ -1,8 +1,8 @@
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.shabinder.common.database.SpotiFlyerDatabase
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.database.Database
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@ -24,7 +24,7 @@ import platform.UIKit.UIImageJPEGRepresentation
|
||||
|
||||
actual class Dir actual constructor(
|
||||
val logger: Kermit,
|
||||
settingsPref: Settings,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
|
||||
@ -35,7 +35,7 @@ actual class Dir actual constructor(
|
||||
private val defaultBaseDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!!.path!!
|
||||
|
||||
// TODO Error Handling
|
||||
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) +
|
||||
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
|
||||
fileSeparator() + "SpotiFlyer" + fileSeparator()
|
||||
|
||||
private val defaultDirURL: NSURL by lazy {
|
||||
@ -176,6 +176,5 @@ actual class Dir actual constructor(
|
||||
// TODO
|
||||
}
|
||||
|
||||
actual val settings: Settings = settingsPref
|
||||
actual val db: Database? = spotiFlyerDatabase.instance
|
||||
}
|
||||
|
@ -16,9 +16,9 @@
|
||||
|
||||
package com.shabinder.common.di
|
||||
|
||||
import com.shabinder.common.models.AllPlatforms
|
||||
import com.shabinder.common.models.DownloadResult
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.SpotiFlyerException
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -34,9 +34,6 @@ val allTracksStatus: HashMap<String, DownloadStatus> = hashMapOf()
|
||||
// IO-Dispatcher
|
||||
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
// Current Platform Info
|
||||
actual val currentPlatform: AllPlatforms = AllPlatforms.Js
|
||||
|
||||
actual suspend fun downloadTracks(
|
||||
list: List<TrackDetails>,
|
||||
fetcher: FetchPlatformQueryResult,
|
||||
@ -45,29 +42,31 @@ actual suspend fun downloadTracks(
|
||||
list.forEach { track ->
|
||||
withContext(dispatcherIO) {
|
||||
allTracksStatus[track.title] = DownloadStatus.Queued
|
||||
val url = fetcher.findMp3DownloadLink(track)
|
||||
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL
|
||||
downloadFile(url).collect {
|
||||
when (it) {
|
||||
is DownloadResult.Success -> {
|
||||
println("Download Completed")
|
||||
dir.saveFileWithMetadata(it.byteArray, track) {}
|
||||
}
|
||||
is DownloadResult.Error -> {
|
||||
allTracksStatus[track.title] = DownloadStatus.Failed
|
||||
println("Download Error: ${track.title}")
|
||||
}
|
||||
is DownloadResult.Progress -> {
|
||||
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
|
||||
println("Download Progress: ${it.progress} : ${track.title}")
|
||||
fetcher.findMp3DownloadLink(track).fold(
|
||||
success = { url ->
|
||||
downloadFile(url).collect {
|
||||
when (it) {
|
||||
is DownloadResult.Success -> {
|
||||
println("Download Completed")
|
||||
dir.saveFileWithMetadata(it.byteArray, track) {}
|
||||
}
|
||||
is DownloadResult.Error -> {
|
||||
allTracksStatus[track.title] = DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause))
|
||||
println("Download Error: ${track.title}")
|
||||
}
|
||||
is DownloadResult.Progress -> {
|
||||
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
|
||||
println("Download Progress: ${it.progress} : ${track.title}")
|
||||
}
|
||||
}
|
||||
DownloadProgressFlow.emit(allTracksStatus)
|
||||
}
|
||||
},
|
||||
failure = { error ->
|
||||
allTracksStatus[track.title] = DownloadStatus.Failed(error)
|
||||
DownloadProgressFlow.emit(allTracksStatus)
|
||||
}
|
||||
} else {
|
||||
allTracksStatus[track.title] = DownloadStatus.Failed
|
||||
DownloadProgressFlow.emit(allTracksStatus)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,13 +17,13 @@
|
||||
package com.shabinder.common.di
|
||||
|
||||
import co.touchlab.kermit.Kermit
|
||||
import com.russhwolf.settings.Settings
|
||||
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.models.DownloadResult
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
import com.shabinder.common.models.corsApi
|
||||
import com.shabinder.database.Database
|
||||
import kotlinext.js.Object
|
||||
import kotlinext.js.js
|
||||
@ -34,7 +34,7 @@ import org.w3c.dom.ImageBitmap
|
||||
|
||||
actual class Dir actual constructor(
|
||||
private val logger: Kermit,
|
||||
settingsPref: Settings,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
spotiFlyerDatabase: SpotiFlyerDatabase,
|
||||
) {
|
||||
/*init {
|
||||
@ -116,7 +116,6 @@ actual class Dir actual constructor(
|
||||
private suspend fun freshImage(url: String): ImageBitmap? = null
|
||||
|
||||
actual val db: Database? = spotiFlyerDatabase.instance
|
||||
actual val settings: Settings = settingsPref
|
||||
}
|
||||
|
||||
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 {
|
||||
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.FetchPlatformQueryResult
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.list.integration.SpotiFlyerListImpl
|
||||
import com.shabinder.common.models.Consumer
|
||||
import com.shabinder.common.models.DownloadStatus
|
||||
@ -61,12 +62,13 @@ interface SpotiFlyerList {
|
||||
/*
|
||||
* Snooze Donation Dialog
|
||||
* */
|
||||
fun snoozeDonationDialog()
|
||||
fun dismissDonationDialogSetOffset()
|
||||
|
||||
interface Dependencies {
|
||||
val storeFactory: StoreFactory
|
||||
val fetchQuery: FetchPlatformQueryResult
|
||||
val dir: Dir
|
||||
val preferenceManager: PreferenceManager
|
||||
val link: String
|
||||
val listOutput: Consumer<Output>
|
||||
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||
@ -83,7 +85,7 @@ interface SpotiFlyerList {
|
||||
val queryResult: PlatformQueryResult? = null,
|
||||
val link: String = "",
|
||||
val trackList: List<TrackDetails> = emptyList(),
|
||||
val errorOccurred: Exception? = null,
|
||||
val errorOccurred: Throwable? = null,
|
||||
val askForDonation: Boolean = false,
|
||||
)
|
||||
}
|
||||
|
@ -18,10 +18,10 @@ package com.shabinder.common.list.integration
|
||||
|
||||
import co.touchlab.stately.ensureNeverFrozen
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.lifecycle.doOnResume
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.shabinder.common.caching.Cache
|
||||
import com.shabinder.common.di.Picture
|
||||
import com.shabinder.common.di.setDonationOffset
|
||||
import com.shabinder.common.di.utils.asValue
|
||||
import com.shabinder.common.list.SpotiFlyerList
|
||||
import com.shabinder.common.list.SpotiFlyerList.Dependencies
|
||||
@ -38,12 +38,16 @@ internal class SpotiFlyerListImpl(
|
||||
|
||||
init {
|
||||
instanceKeeper.ensureNeverFrozen()
|
||||
lifecycle.doOnResume {
|
||||
onRefreshTracksStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
private val store =
|
||||
instanceKeeper.getStore {
|
||||
SpotiFlyerListStoreProvider(
|
||||
dir = this.dir,
|
||||
preferenceManager = preferenceManager,
|
||||
storeFactory = storeFactory,
|
||||
fetchQuery = fetchQuery,
|
||||
downloadProgressFlow = downloadProgressFlow,
|
||||
@ -74,8 +78,8 @@ internal class SpotiFlyerListImpl(
|
||||
store.accept(Intent.RefreshTracksStatuses)
|
||||
}
|
||||
|
||||
override fun snoozeDonationDialog() {
|
||||
dir.setDonationOffset(offset = 10)
|
||||
override fun dismissDonationDialogSetOffset() {
|
||||
preferenceManager.setDonationOffset(offset = 10)
|
||||
}
|
||||
|
||||
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.StoreFactory
|
||||
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
|
||||
import com.shabinder.common.database.getLogger
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
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.store.SpotiFlyerListStore.Intent
|
||||
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.methods
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
internal class SpotiFlyerListStoreProvider(
|
||||
private val dir: Dir,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
private val storeFactory: StoreFactory,
|
||||
private val fetchQuery: FetchPlatformQueryResult,
|
||||
private val link: String,
|
||||
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||
) {
|
||||
val logger = getLogger()
|
||||
|
||||
fun provide(): SpotiFlyerListStore =
|
||||
object :
|
||||
SpotiFlyerListStore,
|
||||
@ -59,8 +57,8 @@ internal class SpotiFlyerListStoreProvider(
|
||||
data class ResultFetched(val result: PlatformQueryResult, val trackList: List<TrackDetails>) : Result()
|
||||
data class UpdateTrackList(val list: List<TrackDetails>) : Result()
|
||||
data class UpdateTrackItem(val item: TrackDetails) : Result()
|
||||
data class ErrorOccurred(val error: Exception) : Result()
|
||||
data class AskForDonation(val isAllowed: Boolean) : Result()
|
||||
data class ErrorOccurred(val error: Throwable) : Result()
|
||||
data class AskForSupport(val isAllowed: Boolean) : Result()
|
||||
}
|
||||
|
||||
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
||||
@ -70,18 +68,18 @@ internal class SpotiFlyerListStoreProvider(
|
||||
|
||||
dir.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
|
||||
// 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")
|
||||
val offset = dir.getDonationOffset
|
||||
fetchQuery.logger.d(message = { "Database List Last ID: $it" }, tag = "Database Last ID")
|
||||
val offset = preferenceManager.getDonationOffset
|
||||
dispatch(
|
||||
Result.AskForDonation(
|
||||
Result.AskForSupport(
|
||||
// Every 3rd Interval or After some offset
|
||||
isAllowed = offset < 4 && (it % offset == 0L)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
downloadProgressFlow.collectLatest { map ->
|
||||
logger.d(map.size.toString(), "ListStore: flow Updated")
|
||||
downloadProgressFlow.collect { map ->
|
||||
// logger.d(map.size.toString(), "ListStore: flow Updated")
|
||||
val updatedTrackList = getState().trackList.updateTracksStatuses(map)
|
||||
if (updatedTrackList.isNotEmpty()) dispatch(Result.UpdateTrackList(updatedTrackList))
|
||||
}
|
||||
@ -90,19 +88,17 @@ internal class SpotiFlyerListStoreProvider(
|
||||
override suspend fun executeIntent(intent: Intent, getState: () -> State) {
|
||||
when (intent) {
|
||||
is Intent.SearchLink -> {
|
||||
try {
|
||||
val result = fetchQuery.query(link)
|
||||
if (result != null) {
|
||||
val resp = fetchQuery.query(link)
|
||||
resp.fold(
|
||||
success = { result ->
|
||||
result.trackList = result.trackList.toMutableList()
|
||||
dispatch((Result.ResultFetched(result, result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() }))))
|
||||
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 -> {
|
||||
@ -133,7 +129,7 @@ internal class SpotiFlyerListStoreProvider(
|
||||
is Result.UpdateTrackList -> copy(trackList = result.list)
|
||||
is Result.UpdateTrackItem -> updateTrackItem(result.item)
|
||||
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 {
|
||||
|
@ -21,6 +21,7 @@ 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.main.integration.SpotiFlyerMainImpl
|
||||
import com.shabinder.common.models.Consumer
|
||||
import com.shabinder.common.models.DownloadRecord
|
||||
@ -58,11 +59,14 @@ interface SpotiFlyerMain {
|
||||
* */
|
||||
suspend fun loadImage(url: String): Picture
|
||||
|
||||
fun dismissDonationDialogOffset()
|
||||
|
||||
interface Dependencies {
|
||||
val mainOutput: Consumer<Output>
|
||||
val storeFactory: StoreFactory
|
||||
val database: Database?
|
||||
val dir: Dir
|
||||
val preferenceManager: PreferenceManager
|
||||
val mainAnalytics: Analytics
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,10 @@ import com.shabinder.common.caching.Cache
|
||||
import com.shabinder.common.di.Picture
|
||||
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.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.SpotiFlyerMainStoreProvider
|
||||
import com.shabinder.common.main.store.getStore
|
||||
@ -41,6 +44,7 @@ internal class SpotiFlyerMainImpl(
|
||||
private val store =
|
||||
instanceKeeper.getStore {
|
||||
SpotiFlyerMainStoreProvider(
|
||||
preferenceManager = preferenceManager,
|
||||
storeFactory = storeFactory,
|
||||
database = database,
|
||||
dir = dir
|
||||
@ -78,4 +82,8 @@ internal class SpotiFlyerMainImpl(
|
||||
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.extensions.coroutines.SuspendExecutor
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.isAnalyticsEnabled
|
||||
import com.shabinder.common.di.toggleAnalytics
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.main.SpotiFlyerMain
|
||||
import com.shabinder.common.main.SpotiFlyerMain.State
|
||||
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
|
||||
@ -39,6 +38,7 @@ import kotlinx.coroutines.flow.map
|
||||
|
||||
internal class SpotiFlyerMainStoreProvider(
|
||||
private val storeFactory: StoreFactory,
|
||||
private val preferenceManager: PreferenceManager,
|
||||
private val dir: Dir,
|
||||
database: Database?
|
||||
) {
|
||||
@ -76,7 +76,7 @@ internal class SpotiFlyerMainStoreProvider(
|
||||
|
||||
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
|
||||
override suspend fun executeAction(action: Unit, getState: () -> State) {
|
||||
dispatch(Result.ToggleAnalytics(dir.isAnalyticsEnabled))
|
||||
dispatch(Result.ToggleAnalytics(preferenceManager.isAnalyticsEnabled))
|
||||
updates?.collect {
|
||||
dispatch(Result.ItemsLoaded(it))
|
||||
}
|
||||
@ -91,7 +91,7 @@ internal class SpotiFlyerMainStoreProvider(
|
||||
is Intent.SelectCategory -> dispatch(Result.CategoryChanged(intent.category))
|
||||
is Intent.ToggleAnalytics -> {
|
||||
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/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.models
|
||||
|
||||
sealed class AllPlatforms {
|
||||
object Js : AllPlatforms()
|
||||
object Jvm : AllPlatforms()
|
||||
object Native : AllPlatforms()
|
||||
plugins {
|
||||
id("android-setup")
|
||||
id("multiplatform-setup")
|
||||
id("multiplatform-setup-test")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
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.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.FetchPlatformQueryResult
|
||||
import com.shabinder.common.di.preference.PreferenceManager
|
||||
import com.shabinder.common.list.SpotiFlyerList
|
||||
import com.shabinder.common.main.SpotiFlyerMain
|
||||
import com.shabinder.common.models.Actions
|
||||
@ -49,9 +50,10 @@ interface SpotiFlyerRoot {
|
||||
interface Dependencies {
|
||||
val storeFactory: StoreFactory
|
||||
val database: Database?
|
||||
val fetchPlatformQueryResult: FetchPlatformQueryResult
|
||||
val directories: Dir
|
||||
val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||
val fetchQuery: FetchPlatformQueryResult
|
||||
val dir: Dir
|
||||
val preferenceManager: PreferenceManager
|
||||
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||
val actions: Actions
|
||||
val analytics: Analytics
|
||||
}
|
||||
|
@ -27,13 +27,10 @@ import com.arkivanov.decompose.router
|
||||
import com.arkivanov.decompose.statekeeper.Parcelable
|
||||
import com.arkivanov.decompose.statekeeper.Parcelize
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.shabinder.common.di.Dir
|
||||
import com.shabinder.common.di.currentPlatform
|
||||
import com.shabinder.common.di.providers.SpotifyProvider
|
||||
import com.shabinder.common.di.dispatcherIO
|
||||
import com.shabinder.common.list.SpotiFlyerList
|
||||
import com.shabinder.common.main.SpotiFlyerMain
|
||||
import com.shabinder.common.models.Actions
|
||||
import com.shabinder.common.models.AllPlatforms
|
||||
import com.shabinder.common.models.Consumer
|
||||
import com.shabinder.common.models.methods
|
||||
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.Dependencies
|
||||
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@ -79,11 +76,8 @@ internal class SpotiFlyerRootImpl(
|
||||
) {
|
||||
instanceKeeper.ensureNeverFrozen()
|
||||
methods.value = dependencies.actions.freeze()
|
||||
/*Authenticate Spotify Client*/
|
||||
authenticateSpotify(
|
||||
dependencies.fetchPlatformQueryResult.spotifyProvider,
|
||||
currentPlatform is AllPlatforms.Js
|
||||
)
|
||||
/*Init App Launch & Authenticate Spotify Client*/
|
||||
initAppLaunchAndAuthenticateSpotify(dependencies.fetchQuery::authenticateSpotifyClient)
|
||||
}
|
||||
|
||||
private val router =
|
||||
@ -134,11 +128,12 @@ internal class SpotiFlyerRootImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateSpotify(spotifyProvider: SpotifyProvider, override: Boolean) {
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun initAppLaunchAndAuthenticateSpotify(authenticator: suspend () -> Unit) {
|
||||
GlobalScope.launch(dispatcherIO) {
|
||||
analytics.appLaunchEvent()
|
||||
/*Authenticate Spotify Client*/
|
||||
spotifyProvider.authenticateSpotifyClient(override)
|
||||
authenticator()
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,10 +151,7 @@ private fun spotiFlyerMain(componentContext: ComponentContext, output: Consumer<
|
||||
componentContext = componentContext,
|
||||
dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies {
|
||||
override val mainOutput: Consumer<SpotiFlyerMain.Output> = output
|
||||
override val dir: Dir = directories
|
||||
override val mainAnalytics = object : SpotiFlyerMain.Analytics {
|
||||
override fun donationDialogVisit() = analytics.donationDialogVisit()
|
||||
}
|
||||
override val mainAnalytics = object : SpotiFlyerMain.Analytics , Analytics by analytics {}
|
||||
}
|
||||
)
|
||||
|
||||
@ -167,11 +159,8 @@ private fun spotiFlyerList(componentContext: ComponentContext, link: String, out
|
||||
SpotiFlyerList(
|
||||
componentContext = componentContext,
|
||||
dependencies = object : SpotiFlyerList.Dependencies, Dependencies by dependencies {
|
||||
override val fetchQuery = fetchPlatformQueryResult
|
||||
override val dir: Dir = directories
|
||||
override val link: String = link
|
||||
override val listOutput: Consumer<SpotiFlyerList.Output> = output
|
||||
override val downloadProgressFlow = downloadProgressReport
|
||||
override val listAnalytics = object : SpotiFlyerList.Analytics {}
|
||||
override val listAnalytics = object : SpotiFlyerList.Analytics, Analytics by 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(project(":common:database"))
|
||||
implementation(project(":common:dependency-injection"))
|
||||
implementation(project(":common:compose"))
|
||||
implementation(project(":common:data-models"))
|
||||
implementation(project(":common:compose"))
|
||||
implementation(project(":common:root"))
|
||||
|
||||
// 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.resume
|
||||
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.PlatformActions
|
||||
import com.shabinder.common.models.TrackDetails
|
||||
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 kotlinx.coroutines.runBlocking
|
||||
import org.piwik.java.tracking.PiwikTracker
|
||||
@ -79,10 +88,11 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
|
||||
componentContext = componentContext,
|
||||
dependencies = object : SpotiFlyerRoot.Dependencies {
|
||||
override val storeFactory = DefaultStoreFactory
|
||||
override val fetchPlatformQueryResult: FetchPlatformQueryResult = koin.get()
|
||||
override val directories: Dir = koin.get()
|
||||
override val database: Database? = directories.db
|
||||
override val downloadProgressReport = DownloadProgressFlow
|
||||
override val fetchQuery: FetchPlatformQueryResult = koin.get()
|
||||
override val dir: Dir = koin.get()
|
||||
override val database: Database? = dir.db
|
||||
override val preferenceManager: PreferenceManager = koin.get()
|
||||
override val downloadProgressFlow = DownloadProgressFlow
|
||||
override val actions: Actions = object: Actions {
|
||||
override val platformActions = object : PlatformActions {}
|
||||
|
||||
@ -100,7 +110,7 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
|
||||
APPROVE_OPTION -> {
|
||||
val directory = fileChooser.selectedFile
|
||||
if(directory.canWrite()){
|
||||
directories.setDownloadDirectory(directory.absolutePath)
|
||||
preferenceManager.setDownloadDirectory(directory.absolutePath)
|
||||
showPopUpMessage("Set New Download Directory:\n${directory.absolutePath}")
|
||||
} else {
|
||||
showPopUpMessage("Cant Write to Selected Directory!")
|
||||
@ -137,10 +147,10 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
|
||||
}
|
||||
override val analytics = object: SpotiFlyerRoot.Analytics {
|
||||
override fun appLaunchEvent() {
|
||||
if(directories.isFirstLaunch) {
|
||||
if(preferenceManager.isFirstLaunch) {
|
||||
// Enable Analytics on First Launch
|
||||
directories.toggleAnalytics(true)
|
||||
directories.firstLaunchDone()
|
||||
preferenceManager.toggleAnalytics(true)
|
||||
preferenceManager.firstLaunchDone()
|
||||
}
|
||||
tracker.trackAsync {
|
||||
eventName = "App Launch"
|
||||
|
@ -22,10 +22,20 @@ include(
|
||||
":common:root",
|
||||
":common:main",
|
||||
":common:list",
|
||||
":common:preference",
|
||||
":common:data-models",
|
||||
":common:dependency-injection",
|
||||
":android",
|
||||
":desktop",
|
||||
":web-app",
|
||||
":console-app",
|
||||
":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