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:
Shabinder Singh 2021-06-26 01:06:31 +05:30 committed by GitHub
commit 00b8c55e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
102 changed files with 2758 additions and 1389 deletions

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "spotiflyer-ios"] [submodule "spotiflyer-ios"]
path = spotiflyer-ios path = spotiflyer-ios
url = https://github.com/Shabinder/spotiflyer-ios url = https://github.com/Shabinder/spotiflyer-ios
[submodule "mosaic"]
path = mosaic
url = https://github.com/JakeWharton/mosaic

View File

@ -121,17 +121,23 @@ dependencies {
implementation(MVIKotlin.mvikotlinTimeTravel) implementation(MVIKotlin.mvikotlinTimeTravel)
// Extras // Extras
Extras.Android.apply { with(Extras.Android) {
implementation(Acra.notification) implementation(Acra.notification)
implementation(Acra.http) implementation(Acra.http)
implementation(appUpdator) implementation(appUpdator)
implementation(matomo) implementation(matomo)
} }
with(Versions.androidxLifecycle) {
implementation("androidx.lifecycle:lifecycle-service:$this")
implementation("androidx.lifecycle:lifecycle-common-java8:$this")
}
implementation(Extras.kermit)
//implementation("com.jakewharton.timber:timber:4.7.1") //implementation("com.jakewharton.timber:timber:4.7.1")
implementation("dev.icerock.moko:parcelize:0.7.0") implementation("dev.icerock.moko:parcelize:0.7.0")
implementation("com.github.shabinder:storage-chooser:2.0.4.45") implementation("com.github.shabinder:storage-chooser:2.0.4.45")
implementation("com.google.accompanist:accompanist-insets:0.11.1") implementation("com.google.accompanist:accompanist-insets:0.12.0")
// Test // Test
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View File

@ -72,6 +72,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name="com.shabinder.common.di.worker.ForegroundService"/> <service android:name=".service.ForegroundService"/>
</application> </application>
</manifest> </manifest>

View File

@ -19,6 +19,7 @@ package com.shabinder.spotiflyer
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import com.shabinder.common.di.initKoin import com.shabinder.common.di.initKoin
import com.shabinder.common.translations.Strings
import com.shabinder.spotiflyer.di.appModule import com.shabinder.spotiflyer.di.appModule
import org.acra.config.httpSender import org.acra.config.httpSender
import org.acra.config.notification import org.acra.config.notification
@ -77,10 +78,10 @@ class App: Application(), KoinComponent {
* Obeying `F-Droid Inclusion Privacy Rules` * Obeying `F-Droid Inclusion Privacy Rules`
* */ * */
notification { notification {
title = getString(R.string.acra_notification_title) title = Strings.acraNotificationTitle()
text = getString(R.string.acra_notification_text) text = Strings.acraNotificationText()
channelName = getString(R.string.acra_notification_channel) channelName = "SpotiFlyer_Crashlytics"
channelDescription = getString(R.string.acra_notification_channel_desc) channelDescription = "Notification Channel to send Spotiflyer Crashes."
sendOnClick = true sendOnClick = true
} }
// Send Crash Report to self hosted Acrarium (FOSS) // Send Crash Report to self hosted Acrarium (FOSS)

View File

@ -17,15 +17,16 @@
package com.shabinder.spotiflyer package com.shabinder.spotiflyer
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@ -51,18 +52,18 @@ import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.statusBarsHeight import com.google.accompanist.insets.statusBarsHeight
import com.google.accompanist.insets.statusBarsPadding import com.google.accompanist.insets.statusBarsPadding
import com.shabinder.common.di.* import com.shabinder.common.di.*
import com.shabinder.common.di.worker.ForegroundService import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.models.Actions import com.shabinder.common.models.Actions
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformActions import com.shabinder.common.models.PlatformActions
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
import com.shabinder.common.models.Status
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRoot.Analytics import com.shabinder.common.root.SpotiFlyerRoot.Analytics
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
import com.shabinder.common.uikit.* import com.shabinder.common.uikit.*
import com.shabinder.spotiflyer.service.ForegroundService
import com.shabinder.spotiflyer.ui.AnalyticsDialog import com.shabinder.spotiflyer.ui.AnalyticsDialog
import com.shabinder.spotiflyer.ui.NetworkDialog import com.shabinder.spotiflyer.ui.NetworkDialog
import com.shabinder.spotiflyer.ui.PermissionDialog import com.shabinder.spotiflyer.ui.PermissionDialog
@ -78,14 +79,20 @@ class MainActivity : ComponentActivity() {
private val fetcher: FetchPlatformQueryResult by inject() private val fetcher: FetchPlatformQueryResult by inject()
private val dir: Dir by inject() private val dir: Dir by inject()
private val preferenceManager: PreferenceManager by inject()
private lateinit var root: SpotiFlyerRoot private lateinit var root: SpotiFlyerRoot
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1) private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
private var permissionGranted = mutableStateOf(true) private var permissionGranted = mutableStateOf(true)
private lateinit var updateUIReceiver: BroadcastReceiver
private lateinit var queryReceiver: BroadcastReceiver
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) } private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
private val tracker get() = (application as App).tracker private val tracker get() = (application as App).tracker
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
// Variable for storing instance of our service class
var foregroundService: ForegroundService? = null
// Boolean to check if our activity is bound to service or not
var isServiceBound: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -124,18 +131,18 @@ class MainActivity : ComponentActivity() {
AnalyticsDialog( AnalyticsDialog(
askForAnalyticsPermission, askForAnalyticsPermission,
enableAnalytics = { enableAnalytics = {
dir.toggleAnalytics(true) preferenceManager.toggleAnalytics(true)
dir.firstLaunchDone() preferenceManager.firstLaunchDone()
}, },
dismissDialog = { dismissDialog = {
askForAnalyticsPermission = false askForAnalyticsPermission = false
dir.firstLaunchDone() preferenceManager.firstLaunchDone()
} }
) )
LaunchedEffect(view) { LaunchedEffect(view) {
permissionGranted.value = checkPermissions() permissionGranted.value = checkPermissions()
if(dir.isFirstLaunch) { if(preferenceManager.isFirstLaunch) {
delay(2500) delay(2500)
// Ask For Analytics Permission on first Dialog // Ask For Analytics Permission on first Dialog
askForAnalyticsPermission = true askForAnalyticsPermission = true
@ -149,63 +156,79 @@ class MainActivity : ComponentActivity() {
} }
private fun initialise() { private fun initialise() {
val isGithubRelease = checkAppSignature(this).also { val isGithubRelease = checkAppSignature(this)
Log.i("SpotiFlyer Github Rel.:",it.toString())
}
/* /*
* Only Send an `Update Notification` on Github Release Builds * Only Send an `Update Notification` on Github Release Builds
* and Track Downloads for all other releases like F-Droid, * and Track Downloads for all other releases like F-Droid,
* for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer * for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer
* */ * */
if(isGithubRelease) { checkIfLatestVersion() } if(isGithubRelease) { checkIfLatestVersion() }
if(dir.isAnalyticsEnabled && !isGithubRelease) { if(preferenceManager.isAnalyticsEnabled && !isGithubRelease) {
// Download/App Install Event for F-Droid builds // Download/App Install Event for F-Droid builds
TrackHelper.track().download().with(tracker) TrackHelper.track().download().with(tracker)
} }
handleIntentFromExternalActivity() handleIntentFromExternalActivity()
initForegroundService()
} }
/*START: Foreground Service Handlers*/
private fun initForegroundService() {
// Start and then Bind to the Service
ContextCompat.startForegroundService(
this@MainActivity,
Intent(this, ForegroundService::class.java)
)
bindService()
}
/**
* Interface for getting the instance of binder from our service class
* So client can get instance of our service class and can directly communicate with it.
*/
private val serviceConnection = object : ServiceConnection {
val tag = "Service Connection"
override fun onServiceConnected(className: ComponentName, iBinder: IBinder) {
Log.d(tag, "connected to service.")
// We've bound to MyService, cast the IBinder and get MyBinder instance
val binder = iBinder as ForegroundService.DownloadServiceBinder
foregroundService = binder.service
isServiceBound = true
lifecycleScope.launch {
foregroundService?.trackStatusFlowMap?.statusFlow?.let {
trackStatusFlow.emitAll(it.conflate())
}
}
}
override fun onServiceDisconnected(arg0: ComponentName) {
Log.d(tag, "disconnected from service.")
isServiceBound = false
}
}
/*Used to bind to our service class*/
private fun bindService() {
Intent(this, ForegroundService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
/*Used to unbind from our service class*/
private fun unbindService() {
Intent(this, ForegroundService::class.java).also {
unbindService(serviceConnection)
}
}
/*END: Foreground Service Handlers*/
@Composable @Composable
private fun isInternetAvailableState(): State<Boolean?> { private fun isInternetAvailableState(): State<Boolean?> {
return internetAvailability.observeAsState() return internetAvailability.observeAsState()
} }
@Suppress("DEPRECATION")
private fun setUpOnPrefClickListener() {
// Initialize Builder
val chooser = StorageChooser.Builder()
.withActivity(this)
.withFragmentManager(fragmentManager)
.withMemoryBar(true)
.setTheme(StorageChooser.Theme(applicationContext).apply {
scheme = applicationContext.resources.getIntArray(R.array.default_dark)
})
.setDialogTitle("Set Download Directory")
.allowCustomPath(true)
.setType(StorageChooser.DIRECTORY_CHOOSER)
.build()
// get path that the user has chosen
chooser.setOnSelectListener { path ->
Log.d("Setting Base Path", path)
val f = File(path)
if (f.canWrite()) {
// hell yeah :)
dir.setDownloadDirectory(path)
showPopUpMessage(
"Download Directory Set to:\n${dir.defaultDir()} "
)
}else{
showPopUpMessage(
"NO WRITE ACCESS on \n$path ,\nReverting Back to Previous"
)
}
}
// Show dialog whenever you want by
chooser.show()
}
private fun showPopUpMessage(string: String, long: Boolean = false) { private fun showPopUpMessage(string: String, long: Boolean = false) {
android.widget.Toast.makeText( android.widget.Toast.makeText(
applicationContext, applicationContext,
@ -225,9 +248,10 @@ class MainActivity : ComponentActivity() {
dependencies = object : SpotiFlyerRoot.Dependencies{ dependencies = object : SpotiFlyerRoot.Dependencies{
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory) override val storeFactory = LoggingStoreFactory(DefaultStoreFactory)
override val database = this@MainActivity.dir.db override val database = this@MainActivity.dir.db
override val fetchPlatformQueryResult = this@MainActivity.fetcher override val fetchQuery = this@MainActivity.fetcher
override val directories: Dir = this@MainActivity.dir override val dir: Dir = this@MainActivity.dir
override val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow override val preferenceManager = this@MainActivity.preferenceManager
override val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
override val actions = object: Actions { override val actions = object: Actions {
override val platformActions = object : PlatformActions { override val platformActions = object : PlatformActions {
@ -243,12 +267,9 @@ class MainActivity : ComponentActivity() {
) )
} }
override fun sendTracksToService(array: ArrayList<TrackDetails>) { override fun sendTracksToService(array: List<TrackDetails>) {
for (list in array.chunked(50)) { if (foregroundService == null) initForegroundService()
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java) foregroundService?.downloadAllTracks(array)
serviceIntent.putParcelableArrayListExtra("object", list as ArrayList)
ContextCompat.startForegroundService(this@MainActivity, serviceIntent)
}
} }
} }
@ -256,12 +277,7 @@ class MainActivity : ComponentActivity() {
override fun setDownloadDirectoryAction() = setUpOnPrefClickListener() override fun setDownloadDirectoryAction() = setUpOnPrefClickListener()
override fun queryActiveTracks() { override fun queryActiveTracks() = this@MainActivity.queryActiveTracks()
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java).apply {
action = "query"
}
ContextCompat.startForegroundService(this@MainActivity, serviceIntent)
}
override fun giveDonation() { override fun giveDonation() {
openPlatform("",platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button") openPlatform("",platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button")
@ -303,7 +319,7 @@ class MainActivity : ComponentActivity() {
* */ * */
override val analytics = object: Analytics { override val analytics = object: Analytics {
override fun appLaunchEvent() { override fun appLaunchEvent() {
if(dir.isAnalyticsEnabled){ if(preferenceManager.isAnalyticsEnabled){
TrackHelper.track() TrackHelper.track()
.event("events","App_Launch") .event("events","App_Launch")
.name("App Launch").with(tracker) .name("App Launch").with(tracker)
@ -311,7 +327,7 @@ class MainActivity : ComponentActivity() {
} }
override fun homeScreenVisit() { override fun homeScreenVisit() {
if(dir.isAnalyticsEnabled){ if(preferenceManager.isAnalyticsEnabled){
// HomeScreen Visit Event // HomeScreen Visit Event
TrackHelper.track().screen("/main_activity/home_screen") TrackHelper.track().screen("/main_activity/home_screen")
.title("HomeScreen").with(tracker) .title("HomeScreen").with(tracker)
@ -319,7 +335,7 @@ class MainActivity : ComponentActivity() {
} }
override fun listScreenVisit() { override fun listScreenVisit() {
if(dir.isAnalyticsEnabled){ if(preferenceManager.isAnalyticsEnabled){
// ListScreen Visit Event // ListScreen Visit Event
TrackHelper.track().screen("/main_activity/list_screen") TrackHelper.track().screen("/main_activity/list_screen")
.title("ListScreen").with(tracker) .title("ListScreen").with(tracker)
@ -327,7 +343,7 @@ class MainActivity : ComponentActivity() {
} }
override fun donationDialogVisit() { override fun donationDialogVisit() {
if (dir.isAnalyticsEnabled) { if (preferenceManager.isAnalyticsEnabled) {
// Donation Dialog Open Event // Donation Dialog Open Event
TrackHelper.track().screen("/main_activity/donation_dialog") TrackHelper.track().screen("/main_activity/donation_dialog")
.title("DonationDialog").with(tracker) .title("DonationDialog").with(tracker)
@ -337,6 +353,54 @@ class MainActivity : ComponentActivity() {
} }
) )
private fun queryActiveTracks() {
lifecycleScope.launch {
foregroundService?.trackStatusFlowMap?.let { tracksStatus ->
trackStatusFlow.emit(tracksStatus)
}
}
}
override fun onResume() {
super.onResume()
queryActiveTracks()
}
@Suppress("DEPRECATION")
private fun setUpOnPrefClickListener() {
// Initialize Builder
val chooser = StorageChooser.Builder()
.withActivity(this)
.withFragmentManager(fragmentManager)
.withMemoryBar(true)
.setTheme(StorageChooser.Theme(applicationContext).apply {
scheme = applicationContext.resources.getIntArray(R.array.default_dark)
})
.setDialogTitle("Set Download Directory")
.allowCustomPath(true)
.setType(StorageChooser.DIRECTORY_CHOOSER)
.build()
// get path that the user has chosen
chooser.setOnSelectListener { path ->
Log.d("Setting Base Path", path)
val f = File(path)
if (f.canWrite()) {
// hell yeah :)
preferenceManager.setDownloadDirectory(path)
showPopUpMessage(
"Download Directory Set to:\n${dir.defaultDir()} "
)
}else{
showPopUpMessage(
"NO WRITE ACCESS on \n$path ,\nReverting Back to Previous"
)
}
}
// Show dialog whenever you want by
chooser.show()
}
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -357,76 +421,6 @@ class MainActivity : ComponentActivity() {
} }
} }
/*
* Broadcast Handlers
* */
private fun initializeBroadcast(){
val intentFilter = IntentFilter().apply {
addAction(Status.QUEUED.name)
addAction(Status.FAILED.name)
addAction(Status.DOWNLOADING.name)
addAction(Status.COMPLETED.name)
addAction("Progress")
addAction("Converting")
}
updateUIReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
//Update Flow with latest details
if (intent != null) {
val trackDetails = intent.getParcelableExtra<TrackDetails?>("track")
trackDetails?.let { track ->
lifecycleScope.launch {
val latestMap = trackStatusFlow.replayCache.getOrElse(0
) { hashMapOf() }.apply {
this[track.title] = when (intent.action) {
Status.QUEUED.name -> DownloadStatus.Queued
Status.FAILED.name -> DownloadStatus.Failed
Status.DOWNLOADING.name -> DownloadStatus.Downloading()
"Progress" -> DownloadStatus.Downloading(intent.getIntExtra("progress", 0))
"Converting" -> DownloadStatus.Converting
Status.COMPLETED.name -> DownloadStatus.Downloaded
else -> DownloadStatus.NotDownloaded
}
}
trackStatusFlow.emit(latestMap)
Log.i("Track Update",track.title + track.downloaded.toString())
}
}
}
}
}
val queryFilter = IntentFilter().apply { addAction("query_result") }
queryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
//UI update here
if (intent != null){
@Suppress("UNCHECKED_CAST")
val trackList = intent.getSerializableExtra("tracks") as? HashMap<String, DownloadStatus>?
trackList?.let { list ->
Log.i("Service Response", "${list.size} Tracks Active")
lifecycleScope.launch {
trackStatusFlow.emit(list)
}
}
}
}
}
registerReceiver(updateUIReceiver, intentFilter)
registerReceiver(queryReceiver, queryFilter)
}
override fun onResume() {
super.onResume()
initializeBroadcast()
}
override fun onPause() {
super.onPause()
unregisterReceiver(updateUIReceiver)
unregisterReceiver(queryReceiver)
}
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntentFromExternalActivity(intent) handleIntentFromExternalActivity(intent)
@ -451,6 +445,11 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onDestroy() {
super.onDestroy()
unbindService()
}
companion object { companion object {
const val disableDozeCode = 1223 const val disableDozeCode = 1223
} }

View File

@ -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
}
}

View File

@ -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 }

View File

@ -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
}
}

View File

@ -1,22 +1,22 @@
package com.shabinder.common.di.worker package com.shabinder.spotiflyer.service
import co.touchlab.kermit.Kermit import android.util.Log
import java.io.File import java.io.File
/** /**
* Cleaning All Residual Files except Mp3 Files * Cleaning All Residual Files except Mp3 Files
**/ **/
fun cleanFiles(dir: File, logger: Kermit) { fun cleanFiles(dir: File) {
try { try {
logger.d("File Cleaning") { "Starting Cleaning in ${dir.path} " } Log.d("File Cleaning","Starting Cleaning in ${dir.path} ")
val fList = dir.listFiles() val fList = dir.listFiles()
fList?.let { fList?.let {
for (file in fList) { for (file in fList) {
if (file.isDirectory) { if (file.isDirectory) {
cleanFiles(file, logger) cleanFiles(file)
} else if (file.isFile) { } else if (file.isFile) {
if (file.path.toString().substringAfterLast(".") != "mp3") { if (file.path.toString().substringAfterLast(".") != "mp3") {
logger.d("Files Cleaning") { "Cleaning ${file.path}" } Log.d("Files Cleaning","Cleaning ${file.path}")
file.delete() file.delete()
} }
} }
@ -24,3 +24,4 @@ fun cleanFiles(dir: File, logger: Kermit) {
} }
} catch (e: Exception) { e.printStackTrace() } } catch (e: Exception) { e.printStackTrace() }
} }

View File

@ -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)
}

View File

@ -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)
}

View File

@ -0,0 +1,7 @@
package com.shabinder.spotiflyer.utils.autoclear
import androidx.lifecycle.DefaultLifecycleObserver
interface LifecycleAutoInitializer<T>: DefaultLifecycleObserver {
var value: T?
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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>

View File

@ -33,12 +33,17 @@ allprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "1.8"
useIR = true freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
} }
} }
afterEvaluate { afterEvaluate {
project.extensions.findByType<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension>()?.let { kmpExt -> project.extensions.findByType<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension>()?.let { kmpExt ->
kmpExt.sourceSets.removeAll { it.name == "androidAndroidTestRelease" } kmpExt.sourceSets.run {
all {
languageSettings.useExperimentalAnnotation("kotlinx.serialization.ExperimentalSerializationApi")
}
removeAll { it.name == "androidAndroidTestRelease" }
}
} }
} }
} }

View File

@ -31,11 +31,12 @@ repositories {
dependencies { dependencies {
implementation("com.android.tools.build:gradle:4.1.1") implementation("com.android.tools.build:gradle:4.1.1")
implementation("org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktLint}")
implementation(JetBrains.Compose.gradlePlugin) implementation(JetBrains.Compose.gradlePlugin)
implementation(JetBrains.Kotlin.gradlePlugin) implementation(JetBrains.Kotlin.gradlePlugin)
implementation(JetBrains.Kotlin.serialization) implementation(JetBrains.Kotlin.serialization)
implementation(SqlDelight.gradlePlugin) implementation(SqlDelight.gradlePlugin)
implementation("org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktLint}")
implementation("de.comahe.i18n4k:i18n4k-gradle-plugin:0.1.1")
} }
kotlin { kotlin {

View File

@ -49,7 +49,7 @@ object Versions {
const val minSdkVersion = 21 const val minSdkVersion = 21
const val compileSdkVersion = 29 const val compileSdkVersion = 29
const val targetSdkVersion = 29 const val targetSdkVersion = 29
const val androidLifecycle = "2.3.0" const val androidxLifecycle = "2.3.1"
} }
object HostOS { object HostOS {
@ -60,6 +60,10 @@ object HostOS {
val isLinux = hostOs.startsWith("Linux",true) val isLinux = hostOs.startsWith("Linux",true)
} }
object MultiPlatformSettings {
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7"
}
object Koin { object Koin {
val core = "io.insert-koin:koin-core:${Versions.koin}" val core = "io.insert-koin:koin-core:${Versions.koin}"
val test = "io.insert-koin:koin-test:${Versions.koin}" val test = "io.insert-koin:koin-test:${Versions.koin}"
@ -141,6 +145,10 @@ object Ktor {
val clientJs = "io.ktor:ktor-client-js:${Versions.ktor}" val clientJs = "io.ktor:ktor-client-js:${Versions.ktor}"
} }
object Internationalization {
const val dep = "de.comahe.i18n4k:i18n4k-core:0.1.1"
}
object Extras { object Extras {
const val youtubeDownloader = "io.github.shabinder:youtube-api-dl:1.2" const val youtubeDownloader = "io.github.shabinder:youtube-api-dl:1.2"
const val fuzzyWuzzy = "io.github.shabinder:fuzzywuzzy:1.1" const val fuzzyWuzzy = "io.github.shabinder:fuzzywuzzy:1.1"

View File

@ -26,6 +26,7 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.shabinder.common.database.R import com.shabinder.common.database.R
import com.shabinder.common.translations.Strings
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
actual fun montserratFont() = FontFamily( actual fun montserratFont() = FontFamily(
@ -43,7 +44,7 @@ actual fun pristineFont() = FontFamily(
actual fun DownloadImageTick() { actual fun DownloadImageTick() {
Image( Image(
painterResource(R.drawable.ic_tick), painterResource(R.drawable.ic_tick),
"Download Done" Strings.downloadDone()
) )
} }
@ -51,7 +52,7 @@ actual fun DownloadImageTick() {
actual fun DownloadImageError() { actual fun DownloadImageError() {
Image( Image(
painterResource(R.drawable.ic_error), painterResource(R.drawable.ic_error),
"Error! Cant Download this track" Strings.downloadError()
) )
} }
@ -59,7 +60,7 @@ actual fun DownloadImageError() {
actual fun DownloadImageArrow(modifier: Modifier) { actual fun DownloadImageArrow(modifier: Modifier) {
Image( Image(
painterResource(R.drawable.ic_arrow), painterResource(R.drawable.ic_arrow),
"Start Download", Strings.downloadStart(),
modifier modifier
) )
} }

View File

@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.translations.Strings
@OptIn(ExperimentalAnimationApi::class) @OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
@ -44,7 +45,7 @@ actual fun DonationDialog(
) { ) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text( Text(
"We Need Your Support!", Strings.supportUs(),
style = SpotiFlyerTypography.h5, style = SpotiFlyerTypography.h5,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = colorAccent, color = colorAccent,
@ -69,7 +70,7 @@ actual fun DonationDialog(
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Worldwide Donations", text = Strings.worldWideDonations(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -92,7 +93,7 @@ actual fun DonationDialog(
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "International Donations (Outside India).", text = Strings.worldWideDonations(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -115,7 +116,7 @@ actual fun DonationDialog(
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Indian Donations (UPI / PayTM / PhonePe / Cards).", text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).",
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -126,11 +127,11 @@ actual fun DonationDialog(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth() modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth()
) { ) {
OutlinedButton(onClick = onSnooze) { OutlinedButton(onClick = onDismiss) {
Text("Dismiss.") Text(Strings.dismiss())
} }
TextButton(onClick = onDismiss, colors = ButtonDefaults.buttonColors()) { TextButton(onClick = onSnooze, colors = ButtonDefaults.buttonColors()) {
Text("Remind Later!") Text(Strings.remindLater())
} }
} }
} }

View File

@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.Color
val colorPrimary = Color(0xFFFC5C7D) val colorPrimary = Color(0xFFFC5C7D)
val colorPrimaryDark = Color(0xFFCE1CFF) val colorPrimaryDark = Color(0xFFCE1CFF)
val colorAccent = Color(0xFF9AB3FF) val colorAccent = Color(0xFF9AB3FF)
val colorAccentVariant = Color(0xFF3457D5)
val colorRedError = Color(0xFFFF9494) val colorRedError = Color(0xFFFF9494)
val colorSuccessGreen = Color(0xFF59C351) val colorSuccessGreen = Color(0xFF59C351)
val darkBackgroundColor = Color(0xFF000000) val darkBackgroundColor = Color(0xFF000000)

View File

@ -17,12 +17,29 @@
package com.shabinder.common.uikit package com.shabinder.common.uikit
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.* import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.* import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExtendedFloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -37,6 +54,8 @@ import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.translations.Strings
import com.shabinder.common.uikit.dialogs.DonationDialogComponent
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@ -49,10 +68,11 @@ fun SpotiFlyerListContent(
LaunchedEffect(model.errorOccurred) { LaunchedEffect(model.errorOccurred) {
/*Handle if Any Exception Occurred*/ /*Handle if Any Exception Occurred*/
model.errorOccurred?.let { model.errorOccurred?.let {
methods.value.showPopUpMessage(it.message ?: "An Error Occurred, Check your Link / Connection") methods.value.showPopUpMessage(it.message ?: Strings.errorOccurred())
component.onBackPressed() component.onBackPressed()
} }
} }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
val result = model.queryResult val result = model.queryResult
if (result == null) { if (result == null) {
@ -60,7 +80,7 @@ fun SpotiFlyerListContent(
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator() CircularProgressIndicator()
Spacer(modifier.padding(8.dp)) Spacer(modifier.padding(8.dp))
Text("Loading..", style = appNameStyle, color = colorPrimary) Text("${Strings.loading()}...", style = appNameStyle, color = colorPrimary)
} }
} else { } else {
@ -83,25 +103,19 @@ fun SpotiFlyerListContent(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Donation Dialog Visibility // Donation Dialog Visibility
var visibilty by remember { mutableStateOf(false) } val (openDonationDialog,dismissDonationDialog,snoozeDonationDialog) = DonationDialogComponent {
DonationDialog( component.dismissDonationDialogSetOffset()
isVisible = visibilty, }
onDismiss = {
visibilty = false
},
onSnooze = {
visibilty = false
component.snoozeDonationDialog()
}
)
DownloadAllButton( DownloadAllButton(
onClick = { onClick = {
component.onDownloadAllClicked(model.trackList) component.onDownloadAllClicked(model.trackList)
// Check If we are allowed to show donation Dialog // Check If we are allowed to show donation Dialog
if (model.askForDonation) { if (model.askForDonation) {
// Show Donation Dialog // Show Donation Dialog
visibilty = true openDonationDialog()
} }
}, },
modifier = Modifier.padding(bottom = 24.dp).align(Alignment.BottomCenter) modifier = Modifier.padding(bottom = 24.dp).align(Alignment.BottomCenter)
@ -129,7 +143,7 @@ fun TrackCard(
ImageLoad( ImageLoad(
track.albumArtURL, track.albumArtURL,
{ loadImage() }, { loadImage() },
"Album Art", Strings.albumArt(),
modifier = Modifier modifier = Modifier
.width(70.dp) .width(70.dp)
.height(70.dp) .height(70.dp)
@ -143,7 +157,7 @@ fun TrackCard(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize() modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
) { ) {
Text("${track.artists.firstOrNull()}...", fontSize = 12.sp, maxLines = 1) Text("${track.artists.firstOrNull()}...", fontSize = 12.sp, maxLines = 1)
Text("${track.durationSec / 60} min, ${track.durationSec % 60} sec", fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${track.durationSec / 60} ${Strings.minute()}, ${track.durationSec % 60} ${Strings.second()}", fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
when (track.downloaded) { when (track.downloaded) {
@ -189,7 +203,7 @@ fun CoverImage(
ImageLoad( ImageLoad(
coverURL, coverURL,
{ loadImage(coverURL, true) }, { loadImage(coverURL, true) },
"Cover Image", Strings.coverImage(),
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(12.dp)
.width(190.dp) .width(190.dp)
@ -212,9 +226,9 @@ fun CoverImage(
@Composable @Composable
fun DownloadAllButton(onClick: () -> Unit, modifier: Modifier = Modifier) { fun DownloadAllButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
text = { Text("Download All") }, text = { Text(Strings.downloadAll()) },
onClick = onClick, onClick = onClick,
icon = { Icon(DownloadAllImage(), "Download All Button", tint = Color(0xFF000000)) }, icon = { Icon(DownloadAllImage(), Strings.downloadAll() + Strings.button(), tint = Color(0xFF000000)) },
backgroundColor = colorAccent, backgroundColor = colorAccent,
modifier = modifier modifier = modifier
) )

View File

@ -17,21 +17,54 @@
package com.shabinder.common.uikit package com.shabinder.common.uikit
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.* import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.* import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.* import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Tab
import androidx.compose.material.TabPosition
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults.textFieldColors import androidx.compose.material.TextFieldDefaults.textFieldColors
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.rounded.* import androidx.compose.material.icons.rounded.CardGiftcard
import androidx.compose.runtime.* import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Insights
import androidx.compose.material.icons.rounded.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -50,11 +83,17 @@ import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
import com.shabinder.common.models.DownloadRecord import com.shabinder.common.models.DownloadRecord
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.translations.Strings
import com.shabinder.common.uikit.dialogs.DonationDialogComponent
@Composable @Composable
fun SpotiFlyerMainContent(component: SpotiFlyerMain) { fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
val model by component.model.subscribeAsState() val model by component.model.subscribeAsState()
val (openDonationDialog,_,_) = DonationDialogComponent {
component.dismissDonationDialogOffset()
}
Column { Column {
SearchPanel( SearchPanel(
model.link, model.link,
@ -65,14 +104,17 @@ fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
HomeTabBar( HomeTabBar(
model.selectedCategory, model.selectedCategory,
HomeCategory.values(), HomeCategory.values(),
component::selectCategory component::selectCategory,
) )
when (model.selectedCategory) { when (model.selectedCategory) {
HomeCategory.About -> AboutColumn( HomeCategory.About -> AboutColumn(
analyticsEnabled = model.isAnalyticsEnabled, analyticsEnabled = model.isAnalyticsEnabled,
donationDialogOpenEvent = { component.analytics.donationDialogVisit() }, toggleAnalytics = component::toggleAnalytics,
toggleAnalytics = component::toggleAnalytics openDonationDialog = {
component.analytics.donationDialogVisit()
openDonationDialog()
}
) )
HomeCategory.History -> HistoryColumn( HomeCategory.History -> HistoryColumn(
model.records.sortedByDescending { it.id }, model.records.sortedByDescending { it.id },
@ -98,6 +140,7 @@ fun HomeTabBar(
} }
TabRow( TabRow(
backgroundColor = transparent,
selectedTabIndex = selectedIndex, selectedTabIndex = selectedIndex,
indicator = indicator, indicator = indicator,
modifier = modifier, modifier = modifier,
@ -109,16 +152,16 @@ fun HomeTabBar(
text = { text = {
Text( Text(
text = when (category) { text = when (category) {
HomeCategory.About -> "About" HomeCategory.About -> Strings.about()
HomeCategory.History -> "History" HomeCategory.History -> Strings.history()
}, },
style = MaterialTheme.typography.body2 style = MaterialTheme.typography.body2
) )
}, },
icon = { icon = {
when (category) { when (category) {
HomeCategory.About -> Icon(Icons.Outlined.Info, "Info Tab") HomeCategory.About -> Icon(Icons.Outlined.Info, Strings.infoTab())
HomeCategory.History -> Icon(Icons.Outlined.History, "History Tab") HomeCategory.History -> Icon(Icons.Outlined.History, Strings.historyTab())
} }
} }
) )
@ -141,9 +184,9 @@ fun SearchPanel(
value = link, value = link,
onValueChange = updateLink, onValueChange = updateLink,
leadingIcon = { leadingIcon = {
Icon(Icons.Rounded.Edit, "Link Text Box", tint = Color.LightGray) Icon(Icons.Rounded.Edit, Strings.linkTextBox(), tint = Color.LightGray)
}, },
label = { Text(text = "Paste Link Here...", color = Color.LightGray) }, label = { Text(text = Strings.pasteLinkHere(), color = Color.LightGray) },
singleLine = true, singleLine = true,
textStyle = TextStyle.Default.merge(TextStyle(fontSize = 18.sp, color = Color.White)), textStyle = TextStyle.Default.merge(TextStyle(fontSize = 18.sp, color = Color.White)),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
@ -170,7 +213,7 @@ fun SearchPanel(
OutlinedButton( OutlinedButton(
modifier = Modifier.padding(12.dp).wrapContentWidth(), modifier = Modifier.padding(12.dp).wrapContentWidth(),
onClick = { onClick = {
if (link.isBlank()) methods.value.showPopUpMessage("Enter A Link!") if (link.isBlank()) methods.value.showPopUpMessage(Strings.enterALink())
else { else {
// TODO if(!isOnline(ctx)) showPopUpMessage("Check Your Internet Connection") else // TODO if(!isOnline(ctx)) showPopUpMessage("Check Your Internet Connection") else
onSearch(link) onSearch(link)
@ -186,7 +229,7 @@ fun SearchPanel(
) )
) )
) { ) {
Text(text = "Search", style = SpotiFlyerTypography.h6, modifier = Modifier.padding(4.dp)) Text(text = Strings.search(), style = SpotiFlyerTypography.h6, modifier = Modifier.padding(4.dp))
} }
} }
} }
@ -195,7 +238,7 @@ fun SearchPanel(
fun AboutColumn( fun AboutColumn(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
analyticsEnabled:Boolean, analyticsEnabled:Boolean,
donationDialogOpenEvent: () -> Unit, openDonationDialog: () -> Unit,
toggleAnalytics: (enabled: Boolean) -> Unit toggleAnalytics: (enabled: Boolean) -> Unit
) { ) {
@ -209,7 +252,7 @@ fun AboutColumn(
) { ) {
Column(modifier.padding(12.dp)) { Column(modifier.padding(12.dp)) {
Text( Text(
text = "Supported Platforms", text = Strings.supportedPlatforms(),
style = SpotiFlyerTypography.body1, style = SpotiFlyerTypography.body1,
color = colorAccent color = colorAccent
) )
@ -217,7 +260,7 @@ fun AboutColumn(
Row(horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth()) {
Icon( Icon(
SpotifyLogo(), SpotifyLogo(),
"Open Spotify", "${Strings.open()} Spotify",
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
onClick = { methods.value.openPlatform("com.spotify.music", "http://open.spotify.com") } onClick = { methods.value.openPlatform("com.spotify.music", "http://open.spotify.com") }
@ -226,7 +269,7 @@ fun AboutColumn(
Spacer(modifier = modifier.padding(start = 16.dp)) Spacer(modifier = modifier.padding(start = 16.dp))
Icon( Icon(
GaanaLogo(), GaanaLogo(),
"Open Gaana", "${Strings.open()} Gaana",
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
onClick = { methods.value.openPlatform("com.gaana", "https://www.gaana.com") } onClick = { methods.value.openPlatform("com.gaana", "https://www.gaana.com") }
@ -235,7 +278,7 @@ fun AboutColumn(
Spacer(modifier = modifier.padding(start = 16.dp)) Spacer(modifier = modifier.padding(start = 16.dp))
Icon( Icon(
SaavnLogo(), SaavnLogo(),
"Open Jio Saavn", "${Strings.open()} Jio Saavn",
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.clickable( modifier = Modifier.clickable(
onClick = { methods.value.openPlatform("com.jio.media.jiobeats", "https://www.jiosaavn.com/") } onClick = { methods.value.openPlatform("com.jio.media.jiobeats", "https://www.jiosaavn.com/") }
@ -244,7 +287,7 @@ fun AboutColumn(
Spacer(modifier = modifier.padding(start = 16.dp)) Spacer(modifier = modifier.padding(start = 16.dp))
Icon( Icon(
YoutubeLogo(), YoutubeLogo(),
"Open Youtube", "${Strings.open()} Youtube",
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
onClick = { methods.value.openPlatform("com.google.android.youtube", "https://m.youtube.com") } onClick = { methods.value.openPlatform("com.google.android.youtube", "https://m.youtube.com") }
@ -253,7 +296,7 @@ fun AboutColumn(
Spacer(modifier = modifier.padding(start = 12.dp)) Spacer(modifier = modifier.padding(start = 12.dp))
Icon( Icon(
YoutubeMusicLogo(), YoutubeMusicLogo(),
"Open Youtube Music", "${Strings.open()} Youtube Music",
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( modifier = Modifier.clip(SpotiFlyerShapes.small).clickable(
onClick = { methods.value.openPlatform("com.google.android.apps.youtube.music", "https://music.youtube.com/") } onClick = { methods.value.openPlatform("com.google.android.apps.youtube.music", "https://music.youtube.com/") }
@ -269,7 +312,7 @@ fun AboutColumn(
) { ) {
Column(modifier.padding(12.dp)) { Column(modifier.padding(12.dp)) {
Text( Text(
text = "Support Development", text = Strings.supportDevelopment(),
style = SpotiFlyerTypography.body1, style = SpotiFlyerTypography.body1,
color = colorAccent color = colorAccent
) )
@ -281,7 +324,7 @@ fun AboutColumn(
) )
.padding(vertical = 6.dp) .padding(vertical = 6.dp)
) { ) {
Icon(GithubLogo(), "Open Project Repo", Modifier.size(32.dp), tint = Color(0xFFCCCCCC)) Icon(GithubLogo(), Strings.openProjectRepo(), Modifier.size(32.dp), tint = Color(0xFFCCCCCC))
Spacer(modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.padding(start = 16.dp))
Column { Column {
Text( Text(
@ -289,7 +332,7 @@ fun AboutColumn(
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Star / Fork the project on Github.", text = Strings.starOrForkProject(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -299,51 +342,34 @@ fun AboutColumn(
.clickable(onClick = { methods.value.openPlatform("", "http://github.com/Shabinder/SpotiFlyer") }), .clickable(onClick = { methods.value.openPlatform("", "http://github.com/Shabinder/SpotiFlyer") }),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.Flag, "Help Translate", Modifier.size(32.dp)) Icon(Icons.Rounded.Flag, Strings.help() + Strings.translate(), Modifier.size(32.dp))
Spacer(modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.padding(start = 16.dp))
Column { Column {
Text( Text(
text = "Translate", text = Strings.translate(),
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Help us translate this app in your local language.", text = Strings.helpTranslateDescription(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
} }
var isDonationDialogVisible by remember { mutableStateOf(false) }
DonationDialog(
isDonationDialogVisible,
onDismiss = {
isDonationDialogVisible = false
},
onSnooze = {
isDonationDialogVisible = false
}
)
Row( Row(
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)
.clickable( .clickable(onClick = openDonationDialog),
onClick = {
isDonationDialogVisible = true
donationDialogOpenEvent()
}
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.CardGiftcard, "Support Developer", Modifier.size(32.dp)) Icon(Icons.Rounded.CardGiftcard, Strings.supportDeveloper(), Modifier.size(32.dp))
Spacer(modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.padding(start = 16.dp))
Column { Column {
Text( Text(
text = "Donate", text = Strings.donate(),
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "If you think I deserve to get paid for my work, you can support me here.", text = Strings.donateDescription(),
// text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.", // text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.",
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
@ -358,15 +384,15 @@ fun AboutColumn(
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.Share, "Share SpotiFlyer App", Modifier.size(32.dp)) Icon(Icons.Rounded.Share, Strings.share() + Strings.title() + "App", Modifier.size(32.dp))
Spacer(modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.padding(start = 16.dp))
Column { Column {
Text( Text(
text = "Share", text = Strings.share(),
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Share this app with your friends and family.", text = Strings.shareDescription(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -380,17 +406,17 @@ fun AboutColumn(
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.Insights, "Analytics Status", Modifier.size(32.dp)) Icon(Icons.Rounded.Insights, Strings.analytics() + Strings.status(), Modifier.size(32.dp))
Spacer(modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.padding(start = 16.dp))
Column( Column(
Modifier.weight(1f) Modifier.weight(1f)
) { ) {
Text( Text(
text = "Analytics", text = Strings.analytics(),
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Your Data is Anonymized and never shared with 3rd party service", text = Strings.analyticsDescription(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -421,10 +447,10 @@ fun HistoryColumn(
if (it.isEmpty()) { if (it.isEmpty()) {
Column(Modifier.padding(8.dp).fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.padding(8.dp).fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Icon( Icon(
Icons.Outlined.Info, "No History Available Yet", modifier = Modifier.size(80.dp), Icons.Outlined.Info, Strings.noHistoryAvailable(), modifier = Modifier.size(80.dp),
colorOffWhite colorOffWhite
) )
Text("No History Available", style = SpotiFlyerTypography.h4.copy(fontWeight = FontWeight.Light), textAlign = TextAlign.Center) Text(Strings.noHistoryAvailable(), style = SpotiFlyerTypography.h4.copy(fontWeight = FontWeight.Light), textAlign = TextAlign.Center)
} }
} else { } else {
Box { Box {
@ -470,7 +496,7 @@ fun DownloadRecordItem(
ImageLoad( ImageLoad(
item.coverUrl, item.coverUrl,
{ loadImage(item.coverUrl) }, { loadImage(item.coverUrl) },
"Album Art", Strings.albumArt(),
modifier = Modifier.height(70.dp).width(70.dp).clip(SpotiFlyerShapes.medium) modifier = Modifier.height(70.dp).width(70.dp).clip(SpotiFlyerShapes.medium)
) )
Column(modifier = Modifier.padding(horizontal = 8.dp).height(60.dp).weight(1f), verticalArrangement = Arrangement.SpaceEvenly) { Column(modifier = Modifier.padding(horizontal = 8.dp).height(60.dp).weight(1f), verticalArrangement = Arrangement.SpaceEvenly) {
@ -481,12 +507,12 @@ fun DownloadRecordItem(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize() modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize()
) { ) {
Text(item.type, fontSize = 13.sp, color = colorOffWhite) Text(item.type, fontSize = 13.sp, color = colorOffWhite)
Text("Tracks: ${item.totalFiles}", fontSize = 13.sp, color = colorOffWhite) Text("${Strings.tracks()}: ${item.totalFiles}", fontSize = 13.sp, color = colorOffWhite)
} }
} }
Image( Image(
ShareImage(), ShareImage(),
"Research", Strings.reSearch(),
modifier = Modifier.clickable( modifier = Modifier.clickable(
onClick = { onClick = {
// if(!isOnline(ctx)) showDialog("Check Your Internet Connection") else // if(!isOnline(ctx)) showDialog("Check Your Internet Connection") else
@ -504,7 +530,7 @@ fun HomeCategoryTabIndicator(
) { ) {
Spacer( Spacer(
modifier.padding(horizontal = 24.dp) modifier.padding(horizontal = 24.dp)
.height(4.dp) .height(3.dp)
.background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100))
) )
} }

View File

@ -56,9 +56,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.jetbrains.Children import com.arkivanov.decompose.extensions.compose.jetbrains.Children
import com.arkivanov.decompose.extensions.compose.jetbrains.animation.child.crossfadeScale import com.arkivanov.decompose.extensions.compose.jetbrains.animation.child.crossfadeScale
import com.arkivanov.decompose.extensions.compose.jetbrains.asState import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRoot.Child import com.shabinder.common.root.SpotiFlyerRoot.Child
import com.shabinder.common.translations.Strings
import com.shabinder.common.uikit.splash.Splash import com.shabinder.common.uikit.splash.Splash
import com.shabinder.common.uikit.splash.SplashState import com.shabinder.common.uikit.splash.SplashState
import com.shabinder.common.uikit.utils.verticalGradientScrim import com.shabinder.common.uikit.utils.verticalGradientScrim
@ -125,7 +126,7 @@ fun MainScreen(modifier: Modifier = Modifier, alpha: Float, topPadding: Dp = 0.d
).then(modifier) ).then(modifier)
) { ) {
val activeComponent = component.routerState.asState() val activeComponent = component.routerState.subscribeAsState()
val callBacks = component.callBacks val callBacks = component.callBacks
AppBar( AppBar(
backgroundColor = appBarColor, backgroundColor = appBarColor,
@ -163,7 +164,7 @@ fun AppBar(
AnimatedVisibility(isBackButtonVisible) { AnimatedVisibility(isBackButtonVisible) {
Icon( Icon(
Icons.Rounded.ArrowBackIosNew, Icons.Rounded.ArrowBackIosNew,
contentDescription = "Back Button", contentDescription = Strings.backButton(),
modifier = Modifier.clickable { onBackPressed() }, modifier = Modifier.clickable { onBackPressed() },
tint = Color.LightGray tint = Color.LightGray
) )
@ -171,12 +172,12 @@ fun AppBar(
} }
Image( Image(
SpotiFlyerLogo(), SpotiFlyerLogo(),
"SpotiFlyer Logo", Strings.spotiflyerLogo(),
Modifier.size(32.dp), Modifier.size(32.dp),
) )
Spacer(Modifier.padding(horizontal = 4.dp)) Spacer(Modifier.padding(horizontal = 4.dp))
Text( Text(
text = "SpotiFlyer", text = Strings.title(),
style = appNameStyle style = appNameStyle
) )
} }
@ -185,7 +186,7 @@ fun AppBar(
IconButton( IconButton(
onClick = { setDownloadDirectory() } onClick = { setDownloadDirectory() }
) { ) {
Icon(Icons.Filled.Settings, "Preferences", tint = Color.Gray) Icon(Icons.Filled.Settings, Strings.preferences(), tint = Color.Gray)
} }
}, },
modifier = modifier, modifier = modifier,

View File

@ -1 +1,33 @@
package com.shabinder.common.uikit.dialogs package com.shabinder.common.uikit.dialogs
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.shabinder.common.uikit.DonationDialog
typealias DonationDialogCallBacks = Triple<openAction,dismissAction,snoozeAction>
private typealias openAction = () -> Unit
private typealias dismissAction = () -> Unit
private typealias snoozeAction = () -> Unit
@Composable
fun DonationDialogComponent(onDismissExtra: () -> Unit): DonationDialogCallBacks {
var isDonationDialogVisible by remember { mutableStateOf(false) }
DonationDialog(
isDonationDialogVisible,
onSnooze = { isDonationDialogVisible = false },
onDismiss = {
isDonationDialogVisible = false
}
)
val openDonationDialog = { isDonationDialogVisible = true }
val snoozeDonationDialog = { isDonationDialogVisible = false }
val dismissDonationDialog = {
onDismissExtra()
isDonationDialogVisible = false
}
return DonationDialogCallBacks(openDonationDialog,dismissDonationDialog,snoozeDonationDialog)
}

View File

@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.shabinder.common.translations.Strings
import com.shabinder.common.uikit.HeartIcon import com.shabinder.common.uikit.HeartIcon
import com.shabinder.common.uikit.SpotiFlyerLogo import com.shabinder.common.uikit.SpotiFlyerLogo
import com.shabinder.common.uikit.SpotiFlyerTypography import com.shabinder.common.uikit.SpotiFlyerTypography
@ -55,7 +56,7 @@ fun Splash(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
delay(SplashWaitTime) delay(SplashWaitTime)
currentOnTimeout() currentOnTimeout()
} }
Image(SpotiFlyerLogo(), "SpotiFlyer Logo") Image(SpotiFlyerLogo(), Strings.spotiflyerLogo())
MadeInIndia(Modifier.align(Alignment.BottomCenter)) MadeInIndia(Modifier.align(Alignment.BottomCenter))
} }
} }
@ -73,21 +74,21 @@ fun MadeInIndia(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Made with ", text = "${Strings.madeWith()} ",
color = colorPrimary, color = colorPrimary,
fontSize = 22.sp fontSize = 22.sp
) )
Spacer(modifier = Modifier.padding(start = 4.dp)) Spacer(modifier = Modifier.padding(start = 4.dp))
Icon(HeartIcon(), "Love", tint = Color.Unspecified) Icon(HeartIcon(), Strings.love(), tint = Color.Unspecified)
Spacer(modifier = Modifier.padding(start = 4.dp)) Spacer(modifier = Modifier.padding(start = 4.dp))
Text( Text(
text = " in India", text = " ${Strings.inIndia()}",
color = colorPrimary, color = colorPrimary,
fontSize = 22.sp fontSize = 22.sp
) )
} }
Text( Text(
"by: Shabinder Singh", Strings.byDeveloperName(),
style = SpotiFlyerTypography.h6, style = SpotiFlyerTypography.h6,
color = colorAccent, color = colorAccent,
fontSize = 14.sp fontSize = 14.sp

View File

@ -20,9 +20,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.v1.Dialog import androidx.compose.ui.window.v1.Dialog
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.translations.Strings
@OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) @OptIn(ExperimentalAnimationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable @Composable
@ -42,7 +42,7 @@ actual fun DonationDialog(
) { ) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text( Text(
"Support Us", Strings.supportUs(),
style = SpotiFlyerTypography.h5, style = SpotiFlyerTypography.h5,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = colorAccent, color = colorAccent,
@ -67,7 +67,7 @@ actual fun DonationDialog(
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "International Donations (Outside India).", text = Strings.worldWideDonations(),
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }
@ -90,7 +90,7 @@ actual fun DonationDialog(
style = SpotiFlyerTypography.h6 style = SpotiFlyerTypography.h6
) )
Text( Text(
text = "Indian Donations (UPI / PayTM / PhonePe / Cards).", text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).",
style = SpotiFlyerTypography.subtitle2 style = SpotiFlyerTypography.subtitle2
) )
} }

View File

@ -1,3 +1,5 @@
import de.comahe.i18n4k.gradle.plugin.i18n4k
/* /*
* * Copyright (c) 2021 Shabinder Singh * * Copyright (c) 2021 Shabinder Singh
* * This program is free software: you can redistribute it and/or modify * * This program is free software: you can redistribute it and/or modify
@ -20,11 +22,18 @@ plugins {
id("multiplatform-setup-test") id("multiplatform-setup-test")
id("kotlin-parcelize") id("kotlin-parcelize")
kotlin("plugin.serialization") kotlin("plugin.serialization")
id("de.comahe.i18n4k")
} }
val statelyVersion = "1.1.7" val statelyVersion = "1.1.7"
val statelyIsoVersion = "1.1.7-a1" val statelyIsoVersion = "1.1.7-a1"
i18n4k {
inputDirectory = "../../translations"
packageName = "com.shabinder.common.translations"
// sourceCodeLocales = listOf("en", "de")
}
kotlin { kotlin {
sourceSets { sourceSets {
/* /*
@ -44,6 +53,8 @@ kotlin {
implementation("co.touchlab:stately-concurrency:$statelyVersion") implementation("co.touchlab:stately-concurrency:$statelyVersion")
implementation("co.touchlab:stately-isolate:$statelyIsoVersion") implementation("co.touchlab:stately-isolate:$statelyIsoVersion")
implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion") implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion")
implementation(Extras.youtubeDownloader)
api(Internationalization.dep)
} }
} }
androidMain { androidMain {

View File

@ -14,7 +14,7 @@ actual interface PlatformActions {
fun addToLibrary(path: String) fun addToLibrary(path: String)
fun sendTracksToService(array: ArrayList<TrackDetails>) fun sendTracksToService(array: List<TrackDetails>)
} }
actual val StubPlatformActions = object : PlatformActions { actual val StubPlatformActions = object : PlatformActions {
@ -24,5 +24,5 @@ actual val StubPlatformActions = object : PlatformActions {
override fun addToLibrary(path: String) {} override fun addToLibrary(path: String) {}
override fun sendTracksToService(array: ArrayList<TrackDetails>) {} override fun sendTracksToService(array: List<TrackDetails>) {}
} }

View File

@ -0,0 +1,3 @@
package com.shabinder.common
fun <T: Any?> T?.requireNotNull() : T = requireNotNull(this)

View File

@ -16,6 +16,9 @@
package com.shabinder.common.models package com.shabinder.common.models
import io.github.shabinder.TargetPlatforms
import io.github.shabinder.activePlatform
sealed class CorsProxy(open val url: String) { sealed class CorsProxy(open val url: String) {
data class SelfHostedCorsProxy(override val url: String = "https://cors.spotiflyer.ml/cors/" /*"https://spotiflyer.azurewebsites.net/"*/) : CorsProxy(url) data class SelfHostedCorsProxy(override val url: String = "https://cors.spotiflyer.ml/cors/" /*"https://spotiflyer.azurewebsites.net/"*/) : CorsProxy(url)
data class PublicProxyWithExtension(override val url: String = "https://cors.bridged.cc/") : CorsProxy(url) data class PublicProxyWithExtension(override val url: String = "https://cors.bridged.cc/") : CorsProxy(url)
@ -45,3 +48,5 @@ sealed class CorsProxy(open val url: String) {
* Default Self Hosted, However ask user to use extension if possible. * Default Self Hosted, However ask user to use extension if possible.
* */ * */
var corsProxy: CorsProxy = CorsProxy.SelfHostedCorsProxy() var corsProxy: CorsProxy = CorsProxy.SelfHostedCorsProxy()
val corsApi get() = if (activePlatform is TargetPlatforms.Js) corsProxy.url else ""

View File

@ -49,5 +49,5 @@ sealed class DownloadStatus : Parcelable {
@Parcelize object Queued : DownloadStatus() @Parcelize object Queued : DownloadStatus()
@Parcelize object NotDownloaded : DownloadStatus() @Parcelize object NotDownloaded : DownloadStatus()
@Parcelize object Converting : DownloadStatus() @Parcelize object Converting : DownloadStatus()
@Parcelize object Failed : DownloadStatus() @Parcelize data class Failed(val error: Throwable) : DownloadStatus()
} }

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -32,7 +32,7 @@ kotlin {
implementation(project(":common:database")) implementation(project(":common:database"))
implementation("org.jetbrains.kotlinx:atomicfu:0.16.1") implementation("org.jetbrains.kotlinx:atomicfu:0.16.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
implementation("com.russhwolf:multiplatform-settings-no-arg:0.7.7") api(MultiPlatformSettings.dep)
implementation(Extras.youtubeDownloader) implementation(Extras.youtubeDownloader)
implementation(Extras.fuzzyWuzzy) implementation(Extras.fuzzyWuzzy)
implementation(MVIKotlin.rx) implementation(MVIKotlin.rx)

View File

@ -16,7 +16,6 @@
package com.shabinder.common.di package com.shabinder.common.di
import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -25,9 +24,6 @@ import kotlinx.coroutines.Dispatchers
// IO-Dispatcher // IO-Dispatcher
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
// Current Platform Info
actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm
actual suspend fun downloadTracks( actual suspend fun downloadTracks(
list: List<TrackDetails>, list: List<TrackDetails>,
fetcher: FetchPlatformQueryResult, fetcher: FetchPlatformQueryResult,

View File

@ -22,8 +22,8 @@ import android.os.Environment
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.mpatric.mp3agic.Mp3File import com.mpatric.mp3agic.Mp3File
import com.russhwolf.settings.Settings
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.utils.ParallelExecutor import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
@ -43,7 +43,7 @@ import java.net.URL
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
actual class Dir actual constructor( actual class Dir actual constructor(
private val logger: Kermit, private val logger: Kermit,
settingsPref: Settings, private val preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@ -54,7 +54,7 @@ actual class Dir actual constructor(
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
// fun call in order to always access Updated Value // fun call in order to always access Updated Value
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) + actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
File.separator + "SpotiFlyer" + File.separator File.separator + "SpotiFlyer" + File.separator
actual fun isPresent(path: String): Boolean = File(path).exists() actual fun isPresent(path: String): Boolean = File(path).exists()
@ -202,5 +202,4 @@ actual class Dir actual constructor(
private val parallelExecutor = ParallelExecutor(Dispatchers.IO) private val parallelExecutor = ParallelExecutor(Dispatchers.IO)
actual val db: Database? = spotiFlyerDatabase.instance actual val db: Database? = spotiFlyerDatabase.instance
actual val settings: Settings = settingsPref
} }

View File

@ -1,8 +1,7 @@
package com.shabinder.common.di.saavn package com.shabinder.common.di.providers.requests.saavn
import android.annotation.SuppressLint import android.annotation.SuppressLint
import io.ktor.util.InternalAPI import io.ktor.util.*
import io.ktor.util.decodeBase64Bytes
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.SecretKey import javax.crypto.SecretKey

View File

@ -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)
}
}

View File

@ -20,21 +20,13 @@ import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import com.shabinder.common.database.databaseModule import com.shabinder.common.database.databaseModule
import com.shabinder.common.database.getLogger import com.shabinder.common.database.getLogger
import com.shabinder.common.di.audioToMp3.AudioToMp3 import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.providers.GaanaProvider import com.shabinder.common.di.providers.providersModule
import com.shabinder.common.di.providers.SaavnProvider import io.ktor.client.*
import com.shabinder.common.di.providers.SpotifyProvider import io.ktor.client.features.*
import com.shabinder.common.di.providers.YoutubeMp3 import io.ktor.client.features.json.*
import com.shabinder.common.di.providers.YoutubeMusic import io.ktor.client.features.json.serializer.*
import com.shabinder.common.di.providers.YoutubeProvider import io.ktor.client.features.logging.*
import io.ktor.client.HttpClient
import io.ktor.client.features.HttpTimeout
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.features.json.serializer.KotlinxSerializer
import io.ktor.client.features.logging.DEFAULT
import io.ktor.client.features.logging.LogLevel
import io.ktor.client.features.logging.Logger
import io.ktor.client.features.logging.Logging
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration import org.koin.dsl.KoinAppDeclaration
@ -45,7 +37,11 @@ import kotlin.native.concurrent.ThreadLocal
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) = fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
startKoin { startKoin {
appDeclaration() appDeclaration()
modules(commonModule(enableNetworkLogs = enableNetworkLogs), databaseModule()) modules(
commonModule(enableNetworkLogs = enableNetworkLogs),
providersModule(),
databaseModule()
)
} }
// Called by IOS // Called by IOS
@ -55,16 +51,9 @@ fun commonModule(enableNetworkLogs: Boolean) = module {
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) } single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
single { Dir(get(), get(), get()) } single { Dir(get(), get(), get()) }
single { Settings() } single { Settings() }
single { PreferenceManager(get()) }
single { Kermit(getLogger()) } single { Kermit(getLogger()) }
single { TokenStore(get(), get()) } single { TokenStore(get(), get()) }
single { AudioToMp3(get(), get()) }
single { SpotifyProvider(get(), get(), get()) }
single { GaanaProvider(get(), get(), get()) }
single { SaavnProvider(get(), get(), get(), get()) }
single { YoutubeProvider(get(), get(), get()) }
single { YoutubeMp3(get(), get(), get()) }
single { YoutubeMusic(get(), get(), get(), get(), get()) }
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get()) }
} }
@ThreadLocal @ThreadLocal

View File

@ -17,33 +17,25 @@
package com.shabinder.common.di package com.shabinder.common.di
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.database.Database import com.shabinder.database.Database
import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.*
import io.ktor.client.request.get import io.ktor.client.statement.*
import io.ktor.client.statement.HttpStatement import io.ktor.http.*
import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlin.math.roundToInt import kotlin.math.roundToInt
const val DirKey = "downloadDir"
const val AnalyticsKey = "analytics"
const val FirstLaunch = "firstLaunch"
const val DonationInterval = "donationInterval"
expect class Dir( expect class Dir(
logger: Kermit, logger: Kermit,
settingsPref: Settings, preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) {
val db: Database? val db: Database?
val settings: Settings
fun isPresent(path: String): Boolean fun isPresent(path: String): Boolean
fun fileSeparator(): String fun fileSeparator(): String
fun defaultDir(): String fun defaultDir(): String
@ -56,22 +48,6 @@ expect class Dir(
fun addToLibrary(path: String) fun addToLibrary(path: String)
} }
val Dir.isAnalyticsEnabled get() = settings.getBooleanOrNull(AnalyticsKey) ?: false
fun Dir.toggleAnalytics(enabled: Boolean) = settings.putBoolean(AnalyticsKey, enabled)
fun Dir.setDownloadDirectory(newBasePath: String) = settings.putString(DirKey, newBasePath)
val Dir.getDonationOffset: Int get() = (settings.getIntOrNull(DonationInterval) ?: 3).also {
// Min. Donation Asking Interval is `3`
if (it < 3) setDonationOffset(3) else setDonationOffset(it - 1)
}
fun Dir.setDonationOffset(offset: Int = 5) = settings.putInt(DonationInterval, offset)
val Dir.isFirstLaunch get() = settings.getBooleanOrNull(FirstLaunch) ?: true
fun Dir.firstLaunchDone() {
settings.putBoolean(FirstLaunch, false)
}
/* /*
* Call this function at startup! * Call this function at startup!
* */ * */
@ -105,7 +81,7 @@ suspend fun downloadFile(url: String): Flow<DownloadResult> {
var offset = 0 var offset = 0
do { do {
// Set Length optimally, after how many kb you want a progress update, now it 0.25mb // Set Length optimally, after how many kb you want a progress update, now it 0.25mb
val currentRead = response.content.readAvailable(data, offset, 250000) val currentRead = response.content.readAvailable(data, offset, 2_50_000)
offset += currentRead offset += currentRead
val progress = (offset * 100f / data.size).roundToInt() val progress = (offset * 100f / data.size).roundToInt()
emit(DownloadResult.Progress(progress)) emit(DownloadResult.Progress(progress))

View File

@ -16,9 +16,8 @@
package com.shabinder.common.di package com.shabinder.common.di
import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import io.ktor.client.request.head import io.ktor.client.request.*
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -34,10 +33,6 @@ expect suspend fun downloadTracks(
@SharedImmutable @SharedImmutable
expect val dispatcherIO: CoroutineDispatcher expect val dispatcherIO: CoroutineDispatcher
// Current Platform Info
@SharedImmutable
expect val currentPlatform: AllPlatforms
suspend fun isInternetAccessible(): Boolean { suspend fun isInternetAccessible(): Boolean {
return withContext(dispatcherIO) { return withContext(dispatcherIO) {
try { try {

View File

@ -16,8 +16,8 @@
package com.shabinder.common.di package com.shabinder.common.di
import co.touchlab.kermit.Kermit
import com.shabinder.common.database.DownloadRecordDatabaseQueries import com.shabinder.common.database.DownloadRecordDatabaseQueries
import com.shabinder.common.di.audioToMp3.AudioToMp3
import com.shabinder.common.di.providers.GaanaProvider import com.shabinder.common.di.providers.GaanaProvider
import com.shabinder.common.di.providers.SaavnProvider import com.shabinder.common.di.providers.SaavnProvider
import com.shabinder.common.di.providers.SpotifyProvider import com.shabinder.common.di.providers.SpotifyProvider
@ -25,26 +25,37 @@ import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.YoutubeMusic import com.shabinder.common.di.providers.YoutubeMusic
import com.shabinder.common.di.providers.YoutubeProvider import com.shabinder.common.di.providers.YoutubeProvider
import com.shabinder.common.di.providers.get import com.shabinder.common.di.providers.get
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.flatMap
import com.shabinder.common.models.event.coroutines.flatMapError
import com.shabinder.common.models.event.coroutines.success
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import com.shabinder.common.requireNotNull
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class FetchPlatformQueryResult( class FetchPlatformQueryResult(
private val gaanaProvider: GaanaProvider, private val gaanaProvider: GaanaProvider,
val spotifyProvider: SpotifyProvider, private val spotifyProvider: SpotifyProvider,
val youtubeProvider: YoutubeProvider, private val youtubeProvider: YoutubeProvider,
private val saavnProvider: SaavnProvider, private val saavnProvider: SaavnProvider,
val youtubeMusic: YoutubeMusic, private val youtubeMusic: YoutubeMusic,
val youtubeMp3: YoutubeMp3, private val youtubeMp3: YoutubeMp3,
val audioToMp3: AudioToMp3, private val audioToMp3: AudioToMp3,
val dir: Dir val dir: Dir,
val logger: Kermit
) { ) {
private val db: DownloadRecordDatabaseQueries? private val db: DownloadRecordDatabaseQueries?
get() = dir.db?.downloadRecordDatabaseQueries get() = dir.db?.downloadRecordDatabaseQueries
suspend fun query(link: String): PlatformQueryResult? { suspend fun authenticateSpotifyClient() = spotifyProvider.authenticateSpotifyClient()
suspend fun query(link: String): SuspendableEvent<PlatformQueryResult,Throwable> {
val result = when { val result = when {
// SPOTIFY // SPOTIFY
link.contains("spotify", true) -> link.contains("spotify", true) ->
@ -63,13 +74,13 @@ class FetchPlatformQueryResult(
gaanaProvider.query(link) gaanaProvider.query(link)
else -> { else -> {
null SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link))
} }
} }
if (result != null) { result.success {
addToDatabaseAsync( addToDatabaseAsync(
link, link,
result.copy() // Send a copy in order to not to freeze Result itself it.copy() // Send a copy in order to not to freeze Result itself
) )
} }
return result return result
@ -79,35 +90,55 @@ class FetchPlatformQueryResult(
// 2) If Not found try finding on Youtube Music // 2) If Not found try finding on Youtube Music
suspend fun findMp3DownloadLink( suspend fun findMp3DownloadLink(
track: TrackDetails track: TrackDetails
): String? = ): SuspendableEvent<String,Throwable> =
if (track.videoID != null) { if (track.videoID != null) {
// We Already have VideoID // We Already have VideoID
when (track.source) { when (track.source) {
Source.JioSaavn -> { Source.JioSaavn -> {
saavnProvider.getSongFromID(track.videoID!!).media_url?.let { m4aLink -> saavnProvider.getSongFromID(track.videoID.requireNotNull()).flatMap { song ->
audioToMp3.convertToMp3(m4aLink) song.media_url?.let { audioToMp3.convertToMp3(it) } ?: findHighestQualityMp3Link(track)
} }
} }
Source.YouTube -> { Source.YouTube -> {
youtubeMp3.getMp3DownloadLink(track.videoID!!) youtubeMp3.getMp3DownloadLink(track.videoID.requireNotNull()).flatMapError {
?: youtubeProvider.ytDownloader?.getVideo(track.videoID!!)?.get()?.url?.let { m4aLink -> youtubeProvider.ytDownloader.getVideo(track.videoID!!).get()?.url?.let { m4aLink ->
audioToMp3.convertToMp3(m4aLink) audioToMp3.convertToMp3(m4aLink)
} } ?: throw SpotiFlyerException.YoutubeLinkNotFound(track.videoID)
}
} }
else -> { else -> {
null/* Do Nothing, We should never reach here for now*/ /*We should never reach here for now*/
findHighestQualityMp3Link(track)
} }
} }
} else { } else {
// First Try Getting A Link From JioSaavn findHighestQualityMp3Link(track)
saavnProvider.findSongDownloadURL(
trackName = track.title,
trackArtists = track.artists
)
// Lets Try Fetching Now From Youtube Music
?: youtubeMusic.findSongDownloadURL(track)
} }
private suspend fun findHighestQualityMp3Link(
track: TrackDetails
):SuspendableEvent<String,Throwable> {
// Try Fetching Track from Jio Saavn
return saavnProvider.findMp3SongDownloadURL(
trackName = track.title,
trackArtists = track.artists
).flatMapError { saavnError ->
logger.e { "Fetching From Saavn Failed: \n${saavnError.stackTraceToString()}" }
// Saavn Failed, Lets Try Fetching Now From Youtube Music
youtubeMusic.findMp3SongDownloadURLYT(track).flatMapError { ytMusicError ->
// If Both Failed Bubble the Exception Up with both StackTraces
SuspendableEvent.error(
SpotiFlyerException.DownloadLinkFetchFailed(
trackName = track.title,
jioSaavnError = saavnError,
ytMusicError = ytMusicError
)
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) { private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) {
GlobalScope.launch(dispatcherIO) { GlobalScope.launch(dispatcherIO) {
db?.add( db?.add(

View File

@ -18,7 +18,7 @@ package com.shabinder.common.di
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.database.TokenDBQueries import com.shabinder.common.database.TokenDBQueries
import com.shabinder.common.di.spotify.authenticateSpotify import com.shabinder.common.di.providers.requests.spotify.authenticateSpotify
import com.shabinder.common.models.spotify.TokenData import com.shabinder.common.models.spotify.TokenData
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -43,7 +43,7 @@ class TokenStore(
logger.d { "System Time:${Clock.System.now().epochSeconds} , Token Expiry:${token?.expiry}" } logger.d { "System Time:${Clock.System.now().epochSeconds} , Token Expiry:${token?.expiry}" }
if (Clock.System.now().epochSeconds > token?.expiry ?: 0 || token == null) { if (Clock.System.now().epochSeconds > token?.expiry ?: 0 || token == null) {
logger.d { "Requesting New Token" } logger.d { "Requesting New Token" }
token = authenticateSpotify() token = authenticateSpotify().component1()
GlobalScope.launch { token?.access_token?.let { save(token) } } GlobalScope.launch { token?.access_token?.let { save(token) } }
} }
return token return token

View File

@ -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)
}

View File

@ -19,13 +19,15 @@ package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.finalOutputDir import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.gaana.GaanaRequests import com.shabinder.common.di.providers.requests.gaana.GaanaRequests
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.gaana.GaanaTrack import com.shabinder.common.models.gaana.GaanaTrack
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import io.ktor.client.HttpClient import io.ktor.client.*
class GaanaProvider( class GaanaProvider(
override val httpClient: HttpClient, override val httpClient: HttpClient,
@ -35,7 +37,7 @@ class GaanaProvider(
private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg" private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg"
suspend fun query(fullLink: String): PlatformQueryResult? { suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
// Link Schema: https://gaana.com/type/link // Link Schema: https://gaana.com/type/link
val gaanaLink = fullLink.substringAfter("gaana.com/") val gaanaLink = fullLink.substringAfter("gaana.com/")
@ -44,17 +46,13 @@ class GaanaProvider(
// Error // Error
if (type == "Error" || link == "Error") { if (type == "Error" || link == "Error") {
return null throw SpotiFlyerException.LinkInvalid()
}
return try {
gaanaSearch(
type,
link
)
} catch (e: Exception) {
e.printStackTrace()
null
} }
gaanaSearch(
type,
link
)
} }
private suspend fun gaanaSearch( private suspend fun gaanaSearch(
@ -137,6 +135,7 @@ class GaanaProvider(
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/) outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
) )
} }
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus { private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
return if (dir.isPresent( return if (dir.isPresent(
dir.finalOutputDir( dir.finalOutputDir(

View File

@ -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()) }
}

View File

@ -2,16 +2,18 @@ package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.audioToMp3.AudioToMp3
import com.shabinder.common.di.finalOutputDir import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.saavn.JioSaavnRequests import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
import com.shabinder.common.di.providers.requests.saavn.JioSaavnRequests
import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.saavn.SaavnSong import com.shabinder.common.models.saavn.SaavnSong
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import io.ktor.client.HttpClient import io.ktor.client.*
class SaavnProvider( class SaavnProvider(
override val httpClient: HttpClient, override val httpClient: HttpClient,
@ -20,19 +22,18 @@ class SaavnProvider(
private val dir: Dir, private val dir: Dir,
) : JioSaavnRequests { ) : JioSaavnRequests {
suspend fun query(fullLink: String): PlatformQueryResult { suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult, Throwable> = SuspendableEvent {
val result = PlatformQueryResult( PlatformQueryResult(
folderType = "", folderType = "",
subFolder = "", subFolder = "",
title = "", title = "",
coverUrl = "", coverUrl = "",
trackList = listOf(), trackList = listOf(),
Source.JioSaavn Source.JioSaavn
) ).apply {
with(result) {
when (fullLink.substringAfter("saavn.com/").substringBefore("/")) { when (fullLink.substringAfter("saavn.com/").substringBefore("/")) {
"song" -> { "song" -> {
getSong(fullLink).let { getSong(fullLink).value.let {
folderType = "Tracks" folderType = "Tracks"
subFolder = "" subFolder = ""
trackList = listOf(it).toTrackDetails(folderType, subFolder) trackList = listOf(it).toTrackDetails(folderType, subFolder)
@ -41,7 +42,7 @@ class SaavnProvider(
} }
} }
"album" -> { "album" -> {
getAlbum(fullLink)?.let { getAlbum(fullLink).value.let {
folderType = "Albums" folderType = "Albums"
subFolder = removeIllegalChars(it.title) subFolder = removeIllegalChars(it.title)
trackList = it.songs.toTrackDetails(folderType, subFolder) trackList = it.songs.toTrackDetails(folderType, subFolder)
@ -50,7 +51,7 @@ class SaavnProvider(
} }
} }
"featured" -> { // Playlist "featured" -> { // Playlist
getPlaylist(fullLink)?.let { getPlaylist(fullLink).value.let {
folderType = "Playlists" folderType = "Playlists"
subFolder = removeIllegalChars(it.listname) subFolder = removeIllegalChars(it.listname)
trackList = it.songs.toTrackDetails(folderType, subFolder) trackList = it.songs.toTrackDetails(folderType, subFolder)
@ -59,12 +60,10 @@ class SaavnProvider(
} }
} }
else -> { else -> {
// Handle Error throw SpotiFlyerException.LinkInvalid(fullLink)
} }
} }
} }
return result
} }
private fun List<SaavnSong>.toTrackDetails(type: String, subFolder: String): List<TrackDetails> = this.map { private fun List<SaavnSong>.toTrackDetails(type: String, subFolder: String): List<TrackDetails> = this.map {

View File

@ -22,22 +22,24 @@ import com.shabinder.common.di.TokenStore
import com.shabinder.common.di.createHttpClient import com.shabinder.common.di.createHttpClient
import com.shabinder.common.di.finalOutputDir import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.globalJson import com.shabinder.common.di.globalJson
import com.shabinder.common.di.spotify.SpotifyRequests import com.shabinder.common.di.providers.requests.spotify.SpotifyRequests
import com.shabinder.common.di.spotify.authenticateSpotify import com.shabinder.common.di.providers.requests.spotify.authenticateSpotify
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.NativeAtomicReference import com.shabinder.common.models.NativeAtomicReference
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.spotify.Album import com.shabinder.common.models.spotify.Album
import com.shabinder.common.models.spotify.Image import com.shabinder.common.models.spotify.Image
import com.shabinder.common.models.spotify.PlaylistTrack import com.shabinder.common.models.spotify.PlaylistTrack
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import com.shabinder.common.models.spotify.Track import com.shabinder.common.models.spotify.Track
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.features.defaultRequest import io.ktor.client.features.*
import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.header import io.ktor.client.request.*
class SpotifyProvider( class SpotifyProvider(
private val tokenStore: TokenStore, private val tokenStore: TokenStore,
@ -46,9 +48,9 @@ class SpotifyProvider(
) : SpotifyRequests { ) : SpotifyRequests {
override suspend fun authenticateSpotifyClient(override: Boolean) { override suspend fun authenticateSpotifyClient(override: Boolean) {
val token = if (override) authenticateSpotify() else tokenStore.getToken() val token = if (override) authenticateSpotify().component1() else tokenStore.getToken()
if (token == null) { if (token == null) {
logger.d { "Please Check your Network Connection" } logger.d { "Spotify Auth Failed: Please Check your Network Connection" }
} else { } else {
logger.d { "Spotify Provider Created with $token" } logger.d { "Spotify Provider Created with $token" }
HttpClient { HttpClient {
@ -64,7 +66,7 @@ class SpotifyProvider(
override val httpClientRef = NativeAtomicReference(createHttpClient(true)) override val httpClientRef = NativeAtomicReference(createHttpClient(true))
suspend fun query(fullLink: String): PlatformQueryResult? { suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult, Throwable> = SuspendableEvent {
var spotifyLink = var spotifyLink =
"https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim() "https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim()
@ -78,15 +80,16 @@ class SpotifyProvider(
val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/')
if (type == "Error" || link == "Error") { if (type == "Error" || link == "Error") {
return null throw SpotiFlyerException.LinkInvalid(fullLink)
} }
if (type == "episode" || type == "show") { if (type == "episode" || type == "show") {
// TODO Implementation throw SpotiFlyerException.FeatureNotImplementedYet(
return null "Support for Spotify's ${type.uppercase()} isn't implemented yet"
)
} }
return try { try {
spotifySearch( spotifySearch(
type, type,
link link
@ -95,16 +98,11 @@ class SpotifyProvider(
e.printStackTrace() e.printStackTrace()
// Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions // Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions
authenticateSpotifyClient(true) authenticateSpotifyClient(true)
// Retry Search
try { spotifySearch(
spotifySearch( type,
type, link
link )
)
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
} }
@ -112,15 +110,14 @@ class SpotifyProvider(
type: String, type: String,
link: String link: String
): PlatformQueryResult { ): PlatformQueryResult {
val result = PlatformQueryResult( return PlatformQueryResult(
folderType = "", folderType = "",
subFolder = "", subFolder = "",
title = "", title = "",
coverUrl = "", coverUrl = "",
trackList = listOf(), trackList = listOf(),
Source.Spotify Source.Spotify
) ).apply {
with(result) {
when (type) { when (type) {
"track" -> { "track" -> {
getTrack(link).also { getTrack(link).also {
@ -186,15 +183,16 @@ class SpotifyProvider(
coverUrl = playlistObject.images?.firstOrNull()?.url.toString() coverUrl = playlistObject.images?.firstOrNull()?.url.toString()
} }
"episode" -> { // TODO "episode" -> { // TODO
throw SpotiFlyerException.FeatureNotImplementedYet()
} }
"show" -> { // TODO "show" -> { // TODO
throw SpotiFlyerException.FeatureNotImplementedYet()
} }
else -> { else -> {
// TODO Handle Error throw SpotiFlyerException.LinkInvalid("Provide: Spotify, Type:$type -> Link:$link")
} }
} }
} }
return result
} }
/* /*

View File

@ -17,28 +17,27 @@
package com.shabinder.common.di.providers package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir import com.shabinder.common.di.providers.requests.youtubeMp3.Yt1sMp3
import com.shabinder.common.di.currentPlatform import com.shabinder.common.models.corsApi
import com.shabinder.common.di.youtubeMp3.Yt1sMp3 import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.event.coroutines.map
import io.ktor.client.* import io.ktor.client.*
class YoutubeMp3( interface YoutubeMp3: Yt1sMp3 {
override val httpClient: HttpClient,
override val logger: Kermit, companion object {
private val dir: Dir, operator fun invoke(
) : Yt1sMp3 { client: HttpClient,
suspend fun getMp3DownloadLink(videoID: String): String? = try { logger: Kermit
logger.i { "Youtube MP3 Link Fetching!" } ): YoutubeMp3 {
getLinkFromYt1sMp3(videoID)?.let { return object : YoutubeMp3 {
logger.i { "Download Link: $it" } override val httpClient: HttpClient = client
if (currentPlatform is AllPlatforms.Js/* && corsProxy !is CorsProxy.PublicProxyWithExtension*/) override val logger: Kermit = logger
"https://cors.spotiflyer.ml/cors/$it" }
// "https://spotiflyer.azurewebsites.net/$it" // Data OUT Limit issue
else it
} }
} catch (e: Exception) { }
e.printStackTrace()
null suspend fun getMp3DownloadLink(videoID: String): SuspendableEvent<String,Throwable> = getLinkFromYt1sMp3(videoID).map {
corsApi + it
} }
} }

View File

@ -17,16 +17,19 @@
package com.shabinder.common.di.providers package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.audioToMp3.AudioToMp3 import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
import com.shabinder.common.di.gaana.corsApi import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.YoutubeTrack import com.shabinder.common.models.YoutubeTrack
import com.shabinder.common.models.corsApi
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.flatMap
import com.shabinder.common.models.event.coroutines.flatMapError
import com.shabinder.common.models.event.coroutines.map
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.request.headers import io.ktor.client.request.*
import io.ktor.client.request.post import io.ktor.http.*
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
@ -37,196 +40,197 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import kotlin.collections.set
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
class YoutubeMusic constructor( class YoutubeMusic constructor(
private val logger: Kermit, private val logger: Kermit,
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val youtubeMp3: YoutubeMp3,
private val youtubeProvider: YoutubeProvider, private val youtubeProvider: YoutubeProvider,
private val youtubeMp3: YoutubeMp3,
private val audioToMp3: AudioToMp3 private val audioToMp3: AudioToMp3
) { ) {
companion object { companion object {
const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
const val tag = "YT Music" const val tag = "YT Music"
} }
suspend fun findSongDownloadURL( // Get Downloadable Link
suspend fun findMp3SongDownloadURLYT(
trackDetails: TrackDetails trackDetails: TrackDetails
): String? { ): SuspendableEvent<String, Throwable> {
val bestMatchVideoID = getYTIDBestMatch(trackDetails) return getYTIDBestMatch(trackDetails).flatMap { videoID ->
return bestMatchVideoID?.let { videoID -> // 1 Try getting Link from Yt1s
youtubeMp3.getMp3DownloadLink(videoID) ?: youtubeProvider.ytDownloader?.getVideo(videoID)?.get()?.url?.let { m4aLink -> youtubeMp3.getMp3DownloadLink(videoID).flatMapError {
audioToMp3.convertToMp3( // 2 if Yt1s failed , Extract Manually
m4aLink youtubeProvider.ytDownloader.getVideo(videoID).get()?.url?.let { m4aLink ->
audioToMp3.convertToMp3(m4aLink)
} ?: throw SpotiFlyerException.YoutubeLinkNotFound(
videoID,
message = "Caught Following Errors While Finding Downloadable Link for $videoID : \n${it.stackTraceToString()}"
) )
} }
} }
} }
suspend fun getYTIDBestMatch( private suspend fun getYTIDBestMatch(
trackDetails: TrackDetails trackDetails: TrackDetails
): String? { ):SuspendableEvent<String,Throwable> =
return try { getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}").map { matchList ->
sortByBestMatch( sortByBestMatch(
getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}"), matchList,
trackName = trackDetails.title, trackName = trackDetails.title,
trackArtists = trackDetails.artists, trackArtists = trackDetails.artists,
trackDurationSec = trackDetails.durationSec trackDurationSec = trackDetails.durationSec
).keys.firstOrNull() ).keys.firstOrNull() ?: throw SpotiFlyerException.NoMatchFound(trackDetails.title)
} catch (e: Exception) {
// All Internet/Client Related Errors
e.printStackTrace()
null
} }
}
private suspend fun getYTTracks(query: String): List<YoutubeTrack> {
val youtubeTracks = mutableListOf<YoutubeTrack>()
val responseObj = Json.parseToJsonElement(getYoutubeMusicResponse(query)) private suspend fun getYTTracks(query: String): SuspendableEvent<List<YoutubeTrack>,Throwable> =
logger.i { "Youtube Music Response Recieved" } getYoutubeMusicResponse(query).map { youtubeResponseData ->
val contentBlocks = responseObj.jsonObject["contents"] val youtubeTracks = mutableListOf<YoutubeTrack>()
?.jsonObject?.get("sectionListRenderer") val responseObj = Json.parseToJsonElement(youtubeResponseData)
?.jsonObject?.get("contents")?.jsonArray // logger.i { "Youtube Music Response Received" }
val contentBlocks = responseObj.jsonObject["contents"]
?.jsonObject?.get("sectionListRenderer")
?.jsonObject?.get("contents")?.jsonArray
val resultBlocks = mutableListOf<JsonArray>() val resultBlocks = mutableListOf<JsonArray>()
if (contentBlocks != null) { if (contentBlocks != null) {
for (cBlock in contentBlocks) { for (cBlock in contentBlocks) {
/**
*Ignore user-suggestion
*The 'itemSectionRenderer' field is for user notices (stuff like - 'showing
*results for xyz, search for abc instead') we have no use for them, the for
*loop below if throw a keyError if we don't ignore them
*/
if (cBlock.jsonObject.containsKey("itemSectionRenderer")) {
continue
}
for (
contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray
?: listOf()
) {
/** /**
* apparently content Blocks without an 'overlay' field don't have linkBlocks *Ignore user-suggestion
* I have no clue what they are and why there even exist *The 'itemSectionRenderer' field is for user notices (stuff like - 'showing
* *results for xyz, search for abc instead') we have no use for them, the for
if(!contents.containsKey("overlay")){ *loop below if throw a keyError if we don't ignore them
println(contents) */
continue if (cBlock.jsonObject.containsKey("itemSectionRenderer")) {
TODO check and correct continue
}*/ }
val result = contents.jsonObject["musicResponsiveListItemRenderer"] for (
?.jsonObject?.get("flexColumns")?.jsonArray contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray
?: listOf()
// Add the linkBlock ) {
val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"] /**
?.jsonObject?.get("overlay") * apparently content Blocks without an 'overlay' field don't have linkBlocks
?.jsonObject?.get("musicItemThumbnailOverlayRenderer") * I have no clue what they are and why there even exist
?.jsonObject?.get("content") *
?.jsonObject?.get("musicPlayButtonRenderer") if(!contents.containsKey("overlay")){
?.jsonObject?.get("playNavigationEndpoint") println(contents)
continue
// detailsBlock is always a list, so we just append the linkBlock to it TODO check and correct
// instead of carrying along all the other junk from "musicResponsiveListItemRenderer" }*/
val finalResult = buildJsonArray {
result?.let { add(it) } val result = contents.jsonObject["musicResponsiveListItemRenderer"]
linkBlock?.let { add(it) } ?.jsonObject?.get("flexColumns")?.jsonArray
// Add the linkBlock
val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"]
?.jsonObject?.get("overlay")
?.jsonObject?.get("musicItemThumbnailOverlayRenderer")
?.jsonObject?.get("content")
?.jsonObject?.get("musicPlayButtonRenderer")
?.jsonObject?.get("playNavigationEndpoint")
// detailsBlock is always a list, so we just append the linkBlock to it
// instead of carrying along all the other junk from "musicResponsiveListItemRenderer"
val finalResult = buildJsonArray {
result?.let { add(it) }
linkBlock?.let { add(it) }
}
resultBlocks.add(finalResult)
} }
resultBlocks.add(finalResult)
} }
}
/* We only need results that are Songs or Videos, so we filter out the rest, since /* We only need results that are Songs or Videos, so we filter out the rest, since
! Songs and Videos are supplied with different details, extracting all details from ! Songs and Videos are supplied with different details, extracting all details from
! both is just carrying on redundant data, so we also have to selectively extract ! both is just carrying on redundant data, so we also have to selectively extract
! relevant details. What you need to know to understand how we do that here: ! relevant details. What you need to know to understand how we do that here:
! !
! Songs details are ALWAYS in the following order: ! Songs details are ALWAYS in the following order:
! 0 - Name ! 0 - Name
! 1 - Type (Song) ! 1 - Type (Song)
! 2 - com.shabinder.spotiflyer.models.gaana.Artist ! 2 - com.shabinder.spotiflyer.models.gaana.Artist
! 3 - Album ! 3 - Album
! 4 - Duration (mm:ss) ! 4 - Duration (mm:ss)
! !
! Video details are ALWAYS in the following order: ! Video details are ALWAYS in the following order:
! 0 - Name ! 0 - Name
! 1 - Type (Video) ! 1 - Type (Video)
! 2 - Channel ! 2 - Channel
! 3 - Viewers ! 3 - Viewers
! 4 - Duration (hh:mm:ss) ! 4 - Duration (hh:mm:ss)
! !
! We blindly gather all the details we get our hands on, then ! We blindly gather all the details we get our hands on, then
! cherry pick the details we need based on their index numbers, ! cherry pick the details we need based on their index numbers,
! we do so only if their Type is 'Song' or 'Video ! we do so only if their Type is 'Song' or 'Video
*/
for (result in resultBlocks) {
// Blindly gather available details
val availableDetails = mutableListOf<String>()
/*
Filter Out dummies here itself
! 'musicResponsiveListItemFlexColumnRenderer' should have more that one
! sub-block, if not its a dummy, why does the YTM response contain dummies?
! I have no clue. We skip these.
! Remember that we appended the linkBlock to result, treating that like the
! other constituents of a result block will lead to errors, hence the 'in
! result[:-1] ,i.e., skip last element in array '
*/ */
for (detailArray in result.subList(0, result.size - 1)) {
for (detail in detailArray.jsonArray) {
if (detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0 < 2) continue
// if not a dummy, collect All Variables for (result in resultBlocks) {
val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]
?.jsonObject?.get("text")
?.jsonObject?.get("runs")?.jsonArray ?: listOf()
for (d in details) { // Blindly gather available details
d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let { val availableDetails = mutableListOf<String>()
if (it != "") {
availableDetails.add(it) /*
Filter Out dummies here itself
! 'musicResponsiveListItemFlexColumnRenderer' should have more that one
! sub-block, if not its a dummy, why does the YTM response contain dummies?
! I have no clue. We skip these.
! Remember that we appended the linkBlock to result, treating that like the
! other constituents of a result block will lead to errors, hence the 'in
! result[:-1] ,i.e., skip last element in array '
*/
for (detailArray in result.subList(0, result.size - 1)) {
for (detail in detailArray.jsonArray) {
if (detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0 < 2) continue
// if not a dummy, collect All Variables
val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]
?.jsonObject?.get("text")
?.jsonObject?.get("runs")?.jsonArray ?: listOf()
for (d in details) {
d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let {
if (it != "") {
availableDetails.add(it)
}
} }
} }
} }
} }
} // logger.d("YT Music details"){availableDetails.toString()}
// logger.d("YT Music details"){availableDetails.toString()}
/*
! Filter Out non-Song/Video results and incomplete results here itself
! From what we know about detail order, note that [1] - indicate result type
*/
if (availableDetails.size == 5 && availableDetails[1] in listOf("Song", "Video")) {
// skip if result is in hours instead of minutes (no song is that long)
if (availableDetails[4].split(':').size != 2) continue
/* /*
! grab Video ID ! Filter Out non-Song/Video results and incomplete results here itself
! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...] ! From what we know about detail order, note that [1] - indicate result type
! so hardcoding the dict keys for data look up is an ardours process, since
! the sub-block pattern is fixed even though the key isn't, we just
! reference the dict keys by index
*/ */
if (availableDetails.size == 5 && availableDetails[1] in listOf("Song", "Video")) {
val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content // skip if result is in hours instead of minutes (no song is that long)
val ytTrack = YoutubeTrack( if (availableDetails[4].split(':').size != 2) continue
name = availableDetails[0],
type = availableDetails[1], /*
artist = availableDetails[2], ! grab Video ID
duration = availableDetails[4], ! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...]
videoId = videoId ! so hardcoding the dict keys for data look up is an ardours process, since
) ! the sub-block pattern is fixed even though the key isn't, we just
youtubeTracks.add(ytTrack) ! reference the dict keys by index
*/
val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content
val ytTrack = YoutubeTrack(
name = availableDetails[0],
type = availableDetails[1],
artist = availableDetails[2],
duration = availableDetails[4],
videoId = videoId
)
youtubeTracks.add(ytTrack)
}
} }
} }
}
// logger.d {youtubeTracks.joinToString("\n")} // logger.d {youtubeTracks.joinToString("\n")}
return youtubeTracks youtubeTracks
} }
private fun sortByBestMatch( private fun sortByBestMatch(
@ -246,8 +250,8 @@ class YoutubeMusic constructor(
// most song results on youtube go by $artist - $songName or artist1/artist2 // most song results on youtube go by $artist - $songName or artist1/artist2
var hasCommonWord = false var hasCommonWord = false
val resultName = result.name?.toLowerCase()?.replace("-", " ")?.replace("/", " ") ?: "" val resultName = result.name?.lowercase()?.replace("-", " ")?.replace("/", " ") ?: ""
val trackNameWords = trackName.toLowerCase().split(" ") val trackNameWords = trackName.lowercase().split(" ")
for (nameWord in trackNameWords) { for (nameWord in trackNameWords) {
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
@ -266,12 +270,12 @@ class YoutubeMusic constructor(
if (result.type == "Song") { if (result.type == "Song") {
for (artist in trackArtists) { for (artist in trackArtists) {
if (FuzzySearch.ratio(artist.toLowerCase(), result.artist?.toLowerCase() ?: "") > 85) if (FuzzySearch.ratio(artist.lowercase(), result.artist?.lowercase() ?: "") > 85)
artistMatchNumber++ artistMatchNumber++
} }
} else { // i.e. is a Video } else { // i.e. is a Video
for (artist in trackArtists) { for (artist in trackArtists) {
if (FuzzySearch.partialRatio(artist.toLowerCase(), result.name?.toLowerCase() ?: "") > 85) if (FuzzySearch.partialRatio(artist.lowercase(), result.name?.lowercase() ?: "") > 85)
artistMatchNumber++ artistMatchNumber++
} }
} }
@ -303,9 +307,8 @@ class YoutubeMusic constructor(
} }
} }
private suspend fun getYoutubeMusicResponse(query: String): String { private suspend fun getYoutubeMusicResponse(query: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
logger.i { "Fetching Youtube Music Response" } httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") {
return httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
headers { headers {
append("referer", "https://music.youtube.com/search") append("referer", "https://music.youtube.com/search")

View File

@ -22,7 +22,9 @@ import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import io.github.shabinder.YoutubeDownloader import io.github.shabinder.YoutubeDownloader
import io.github.shabinder.models.YoutubeVideo import io.github.shabinder.models.YoutubeVideo
@ -49,7 +51,7 @@ class YoutubeProvider(
private val sampleDomain2 = "youtube.com" private val sampleDomain2 = "youtube.com"
private val sampleDomain3 = "youtu.be" private val sampleDomain3 = "youtu.be"
suspend fun query(fullLink: String): PlatformQueryResult? { suspend fun query(fullLink: String): SuspendableEvent<PlatformQueryResult,Throwable> {
val link = fullLink.removePrefix("https://").removePrefix("http://") val link = fullLink.removePrefix("https://").removePrefix("http://")
if (link.contains("playlist", true) || link.contains("list", true)) { if (link.contains("playlist", true) || link.contains("list", true)) {
// Given Link is of a Playlist // Given Link is of a Playlist
@ -77,74 +79,15 @@ class YoutubeProvider(
) )
} else { } else {
logger.d { "Your Youtube Link is not of a Video!!" } logger.d { "Your Youtube Link is not of a Video!!" }
null SuspendableEvent.error(SpotiFlyerException.LinkInvalid(fullLink))
} }
} }
} }
private suspend fun getYTPlaylist( private suspend fun getYTPlaylist(
searchId: String searchId: String
): PlatformQueryResult? { ): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
val result = PlatformQueryResult( PlatformQueryResult(
folderType = "",
subFolder = "",
title = "",
coverUrl = "",
trackList = listOf(),
Source.YouTube
)
result.apply {
try {
val playlist = ytDownloader.getPlaylist(searchId)
val playlistDetails = playlist.details
val name = playlistDetails.title
subFolder = removeIllegalChars(name)
val videos = playlist.videos
coverUrl = "https://i.ytimg.com/vi/${
videos.firstOrNull()?.videoId
}/hqdefault.jpg"
title = name
trackList = videos.map {
TrackDetails(
title = it.title ?: "N/A",
artists = listOf(it.author ?: "N/A"),
durationSec = it.lengthSeconds,
albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg",
source = Source.YouTube,
albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg",
downloaded = if (dir.isPresent(
dir.finalOutputDir(
itemName = it.title ?: "N/A",
type = folderType,
subFolder = subFolder,
dir.defaultDir()
)
)
)
DownloadStatus.Downloaded
else {
DownloadStatus.NotDownloaded
},
outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
videoID = it.videoId
)
}
} catch (e: Exception) {
e.printStackTrace()
logger.d { "An Error Occurred While Processing!" }
}
}
return if (result.title.isNotBlank()) result
else null
}
@Suppress("DefaultLocale")
private suspend fun getYTTrack(
searchId: String,
): PlatformQueryResult? {
val result = PlatformQueryResult(
folderType = "", folderType = "",
subFolder = "", subFolder = "",
title = "", title = "",
@ -152,47 +95,90 @@ class YoutubeProvider(
trackList = listOf(), trackList = listOf(),
Source.YouTube Source.YouTube
).apply { ).apply {
try { val playlist = ytDownloader.getPlaylist(searchId)
logger.i { searchId } val playlistDetails = playlist.details
val video = ytDownloader.getVideo(searchId) val name = playlistDetails.title
coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" subFolder = removeIllegalChars(name)
val detail = video.videoDetails val videos = playlist.videos
val name = detail.title?.replace(detail.author?.uppercase() ?: "", "", true)
?: detail.title ?: "" coverUrl = "https://i.ytimg.com/vi/${
// logger.i{ detail.toString() } videos.firstOrNull()?.videoId
trackList = listOf( }/hqdefault.jpg"
TrackDetails( title = name
title = name,
artists = listOf(detail.author ?: "N/A"), trackList = videos.map {
durationSec = detail.lengthSeconds, TrackDetails(
albumArtPath = dir.imageCacheDir() + "$searchId.jpeg", title = it.title ?: "N/A",
source = Source.YouTube, artists = listOf(it.author ?: "N/A"),
albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", durationSec = it.lengthSeconds,
downloaded = if (dir.isPresent( albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg",
dir.finalOutputDir( source = Source.YouTube,
itemName = name, albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg",
type = folderType, downloaded = if (dir.isPresent(
subFolder = subFolder, dir.finalOutputDir(
defaultDir = dir.defaultDir() itemName = it.title ?: "N/A",
) type = folderType,
subFolder = subFolder,
dir.defaultDir()
) )
) )
DownloadStatus.Downloaded
else {
DownloadStatus.NotDownloaded
},
outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
videoID = searchId
) )
DownloadStatus.Downloaded
else {
DownloadStatus.NotDownloaded
},
outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
videoID = it.videoId
) )
title = name
} catch (e: Exception) {
e.printStackTrace()
logger.e { "An Error Occurred While Processing!,$searchId" }
} }
} }
return if (result.title.isNotBlank()) result }
else null
@Suppress("DefaultLocale")
private suspend fun getYTTrack(
searchId: String,
): SuspendableEvent<PlatformQueryResult,Throwable> = SuspendableEvent {
PlatformQueryResult(
folderType = "",
subFolder = "",
title = "",
coverUrl = "",
trackList = listOf(),
Source.YouTube
).apply {
val video = ytDownloader.getVideo(searchId)
coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg"
val detail = video.videoDetails
val name = detail.title?.replace(detail.author?.uppercase() ?: "", "", true)
?: detail.title ?: ""
// logger.i{ detail.toString() }
trackList = listOf(
TrackDetails(
title = name,
artists = listOf(detail.author ?: "N/A"),
durationSec = detail.lengthSeconds,
albumArtPath = dir.imageCacheDir() + "$searchId.jpeg",
source = Source.YouTube,
albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg",
downloaded = if (dir.isPresent(
dir.finalOutputDir(
itemName = name,
type = folderType,
subFolder = subFolder,
defaultDir = dir.defaultDir()
)
)
)
DownloadStatus.Downloaded
else {
DownloadStatus.NotDownloaded
},
outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/),
videoID = searchId
)
)
title = name
}
} }
} }

View File

@ -1,15 +1,15 @@
package com.shabinder.common.di.audioToMp3 package com.shabinder.common.di.providers.requests.audioToMp3
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.AudioQuality
import io.ktor.client.HttpClient import com.shabinder.common.models.SpotiFlyerException
import io.ktor.client.request.forms.formData import com.shabinder.common.models.event.coroutines.SuspendableEvent
import io.ktor.client.request.forms.submitFormWithBinaryData import io.ktor.client.*
import io.ktor.client.request.get import io.ktor.client.features.*
import io.ktor.client.request.header import io.ktor.client.request.*
import io.ktor.client.request.headers import io.ktor.client.request.forms.*
import io.ktor.client.statement.HttpStatement import io.ktor.client.statement.*
import io.ktor.http.isSuccess import io.ktor.http.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
interface AudioToMp3 { interface AudioToMp3 {
@ -32,9 +32,10 @@ interface AudioToMp3 {
suspend fun convertToMp3( suspend fun convertToMp3(
URL: String, URL: String,
audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)), audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)),
): String? { ): SuspendableEvent<String,Throwable> = SuspendableEvent {
val activeHost = getHost() // ex - https://hostveryfast.onlineconverter.com/file/send // Active Host ex - https://hostveryfast.onlineconverter.com/file/send
val jobLink = convertRequest(URL, activeHost, audioQuality) // ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd // Convert Job Request ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
var (activeHost,jobLink) = convertRequest(URL, audioQuality).value
// (jobStatus.contains("d")) == COMPLETION // (jobStatus.contains("d")) == COMPLETION
var jobStatus: String var jobStatus: String
@ -47,17 +48,23 @@ interface AudioToMp3 {
) )
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
if(e is ClientRequestException && e.response.status.value == 404) {
// No Need to Retry, Host/Converter is Busy
throw SpotiFlyerException.MP3ConversionFailed(e.message)
}
// Try Using New Host/Converter
convertRequest(URL, audioQuality).value.also {
activeHost = it.first
jobLink = it.second
}
"" ""
} }
retryCount-- retryCount--
logger.i("Job Status") { jobStatus } logger.i("Job Status") { jobStatus }
if (!jobStatus.contains("d")) delay(400) // Add Delay , to give Server Time to process audio if (!jobStatus.contains("d")) delay(600) // Add Delay , to give Server Time to process audio
} while (!jobStatus.contains("d", true) && retryCount != 0) } while (!jobStatus.contains("d", true) && retryCount > 0)
return if (jobStatus.equals("d", true)) { "${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
// Return MP3 Download Link
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
} else null
} }
/* /*
@ -66,11 +73,10 @@ interface AudioToMp3 {
* */ * */
private suspend fun convertRequest( private suspend fun convertRequest(
URL: String, URL: String,
host: String? = null,
audioQuality: AudioQuality = AudioQuality.KBPS160, audioQuality: AudioQuality = AudioQuality.KBPS160,
): String { ): SuspendableEvent<Pair<String,String>,Throwable> = SuspendableEvent {
val activeHost = host ?: getHost() val activeHost by getHost()
val res = client.submitFormWithBinaryData<String>( val convertJob = client.submitFormWithBinaryData<String>(
url = activeHost, url = activeHost,
formData = formData { formData = formData {
append("class", "audio") append("class", "audio")
@ -87,28 +93,30 @@ interface AudioToMp3 {
header("Referer", "https://www.onlineconverter.com/") header("Referer", "https://www.onlineconverter.com/")
} }
}.run { }.run {
logger.d { this } // logger.d { this }
dropLast(3) // last 3 are useless unicode char dropLast(3) // last 3 are useless unicode char
} }
val job = client.get<HttpStatement>(res) { val job = client.get<HttpStatement>(convertJob) {
headers { headers {
header("Host", "www.onlineconverter.com") header("Host", "www.onlineconverter.com")
} }
}.execute() }.execute()
logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() } logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() }
return res
Pair(activeHost,convertJob)
} }
// Active Host free to process conversion // Active Host free to process conversion
// ex - https://hostveryfast.onlineconverter.com/file/send // ex - https://hostveryfast.onlineconverter.com/file/send
private suspend fun getHost(): String { private suspend fun getHost(): SuspendableEvent<String,Throwable> = SuspendableEvent {
return client.get<String>("https://www.onlineconverter.com/get/host") { client.get<String>("https://www.onlineconverter.com/get/host") {
headers { headers {
header("Host", "www.onlineconverter.com") header("Host", "www.onlineconverter.com")
} }
}.also { logger.i("Active Host") { it } } }//.also { logger.i("Active Host") { it } }
} }
// Extract full Domain from URL // Extract full Domain from URL
// ex - hostveryfast.onlineconverter.com // ex - hostveryfast.onlineconverter.com
private fun String.getHostDomain(): String { private fun String.getHostDomain(): String {

View File

@ -14,23 +14,17 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.di.gaana package com.shabinder.common.di.providers.requests.gaana
import com.shabinder.common.di.currentPlatform import com.shabinder.common.models.corsApi
import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.corsProxy
import com.shabinder.common.models.gaana.GaanaAlbum import com.shabinder.common.models.gaana.GaanaAlbum
import com.shabinder.common.models.gaana.GaanaArtistDetails import com.shabinder.common.models.gaana.GaanaArtistDetails
import com.shabinder.common.models.gaana.GaanaArtistTracks import com.shabinder.common.models.gaana.GaanaArtistTracks
import com.shabinder.common.models.gaana.GaanaPlaylist import com.shabinder.common.models.gaana.GaanaPlaylist
import com.shabinder.common.models.gaana.GaanaSong import com.shabinder.common.models.gaana.GaanaSong
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.request.get import io.ktor.client.request.*
val corsApi get() = if (currentPlatform is AllPlatforms.Js) {
corsProxy.url
} // "https://spotiflyer-cors.azurewebsites.net/" //"https://spotiflyer-cors.herokuapp.com/"//"https://cors.bridged.cc/"
else ""
private const val TOKEN = "b2e6d7fbc136547a940516e9b77e5990" private const val TOKEN = "b2e6d7fbc136547a940516e9b77e5990"
private val BASE_URL get() = "${corsApi}https://api.gaana.com" private val BASE_URL get() = "${corsApi}https://api.gaana.com"

View File

@ -1,21 +1,24 @@
package com.shabinder.common.di.saavn package com.shabinder.common.di.providers.requests.saavn
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.audioToMp3.AudioToMp3
import com.shabinder.common.di.gaana.corsApi
import com.shabinder.common.di.globalJson import com.shabinder.common.di.globalJson
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
import com.shabinder.common.models.corsApi
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.map
import com.shabinder.common.models.event.coroutines.success
import com.shabinder.common.models.saavn.SaavnAlbum import com.shabinder.common.models.saavn.SaavnAlbum
import com.shabinder.common.models.saavn.SaavnPlaylist import com.shabinder.common.models.saavn.SaavnPlaylist
import com.shabinder.common.models.saavn.SaavnSearchResult import com.shabinder.common.models.saavn.SaavnSearchResult
import com.shabinder.common.models.saavn.SaavnSong import com.shabinder.common.models.saavn.SaavnSong
import com.shabinder.common.requireNotNull
import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch
import io.github.shabinder.utils.getBoolean import io.github.shabinder.utils.getBoolean
import io.github.shabinder.utils.getJsonArray import io.github.shabinder.utils.getJsonArray
import io.github.shabinder.utils.getJsonObject import io.github.shabinder.utils.getJsonObject
import io.github.shabinder.utils.getString import io.github.shabinder.utils.getString
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.features.ServerResponseException import io.ktor.client.request.*
import io.ktor.client.request.get
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@ -24,6 +27,7 @@ import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlin.collections.set
interface JioSaavnRequests { interface JioSaavnRequests {
@ -31,63 +35,64 @@ interface JioSaavnRequests {
val httpClient: HttpClient val httpClient: HttpClient
val logger: Kermit val logger: Kermit
suspend fun findSongDownloadURL( suspend fun findMp3SongDownloadURL(
trackName: String, trackName: String,
trackArtists: List<String>, trackArtists: List<String>,
): String? { ): SuspendableEvent<String,Throwable> = searchForSong(trackName).map { songs ->
val songs = searchForSong(trackName)
val bestMatches = sortByBestMatch(songs, trackName, trackArtists) val bestMatches = sortByBestMatch(songs, trackName, trackArtists)
val m4aLink: String? = bestMatches.keys.firstOrNull()?.let {
getSongFromID(it).media_url val m4aLink: String by getSongFromID(bestMatches.keys.first()).map { song ->
song.media_url.requireNotNull()
} }
val mp3Link = m4aLink?.let { audioToMp3.convertToMp3(it) }
return mp3Link val mp3Link by audioToMp3.convertToMp3(m4aLink)
mp3Link
} }
suspend fun searchForSong( suspend fun searchForSong(
query: String, query: String,
includeLyrics: Boolean = false includeLyrics: Boolean = false
): List<SaavnSearchResult> { ): SuspendableEvent<List<SaavnSearchResult>,Throwable> = SuspendableEvent {
/*if (query.startsWith("http") && query.contains("saavn.com")) {
return listOf(getSong(query))
}*/
val searchURL = search_base_url + query val searchURL = search_base_url + query
val results = mutableListOf<SaavnSearchResult>() val results = mutableListOf<SaavnSearchResult>()
try {
(globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject).getJsonObject("songs").getJsonArray("data")?.forEach { (globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject)
(it as? JsonObject)?.formatData()?.let { jsonObject -> .getJsonObject("songs")
.getJsonArray("data").requireNotNull().forEach {
(it as JsonObject).formatData().let { jsonObject ->
results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject)) results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject))
} }
} }
}catch (e: ServerResponseException) {}
return results results
} }
suspend fun getLyrics(ID: String): String? { suspend fun getLyrics(ID: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
return try { (Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject)
(Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject) .getString("lyrics").requireNotNull()
.getString("lyrics")
}catch (e:Exception) { null }
} }
suspend fun getSong( suspend fun getSong(
URL: String, URL: String,
fetchLyrics: Boolean = false fetchLyrics: Boolean = false
): SaavnSong { ): SuspendableEvent<SaavnSong,Throwable> = SuspendableEvent {
val id = getSongID(URL) val id = getSongID(URL)
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject) val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
.formatData(fetchLyrics) .formatData(fetchLyrics)
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
} }
suspend fun getSongFromID( suspend fun getSongFromID(
ID: String, ID: String,
fetchLyrics: Boolean = false fetchLyrics: Boolean = false
): SaavnSong { ): SuspendableEvent<SaavnSong,Throwable> = SuspendableEvent {
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject) val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject)
.formatData(fetchLyrics) .formatData(fetchLyrics)
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
} }
private suspend fun getSongID( private suspend fun getSongID(
@ -104,24 +109,19 @@ interface JioSaavnRequests {
suspend fun getPlaylist( suspend fun getPlaylist(
URL: String, URL: String,
includeLyrics: Boolean = false includeLyrics: Boolean = false
): SaavnPlaylist? { ): SuspendableEvent<SaavnPlaylist,Throwable> = SuspendableEvent {
return try { globalJson.decodeFromJsonElement(
globalJson.decodeFromJsonElement( SaavnPlaylist.serializer(),
SaavnPlaylist.serializer(), (globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL).value)) as JsonObject)
(globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL))) as JsonObject) .formatData(includeLyrics)
.formatData(includeLyrics) )
)
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
private suspend fun getPlaylistID( private suspend fun getPlaylistID(
URL: String URL: String
): String { ): SuspendableEvent<String,Throwable> = SuspendableEvent {
val res = httpClient.get<String>(URL) val res = httpClient.get<String>(URL)
return try { try {
res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0] res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0]
} catch (e: IndexOutOfBoundsException) { } catch (e: IndexOutOfBoundsException) {
res.split("\"page_id\",\"")[1].split("\",\"")[0] res.split("\"page_id\",\"")[1].split("\",\"")[0]
@ -131,24 +131,19 @@ interface JioSaavnRequests {
suspend fun getAlbum( suspend fun getAlbum(
URL: String, URL: String,
includeLyrics: Boolean = false includeLyrics: Boolean = false
): SaavnAlbum? { ): SuspendableEvent<SaavnAlbum,Throwable> = SuspendableEvent {
return try { globalJson.decodeFromJsonElement(
globalJson.decodeFromJsonElement( SaavnAlbum.serializer(),
SaavnAlbum.serializer(), (globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL).value)) as JsonObject)
(globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL))) as JsonObject) .formatData(includeLyrics)
.formatData(includeLyrics) )
)
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
private suspend fun getAlbumID( private suspend fun getAlbumID(
URL: String URL: String
): String { ): SuspendableEvent<String,Throwable> = SuspendableEvent {
val res = httpClient.get<String>(URL) val res = httpClient.get<String>(URL)
return try { try {
res.split("\"album_id\":\"")[1].split('"')[0] res.split("\"album_id\":\"")[1].split('"')[0]
} catch (e: IndexOutOfBoundsException) { } catch (e: IndexOutOfBoundsException) {
res.split("\"page_id\",\"")[1].split("\",\"")[0] res.split("\"page_id\",\"")[1].split("\",\"")[0]
@ -214,8 +209,10 @@ interface JioSaavnRequests {
// Fetch Lyrics if Requested // Fetch Lyrics if Requested
// Lyrics is HTML Based // Lyrics is HTML Based
if (includeLyrics) { if (includeLyrics) {
if (getBoolean("has_lyrics") == true) { if (getBoolean("has_lyrics") == true && containsKey("id")) {
put("lyrics", getString("id")?.let { getLyrics(it) }) getLyrics(getString("id").requireNotNull()).success {
put("lyrics", it)
}
} else { } else {
put("lyrics", "") put("lyrics", "")
} }
@ -237,8 +234,8 @@ interface JioSaavnRequests {
for (result in tracks) { for (result in tracks) {
var hasCommonWord = false var hasCommonWord = false
val resultName = result.title.toLowerCase().replace("/", " ") val resultName = result.title.lowercase().replace("/", " ")
val trackNameWords = trackName.toLowerCase().split(" ") val trackNameWords = trackName.lowercase().split(" ")
for (nameWord in trackNameWords) { for (nameWord in trackNameWords) {
if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true
@ -258,11 +255,11 @@ interface JioSaavnRequests {
// String Containing All Artist Names from JioSaavn Search Result // String Containing All Artist Names from JioSaavn Search Result
val artistListString = mutableSetOf<String>().apply { val artistListString = mutableSetOf<String>().apply {
result.more_info?.singers?.split(",")?.let { addAll(it) } result.more_info?.singers?.split(",")?.let { addAll(it) }
result.more_info?.primary_artists?.toLowerCase()?.split(",")?.let { addAll(it) } result.more_info?.primary_artists?.lowercase()?.split(",")?.let { addAll(it) }
}.joinToString(" , ") }.joinToString(" , ")
for (artist in trackArtists) { for (artist in trackArtists) {
if (FuzzySearch.partialRatio(artist.toLowerCase(), artistListString) > 85) if (FuzzySearch.partialRatio(artist.lowercase(), artistListString) > 85)
artistMatchNumber++ artistMatchNumber++
} }

View File

@ -1,4 +1,6 @@
package com.shabinder.common.di.saavn package com.shabinder.common.di.providers.requests.saavn
import com.shabinder.common.di.utils.unescape
expect suspend fun decryptURL(url: String): String expect suspend fun decryptURL(url: String): String

View File

@ -14,30 +14,29 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.di.spotify package com.shabinder.common.di.providers.requests.spotify
import com.shabinder.common.di.globalJson import com.shabinder.common.di.globalJson
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.models.spotify.TokenData import com.shabinder.common.models.spotify.TokenData
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.features.auth.Auth import io.ktor.client.features.auth.*
import io.ktor.client.features.auth.providers.basic import io.ktor.client.features.auth.providers.*
import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.forms.FormDataContent import io.ktor.client.request.*
import io.ktor.client.request.post import io.ktor.client.request.forms.*
import io.ktor.http.Parameters import io.ktor.http.*
import kotlin.native.concurrent.SharedImmutable import kotlin.native.concurrent.SharedImmutable
suspend fun authenticateSpotify(): TokenData? { suspend fun authenticateSpotify(): SuspendableEvent<TokenData,Throwable> = SuspendableEvent {
return try { if (methods.value.isInternetAvailable) {
if (methods.value.isInternetAvailable) spotifyAuthClient.post("https://accounts.spotify.com/api/token") { spotifyAuthClient.post("https://accounts.spotify.com/api/token") {
body = FormDataContent(Parameters.build { append("grant_type", "client_credentials") }) body = FormDataContent(Parameters.build { append("grant_type", "client_credentials") })
} else null }
} catch (e: Exception) { } else throw SpotiFlyerException.NoInternetException()
e.printStackTrace()
null
}
} }
@SharedImmutable @SharedImmutable
@ -48,9 +47,10 @@ private val spotifyAuthClient by lazy {
install(Auth) { install(Auth) {
basic { basic {
sendWithoutRequest = true sendWithoutRequest { true }
username = clientId credentials {
password = clientSecret BasicAuthCredentials(clientId, clientSecret)
}
} }
} }
install(JsonFeature) { install(JsonFeature) {

View File

@ -14,16 +14,18 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.di.spotify package com.shabinder.common.di.providers.requests.spotify
import com.shabinder.common.di.gaana.corsApi
import com.shabinder.common.models.NativeAtomicReference import com.shabinder.common.models.NativeAtomicReference
import com.shabinder.common.models.corsApi
import com.shabinder.common.models.spotify.Album import com.shabinder.common.models.spotify.Album
import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack
import com.shabinder.common.models.spotify.Playlist import com.shabinder.common.models.spotify.Playlist
import com.shabinder.common.models.spotify.Track import com.shabinder.common.models.spotify.Track
import io.ktor.client.HttpClient import io.github.shabinder.TargetPlatforms
import io.ktor.client.request.get import io.github.shabinder.activePlatform
import io.ktor.client.*
import io.ktor.client.request.*
private val BASE_URL get() = "${corsApi}https://api.spotify.com/v1" private val BASE_URL get() = "${corsApi}https://api.spotify.com/v1"
@ -32,7 +34,7 @@ interface SpotifyRequests {
val httpClientRef: NativeAtomicReference<HttpClient> val httpClientRef: NativeAtomicReference<HttpClient>
val httpClient: HttpClient get() = httpClientRef.value val httpClient: HttpClient get() = httpClientRef.value
suspend fun authenticateSpotifyClient(override: Boolean = false) suspend fun authenticateSpotifyClient(override: Boolean = activePlatform is TargetPlatforms.Js)
suspend fun getPlaylist(playlistID: String): Playlist { suspend fun getPlaylist(playlistID: String): Playlist {
return httpClient.get("$BASE_URL/playlists/$playlistID") return httpClient.get("$BASE_URL/playlists/$playlistID")

View File

@ -14,14 +14,18 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.di.youtubeMp3 package com.shabinder.common.di.providers.requests.youtubeMp3
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.gaana.corsApi import com.shabinder.common.models.corsApi
import io.ktor.client.HttpClient import com.shabinder.common.models.event.coroutines.SuspendableEvent
import io.ktor.client.request.forms.FormDataContent import com.shabinder.common.models.event.coroutines.flatMap
import io.ktor.client.request.post import com.shabinder.common.models.event.coroutines.map
import io.ktor.http.Parameters import com.shabinder.common.requireNotNull
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@ -33,18 +37,23 @@ interface Yt1sMp3 {
val httpClient: HttpClient val httpClient: HttpClient
val logger: Kermit val logger: Kermit
/* /*
* Downloadable Mp3 Link for YT videoID. * Downloadable Mp3 Link for YT videoID.
* */ * */
suspend fun getLinkFromYt1sMp3(videoID: String): String? = suspend fun getLinkFromYt1sMp3(videoID: String): SuspendableEvent<String,Throwable> = getKey(videoID).flatMap { key ->
getConvertedMp3Link(videoID, getKey(videoID))?.get("dlink")?.jsonPrimitive?.toString()?.replace("\"", "") getConvertedMp3Link(videoID, key).map {
it["dlink"].requireNotNull()
.jsonPrimitive.content.replace("\"", "")
}
}
/* /*
* POST:https://yt1s.com/api/ajaxSearch/index * POST:https://yt1s.com/api/ajaxSearch/index
* Body Form= q:yt video link ,vt:format=mp3 * Body Form= q:yt video link ,vt:format=mp3
* */ * */
private suspend fun getKey(videoID: String): String { private suspend fun getKey(videoID: String): SuspendableEvent<String,Throwable> = SuspendableEvent {
val response: JsonObject? = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") { val response: JsonObject = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") {
body = FormDataContent( body = FormDataContent(
Parameters.build { Parameters.build {
append("q", "https://www.youtube.com/watch?v=$videoID") append("q", "https://www.youtube.com/watch?v=$videoID")
@ -52,11 +61,12 @@ interface Yt1sMp3 {
} }
) )
} }
return response?.get("kc")?.jsonPrimitive.toString()
response["kc"].requireNotNull().jsonPrimitive.content
} }
private suspend fun getConvertedMp3Link(videoID: String, key: String): JsonObject? { private suspend fun getConvertedMp3Link(videoID: String, key: String): SuspendableEvent<JsonObject,Throwable> = SuspendableEvent {
return httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") { httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") {
body = FormDataContent( body = FormDataContent(
Parameters.build { Parameters.build {
append("vid", videoID) append("vid", videoID)

View File

@ -1,4 +1,4 @@
package com.shabinder.common.di.saavn package com.shabinder.common.di.utils
/* /*
* JSON UTILS * JSON UTILS
@ -6,7 +6,7 @@ package com.shabinder.common.di.saavn
fun String.escape(): String { fun String.escape(): String {
val output = StringBuilder() val output = StringBuilder()
for (element in this) { for (element in this) {
val chx = element.toInt() val chx = element.code
if (chx != 0) { if (chx != 0) {
when (element) { when (element) {
'\n' -> { '\n' -> {
@ -76,7 +76,7 @@ fun String.unescape(): String {
/*if (!x.isLetterOrDigit()) { /*if (!x.isLetterOrDigit()) {
throw RuntimeException("Bad character in unicode escape.") throw RuntimeException("Bad character in unicode escape.")
}*/ }*/
hex.append(x.toLowerCase()) hex.append(x.lowercaseChar())
} }
i += 4 // consume those four digits. i += 4 // consume those four digits.
val code = hex.toString().toInt(16) val code = hex.toString().toInt(16)

View File

@ -22,7 +22,7 @@ package com.shabinder.common.di.utils
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e // Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
import com.shabinder.common.di.dispatcherIO import com.shabinder.common.di.dispatcherIO
import io.ktor.utils.io.core.Closeable import io.ktor.utils.io.core.*
import kotlinx.atomicfu.atomic import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@ -96,7 +96,7 @@ class ParallelExecutor(
return return
var change = expectedCount - actualCount var change = expectedCount - actualCount
while (change > 0 && killQueue.poll() != null) while (change > 0 && killQueue.tryReceive().getOrNull() != null)
change -= 1 change -= 1
if (change > 0) if (change > 0)
@ -104,7 +104,7 @@ class ParallelExecutor(
repeat(change) { launchProcessor() } repeat(change) { launchProcessor() }
} }
else else
repeat(-change) { killQueue.offer(Unit) } repeat(-change) { killQueue.trySend(Unit).isSuccess }
} }
private class Operation<Result>( private class Operation<Result>(

View File

@ -17,9 +17,9 @@
package com.shabinder.common.di package com.shabinder.common.di
import com.shabinder.common.di.utils.ParallelExecutor import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -34,9 +34,6 @@ val DownloadScope = ParallelExecutor(Dispatchers.IO)
// IO-Dispatcher // IO-Dispatcher
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
// Current Platform Info
actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm
actual suspend fun downloadTracks( actual suspend fun downloadTracks(
list: List<TrackDetails>, list: List<TrackDetails>,
fetcher: FetchPlatformQueryResult, fetcher: FetchPlatformQueryResult,
@ -44,41 +41,43 @@ actual suspend fun downloadTracks(
) { ) {
list.forEach { trackDetails -> list.forEach { trackDetails ->
DownloadScope.execute { // Send Download to Pool. DownloadScope.execute { // Send Download to Pool.
val url = fetcher.findMp3DownloadLink(trackDetails) fetcher.findMp3DownloadLink(trackDetails).fold(
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL success = { url ->
downloadFile(url).collect { downloadFile(url).collect {
when (it) { when (it) {
is DownloadResult.Error -> { is DownloadResult.Error -> {
DownloadProgressFlow.emit( DownloadProgressFlow.emit(
DownloadProgressFlow.replayCache.getOrElse( DownloadProgressFlow.replayCache.getOrElse(
0 0
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed) } ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause))) }
) )
} }
is DownloadResult.Progress -> { is DownloadResult.Progress -> {
DownloadProgressFlow.emit( DownloadProgressFlow.emit(
DownloadProgressFlow.replayCache.getOrElse( DownloadProgressFlow.replayCache.getOrElse(
0 0
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) } ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) }
) )
} }
is DownloadResult.Success -> { // Todo clear map is DownloadResult.Success -> { // Todo clear map
dir.saveFileWithMetadata(it.byteArray, trackDetails) {} dir.saveFileWithMetadata(it.byteArray, trackDetails) {}
DownloadProgressFlow.emit( DownloadProgressFlow.emit(
DownloadProgressFlow.replayCache.getOrElse( DownloadProgressFlow.replayCache.getOrElse(
0 0
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloaded) } ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloaded) }
) )
}
} }
} }
},
failure = { error ->
DownloadProgressFlow.emit(
DownloadProgressFlow.replayCache.getOrElse(
0
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(error)) }
)
} }
} else { )
DownloadProgressFlow.emit(
DownloadProgressFlow.replayCache.getOrElse(
0
) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed) }
)
}
} }
} }
} }

View File

@ -20,8 +20,8 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.mpatric.mp3agic.Mp3File import com.mpatric.mp3agic.Mp3File
import com.russhwolf.settings.Settings
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.database.Database import com.shabinder.database.Database
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -40,7 +40,7 @@ import javax.imageio.ImageIO
actual class Dir actual constructor( actual class Dir actual constructor(
private val logger: Kermit, private val logger: Kermit,
settingsPref: Settings, private val preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) {
@ -55,7 +55,7 @@ actual class Dir actual constructor(
private val defaultBaseDir = System.getProperty("user.home") private val defaultBaseDir = System.getProperty("user.home")
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) + fileSeparator() + actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
"SpotiFlyer" + fileSeparator() "SpotiFlyer" + fileSeparator()
actual fun isPresent(path: String): Boolean = File(path).exists() actual fun isPresent(path: String): Boolean = File(path).exists()
@ -199,7 +199,6 @@ actual class Dir actual constructor(
} }
actual val db: Database? = spotiFlyerDatabase.instance actual val db: Database? = spotiFlyerDatabase.instance
actual val settings: Settings = settingsPref
} }
fun BufferedImage.toImageBitmap() = Image.makeFromEncoded( fun BufferedImage.toImageBitmap() = Image.makeFromEncoded(

View File

@ -1,7 +1,6 @@
package com.shabinder.common.di.saavn package com.shabinder.common.di.providers.requests.saavn
import io.ktor.util.InternalAPI import io.ktor.util.*
import io.ktor.util.decodeBase64Bytes
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.SecretKey import javax.crypto.SecretKey

View File

@ -1,8 +1,8 @@
package com.shabinder.common.di package com.shabinder.common.di
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.database.Database import com.shabinder.database.Database
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@ -24,7 +24,7 @@ import platform.UIKit.UIImageJPEGRepresentation
actual class Dir actual constructor( actual class Dir actual constructor(
val logger: Kermit, val logger: Kermit,
settingsPref: Settings, private val preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) {
@ -35,7 +35,7 @@ actual class Dir actual constructor(
private val defaultBaseDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!!.path!! private val defaultBaseDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!!.path!!
// TODO Error Handling // TODO Error Handling
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) + actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
fileSeparator() + "SpotiFlyer" + fileSeparator() fileSeparator() + "SpotiFlyer" + fileSeparator()
private val defaultDirURL: NSURL by lazy { private val defaultDirURL: NSURL by lazy {
@ -176,6 +176,5 @@ actual class Dir actual constructor(
// TODO // TODO
} }
actual val settings: Settings = settingsPref
actual val db: Database? = spotiFlyerDatabase.instance actual val db: Database? = spotiFlyerDatabase.instance
} }

View File

@ -16,9 +16,9 @@
package com.shabinder.common.di package com.shabinder.common.di
import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -34,9 +34,6 @@ val allTracksStatus: HashMap<String, DownloadStatus> = hashMapOf()
// IO-Dispatcher // IO-Dispatcher
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default
// Current Platform Info
actual val currentPlatform: AllPlatforms = AllPlatforms.Js
actual suspend fun downloadTracks( actual suspend fun downloadTracks(
list: List<TrackDetails>, list: List<TrackDetails>,
fetcher: FetchPlatformQueryResult, fetcher: FetchPlatformQueryResult,
@ -45,29 +42,31 @@ actual suspend fun downloadTracks(
list.forEach { track -> list.forEach { track ->
withContext(dispatcherIO) { withContext(dispatcherIO) {
allTracksStatus[track.title] = DownloadStatus.Queued allTracksStatus[track.title] = DownloadStatus.Queued
val url = fetcher.findMp3DownloadLink(track) fetcher.findMp3DownloadLink(track).fold(
if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL success = { url ->
downloadFile(url).collect { downloadFile(url).collect {
when (it) { when (it) {
is DownloadResult.Success -> { is DownloadResult.Success -> {
println("Download Completed") println("Download Completed")
dir.saveFileWithMetadata(it.byteArray, track) {} dir.saveFileWithMetadata(it.byteArray, track) {}
} }
is DownloadResult.Error -> { is DownloadResult.Error -> {
allTracksStatus[track.title] = DownloadStatus.Failed allTracksStatus[track.title] = DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause))
println("Download Error: ${track.title}") println("Download Error: ${track.title}")
} }
is DownloadResult.Progress -> { is DownloadResult.Progress -> {
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress) allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
println("Download Progress: ${it.progress} : ${track.title}") println("Download Progress: ${it.progress} : ${track.title}")
}
} }
DownloadProgressFlow.emit(allTracksStatus)
} }
},
failure = { error ->
allTracksStatus[track.title] = DownloadStatus.Failed(error)
DownloadProgressFlow.emit(allTracksStatus) DownloadProgressFlow.emit(allTracksStatus)
} }
} else { )
allTracksStatus[track.title] = DownloadStatus.Failed
DownloadProgressFlow.emit(allTracksStatus)
}
} }
} }
} }

View File

@ -17,13 +17,13 @@
package com.shabinder.common.di package com.shabinder.common.di
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.gaana.corsApi import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.corsApi
import com.shabinder.database.Database import com.shabinder.database.Database
import kotlinext.js.Object import kotlinext.js.Object
import kotlinext.js.js import kotlinext.js.js
@ -34,7 +34,7 @@ import org.w3c.dom.ImageBitmap
actual class Dir actual constructor( actual class Dir actual constructor(
private val logger: Kermit, private val logger: Kermit,
settingsPref: Settings, private val preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) {
/*init { /*init {
@ -116,7 +116,6 @@ actual class Dir actual constructor(
private suspend fun freshImage(url: String): ImageBitmap? = null private suspend fun freshImage(url: String): ImageBitmap? = null
actual val db: Database? = spotiFlyerDatabase.instance actual val db: Database? = spotiFlyerDatabase.instance
actual val settings: Settings = settingsPref
} }
fun ByteArray.toArrayBuffer(): ArrayBuffer { fun ByteArray.toArrayBuffer(): ArrayBuffer {

View File

@ -1,4 +1,4 @@
package com.shabinder.common.di.saavn package com.shabinder.common.di.providers.requests.saavn
actual suspend fun decryptURL(url: String): String { actual suspend fun decryptURL(url: String): String {
TODO("Not yet implemented") TODO("Not yet implemented")

View File

@ -22,6 +22,7 @@ import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.Picture import com.shabinder.common.di.Picture
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.list.integration.SpotiFlyerListImpl import com.shabinder.common.list.integration.SpotiFlyerListImpl
import com.shabinder.common.models.Consumer import com.shabinder.common.models.Consumer
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
@ -61,12 +62,13 @@ interface SpotiFlyerList {
/* /*
* Snooze Donation Dialog * Snooze Donation Dialog
* */ * */
fun snoozeDonationDialog() fun dismissDonationDialogSetOffset()
interface Dependencies { interface Dependencies {
val storeFactory: StoreFactory val storeFactory: StoreFactory
val fetchQuery: FetchPlatformQueryResult val fetchQuery: FetchPlatformQueryResult
val dir: Dir val dir: Dir
val preferenceManager: PreferenceManager
val link: String val link: String
val listOutput: Consumer<Output> val listOutput: Consumer<Output>
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
@ -83,7 +85,7 @@ interface SpotiFlyerList {
val queryResult: PlatformQueryResult? = null, val queryResult: PlatformQueryResult? = null,
val link: String = "", val link: String = "",
val trackList: List<TrackDetails> = emptyList(), val trackList: List<TrackDetails> = emptyList(),
val errorOccurred: Exception? = null, val errorOccurred: Throwable? = null,
val askForDonation: Boolean = false, val askForDonation: Boolean = false,
) )
} }

View File

@ -18,10 +18,10 @@ package com.shabinder.common.list.integration
import co.touchlab.stately.ensureNeverFrozen import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.lifecycle.doOnResume
import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.Value
import com.shabinder.common.caching.Cache import com.shabinder.common.caching.Cache
import com.shabinder.common.di.Picture import com.shabinder.common.di.Picture
import com.shabinder.common.di.setDonationOffset
import com.shabinder.common.di.utils.asValue import com.shabinder.common.di.utils.asValue
import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.list.SpotiFlyerList.Dependencies import com.shabinder.common.list.SpotiFlyerList.Dependencies
@ -38,12 +38,16 @@ internal class SpotiFlyerListImpl(
init { init {
instanceKeeper.ensureNeverFrozen() instanceKeeper.ensureNeverFrozen()
lifecycle.doOnResume {
onRefreshTracksStatuses()
}
} }
private val store = private val store =
instanceKeeper.getStore { instanceKeeper.getStore {
SpotiFlyerListStoreProvider( SpotiFlyerListStoreProvider(
dir = this.dir, dir = this.dir,
preferenceManager = preferenceManager,
storeFactory = storeFactory, storeFactory = storeFactory,
fetchQuery = fetchQuery, fetchQuery = fetchQuery,
downloadProgressFlow = downloadProgressFlow, downloadProgressFlow = downloadProgressFlow,
@ -74,8 +78,8 @@ internal class SpotiFlyerListImpl(
store.accept(Intent.RefreshTracksStatuses) store.accept(Intent.RefreshTracksStatuses)
} }
override fun snoozeDonationDialog() { override fun dismissDonationDialogSetOffset() {
dir.setDonationOffset(offset = 10) preferenceManager.setDonationOffset(offset = 10)
} }
override suspend fun loadImage(url: String, isCover: Boolean): Picture { override suspend fun loadImage(url: String, isCover: Boolean): Picture {

View File

@ -21,11 +21,10 @@ import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
import com.shabinder.common.database.getLogger
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.downloadTracks import com.shabinder.common.di.downloadTracks
import com.shabinder.common.di.getDonationOffset import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.list.SpotiFlyerList.State import com.shabinder.common.list.SpotiFlyerList.State
import com.shabinder.common.list.store.SpotiFlyerListStore.Intent import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
@ -33,17 +32,16 @@ import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collect
internal class SpotiFlyerListStoreProvider( internal class SpotiFlyerListStoreProvider(
private val dir: Dir, private val dir: Dir,
private val preferenceManager: PreferenceManager,
private val storeFactory: StoreFactory, private val storeFactory: StoreFactory,
private val fetchQuery: FetchPlatformQueryResult, private val fetchQuery: FetchPlatformQueryResult,
private val link: String, private val link: String,
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
) { ) {
val logger = getLogger()
fun provide(): SpotiFlyerListStore = fun provide(): SpotiFlyerListStore =
object : object :
SpotiFlyerListStore, SpotiFlyerListStore,
@ -59,8 +57,8 @@ internal class SpotiFlyerListStoreProvider(
data class ResultFetched(val result: PlatformQueryResult, val trackList: List<TrackDetails>) : Result() data class ResultFetched(val result: PlatformQueryResult, val trackList: List<TrackDetails>) : Result()
data class UpdateTrackList(val list: List<TrackDetails>) : Result() data class UpdateTrackList(val list: List<TrackDetails>) : Result()
data class UpdateTrackItem(val item: TrackDetails) : Result() data class UpdateTrackItem(val item: TrackDetails) : Result()
data class ErrorOccurred(val error: Exception) : Result() data class ErrorOccurred(val error: Throwable) : Result()
data class AskForDonation(val isAllowed: Boolean) : Result() data class AskForSupport(val isAllowed: Boolean) : Result()
} }
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() { private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
@ -70,18 +68,18 @@ internal class SpotiFlyerListStoreProvider(
dir.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also { dir.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
// See if It's Time we can request for support for maintaining this project or not // See if It's Time we can request for support for maintaining this project or not
logger.d(message = "Database List Last ID: $it", tag = "Database Last ID") fetchQuery.logger.d(message = { "Database List Last ID: $it" }, tag = "Database Last ID")
val offset = dir.getDonationOffset val offset = preferenceManager.getDonationOffset
dispatch( dispatch(
Result.AskForDonation( Result.AskForSupport(
// Every 3rd Interval or After some offset // Every 3rd Interval or After some offset
isAllowed = offset < 4 && (it % offset == 0L) isAllowed = offset < 4 && (it % offset == 0L)
) )
) )
} }
downloadProgressFlow.collectLatest { map -> downloadProgressFlow.collect { map ->
logger.d(map.size.toString(), "ListStore: flow Updated") // logger.d(map.size.toString(), "ListStore: flow Updated")
val updatedTrackList = getState().trackList.updateTracksStatuses(map) val updatedTrackList = getState().trackList.updateTracksStatuses(map)
if (updatedTrackList.isNotEmpty()) dispatch(Result.UpdateTrackList(updatedTrackList)) if (updatedTrackList.isNotEmpty()) dispatch(Result.UpdateTrackList(updatedTrackList))
} }
@ -90,19 +88,17 @@ internal class SpotiFlyerListStoreProvider(
override suspend fun executeIntent(intent: Intent, getState: () -> State) { override suspend fun executeIntent(intent: Intent, getState: () -> State) {
when (intent) { when (intent) {
is Intent.SearchLink -> { is Intent.SearchLink -> {
try { val resp = fetchQuery.query(link)
val result = fetchQuery.query(link) resp.fold(
if (result != null) { success = { result ->
result.trackList = result.trackList.toMutableList() result.trackList = result.trackList.toMutableList()
dispatch((Result.ResultFetched(result, result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() })))) dispatch((Result.ResultFetched(result, result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() }))))
executeIntent(Intent.RefreshTracksStatuses, getState) executeIntent(Intent.RefreshTracksStatuses, getState)
} else { },
throw Exception("An Error Occurred, Check your Link / Connection") failure = {
dispatch(Result.ErrorOccurred(it))
} }
} catch (e: Exception) { )
e.printStackTrace()
dispatch(Result.ErrorOccurred(e))
}
} }
is Intent.StartDownloadAll -> { is Intent.StartDownloadAll -> {
@ -133,7 +129,7 @@ internal class SpotiFlyerListStoreProvider(
is Result.UpdateTrackList -> copy(trackList = result.list) is Result.UpdateTrackList -> copy(trackList = result.list)
is Result.UpdateTrackItem -> updateTrackItem(result.item) is Result.UpdateTrackItem -> updateTrackItem(result.item)
is Result.ErrorOccurred -> copy(errorOccurred = result.error) is Result.ErrorOccurred -> copy(errorOccurred = result.error)
is Result.AskForDonation -> copy(askForDonation = result.isAllowed) is Result.AskForSupport -> copy(askForDonation = result.isAllowed)
} }
private fun State.updateTrackItem(item: TrackDetails): State { private fun State.updateTrackItem(item: TrackDetails): State {

View File

@ -21,6 +21,7 @@ import com.arkivanov.decompose.value.Value
import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.Picture import com.shabinder.common.di.Picture
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.main.integration.SpotiFlyerMainImpl import com.shabinder.common.main.integration.SpotiFlyerMainImpl
import com.shabinder.common.models.Consumer import com.shabinder.common.models.Consumer
import com.shabinder.common.models.DownloadRecord import com.shabinder.common.models.DownloadRecord
@ -58,11 +59,14 @@ interface SpotiFlyerMain {
* */ * */
suspend fun loadImage(url: String): Picture suspend fun loadImage(url: String): Picture
fun dismissDonationDialogOffset()
interface Dependencies { interface Dependencies {
val mainOutput: Consumer<Output> val mainOutput: Consumer<Output>
val storeFactory: StoreFactory val storeFactory: StoreFactory
val database: Database? val database: Database?
val dir: Dir val dir: Dir
val preferenceManager: PreferenceManager
val mainAnalytics: Analytics val mainAnalytics: Analytics
} }

View File

@ -23,7 +23,10 @@ import com.shabinder.common.caching.Cache
import com.shabinder.common.di.Picture import com.shabinder.common.di.Picture
import com.shabinder.common.di.utils.asValue import com.shabinder.common.di.utils.asValue
import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.* import com.shabinder.common.main.SpotiFlyerMain.Dependencies
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
import com.shabinder.common.main.SpotiFlyerMain.Output
import com.shabinder.common.main.SpotiFlyerMain.State
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
import com.shabinder.common.main.store.getStore import com.shabinder.common.main.store.getStore
@ -41,6 +44,7 @@ internal class SpotiFlyerMainImpl(
private val store = private val store =
instanceKeeper.getStore { instanceKeeper.getStore {
SpotiFlyerMainStoreProvider( SpotiFlyerMainStoreProvider(
preferenceManager = preferenceManager,
storeFactory = storeFactory, storeFactory = storeFactory,
database = database, database = database,
dir = dir dir = dir
@ -78,4 +82,8 @@ internal class SpotiFlyerMainImpl(
dir.loadImage(url, 150, 150) dir.loadImage(url, 150, 150)
} }
} }
override fun dismissDonationDialogOffset() {
preferenceManager.setDonationOffset()
}
} }

View File

@ -22,8 +22,7 @@ import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.isAnalyticsEnabled import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.toggleAnalytics
import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.State import com.shabinder.common.main.SpotiFlyerMain.State
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
@ -39,6 +38,7 @@ import kotlinx.coroutines.flow.map
internal class SpotiFlyerMainStoreProvider( internal class SpotiFlyerMainStoreProvider(
private val storeFactory: StoreFactory, private val storeFactory: StoreFactory,
private val preferenceManager: PreferenceManager,
private val dir: Dir, private val dir: Dir,
database: Database? database: Database?
) { ) {
@ -76,7 +76,7 @@ internal class SpotiFlyerMainStoreProvider(
private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() { private inner class ExecutorImpl : SuspendExecutor<Intent, Unit, State, Result, Nothing>() {
override suspend fun executeAction(action: Unit, getState: () -> State) { override suspend fun executeAction(action: Unit, getState: () -> State) {
dispatch(Result.ToggleAnalytics(dir.isAnalyticsEnabled)) dispatch(Result.ToggleAnalytics(preferenceManager.isAnalyticsEnabled))
updates?.collect { updates?.collect {
dispatch(Result.ItemsLoaded(it)) dispatch(Result.ItemsLoaded(it))
} }
@ -91,7 +91,7 @@ internal class SpotiFlyerMainStoreProvider(
is Intent.SelectCategory -> dispatch(Result.CategoryChanged(intent.category)) is Intent.SelectCategory -> dispatch(Result.CategoryChanged(intent.category))
is Intent.ToggleAnalytics -> { is Intent.ToggleAnalytics -> {
dispatch(Result.ToggleAnalytics(intent.enabled)) dispatch(Result.ToggleAnalytics(intent.enabled))
dir.toggleAnalytics(intent.enabled) preferenceManager.toggleAnalytics(intent.enabled)
} }
} }
} }

View File

@ -14,10 +14,22 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.models plugins {
id("android-setup")
sealed class AllPlatforms { id("multiplatform-setup")
object Js : AllPlatforms() id("multiplatform-setup-test")
object Jvm : AllPlatforms() id("kotlin-parcelize")
object Native : AllPlatforms() }
kotlin {
sourceSets {
commonMain {
dependencies {
implementation(project(":common:dependency-injection"))
implementation(project(":common:data-models"))
implementation(project(":common:database"))
implementation(SqlDelight.coroutineExtensions)
}
}
}
} }

View 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"/>

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -22,6 +22,7 @@ import com.arkivanov.decompose.value.Value
import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.models.Actions import com.shabinder.common.models.Actions
@ -49,9 +50,10 @@ interface SpotiFlyerRoot {
interface Dependencies { interface Dependencies {
val storeFactory: StoreFactory val storeFactory: StoreFactory
val database: Database? val database: Database?
val fetchPlatformQueryResult: FetchPlatformQueryResult val fetchQuery: FetchPlatformQueryResult
val directories: Dir val dir: Dir
val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> val preferenceManager: PreferenceManager
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
val actions: Actions val actions: Actions
val analytics: Analytics val analytics: Analytics
} }

View File

@ -27,13 +27,10 @@ import com.arkivanov.decompose.router
import com.arkivanov.decompose.statekeeper.Parcelable import com.arkivanov.decompose.statekeeper.Parcelable
import com.arkivanov.decompose.statekeeper.Parcelize import com.arkivanov.decompose.statekeeper.Parcelize
import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.Value
import com.shabinder.common.di.Dir import com.shabinder.common.di.dispatcherIO
import com.shabinder.common.di.currentPlatform
import com.shabinder.common.di.providers.SpotifyProvider
import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.models.Actions import com.shabinder.common.models.Actions
import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.Consumer import com.shabinder.common.models.Consumer
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
@ -41,7 +38,7 @@ import com.shabinder.common.root.SpotiFlyerRoot.Analytics
import com.shabinder.common.root.SpotiFlyerRoot.Child import com.shabinder.common.root.SpotiFlyerRoot.Child
import com.shabinder.common.root.SpotiFlyerRoot.Dependencies import com.shabinder.common.root.SpotiFlyerRoot.Dependencies
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -79,11 +76,8 @@ internal class SpotiFlyerRootImpl(
) { ) {
instanceKeeper.ensureNeverFrozen() instanceKeeper.ensureNeverFrozen()
methods.value = dependencies.actions.freeze() methods.value = dependencies.actions.freeze()
/*Authenticate Spotify Client*/ /*Init App Launch & Authenticate Spotify Client*/
authenticateSpotify( initAppLaunchAndAuthenticateSpotify(dependencies.fetchQuery::authenticateSpotifyClient)
dependencies.fetchPlatformQueryResult.spotifyProvider,
currentPlatform is AllPlatforms.Js
)
} }
private val router = private val router =
@ -134,11 +128,12 @@ internal class SpotiFlyerRootImpl(
} }
} }
private fun authenticateSpotify(spotifyProvider: SpotifyProvider, override: Boolean) { @OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Default) { private fun initAppLaunchAndAuthenticateSpotify(authenticator: suspend () -> Unit) {
GlobalScope.launch(dispatcherIO) {
analytics.appLaunchEvent() analytics.appLaunchEvent()
/*Authenticate Spotify Client*/ /*Authenticate Spotify Client*/
spotifyProvider.authenticateSpotifyClient(override) authenticator()
} }
} }
@ -156,10 +151,7 @@ private fun spotiFlyerMain(componentContext: ComponentContext, output: Consumer<
componentContext = componentContext, componentContext = componentContext,
dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies { dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies {
override val mainOutput: Consumer<SpotiFlyerMain.Output> = output override val mainOutput: Consumer<SpotiFlyerMain.Output> = output
override val dir: Dir = directories override val mainAnalytics = object : SpotiFlyerMain.Analytics , Analytics by analytics {}
override val mainAnalytics = object : SpotiFlyerMain.Analytics {
override fun donationDialogVisit() = analytics.donationDialogVisit()
}
} }
) )
@ -167,11 +159,8 @@ private fun spotiFlyerList(componentContext: ComponentContext, link: String, out
SpotiFlyerList( SpotiFlyerList(
componentContext = componentContext, componentContext = componentContext,
dependencies = object : SpotiFlyerList.Dependencies, Dependencies by dependencies { dependencies = object : SpotiFlyerList.Dependencies, Dependencies by dependencies {
override val fetchQuery = fetchPlatformQueryResult
override val dir: Dir = directories
override val link: String = link override val link: String = link
override val listOutput: Consumer<SpotiFlyerList.Output> = output override val listOutput: Consumer<SpotiFlyerList.Output> = output
override val downloadProgressFlow = downloadProgressReport override val listAnalytics = object : SpotiFlyerList.Analytics, Analytics by analytics {}
override val listAnalytics = object : SpotiFlyerList.Analytics {}
} }
) )

View 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()
}

View 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
}
}

View 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
)
}
}

View 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
}
}

View 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)

View 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")

View File

@ -0,0 +1,6 @@
package utils
import kotlinx.coroutines.runBlocking
// Test Class- at development Time
fun main(): Unit = runBlocking {}

View File

@ -38,8 +38,8 @@ kotlin {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(project(":common:database")) implementation(project(":common:database"))
implementation(project(":common:dependency-injection")) implementation(project(":common:dependency-injection"))
implementation(project(":common:compose"))
implementation(project(":common:data-models")) implementation(project(":common:data-models"))
implementation(project(":common:compose"))
implementation(project(":common:root")) implementation(project(":common:root"))
// Decompose // Decompose

View File

@ -27,12 +27,21 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponen
import com.arkivanov.mvikotlin.core.lifecycle.LifecycleRegistry import com.arkivanov.mvikotlin.core.lifecycle.LifecycleRegistry
import com.arkivanov.mvikotlin.core.lifecycle.resume import com.arkivanov.mvikotlin.core.lifecycle.resume
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.shabinder.common.di.* import com.shabinder.common.di.Dir
import com.shabinder.common.di.DownloadProgressFlow
import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.initKoin
import com.shabinder.common.di.isInternetAccessible
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.models.Actions import com.shabinder.common.models.Actions
import com.shabinder.common.models.PlatformActions import com.shabinder.common.models.PlatformActions
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.uikit.* import com.shabinder.common.uikit.SpotiFlyerColors
import com.shabinder.common.uikit.SpotiFlyerRootContent
import com.shabinder.common.uikit.SpotiFlyerShapes
import com.shabinder.common.uikit.SpotiFlyerTypography
import com.shabinder.common.uikit.colorOffWhite
import com.shabinder.database.Database import com.shabinder.database.Database
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.piwik.java.tracking.PiwikTracker import org.piwik.java.tracking.PiwikTracker
@ -79,10 +88,11 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
componentContext = componentContext, componentContext = componentContext,
dependencies = object : SpotiFlyerRoot.Dependencies { dependencies = object : SpotiFlyerRoot.Dependencies {
override val storeFactory = DefaultStoreFactory override val storeFactory = DefaultStoreFactory
override val fetchPlatformQueryResult: FetchPlatformQueryResult = koin.get() override val fetchQuery: FetchPlatformQueryResult = koin.get()
override val directories: Dir = koin.get() override val dir: Dir = koin.get()
override val database: Database? = directories.db override val database: Database? = dir.db
override val downloadProgressReport = DownloadProgressFlow override val preferenceManager: PreferenceManager = koin.get()
override val downloadProgressFlow = DownloadProgressFlow
override val actions: Actions = object: Actions { override val actions: Actions = object: Actions {
override val platformActions = object : PlatformActions {} override val platformActions = object : PlatformActions {}
@ -100,7 +110,7 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
APPROVE_OPTION -> { APPROVE_OPTION -> {
val directory = fileChooser.selectedFile val directory = fileChooser.selectedFile
if(directory.canWrite()){ if(directory.canWrite()){
directories.setDownloadDirectory(directory.absolutePath) preferenceManager.setDownloadDirectory(directory.absolutePath)
showPopUpMessage("Set New Download Directory:\n${directory.absolutePath}") showPopUpMessage("Set New Download Directory:\n${directory.absolutePath}")
} else { } else {
showPopUpMessage("Cant Write to Selected Directory!") showPopUpMessage("Cant Write to Selected Directory!")
@ -137,10 +147,10 @@ private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot =
} }
override val analytics = object: SpotiFlyerRoot.Analytics { override val analytics = object: SpotiFlyerRoot.Analytics {
override fun appLaunchEvent() { override fun appLaunchEvent() {
if(directories.isFirstLaunch) { if(preferenceManager.isFirstLaunch) {
// Enable Analytics on First Launch // Enable Analytics on First Launch
directories.toggleAnalytics(true) preferenceManager.toggleAnalytics(true)
directories.firstLaunchDone() preferenceManager.firstLaunchDone()
} }
tracker.trackAsync { tracker.trackAsync {
eventName = "App Launch" eventName = "App Launch"

View File

@ -22,10 +22,20 @@ include(
":common:root", ":common:root",
":common:main", ":common:main",
":common:list", ":common:list",
":common:preference",
":common:data-models", ":common:data-models",
":common:dependency-injection", ":common:dependency-injection",
":android", ":android",
":desktop", ":desktop",
":web-app", ":web-app",
":console-app",
":maintenance-tasks" ":maintenance-tasks"
) )
includeBuild("mosaic/mosaic") {
dependencySubstitution {
substitute(module("com.jakewharton.mosaic:mosaic-gradle-plugin")).with(project(":mosaic-gradle-plugin"))
substitute(module("com.jakewharton.mosaic:mosaic-runtime")).with(project(":mosaic-runtime"))
substitute(module("com.jakewharton.mosaic:compose-compiler")).with(project(":compose:compiler"))
}
}

View 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