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"]
path = 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)
// Extras
Extras.Android.apply {
with(Extras.Android) {
implementation(Acra.notification)
implementation(Acra.http)
implementation(appUpdator)
implementation(matomo)
}
with(Versions.androidxLifecycle) {
implementation("androidx.lifecycle:lifecycle-service:$this")
implementation("androidx.lifecycle:lifecycle-common-java8:$this")
}
implementation(Extras.kermit)
//implementation("com.jakewharton.timber:timber:4.7.1")
implementation("dev.icerock.moko:parcelize:0.7.0")
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
implementation("com.google.accompanist:accompanist-insets:0.11.1")
implementation("com.google.accompanist:accompanist-insets:0.12.0")
// Test
testImplementation("junit:junit:4.13.2")

View File

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

View File

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

View File

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

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
/**
* Cleaning All Residual Files except Mp3 Files
**/
fun cleanFiles(dir: File, logger: Kermit) {
fun cleanFiles(dir: File) {
try {
logger.d("File Cleaning") { "Starting Cleaning in ${dir.path} " }
Log.d("File Cleaning","Starting Cleaning in ${dir.path} ")
val fList = dir.listFiles()
fList?.let {
for (file in fList) {
if (file.isDirectory) {
cleanFiles(file, logger)
cleanFiles(file)
} else if (file.isFile) {
if (file.path.toString().substringAfterLast(".") != "mp3") {
logger.d("Files Cleaning") { "Cleaning ${file.path}" }
Log.d("Files Cleaning","Cleaning ${file.path}")
file.delete()
}
}
@ -24,3 +24,4 @@ fun cleanFiles(dir: File, logger: Kermit) {
}
} catch (e: Exception) { e.printStackTrace() }
}

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 {
kotlinOptions {
jvmTarget = "1.8"
useIR = true
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
}
}
afterEvaluate {
project.extensions.findByType<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension>()?.let { kmpExt ->
kmpExt.sourceSets.removeAll { it.name == "androidAndroidTestRelease" }
kmpExt.sourceSets.run {
all {
languageSettings.useExperimentalAnnotation("kotlinx.serialization.ExperimentalSerializationApi")
}
removeAll { it.name == "androidAndroidTestRelease" }
}
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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("org.jetbrains.kotlinx:atomicfu:0.16.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
implementation("com.russhwolf:multiplatform-settings-no-arg:0.7.7")
api(MultiPlatformSettings.dep)
implementation(Extras.youtubeDownloader)
implementation(Extras.fuzzyWuzzy)
implementation(MVIKotlin.rx)

View File

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

View File

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

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 io.ktor.util.InternalAPI
import io.ktor.util.decodeBase64Bytes
import io.ktor.util.*
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.decodeBase64Bytes
import io.ktor.util.*
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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(project(":common:database"))
implementation(project(":common:dependency-injection"))
implementation(project(":common:compose"))
implementation(project(":common:data-models"))
implementation(project(":common:compose"))
implementation(project(":common:root"))
// Decompose

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

View File

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

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