From 099a103e987e3d81b78b14e4456ae8c823695d6f Mon Sep 17 00:00:00 2001 From: Shabinder Date: Sat, 7 Nov 2020 03:56:10 +0530 Subject: [PATCH 01/14] No More Web Scraping!,Speed Boosted& Less Crashes. --- .idea/dictionaries/shabinder.xml | 1 + app/build.gradle | 5 +- .../com/shabinder/spotiflyer/MainActivity.kt | 6 +- .../downloadHelper/SpotifyDownloadHelper.kt | 153 ++++---------- .../downloadHelper/YoutubeProvider.kt | 190 ++++++++++++++++++ .../spotiflyer/models/YoutubeTrack.kt | 29 +++ .../spotiflyer/ui/spotify/SpotifyFragment.kt | 9 +- .../shabinder/spotiflyer/utils/Provider.kt | 15 ++ .../spotiflyer/utils/YoutubeMusicApi.kt | 58 ++++++ .../spotiflyer/worker/ForegroundService.kt | 47 ++--- app/src/main/res/layout/spotify_fragment.xml | 9 - 11 files changed, 366 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt diff --git a/.idea/dictionaries/shabinder.xml b/.idea/dictionaries/shabinder.xml index 7218236f..726356e5 100755 --- a/.idea/dictionaries/shabinder.xml +++ b/.idea/dictionaries/shabinder.xml @@ -25,6 +25,7 @@ spotifydownloader spotifyler thru + weyfdnx youtu diff --git a/app/build.gradle b/app/build.gradle index d390587f..36a5fdfe 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -115,10 +115,13 @@ dependencies { implementation 'com.squareup.moshi:moshi:1.11.0' implementation 'com.squareup.moshi:moshi-kotlin:1.11.0' implementation "com.squareup.retrofit2:converter-moshi:2.9.0" + implementation "com.squareup.retrofit2:converter-scalars:2.9.0" + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.beust:klaxon:5.4' implementation 'com.mpatric:mp3agic:0.9.1' implementation 'com.shreyaspatil:EasyUpiPayment:3.0.0' - implementation 'com.github.sealedtx:java-youtube-downloader:2.4.2' + implementation 'com.github.sealedtx:java-youtube-downloader:2.4.3' implementation "androidx.tonyodev.fetch2:xfetch2:3.1.5" implementation 'com.github.javiersantos:AppUpdater:2.7' diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index b4a1272e..4868a89b 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -69,9 +69,6 @@ class MainActivity : AppCompatActivity(){ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) sharedPref = this.getPreferences(Context.MODE_PRIVATE) - //starting Notification and Downloader Service! - SpotifyDownloadHelper.startService(this) - if(sharedViewModel.spotifyService.value == null){ authenticateSpotify() }else{ @@ -86,6 +83,9 @@ class MainActivity : AppCompatActivity(){ sharedViewModel.isConnected.value = isConnected Log.i("Connection Status", isConnected.toString()) + //starting Notification and Downloader Service! + SpotifyDownloadHelper.startService(this) + handleIntentFromExternalActivity() } diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt index a68427d6..b783a9a5 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt @@ -17,18 +17,13 @@ package com.shabinder.spotiflyer.downloadHelper -import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.os.Build import android.os.Environment -import android.os.Handler import android.util.Log import android.view.View import android.view.animation.AlphaAnimation import android.view.animation.Animation -import android.webkit.WebView -import android.webkit.WebViewClient import android.widget.TextView import androidx.core.content.ContextCompat import com.github.kiulian.downloader.YoutubeDownloader @@ -37,25 +32,27 @@ import com.github.kiulian.downloader.model.quality.AudioQuality import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel +import com.shabinder.spotiflyer.utils.YoutubeMusicApi import com.shabinder.spotiflyer.utils.getEmojiByUnicode +import com.shabinder.spotiflyer.utils.makeJsonBody import com.shabinder.spotiflyer.worker.ForegroundService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response import java.io.File object SpotifyDownloadHelper { - var webView:WebView? = null var context : Context? = null var statusBar:TextView? = null + var youtubeMusicApi:YoutubeMusicApi? = null val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator var spotifyViewModel: SpotifyViewModel? = null - private var isBrowserLoading = false - private var total = 0 - private var Processed = 0 - private var notFound = 0 - private var listProcessed:Boolean = false - var youtubeList = mutableListOf() + var total = 0 + var Processed = 0 + var notFound = 0 /** * Function To Download All Tracks Available in a List @@ -70,16 +67,9 @@ object SpotifyDownloadHelper { if(it.downloaded == "Downloaded"){//Download Already Present!! Processed++ }else{ - if(isBrowserLoading){//WebView Busy!! - if (listProcessed){//Previous List request progress check - getYTLink(type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it) - listProcessed = false//Notifying A list Processing Started - }else{//Adding Requests to a Queue - youtubeList.add(YoutubeRequest(type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it)) - } - }else{ - getYTLink(type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it) - } + val artistsList = mutableListOf() + it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } + searchYTMusic(type,subFolder,ytDownloader,"${it.name} - ${artistsList.joinToString(",")}", it) } updateStatusBar() } @@ -88,66 +78,32 @@ object SpotifyDownloadHelper { } - - //TODO CleanUp here and there!! - @SuppressLint("SetJavaScriptEnabled") - suspend fun getYTLink(type:String, - subFolder:String?, - ytDownloader: YoutubeDownloader?, - searchQuery: String, - track: Track){ - isBrowserLoading = true // Notify Web View Started Loading - val searchText = searchQuery.replace("\\s".toRegex(), "+") - val url = "https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q=$searchText" - Log.i("DH YT LINK ",url) - applyWebViewSettings(webView!!) - withContext(Dispatchers.Main){ - webView!!.loadUrl(url) - webView!!.webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - view?.evaluateJavascript( - "document.getElementsByClassName(\"yt-simple-endpoint style-scope ytd-video-renderer\")[0].href" - ) { value -> - Log.i("YT-id Link", value.toString().replace("\"", "")) - val id = value!!.substringAfterLast("=", "error").replace("\"", "") - Log.i("YT-ID", id) - if (id != "error") {//Link extracting error - Processed++ - downloadFile(subFolder, type, track, ytDownloader, id) - }else notFound++ - updateStatusBar() - if (youtubeList.isNotEmpty()) { - val request = youtubeList[0] - spotifyViewModel!!.uiScope.launch { - getYTLink( - request.type, - request.subFolder, - request.ytDownloader, - request.searchQuery, - request.track - ) - } - youtubeList.remove(request) - if (youtubeList.size == 0) {//list processing completed , webView is free again! - isBrowserLoading = false - listProcessed = true - } - } else {//YT List Empty....Maybe it was one Single Download - Handler().postDelayed({//Delay of 1.5 sec - if (youtubeList.isEmpty()) {//Lets Make It sure , There are No more Downloads In Queue..... - isBrowserLoading = false - listProcessed = true - } - }, 1500) - } + suspend fun searchYTMusic(type:String, + subFolder:String?, + ytDownloader: YoutubeDownloader?, + searchQuery: String, + track: Track){ + val jsonBody = makeJsonBody(searchQuery.trim()) + youtubeMusicApi?.getYoutubeMusicResponse(jsonBody)?.enqueue( + object : Callback{ + override fun onResponse(call: Call, response: Response) { + spotifyViewModel?.uiScope?.launch { + Log.i("YT API BODY",response.body().toString()) + Log.i("YT Search Query",searchQuery) + getYTLink(type,subFolder,ytDownloader,response.body().toString(),track) } } + + override fun onFailure(call: Call, t: Throwable) { + Log.i("YT API Fail",t.message.toString()) + } } - } + ) + } - private fun updateStatusBar() { + + fun updateStatusBar() { statusBar!!.visibility = View.VISIBLE statusBar?.text = "Total: $total ${getEmojiByUnicode(0x2705)}: $Processed ${getEmojiByUnicode(0x274C)}: $notFound" } @@ -158,7 +114,6 @@ object SpotifyDownloadHelper { withContext(Dispatchers.IO) { try { val video = ytDownloader?.getVideo(id) - val detail = video?.details() val format: Format? = try { video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format } catch (e: java.lang.IndexOutOfBoundsException) { @@ -191,18 +146,21 @@ object SpotifyDownloadHelper { ) Log.i("DH", outputFile) startService(context!!, downloadObject) + Processed++ + spotifyViewModel?.uiScope?.launch(Dispatchers.Main) { + updateStatusBar() + } } }catch (e: com.github.kiulian.downloader.YoutubeException){ - Log.i("DH", "Error- Maybe Network") + Log.i("DH", e.message) } } } } - fun startService(context:Context,obj:DownloadObject? = null ) { val serviceIntent = Intent(context, ForegroundService::class.java) - serviceIntent.putExtra("object",obj) + obj?.let { serviceIntent.putExtra("object",it) } ContextCompat.startForegroundService(context, serviceIntent) } @@ -255,35 +213,4 @@ object SpotifyDownloadHelper { anim.repeatCount = Animation.INFINITE statusBar?.animation = anim } - @SuppressLint("SetJavaScriptEnabled") - fun applyWebViewSettings(webView: WebView) { - val desktopUserAgent = - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:82.0) Gecko/20100101 Firefox/82.0" - val mobileUserAgent = - "Mozilla/5.0 (Linux; U; Android 4.4; en-us; Nexus 4 Build/JOP24G) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30" - - //Choose Mobile/Desktop client. - webView.settings.userAgentString = desktopUserAgent - webView.settings.loadWithOverviewMode = true - webView.settings.builtInZoomControls = true - webView.settings.setSupportZoom(true) - webView.isScrollbarFadingEnabled = false - webView.scrollBarStyle = WebView.SCROLLBARS_OUTSIDE_OVERLAY - webView.settings.displayZoomControls = false - webView.settings.useWideViewPort = true - webView.settings.javaScriptEnabled = true - webView.settings.loadsImagesAutomatically = false - webView.settings.blockNetworkImage = true - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - webView.settings.safeBrowsingEnabled = true - } - } -} -data class YoutubeRequest( - val type:String, - val subFolder:String?, - val ytDownloader: YoutubeDownloader?, - val searchQuery: String, - val track: Track, - val index: Int? = null -) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt new file mode 100644 index 00000000..daa90974 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.downloadHelper + +import android.util.Log +import com.beust.klaxon.JsonArray +import com.beust.klaxon.JsonObject +import com.beust.klaxon.Parser +import com.github.kiulian.downloader.YoutubeDownloader +import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadFile +import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.notFound +import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.YoutubeTrack + +/* +* Thanks and credits To https://github.com/spotDL/spotify-downloader +* */ +fun getYTLink(type:String, + subFolder:String?, + ytDownloader: YoutubeDownloader?, + response: String, + track: Track +){ + //TODO Download File + val youtubeTracks = mutableListOf() + val parser: Parser = Parser.default() + val stringBuilder: StringBuilder = StringBuilder(response) + val responseObj: JsonObject = parser.parse(stringBuilder) as JsonObject + val contentBlocks = responseObj.obj("contents")?.obj("sectionListRenderer")?.array("contents") + val resultBlocks = mutableListOf>() + if (contentBlocks != null) { + Log.i("Total Content Blocks:", contentBlocks.size.toString()) + 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.containsKey("itemSectionRenderer")){ + continue + } + + for(contents in cBlock.obj("musicShelfRenderer")?.array("contents") ?: 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.obj("musicResponsiveListItemRenderer") + ?.array("flexColumns") + + //Add the linkBlock + val linkBlock = contents.obj("musicResponsiveListItemRenderer") + ?.obj("overlay") + ?.obj("musicItemThumbnailOverlayRenderer") + ?.obj("content") + ?.obj("musicPlayButtonRenderer") + ?.obj("playNavigationEndpoint") + + // detailsBlock is always a list, so we just append the linkBlock to it + // instead of carrying along all the other junk from "musicResponsiveListItemRenderer" + linkBlock?.let { result?.add(it) } + result?.let { resultBlocks.add(it) } + } + } + + /* 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 - 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 + ! cherrypick the details we need based on their index numbers, + ! we do so only if their Type is 'Song' or 'Video + */ + + val simplifiedResults = mutableListOf() + + for(result in resultBlocks){ + + // Blindly gather available details + val availableDetails = mutableListOf() + + /* + 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(detail in result.subList(0,result.size-2)){ + if(detail.obj("musicResponsiveListItemFlexColumnRenderer")?.size!! < 2) continue + + // if not a dummy, collect All Variables + detail.obj("musicResponsiveListItemFlexColumnRenderer") + ?.obj("text") + ?.array("runs")?.get(0)?.get("text")?.let { + availableDetails.add( + it.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 > 1 && 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 TODO + + /* + ! grab position of result + ! This helps for those oddball cases where 2+ results are rated equally, + ! lower position --> better match + */ + val resultPosition = resultBlocks.indexOf(result) + + /* + ! 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().obj("watchEndpoint")?.get("videoId") as String + val ytTrack = YoutubeTrack( + name = availableDetails[0], + type = availableDetails[1], + artist = availableDetails[2], + videoId = videoId + ) + youtubeTracks.add(ytTrack) + } + } + } + //Songs First, Videos Later + youtubeTracks.sortWith { o1: YoutubeTrack, o2: YoutubeTrack -> o1.type.toString().compareTo(o2.type.toString()) } + + if(youtubeTracks.firstOrNull()?.videoId.isNullOrBlank()) notFound++ + else downloadFile( + subFolder, + type, + track, + ytDownloader, + id = youtubeTracks[0].videoId.toString() + ) + Log.i("DHelper YT ID", youtubeTracks.firstOrNull()?.videoId ?: "Not Found") + SpotifyDownloadHelper.updateStatusBar() +} diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt new file mode 100644 index 00000000..4ea7de9d --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class YoutubeTrack( + var name: String? = null, + var type: String? = null, // Song / Video + var artist: String? = null, + var videoId: String? = null +):Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index 9e2835b3..b4a2d6e5 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -29,7 +29,6 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.webkit.WebView import android.widget.Toast import androidx.core.net.toUri import androidx.databinding.DataBindingUtil @@ -50,6 +49,7 @@ import com.shabinder.spotiflyer.databinding.SpotifyFragmentBinding import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter +import com.shabinder.spotiflyer.utils.YoutubeMusicApi import com.shabinder.spotiflyer.utils.bindImage import com.shabinder.spotiflyer.utils.copyTo import com.shabinder.spotiflyer.utils.rotateAnim @@ -70,7 +70,7 @@ class SpotifyFragment : Fragment() { private lateinit var sharedViewModel: SharedViewModel private lateinit var adapterSpotify:SpotifyTrackListAdapter @Inject lateinit var ytDownloader:YoutubeDownloader - private var webView: WebView? = null + @Inject lateinit var youtubeMusicApi: YoutubeMusicApi private var intentFilter:IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null @@ -88,7 +88,7 @@ class SpotifyFragment : Fragment() { val args = SpotifyFragmentArgs.fromBundle(requireArguments()) val spotifyLink = args.link - + val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?') val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') @@ -231,14 +231,13 @@ class SpotifyFragment : Fragment() { * Basic Initialization **/ private fun initializeAll() { - webView = binding.webViewSpotify sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) spotifyViewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) sharedViewModel.spotifyService.observe(viewLifecycleOwner, Observer { spotifyViewModel.spotifyService = it }) - SpotifyDownloadHelper.webView = binding.webViewSpotify SpotifyDownloadHelper.context = requireContext() + SpotifyDownloadHelper.youtubeMusicApi = youtubeMusicApi SpotifyDownloadHelper.spotifyViewModel = spotifyViewModel SpotifyDownloadHelper.statusBar = binding.StatusBarSpotify binding.trackListSpotify.adapter = adapterSpotify diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 57726455..3638edf0 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -35,7 +35,9 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory import javax.inject.Singleton @InstallIn(ApplicationComponent::class) @@ -97,4 +99,17 @@ object Provider { return retrofit.create(SpotifyServiceTokenRequest::class.java) } + @Provides + @Singleton + fun getYoutubeMusicApi():YoutubeMusicApi{ + + val retrofit = Retrofit.Builder() + .baseUrl("https://music.youtube.com/youtubei/v1/") + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + return retrofit.create(YoutubeMusicApi::class.java) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt new file mode 100644 index 00000000..ccd72c67 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.utils + +import com.beust.klaxon.JsonObject +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.Headers +import retrofit2.http.POST + + +const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" +/*val body = """{ + "context": { + "client": { + "clientName": "WEB_REMIX", + "clientVersion": "0.1" + } + }, + "query": "songSearchQuery" +}"""*/ +interface YoutubeMusicApi { + + @Headers("Content-Type: application/json", "Referer: https://music.youtube.com/search") + @POST("search?alt=json&key=$apiKey") + fun getYoutubeMusicResponse(@Body text: JsonObject): Call + +} + +fun makeJsonBody(query: String):JsonObject{ + val client = JsonObject() + client["clientName"] = "WEB_REMIX" + client["clientVersion"] = "0.1" + + val context = JsonObject() + context["client"] = client + + val mainObject = JsonObject() + mainObject["context"] = context + mainObject["query"] = query + + return mainObject +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt index a1922fa2..ba103032 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt @@ -17,6 +17,7 @@ package com.shabinder.spotiflyer.worker +import android.annotation.SuppressLint import android.app.* import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED import android.content.BroadcastReceiver @@ -144,33 +145,29 @@ class ForegroundService : Service(){ return channelId } + @SuppressLint("WakelockTimeout") override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { // Send a notification that service is started Log.i(tag,"Service Started.") startForeground() - //do heavy work on a background thread - //val list = intent.getSerializableExtra("list") as List -// val list = intent.getParcelableArrayListExtra("list") ?: intent.extras?.getParcelableArrayList("list") -// Log.i(tag,"Intent List Size: ${list!!.size}") - val obj = intent.getParcelableExtra("object") ?: intent.extras?.getParcelable("object") + val obj:DownloadObject? = intent.getParcelableExtra("object") ?: intent.extras?.getParcelable("object") obj?.let { total ++ -// Log.i(tag,"Intent List Size: ${list!!.size}") updateNotification() serviceScope.launch { - val request= Request(obj.url, obj.outputDir) - request.priority = Priority.NORMAL - request.networkType = NetworkType.ALL + val request= Request(obj.url, obj.outputDir) + request.priority = Priority.NORMAL + request.networkType = NetworkType.ALL - fetch!!.enqueue(request, - { - obj.track?.let { it1 -> requestMap.put(it, it1) } - downloadList.remove(obj) - Log.i(tag, "Enqueuing Download") - }, - { - Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")} - ) + fetch!!.enqueue(request, + { + obj.track?.let { it1 -> requestMap.put(it, it1) } + downloadList.remove(obj) + Log.i(tag, "Enqueuing Download") + }, + { + Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")} + ) } } @@ -316,12 +313,6 @@ class ForegroundService : Service(){ messageList[messageList.indexOf(message)] = "" } } - //Notify Download Completed - val intent = Intent() - .setAction("track_download_completed") - .putExtra("track",track) - this@ForegroundService.sendBroadcast(intent) - serviceScope.launch { try{ @@ -457,11 +448,17 @@ class ForegroundService : Service(){ newFile.renameTo(file) converted++ updateNotification() + + //Notify Download Completed + val intent = Intent() + .setAction("track_download_completed") + .putExtra("track",track) + this@ForegroundService.sendBroadcast(intent) + //All tasks completed (REST IN PEACE) if(converted == total){ onDestroy() } - } /** diff --git a/app/src/main/res/layout/spotify_fragment.xml b/app/src/main/res/layout/spotify_fragment.xml index 2270feaf..6b363de5 100755 --- a/app/src/main/res/layout/spotify_fragment.xml +++ b/app/src/main/res/layout/spotify_fragment.xml @@ -154,14 +154,5 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/appbar_spotify" /> - - - From c0e3a358983775cb9f569dc6b55a8fdf5193a892 Mon Sep 17 00:00:00 2001 From: Shabinder Date: Sun, 8 Nov 2020 01:25:47 +0530 Subject: [PATCH 02/14] Youtube Playlist and more support --- .idea/dictionaries/shabinder.xml | 1 + app/build.gradle | 1 + .../com/shabinder/spotiflyer/MainActivity.kt | 6 +- .../downloadHelper/SpotifyDownloadHelper.kt | 257 +++++------ .../downloadHelper/YTDownloadHelper.kt | 67 ++- .../downloadHelper/YoutubeProvider.kt | 124 +++-- .../spotiflyer/models/DownloadObject.kt | 31 +- .../com/shabinder/spotiflyer/models/Track.kt | 5 +- .../spotiflyer/models/YoutubeTrack.kt | 1 + .../recyclerView/SpotifyTrackListAdapter.kt | 22 +- .../recyclerView/YoutubeTrackListAdapter.kt | 31 +- .../spotiflyer/ui/spotify/SpotifyFragment.kt | 135 ++---- .../spotiflyer/ui/spotify/SpotifyViewModel.kt | 14 +- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 81 ++-- .../spotiflyer/ui/youtube/YoutubeViewModel.kt | 141 ++++-- .../spotiflyer/utils/BindingAdapter.kt | 138 ------ .../shabinder/spotiflyer/utils/Provider.kt | 17 +- .../com/shabinder/spotiflyer/utils/Utils.kt | 202 +++++++- .../spotiflyer/worker/ForegroundService.kt | 436 +++++++++++------- app/src/main/res/layout/spotify_fragment.xml | 28 +- app/src/main/res/layout/youtube_fragment.xml | 28 +- 21 files changed, 977 insertions(+), 789 deletions(-) delete mode 100755 app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt mode change 100644 => 100755 app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt diff --git a/.idea/dictionaries/shabinder.xml b/.idea/dictionaries/shabinder.xml index 726356e5..6457524e 100755 --- a/.idea/dictionaries/shabinder.xml +++ b/.idea/dictionaries/shabinder.xml @@ -1,6 +1,7 @@ + cherrypick downloadrecord emoji ffmpeg diff --git a/app/build.gradle b/app/build.gradle index 36a5fdfe..236c0f2b 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -118,6 +118,7 @@ dependencies { implementation "com.squareup.retrofit2:converter-scalars:2.9.0" implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.beust:klaxon:5.4' + implementation 'me.xdrop:fuzzywuzzy:1.3.1' implementation 'com.mpatric:mp3agic:0.9.1' implementation 'com.shreyaspatil:EasyUpiPayment:3.0.0' diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index 4868a89b..3b7745d6 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -35,10 +35,11 @@ import androidx.lifecycle.ViewModelProvider import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.enums.UpdateFrom import com.shabinder.spotiflyer.databinding.MainActivityBinding -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.SpotifyService import com.shabinder.spotiflyer.utils.SpotifyServiceTokenRequest import com.shabinder.spotiflyer.utils.createDirectories +import com.shabinder.spotiflyer.utils.startService import com.squareup.moshi.Moshi import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -84,7 +85,7 @@ class MainActivity : AppCompatActivity(){ Log.i("Connection Status", isConnected.toString()) //starting Notification and Downloader Service! - SpotifyDownloadHelper.startService(this) + startService(this) handleIntentFromExternalActivity() } @@ -227,5 +228,6 @@ class MainActivity : AppCompatActivity(){ } init { instance = this + activity = this } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt index b783a9a5..8d8a903d 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt @@ -17,25 +17,20 @@ package com.shabinder.spotiflyer.downloadHelper -import android.content.Context -import android.content.Intent +import android.annotation.SuppressLint import android.os.Environment +import android.os.Handler import android.util.Log import android.view.View import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.widget.TextView -import androidx.core.content.ContextCompat -import com.github.kiulian.downloader.YoutubeDownloader -import com.github.kiulian.downloader.model.formats.Format -import com.github.kiulian.downloader.model.quality.AudioQuality -import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Track -import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel -import com.shabinder.spotiflyer.utils.YoutubeMusicApi -import com.shabinder.spotiflyer.utils.getEmojiByUnicode -import com.shabinder.spotiflyer.utils.makeJsonBody -import com.shabinder.spotiflyer.worker.ForegroundService +import android.widget.Toast +import com.shabinder.spotiflyer.SharedViewModel +import com.shabinder.spotiflyer.models.* +import com.shabinder.spotiflyer.utils.* +import com.shabinder.spotiflyer.utils.Provider.activity +import com.shabinder.spotiflyer.utils.Provider.defaultDir import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -45,13 +40,13 @@ import retrofit2.Response import java.io.File object SpotifyDownloadHelper { - var context : Context? = null + var statusBar:TextView? = null var youtubeMusicApi:YoutubeMusicApi? = null - val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator - var spotifyViewModel: SpotifyViewModel? = null - var total = 0 - var Processed = 0 + var sharedViewModel: SharedViewModel? = null + + private var total = 0 + private var processed = 0 var notFound = 0 /** @@ -60,16 +55,94 @@ object SpotifyDownloadHelper { suspend fun downloadAllTracks( type:String, subFolder: String?, - trackList: List, ytDownloader: YoutubeDownloader?) { + trackList: List) { + val downloadList = ArrayList() + withContext(Dispatchers.Main){ total += trackList.size // Adding New Download List Count to StatusBar - trackList.forEach { - if(it.downloaded == "Downloaded"){//Download Already Present!! - Processed++ + trackList.forEachIndexed { index, it -> + if(it.downloaded == DownloadStatus.Downloaded){//Download Already Present!! + processed++ + if(index == (trackList.size-1)){//LastElement + Handler().postDelayed({ + //Delay is Added ,if a request is in processing it may finish + Log.i("Spotify Helper","Download Request Sent") + sharedViewModel?.uiScope?.launch (Dispatchers.Main){ + Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + } + startService(activity,downloadList) + },5000) + } }else{ val artistsList = mutableListOf() it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } - searchYTMusic(type,subFolder,ytDownloader,"${it.name} - ${artistsList.joinToString(",")}", it) + val searchQuery = "${it.name} - ${artistsList.joinToString(",")}" + + val jsonBody = makeJsonBody(searchQuery.trim()) + youtubeMusicApi?.getYoutubeMusicResponse(jsonBody)?.enqueue( + object : Callback{ + override fun onResponse(call: Call, response: Response) { + sharedViewModel?.uiScope?.launch { + val videoId = sortByBestMatch( + getYTTracks(response.body().toString()), + trackName = it.name.toString(), + trackArtists = artistsList, + trackDurationSec = (it.duration_ms/1000).toInt() + ).keys.firstOrNull() + Log.i("Spotify Helper Video ID",videoId ?: "Not Found") + + if(videoId.isNullOrBlank()) {notFound++ ; updateStatusBar()} + else {//Found Youtube Video ID + val trackDetails = TrackDetails( + title = it.name.toString(), + artists = artistsList, + durationSec = (it.duration_ms/1000).toInt(), + albumArt = File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + (it.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), + albumName = it.album?.name, + year = it.album?.release_date, + comment = "Genres:${it.album?.genres?.joinToString()}", + trackUrl = it.href, + source = Source.Spotify + ) + + val outputFile: String = + Environment.getExternalStorageDirectory().toString() + File.separator + + defaultDir + + removeIllegalChars(type) + File.separator + + (if (subFolder == null) { "" } + else { removeIllegalChars(subFolder) + File.separator } + + removeIllegalChars(it.name!!) + ".m4a") + + val downloadObject = DownloadObject( + trackDetails = trackDetails, + ytVideoId = videoId, + outputFile = outputFile + ) + processed++ + sharedViewModel?.uiScope?.launch(Dispatchers.Main) { + updateStatusBar() + } + downloadList.add(downloadObject) + if(index == (trackList.size-1)){//LastElement + Handler().postDelayed({ + //Delay is Added ,if a request is in processing it may finish + Log.i("Spotify Helper","Download Request Sent") + sharedViewModel?.uiScope?.launch (Dispatchers.Main){ + Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + } + startService(activity,downloadList) + },5000) + } + } + } + } + override fun onFailure(call: Call, t: Throwable) { + Log.i("YT API Req. Fail",t.message.toString()) + } + } + ) } updateStatusBar() } @@ -77,140 +150,18 @@ object SpotifyDownloadHelper { } } - - suspend fun searchYTMusic(type:String, - subFolder:String?, - ytDownloader: YoutubeDownloader?, - searchQuery: String, - track: Track){ - val jsonBody = makeJsonBody(searchQuery.trim()) - youtubeMusicApi?.getYoutubeMusicResponse(jsonBody)?.enqueue( - object : Callback{ - override fun onResponse(call: Call, response: Response) { - spotifyViewModel?.uiScope?.launch { - Log.i("YT API BODY",response.body().toString()) - Log.i("YT Search Query",searchQuery) - getYTLink(type,subFolder,ytDownloader,response.body().toString(),track) - } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.i("YT API Fail",t.message.toString()) - } - } - ) - - } - - - fun updateStatusBar() { - statusBar!!.visibility = View.VISIBLE - statusBar?.text = "Total: $total ${getEmojiByUnicode(0x2705)}: $Processed ${getEmojiByUnicode(0x274C)}: $notFound" - } - - - fun downloadFile(subFolder: String?, type: String, track:Track, ytDownloader: YoutubeDownloader?, id: String) { - spotifyViewModel!!.uiScope.launch { - withContext(Dispatchers.IO) { - try { - val video = ytDownloader?.getVideo(id) - val format: Format? = try { - video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format - } catch (e: java.lang.IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format - } catch (e: java.lang.IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format - } catch (e: java.lang.IndexOutOfBoundsException) { - Log.i("YTDownloader", e.toString()) - null - } - } - } - format?.let { - val url: String = format.url() - Log.i("DHelper Link Found", url) - val outputFile: String = - Environment.getExternalStorageDirectory().toString() + File.separator + - defaultDir + removeIllegalChars(type) + File.separator + (if (subFolder == null) { - "" - } else { - removeIllegalChars(subFolder) + File.separator - } + removeIllegalChars(track.name!!) + ".m4a") - - val downloadObject = DownloadObject( - track = track, - url = url, - outputDir = outputFile - ) - Log.i("DH", outputFile) - startService(context!!, downloadObject) - Processed++ - spotifyViewModel?.uiScope?.launch(Dispatchers.Main) { - updateStatusBar() - } - } - }catch (e: com.github.kiulian.downloader.YoutubeException){ - Log.i("DH", e.message) - } - } - } - } - - fun startService(context:Context,obj:DownloadObject? = null ) { - val serviceIntent = Intent(context, ForegroundService::class.java) - obj?.let { serviceIntent.putExtra("object",it) } - ContextCompat.startForegroundService(context, serviceIntent) - } - - /** - * Removing Illegal Chars from File Name - * **/ - fun removeIllegalChars(fileName: String): String? { - val illegalCharArray = charArrayOf( - '/', - '\n', - '\r', - '\t', - '\u0000', - '\u000C', - '`', - '?', - '*', - '\\', - '<', - '>', - '|', - '\"', - '.', - '-', - '\'' - ) - - var name = fileName - for (c in illegalCharArray) { - name = fileName.replace(c, '_') - } - name = name.replace("\\s".toRegex(), "_") - name = name.replace("\\)".toRegex(), "") - name = name.replace("\\(".toRegex(), "") - name = name.replace("\\[".toRegex(), "") - name = name.replace("]".toRegex(), "") - name = name.replace("\\.".toRegex(), "") - name = name.replace("\"".toRegex(), "") - name = name.replace("\'".toRegex(), "") - name = name.replace(":".toRegex(), "") - name = name.replace("\\|".toRegex(), "") - return name - } - private fun animateStatusBar() { - val anim: Animation = AlphaAnimation(0.0f, 0.9f) - anim.duration = 650 //You can manage the blinking time with this parameter + val anim: Animation = AlphaAnimation(0.3f, 0.9f) + anim.duration = 1500 //You can manage the blinking time with this parameter anim.startOffset = 20 anim.repeatMode = Animation.REVERSE anim.repeatCount = Animation.INFINITE statusBar?.animation = anim } + + @SuppressLint("SetTextI18n") + fun updateStatusBar() { + statusBar!!.visibility = View.VISIBLE + statusBar?.text = "Total: $total ${getEmojiByUnicode(0x2705)}: $processed ${getEmojiByUnicode(0x274C)}: $notFound" + } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt index 92856c54..02e17f8f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt @@ -17,49 +17,48 @@ package com.shabinder.spotiflyer.downloadHelper -import android.content.Context -import android.content.Intent import android.os.Environment import android.util.Log -import android.view.View -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.github.kiulian.downloader.model.formats.Format +import android.widget.Toast import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Track -import com.shabinder.spotiflyer.worker.ForegroundService +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.Provider.activity +import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.removeIllegalChars +import com.shabinder.spotiflyer.utils.startService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File object YTDownloadHelper { - var context : Context? = null - var statusBar: TextView? = null - - fun downloadFile(subFolder: String?, type: String,ytTrack: Track,format: Format?) { - format?.let { - val url:String = format.url() -// Log.i("DHelper Link Found", url) - val outputFile:String = Environment.getExternalStorageDirectory().toString() + File.separator + - SpotifyDownloadHelper.defaultDir + SpotifyDownloadHelper.removeIllegalChars(type) + File.separator + (if(subFolder == null){""}else{ SpotifyDownloadHelper.removeIllegalChars(subFolder) + File.separator} + SpotifyDownloadHelper.removeIllegalChars( - ytTrack.name!! - ) +".m4a") + suspend fun downloadYTTracks( + type:String, + subFolder: String?, + tracks:List, + ){ + val downloadList = ArrayList() + tracks.forEach { + val outputFile: String = + Environment.getExternalStorageDirectory().toString() + File.separator + + defaultDir + + removeIllegalChars(type) + File.separator + + (if (subFolder == null) { "" } + else { removeIllegalChars(subFolder) + File.separator } + + removeIllegalChars(it.title) + ".m4a") val downloadObject = DownloadObject( - track = ytTrack, - url = url, - outputDir = outputFile + trackDetails = it, + ytVideoId = "https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") + .substringBeforeLast(".")}/maxresdefault.jpg", + outputFile = outputFile ) - Log.i("DH",outputFile) - startService(context!!, downloadObject) - statusBar?.visibility= View.VISIBLE + + downloadList.add(downloadObject) + } + Log.i("YT Downloader Helper","Download Request Sent") + withContext(Dispatchers.Main){ + Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + startService(activity,downloadList) } } - - - - private fun startService(context:Context, obj: DownloadObject? = null ) { - val serviceIntent = Intent(context, ForegroundService::class.java) - serviceIntent.putExtra("object",obj) - ContextCompat.startForegroundService(context, serviceIntent) - } - } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt index daa90974..1ca9a5b5 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt @@ -17,34 +17,26 @@ package com.shabinder.spotiflyer.downloadHelper +import android.annotation.SuppressLint import android.util.Log import com.beust.klaxon.JsonArray import com.beust.klaxon.JsonObject import com.beust.klaxon.Parser -import com.github.kiulian.downloader.YoutubeDownloader -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadFile -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.notFound -import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.models.YoutubeTrack +import me.xdrop.fuzzywuzzy.FuzzySearch +import kotlin.math.absoluteValue /* -* Thanks and credits To https://github.com/spotDL/spotify-downloader +* Thanks To https://github.com/spotDL/spotify-downloader * */ -fun getYTLink(type:String, - subFolder:String?, - ytDownloader: YoutubeDownloader?, - response: String, - track: Track -){ - //TODO Download File +fun getYTTracks(response: String):List{ val youtubeTracks = mutableListOf() - val parser: Parser = Parser.default() + val stringBuilder: StringBuilder = StringBuilder(response) - val responseObj: JsonObject = parser.parse(stringBuilder) as JsonObject + val responseObj: JsonObject = Parser.default().parse(stringBuilder) as JsonObject val contentBlocks = responseObj.obj("contents")?.obj("sectionListRenderer")?.array("contents") val resultBlocks = mutableListOf>() if (contentBlocks != null) { - Log.i("Total Content Blocks:", contentBlocks.size.toString()) for (cBlock in contentBlocks){ /** *Ignore user-suggestion @@ -109,8 +101,6 @@ fun getYTLink(type:String, ! we do so only if their Type is 'Song' or 'Video */ - val simplifiedResults = mutableListOf() - for(result in resultBlocks){ // Blindly gather available details @@ -126,7 +116,7 @@ fun getYTLink(type:String, ! other constituents of a result block will lead to errors, hence the 'in ! result[:-1] ,i.e., skip last element in array ' */ - for(detail in result.subList(0,result.size-2)){ + for(detail in result.subList(0,result.size-1)){ if(detail.obj("musicResponsiveListItemFlexColumnRenderer")?.size!! < 2) continue // if not a dummy, collect All Variables @@ -138,7 +128,7 @@ fun getYTLink(type:String, ) } } - + //Log.i("Text Api",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 @@ -146,14 +136,7 @@ fun getYTLink(type:String, if ( availableDetails.size > 1 && 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 TODO - - /* - ! grab position of result - ! This helps for those oddball cases where 2+ results are rated equally, - ! lower position --> better match - */ - val resultPosition = resultBlocks.indexOf(result) + if(availableDetails[4].split(':').size != 2) continue //Has Been Giving Issues /* ! grab Video ID @@ -168,23 +151,86 @@ fun getYTLink(type:String, name = availableDetails[0], type = availableDetails[1], artist = availableDetails[2], + duration = availableDetails[4], videoId = videoId ) youtubeTracks.add(ytTrack) } } } - //Songs First, Videos Later - youtubeTracks.sortWith { o1: YoutubeTrack, o2: YoutubeTrack -> o1.type.toString().compareTo(o2.type.toString()) } - if(youtubeTracks.firstOrNull()?.videoId.isNullOrBlank()) notFound++ - else downloadFile( - subFolder, - type, - track, - ytDownloader, - id = youtubeTracks[0].videoId.toString() - ) - Log.i("DHelper YT ID", youtubeTracks.firstOrNull()?.videoId ?: "Not Found") - SpotifyDownloadHelper.updateStatusBar() + return youtubeTracks +} + +@SuppressLint("DefaultLocale") +fun sortByBestMatch(ytTracks:List, + trackName:String, + trackArtists:List, + trackDurationSec:Int, + ):Map{ + /* + * "linksWithMatchValue" is map with Youtube VideoID and its rating/match with 100 as Max Value + **/ + val linksWithMatchValue = mutableMapOf() + + for (result in ytTracks){ + + // LoweCasing Name to match Properly + // 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(" ") + + for (nameWord in trackNameWords){ + if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord,resultName) > 85) hasCommonWord = true + } + + // Skip this Result if No Word is Common in Name + if (!hasCommonWord) { + Log.i("YT Api Removing", result.toString()) + continue + } + + + // Find artist match + // Will Be Using Fuzzy Search Because YT Spelling might be mucked up + // match = (no of artist names in result) / (no. of artist names on spotify) * 100 + var artistMatchNumber = 0 + + if(result.type == "Song"){ + for (artist in trackArtists){ + if(FuzzySearch.ratio(artist.toLowerCase(),result.artist?.toLowerCase()) > 85) + artistMatchNumber++ + } + }else{//i.e. is a Video + for (artist in trackArtists) { + if(FuzzySearch.partialRatio(artist.toLowerCase(),result.name?.toLowerCase()) > 85) + artistMatchNumber++ + } + } + + if(artistMatchNumber == 0) { + Log.i("YT Api Removing", result.toString()) + continue + } + + val artistMatch = (artistMatchNumber / trackArtists.size ) * 100 + + // Duration Match + /*! time match = 100 - (delta(duration)**2 / original duration * 100) + ! difference in song duration (delta) is usually of the magnitude of a few + ! seconds, we need to amplify the delta if it is to have any meaningful impact + ! wen we calculate the avg match value*/ + val difference = result.duration?.split(":")?.get(0)?.toInt()?.times(60) + ?.plus(result.duration?.split(":")?.get(1)?.toInt()?:0) + ?.minus(trackDurationSec)?.absoluteValue ?: 0 + val nonMatchValue :Float= ((difference*difference).toFloat()/trackDurationSec.toFloat()) + val durationMatch = 100 - (nonMatchValue*100) + + val avgMatch = (artistMatch + durationMatch)/2 + linksWithMatchValue[result.videoId.toString()] = avgMatch.toInt() + } + Log.i("YT Api Result", "$trackName - $linksWithMatchValue") + return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap() } diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt index a630a217..033a6944 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt @@ -19,11 +19,32 @@ package com.shabinder.spotiflyer.models import android.os.Parcelable import kotlinx.android.parcel.Parcelize +import java.io.File @Parcelize data class DownloadObject( - var ytVideo: YTTrack?=null, - var track: Track?=null, - var url:String, - var outputDir:String -):Parcelable \ No newline at end of file + var trackDetails: TrackDetails, + var ytVideoId:String, + var outputFile:String +):Parcelable + +@Parcelize +data class TrackDetails( + var title:String, + var artists:List, + var durationSec:Int, + var albumName:String?=null, + var year:String?=null, + var comment:String?=null, + var lyrics:String?=null, + var trackUrl:String?=null, + var albumArt: File, + var source:Source, + var downloaded:DownloadStatus = DownloadStatus.NotDownloaded +):Parcelable + +enum class DownloadStatus{ + Downloaded, + Downloading, + NotDownloaded +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt b/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt index 9ce8893c..01f8e984 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt @@ -31,7 +31,6 @@ data class Track( var explicit: Boolean? = null, var external_urls: Map? = null, var href: String? = null, - var id: String? = null, var name: String? = null, var preview_url: String? = null, var track_number: Int = 0, @@ -40,5 +39,5 @@ data class Track( var album: Album? = null, var external_ids: Map? = null, var popularity: Int? = null, - var ytCoverUrl:String? = null, - var downloaded:String? = "notDownloaded"):Parcelable \ No newline at end of file + var downloaded:DownloadStatus? = DownloadStatus.NotDownloaded):Parcelable + diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt index 4ea7de9d..03b588ba 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt @@ -25,5 +25,6 @@ data class YoutubeTrack( var name: String? = null, var type: String? = null, // Song / Video var artist: String? = null, + var duration:String? = null, var videoId: String? = null ):Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt index ec6af4e4..277ee1f4 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt @@ -17,6 +17,7 @@ package com.shabinder.spotiflyer.recyclerView +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -24,13 +25,14 @@ import android.widget.Toast import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.TrackListItemBinding -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.context import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadAllTracks +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel +import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.bindImage import com.shabinder.spotiflyer.utils.rotateAnim import kotlinx.coroutines.launch @@ -40,7 +42,6 @@ class SpotifyTrackListAdapter: ListAdapter { + DownloadStatus.Downloaded -> { holder.binding.btnDownload.setImageResource(R.drawable.ic_tick) holder.binding.btnDownload.clearAnimation() } - "Downloading" -> { + DownloadStatus.Downloading -> { holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) rotateAnim(holder.binding.btnDownload) } - "notDownloaded" -> { + DownloadStatus.NotDownloaded -> { holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow) holder.binding.btnDownload.clearAnimation() holder.binding.btnDownload.setOnClickListener{ - Toast.makeText(context,"Starting Download",Toast.LENGTH_SHORT).show() + Toast.makeText(activity,"Processing!",Toast.LENGTH_SHORT).show() holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) rotateAnim(it) - item.downloaded = "Downloading" + item.downloaded = DownloadStatus.Downloading spotifyViewModel!!.uiScope.launch { val itemList = mutableListOf() itemList.add(item) - downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList,ytDownloader) + downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList) } notifyItemChanged(position)//start showing anim! } diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt index d3e5e224..ae0b1a61 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt @@ -22,18 +22,16 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.github.kiulian.downloader.model.formats.Format import com.shabinder.spotiflyer.databinding.TrackListItemBinding -import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.utils.bindImage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class YoutubeTrackListAdapter: ListAdapter(YouTubeTrackDiffCallback()) { +class YoutubeTrackListAdapter: ListAdapter(YouTubeTrackDiffCallback()) { - var format:Format? = null private val adapterScope = CoroutineScope(Dispatchers.Default) override fun onCreateViewHolder( @@ -51,26 +49,31 @@ class YoutubeTrackListAdapter: ListAdapter(){ - override fun areItemsTheSame(oldItem: Track, newItem: Track): Boolean { - return oldItem.name == newItem.name +class YouTubeTrackDiffCallback: DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean { + return oldItem.title == newItem.title } - override fun areContentsTheSame(oldItem: Track, newItem: Track): Boolean { + override fun areContentsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean { return oldItem == newItem } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index b4a2d6e5..24c7d99c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -24,41 +24,33 @@ import android.content.Intent import android.content.IntentFilter import android.net.ConnectivityManager import android.os.Bundle -import android.os.Environment import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.core.net.toUri import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.SimpleItemAnimator -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target -import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.SpotifyFragmentBinding import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter import com.shabinder.spotiflyer.utils.YoutubeMusicApi import com.shabinder.spotiflyer.utils.bindImage -import com.shabinder.spotiflyer.utils.copyTo +import com.shabinder.spotiflyer.utils.loadAllImages import com.shabinder.spotiflyer.utils.rotateAnim import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException import javax.inject.Inject @Suppress("DEPRECATION") @@ -69,7 +61,6 @@ class SpotifyFragment : Fragment() { private lateinit var spotifyViewModel: SpotifyViewModel private lateinit var sharedViewModel: SharedViewModel private lateinit var adapterSpotify:SpotifyTrackListAdapter - @Inject lateinit var ytDownloader:YoutubeDownloader @Inject lateinit var youtubeMusicApi: YoutubeMusicApi private var intentFilter:IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null @@ -109,30 +100,34 @@ class SpotifyFragment : Fragment() { spotifyViewModel.spotifySearch(type,link) if(type=="album")adapterSpotify.isAlbum = true - binding.btnDownloadAllSpotify.setOnClickListener { - for (track in spotifyViewModel.trackList.value!!){ - if(track.downloaded != "Downloaded"){ - track.downloaded = "Downloading" - } - } - binding.btnDownloadAllSpotify.visibility = View.GONE - binding.downloadingFabSpotify.visibility = View.VISIBLE + binding.btnDownloadAll.setOnClickListener { + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.visibility = View.VISIBLE - rotateAnim(binding.downloadingFabSpotify) + rotateAnim(binding.downloadingFab) for (track in spotifyViewModel.trackList.value!!){ - if(track.downloaded != "Downloaded"){ + if(track.downloaded != DownloadStatus.Downloaded){ + track.downloaded = DownloadStatus.Downloading adapterSpotify.notifyItemChanged(spotifyViewModel.trackList.value!!.indexOf(track)) } } - showToast("Starting Download in Few Seconds") - spotifyViewModel.uiScope.launch(Dispatchers.Default){loadAllImages(spotifyViewModel.trackList.value!!)} + showToast("Processing!") + sharedViewModel.uiScope.launch(Dispatchers.Default){ + val urlList = arrayListOf() + spotifyViewModel.trackList.value?.forEach { urlList.add(it.album?.images?.get(0)?.url.toString()) } + //Appending Source + urlList.add("spotify") + loadAllImages( + requireActivity(), + urlList + ) + } spotifyViewModel.uiScope.launch { SpotifyDownloadHelper.downloadAllTracks( spotifyViewModel.folderType, spotifyViewModel.subFolder, spotifyViewModel.trackList.value!!, - ytDownloader ) } } @@ -154,15 +149,18 @@ class SpotifyFragment : Fragment() { override fun onReceive(context: Context?, intent: Intent?) { //UI update here if (intent != null){ - val track = intent.getParcelableExtra("track") - track?.let { - val position: Int = spotifyViewModel.trackList.value?.indexOf(track)!! + val trackDetails = intent.getParcelableExtra("track") + trackDetails?.let { + val position: Int = spotifyViewModel.trackList.value?.map { it.name }?.indexOf(trackDetails.title) ?: -1 Log.i("Track","Download Completed Intent :$position") - track.downloaded = "Downloaded" if(position != -1) { - spotifyViewModel.trackList.value?.set(position, track) - adapterSpotify.notifyItemChanged(position) - checkIfAllDownloaded() + val track = spotifyViewModel.trackList.value?.get(position) + track?.let{ + it.downloaded = DownloadStatus.Downloaded + spotifyViewModel.trackList.value?.set(position, it) + adapterSpotify.notifyItemChanged(position) + checkIfAllDownloaded() + } } } } @@ -184,7 +182,7 @@ class SpotifyFragment : Fragment() { * CoverUrl Binding Observer! **/ spotifyViewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.spotifyCoverImage,it) + if(it!="Loading") bindImage(binding.coverImage,it,Source.Spotify) }) /** @@ -202,7 +200,7 @@ class SpotifyFragment : Fragment() { * Title Binding Observer! **/ spotifyViewModel.title.observe(viewLifecycleOwner, { - binding.titleViewSpotify.text = it + binding.titleView.text = it }) sharedViewModel.intentString.observe(viewLifecycleOwner,{ @@ -215,10 +213,10 @@ class SpotifyFragment : Fragment() { } private fun checkIfAllDownloaded() { - if(!spotifyViewModel.trackList.value!!.any { it.downloaded != "Downloaded" }){ + if(!spotifyViewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ //All Tracks Downloaded - binding.btnDownloadAllSpotify.visibility = View.GONE - binding.downloadingFabSpotify.apply{ + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.apply{ setImageResource(R.drawable.ic_tick) visibility = View.VISIBLE clearAnimation() @@ -236,69 +234,17 @@ class SpotifyFragment : Fragment() { sharedViewModel.spotifyService.observe(viewLifecycleOwner, Observer { spotifyViewModel.spotifyService = it }) - SpotifyDownloadHelper.context = requireContext() SpotifyDownloadHelper.youtubeMusicApi = youtubeMusicApi - SpotifyDownloadHelper.spotifyViewModel = spotifyViewModel - SpotifyDownloadHelper.statusBar = binding.StatusBarSpotify - binding.trackListSpotify.adapter = adapterSpotify - (binding.trackListSpotify.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - } - - /** - * Function to fetch all Images for using in mp3 tag. - **/ - private suspend fun loadAllImages(trackList: List) { - trackList.forEach { - val imgUrl = it.album?.images?.get(0)?.url - imgUrl?.let { - val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() - Glide - .with(requireContext()) - .asFile() - .load(imgUri) - .listener(object: RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - Log.i("Glide","LoadFailed") - return false - } - - override fun onResourceReady( - resource: File?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - sharedViewModel.uiScope.launch { - withContext(Dispatchers.IO){ - try { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir+".Images/" + imgUrl.substringAfterLast('/') + ".jpeg" - ) - resource?.copyTo(file) - } catch (e: IOException) { - e.printStackTrace() - } - } - } - return false - } - }).submit() - } - } + SpotifyDownloadHelper.sharedViewModel = sharedViewModel + SpotifyDownloadHelper.statusBar = binding.statusBar + binding.trackList.adapter = adapterSpotify + (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } /** * Configure Recycler View Adapter **/ private fun adapterConfig(trackList: List){ - adapterSpotify.ytDownloader = ytDownloader adapterSpotify.spotifyViewModel = spotifyViewModel adapterSpotify.submitList(trackList) } @@ -320,4 +266,5 @@ class SpotifyFragment : Fragment() { val netInfo = cm.activeNetworkInfo return netInfo != null && netInfo.isConnectedOrConnecting } + } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt index 6a3a9082..2ab721a6 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt @@ -52,7 +52,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO folderType = "Tracks" val tempTrackList = mutableListOf() if(File(finalOutputDir(trackObject?.name!!,folderType,subFolder)).exists()){//Download Already Present!! - trackObject.downloaded = "Downloaded" + trackObject.downloaded = DownloadStatus.Downloaded } tempTrackList.add(trackObject) trackList.value = tempTrackList @@ -65,7 +65,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO link = "https://open.spotify.com/$type/$link", coverUrl = coverUrl.value!!, totalFiles = tempTrackList.size, - downloaded = trackObject.downloaded =="Downloaded", + downloaded = trackObject.downloaded == DownloadStatus.Downloaded, directory = finalOutputDir(trackObject.name!!,folderType,subFolder) )) } @@ -80,7 +80,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO val tempTrackList = mutableListOf() albumObject?.tracks?.items?.forEach { if(File(finalOutputDir(it.name!!,folderType,subFolder)).exists()){//Download Already Present!! - it.downloaded = "Downloaded" + it.downloaded = DownloadStatus.Downloaded } it.album = Album(images = listOf(Image(url = albumObject.images?.get(0)?.url))) tempTrackList.add(it) @@ -112,7 +112,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO playlistObject?.tracks?.items?.forEach { it.track?.let { it1 -> if(File(finalOutputDir(it1.name!!,folderType,subFolder)).exists()){//Download Already Present!! - it1.downloaded = "Downloaded" + it1.downloaded = DownloadStatus.Downloaded } tempTrackList.add(it1) } @@ -130,13 +130,13 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO Log.i("Total Tracks Fetched",tempTrackList.size.toString()) trackList.value = tempTrackList title.value = playlistObject?.name - coverUrl.value = playlistObject?.images?.get(0)!!.url!! + coverUrl.value = playlistObject?.images?.get(0)?.url.toString() withContext(Dispatchers.IO){ databaseDAO.insert(DownloadRecord( type = "Playlist", - name = title.value!!, + name = title.value.toString(), link = "https://open.spotify.com/$type/$link", - coverUrl = coverUrl.value!!, + coverUrl = coverUrl.value.toString(), totalFiles = tempTrackList.size, downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == tempTrackList.size, directory = finalOutputDir(type = folderType,subFolder = subFolder) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index cc3678ad..9801997a 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -24,17 +24,22 @@ import android.view.ViewGroup import android.widget.Toast import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.YoutubeFragmentBinding import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.recyclerView.YoutubeTrackListAdapter import com.shabinder.spotiflyer.utils.bindImage +import com.shabinder.spotiflyer.utils.loadAllImages +import com.shabinder.spotiflyer.utils.rotateAnim import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -43,10 +48,10 @@ class YoutubeFragment : Fragment() { private lateinit var binding:YoutubeFragmentBinding private lateinit var youtubeViewModel: YoutubeViewModel private lateinit var sharedViewModel: SharedViewModel + @Inject lateinit var ytDownloader: YoutubeDownloader private lateinit var adapter : YoutubeTrackListAdapter private val sampleDomain1 = "youtube.com" private val sampleDomain2 = "youtu.be" - @Inject lateinit var ytDownloader: YoutubeDownloader override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -56,9 +61,7 @@ class YoutubeFragment : Fragment() { youtubeViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) adapter = YoutubeTrackListAdapter() - YTDownloadHelper.context = requireContext() - YTDownloadHelper.statusBar = binding.StatusBarYoutube - binding.trackListYoutube.adapter = adapter + binding.trackList.adapter = adapter initializeLiveDataObservers() @@ -70,7 +73,11 @@ class YoutubeFragment : Fragment() { private fun youtubeSearch(linkSearch:String) { val link = linkSearch.removePrefix("https://").removePrefix("http://") - if(!link.contains("playlist",true)){ + if(link.contains("playlist",true) || link.contains("list",true)){ + // Given Link is of a Playlist + val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&") + youtubeViewModel.getYTPlaylist(playlistId,ytDownloader) + }else{//Given Link is of a Video var searchId = "error" if(link.contains(sampleDomain1,true) ){ searchId = link.substringAfterLast("=","error") @@ -79,41 +86,63 @@ class YoutubeFragment : Fragment() { searchId = link.substringAfterLast("/","error") } if(searchId != "error") { - youtubeViewModel.getYTTrack(searchId,ytDownloader) - binding.btnDownloadAllYoutube.setOnClickListener { - YTDownloadHelper.downloadFile(null,"YT_Downloads", - youtubeViewModel.ytTrack.value!!,youtubeViewModel.format.value) - } + youtubeViewModel.getYTTrack(searchId,ytDownloader) }else{showToast("Your Youtube Link is not of a Video!!")} - }else(showToast("Your Youtube Link is not of a Video!!")) + } + binding.btnDownloadAll.setOnClickListener { + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.visibility = View.VISIBLE + + rotateAnim(binding.downloadingFab) + + for (track in youtubeViewModel.ytTrackList.value?: listOf()){ + if(track.downloaded != DownloadStatus.Downloaded){ + track.downloaded = DownloadStatus.Downloading + adapter.notifyItemChanged(youtubeViewModel.ytTrackList.value!!.indexOf(track)) + } + } + showToast("Processing!") + sharedViewModel.uiScope.launch(Dispatchers.Default){ + val urlList = arrayListOf() + youtubeViewModel.ytTrackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") + .substringBeforeLast(".")}/maxresdefault.jpg")} + //Appending Source + urlList.add("youtube") + loadAllImages( + requireActivity(), + urlList + ) + } + youtubeViewModel.uiScope.launch { + YTDownloadHelper.downloadYTTracks( + type = youtubeViewModel.folderType, + subFolder = youtubeViewModel.subFolder, + tracks = youtubeViewModel.ytTrackList.value ?: listOf() + ) + } + } } private fun initializeLiveDataObservers() { /** * CoverUrl Binding Observer! **/ - youtubeViewModel.coverUrl.observe(viewLifecycleOwner, Observer { - if(it!="Loading") bindImage(binding.youtubeCoverImage,it) + youtubeViewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it,Source.YouTube) }) /** * TrackList Binding Observer! **/ - youtubeViewModel.ytTrack.observe(viewLifecycleOwner, Observer { - val list = mutableListOf() - list.add(it) - adapterConfig(list) - }) - - youtubeViewModel.format.observe(viewLifecycleOwner, Observer { - adapter.format = it + youtubeViewModel.ytTrackList.observe(viewLifecycleOwner, { + adapterConfig(it) }) /** * Title Binding Observer! **/ - youtubeViewModel.title.observe(viewLifecycleOwner, Observer { - binding.titleViewYoutube.text = it + youtubeViewModel.title.observe(viewLifecycleOwner, { + binding.titleView.text = it }) } @@ -121,7 +150,7 @@ class YoutubeFragment : Fragment() { /** * Configure Recycler View Adapter **/ - private fun adapterConfig(list:List){ + private fun adapterConfig(list:List){ adapter.submitList(list) } diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt index 48beedee..f9a7b15c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt @@ -17,78 +17,119 @@ package com.shabinder.spotiflyer.ui.youtube +import android.annotation.SuppressLint +import android.os.Environment import android.util.Log import androidx.hilt.lifecycle.ViewModelInject import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.github.kiulian.downloader.YoutubeDownloader import com.github.kiulian.downloader.model.formats.Format -import com.github.kiulian.downloader.model.quality.AudioQuality import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecord -import com.shabinder.spotiflyer.models.Artist -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.utils.Provider.defaultDir import com.shabinder.spotiflyer.utils.finalOutputDir +import com.shabinder.spotiflyer.utils.removeIllegalChars import kotlinx.coroutines.* +import java.io.File -class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : - ViewModel(){ +class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : ViewModel(){ - val ytTrack = MutableLiveData() + /* + * YT Album Art Schema + * Normal Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + * */ + + val ytTrackList = MutableLiveData>() val format = MutableLiveData() private val loading = "Loading" var title = MutableLiveData().apply { value = "\"Loading!\"" } var coverUrl = MutableLiveData().apply { value = loading } - + val folderType = "YT_Downloads" + var subFolder = "" private var viewModelJob = Job() val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) - fun getYTTrack(searchId:String,ytDownloader:YoutubeDownloader) { - uiScope.launch { - withContext(Dispatchers.IO){ - Log.i("YT View Model",searchId) - val video = ytDownloader.getVideo(searchId) - val detail = video?.details() - val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() - Log.i("YT View Model",detail.toString()) - ytTrack.postValue( - Track( - id = searchId, - name = name, - artists = listOf(Artist(name = detail?.author())), - duration_ms = detail?.lengthSeconds()?.times(1000)?.toLong()?:0, - ytCoverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" - )) - coverUrl.postValue("https://i.ytimg.com/vi/$searchId/maxresdefault.jpg") - title.postValue( - if(name?.length!! > 17){"${name.subSequence(0,16)}..."}else{name} + fun getYTPlaylist(searchId:String, ytDownloader:YoutubeDownloader){ + uiScope.launch(Dispatchers.IO) { + Log.i("YT Playlist",searchId) + val playlist = ytDownloader.getPlaylist(searchId) + val playlistDetails = playlist.details() + val name = playlistDetails.title() + subFolder = removeIllegalChars(name).toString() + val videos = playlist.videos() + coverUrl.postValue("https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg") + title.postValue( + if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} + ) + ytTrackList.postValue(videos.map { + TrackDetails( + title = it.title(), + artists = listOf(it.author().toString()), + durationSec = it.lengthSeconds(), + albumArt = File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + it.videoId() + ".jpeg" + ), + source = Source.YouTube, + downloaded = if(File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists()) + DownloadStatus.Downloaded + else DownloadStatus.NotDownloaded ) - format.postValue(try { - video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format - } catch (e: IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format - } catch (e: IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format - } catch (e: IndexOutOfBoundsException) { - Log.i("YTDownloader", e.toString()) - null - } - } - }) + }) + withContext(Dispatchers.IO){ - databaseDAO.insert(DownloadRecord( - type = "Track", - name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, - link = "https://www.youtube.com/watch?v=$searchId", - coverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg", - totalFiles = 1, - downloaded = false, - directory = finalOutputDir(type = "YT_Downloads") - )) - } + databaseDAO.insert(DownloadRecord( + type = "PlayList", + name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, + link = "https://www.youtube.com/playlist?list=$searchId", + coverUrl = "https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg", + totalFiles = videos.size, + directory = finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder), + downloaded = File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists() + )) + } + } + } + + @SuppressLint("DefaultLocale") + fun getYTTrack(searchId:String, ytDownloader:YoutubeDownloader) { + uiScope.launch(Dispatchers.IO) { + Log.i("YT Video",searchId) + val video = ytDownloader.getVideo(searchId) + coverUrl.postValue("https://i.ytimg.com/vi/$searchId/maxresdefault.jpg") + val detail = video?.details() + val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() ?: "" + Log.i("YT View Model",detail.toString()) + ytTrackList.postValue(listOf(TrackDetails( + title = name, + artists = listOf(detail?.author().toString()), + durationSec = detail?.lengthSeconds()?:0, + albumArt = File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + searchId + ".jpeg" + ), + source = Source.YouTube + ))) + title.postValue( + if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} + ) + + withContext(Dispatchers.IO){ + databaseDAO.insert(DownloadRecord( + type = "Track", + name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, + link = "https://www.youtube.com/watch?v=$searchId", + coverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg", + totalFiles = 1, + downloaded = false, + directory = finalOutputDir(type = "YT_Downloads") + )) } } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt deleted file mode 100755 index b18980a7..00000000 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (C) 2020 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 . - */ - -package com.shabinder.spotiflyer.utils - -import android.os.Environment -import android.util.Log -import android.view.View -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.view.animation.RotateAnimation -import android.widget.ImageView -import androidx.core.net.toUri -import androidx.databinding.BindingAdapter -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target -import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException - -fun finalOutputDir(itemName:String? = null,type:String, subFolder:String?=null,extension:String? = ".mp3"): String{ - return Environment.getExternalStorageDirectory().toString() + File.separator + - SpotifyDownloadHelper.defaultDir + SpotifyDownloadHelper.removeIllegalChars(type) + File.separator + - (if(subFolder == null){""}else{ SpotifyDownloadHelper.removeIllegalChars(subFolder) + File.separator} - + itemName?.let { SpotifyDownloadHelper.removeIllegalChars(it) + extension}) -} - -fun rotateAnim(view: View){ - val rotate = RotateAnimation( - 0F, 360F, - Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f - ) - rotate.duration = 1000 - rotate.repeatCount = Animation.INFINITE - rotate.repeatMode = Animation.INFINITE - rotate.interpolator = LinearInterpolator() - view.animation = rotate -} - - -@BindingAdapter("imageUrl") -fun bindImage(imgView: ImageView, imgUrl: String?) { - imgUrl?.let { - val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() - Glide - .with(imgView) - .asFile() - .load(imgUri) - .placeholder(R.drawable.ic_song_placeholder) - .error(R.drawable.ic_musicplaceholder) - .listener(object:RequestListener{ - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - Log.i("Glide","LoadFailed") - return false - } - - override fun onResourceReady( - resource: File?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - CoroutineScope(Dispatchers.Main).launch { - try { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg" - ) // the File to save , append increasing numeric counter to prevent files from getting overwritten. - resource?.copyTo(file) - withContext(Dispatchers.Main){ - Glide.with(imgView) - .load(file) - .placeholder(R.drawable.ic_song_placeholder) - .into(imgView) -// Log.i("Glide","imageSaved") - } - } catch (e: IOException) { - e.printStackTrace() - } - } - return false - } - }).submit() - } - } - -/** - *Extension Function For Copying Files! - **/ -fun File.copyTo(file: File) { - inputStream().use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } -} -fun createDirectory(dir:String){ - val yourAppDir = File(Environment.getExternalStorageDirectory(), - dir) - - if(!yourAppDir.exists() && !yourAppDir.isDirectory) - { // create empty directory - if (yourAppDir.mkdirs()) - {Log.i("CreateDir","App dir created")} - else - {Log.w("CreateDir","Unable to create app dir!")} - } - else - {Log.i("CreateDir","App dir already exists")} -} diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 3638edf0..123a2fd2 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -18,6 +18,7 @@ package com.shabinder.spotiflyer.utils import android.content.Context +import android.os.Environment import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.App import com.shabinder.spotiflyer.MainActivity @@ -38,18 +39,28 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory +import java.io.File import javax.inject.Singleton @InstallIn(ApplicationComponent::class) @Module object Provider { + lateinit var activity: MainActivity + val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator + + @Provides fun databaseDAO(@ApplicationContext appContext: Context):DatabaseDAO{ return DownloadRecordDatabase.getInstance(appContext).databaseDAO } + @Provides + @Singleton + fun getYTDownloader():YoutubeDownloader{ + return YoutubeDownloader() + } @Provides @Singleton @@ -72,12 +83,6 @@ object Provider { .build() } - @Provides - @Singleton - fun getYTDownloader():YoutubeDownloader{ - return YoutubeDownloader() - } - @Provides @Singleton fun getSpotifyTokenInterface():SpotifyServiceTokenRequest{ diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt old mode 100644 new mode 100755 index 79f44b5a..8435cb1e --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -17,15 +17,203 @@ package com.shabinder.spotiflyer.utils -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import android.content.Context +import android.content.Intent +import android.os.Environment +import android.util.Log +import android.view.View +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import android.view.animation.RotateAnimation +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.shabinder.spotiflyer.R +import com.shabinder.spotiflyer.models.DownloadObject +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.worker.ForegroundService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException + +fun loadAllImages(context: Context?, images:ArrayList? = null ) { + val serviceIntent = Intent(context, ForegroundService::class.java) + images?.let { serviceIntent.putStringArrayListExtra("imagesList",it) } + context?.let { ContextCompat.startForegroundService(it, serviceIntent) } +} + +fun startService(context:Context?,objects:ArrayList? = null ) { + val serviceIntent = Intent(context, ForegroundService::class.java) + objects?.let { serviceIntent.putParcelableArrayListExtra("object",it) } + context?.let { ContextCompat.startForegroundService(it, serviceIntent) } +} + +fun finalOutputDir(itemName:String? = null,type:String, subFolder:String?=null,extension:String? = ".mp3"): String{ + return Environment.getExternalStorageDirectory().toString() + File.separator + + defaultDir + removeIllegalChars(type) + File.separator + + (if(subFolder == null){""}else{ removeIllegalChars(subFolder) + File.separator} + + itemName?.let { removeIllegalChars(it) + extension}) +} + +fun rotateAnim(view: View){ + val rotate = RotateAnimation( + 0F, 360F, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f + ) + rotate.duration = 1000 + rotate.repeatCount = Animation.INFINITE + rotate.repeatMode = Animation.INFINITE + rotate.interpolator = LinearInterpolator() + view.animation = rotate +} + +@BindingAdapter("imageUrl") +fun bindImage(imgView: ImageView, imgUrl: String?,source: Source) { + imgUrl?.let { + val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() + Glide + .with(imgView) + .asFile() + .load(imgUri) + .placeholder(R.drawable.ic_song_placeholder) + .error(R.drawable.ic_musicplaceholder) + .listener(object:RequestListener{ + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + Log.i("Glide","LoadFailed") + return false + } + + override fun onResourceReady( + resource: File?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + CoroutineScope(Dispatchers.Main).launch { + try { + val file = when(source){ + Source.Spotify->{ + File( + Environment.getExternalStorageDirectory(), + defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg" + ) + } + Source.YouTube->{ + //Url Format: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + // We Are Naming using "$searchId" + File( + Environment.getExternalStorageDirectory(), + defaultDir+".Images/" + imgUrl.substringBeforeLast('/',imgUrl).substringAfterLast('/',imgUrl) + ".jpeg" + ) + } + } + // the File to save , append increasing numeric counter to prevent files from getting overwritten. + resource?.copyTo(file) + withContext(Dispatchers.Main){ + Glide.with(imgView) + .load(file) + .placeholder(R.drawable.ic_song_placeholder) + .into(imgView) +// Log.i("Glide","imageSaved") + } + } catch (e: IOException) { + e.printStackTrace() + } + } + return false + } + }).submit() + } + } + +/** + *Extension Function For Copying Files! + **/ +fun File.copyTo(file: File) { + inputStream().use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } +} +fun createDirectory(dir:String){ + val yourAppDir = File(Environment.getExternalStorageDirectory(), + dir) + + if(!yourAppDir.exists() && !yourAppDir.isDirectory) + { // create empty directory + if (yourAppDir.mkdirs()) + {Log.i("CreateDir","App dir created")} + else + {Log.w("CreateDir","Unable to create app dir!")} + } + else + {Log.i("CreateDir","App dir already exists")} +} +/** + * Removing Illegal Chars from File Name + * **/ +fun removeIllegalChars(fileName: String): String? { + val illegalCharArray = charArrayOf( + '/', + '\n', + '\r', + '\t', + '\u0000', + '\u000C', + '`', + '?', + '*', + '\\', + '<', + '>', + '|', + '\"', + '.', + '-', + '\'' + ) + + var name = fileName + for (c in illegalCharArray) { + name = fileName.replace(c, '_') + } + name = name.replace("\\s".toRegex(), "_") + name = name.replace("\\)".toRegex(), "") + name = name.replace("\\(".toRegex(), "") + name = name.replace("\\[".toRegex(), "") + name = name.replace("]".toRegex(), "") + name = name.replace("\\.".toRegex(), "") + name = name.replace("\"".toRegex(), "") + name = name.replace("\'".toRegex(), "") + name = name.replace(":".toRegex(), "") + name = name.replace("\\|".toRegex(), "") + return name +} fun createDirectories() { - createDirectory(SpotifyDownloadHelper.defaultDir) - createDirectory(SpotifyDownloadHelper.defaultDir + ".Images/") - createDirectory(SpotifyDownloadHelper.defaultDir + "Tracks/") - createDirectory(SpotifyDownloadHelper.defaultDir + "Albums/") - createDirectory(SpotifyDownloadHelper.defaultDir + "Playlists/") - createDirectory(SpotifyDownloadHelper.defaultDir + "YT_Downloads/") + createDirectory(defaultDir) + createDirectory(defaultDir + ".Images/") + createDirectory(defaultDir + "Tracks/") + createDirectory(defaultDir + "Albums/") + createDirectory(defaultDir + "Playlists/") + createDirectory(defaultDir + "YT_Downloads/") } fun getEmojiByUnicode(unicode: Int): String? { return String(Character.toChars(unicode)) diff --git a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt index ba103032..9dcf145e 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt @@ -29,27 +29,36 @@ import android.os.* import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import com.arthenica.mobileffmpeg.Config import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS import com.arthenica.mobileffmpeg.FFmpeg +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.github.kiulian.downloader.YoutubeDownloader +import com.github.kiulian.downloader.model.formats.Format +import com.github.kiulian.downloader.model.quality.AudioQuality import com.mpatric.mp3agic.ID3v1Tag import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.Mp3File import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.copyTo import com.tonyodev.fetch2.* import com.tonyodev.fetch2core.DownloadBlock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.io.File import java.io.FileInputStream +import java.io.IOException +import java.util.* +@Suppress("DEPRECATION") class ForegroundService : Service(){ private val tag = "Foreground Service" private val channelId = "ForegroundDownloaderService" @@ -57,22 +66,21 @@ class ForegroundService : Service(){ private var total = 0 //Total Downloads Requested private var converted = 0//Total Files Converted private var downloaded = 0//Total Files downloaded - private var fetch:Fetch? = null - private var downloadManager : DownloadManager? = null - private var downloadList = mutableListOf() + private lateinit var fetch:Fetch + private lateinit var ytDownloader: YoutubeDownloader + private lateinit var downloadManager : DownloadManager private var serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) - private val requestMap = mutableMapOf() - private val downloadMap = mutableMapOf() + private val requestMap = mutableMapOf() private var speed :Long = 0 - private var defaultDirectory = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator + private var defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator private val parentDirectory = File(Environment.getExternalStorageDirectory(), - defaultDirectory+File.separator + defaultDir +File.separator ) private var wakeLock: PowerManager.WakeLock? = null private var isServiceStarted = false var notificationLine = 0 - val messageList = mutableListOf("","","","") + val messageList = mutableListOf("","","","") private var pendingIntent:PendingIntent? = null @@ -89,7 +97,7 @@ class ForegroundService : Service(){ 0, notificationIntent, 0 ) downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - + ytDownloader = YoutubeDownloader() val fetchConfiguration = FetchConfiguration.Builder(this) .setDownloadConcurrentLimit(4) @@ -98,86 +106,41 @@ class ForegroundService : Service(){ Fetch.setDefaultInstanceConfiguration(fetchConfiguration) fetch = Fetch.getDefaultInstance() -// fetch?.enableLogging(true) - fetch?.addListener(fetchListener) + fetch.addListener(fetchListener) //clearing all not completed Downloads //Starting fresh - fetch?.removeAll() + fetch.removeAll() startForeground() } - /** - *Starting Service with Notification as Foreground! - **/ - private fun startForeground() { - val channelId = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(channelId, "Downloader Service") - } else { - // If earlier version channel ID is not used - // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) - "" - } - - val notification = NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.down_arrowbw) - .setNotificationSilent() - .setSubText("Total: $total Completed:$converted") - .setStyle(NotificationCompat.InboxStyle() - .setBigContentTitle("Speed: $speed KB/s") - .addLine(messageList[0]) - .addLine(messageList[1]) - .addLine(messageList[2]) - .addLine(messageList[3])) - .setContentIntent(pendingIntent) - .build() - startForeground(notificationId, notification) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel(channelId: String, channelName: String): String{ - val chan = NotificationChannel(channelId, - channelName, NotificationManager.IMPORTANCE_DEFAULT) - chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - service.createNotificationChannel(chan) - return channelId - } - @SuppressLint("WakelockTimeout") override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { // Send a notification that service is started Log.i(tag,"Service Started.") startForeground() - val obj:DownloadObject? = intent.getParcelableExtra("object") ?: intent.extras?.getParcelable("object") - obj?.let { - total ++ - updateNotification() - serviceScope.launch { - val request= Request(obj.url, obj.outputDir) - request.priority = Priority.NORMAL - request.networkType = NetworkType.ALL + val downloadObjects: ArrayList? = (intent.getParcelableArrayListExtra("object") ?: intent.extras?.getParcelableArrayList("object")) + val imagesList: ArrayList? = (intent.getStringArrayListExtra("imagesList") ?: intent.extras?.getStringArrayList("imagesList")) - fetch!!.enqueue(request, - { - obj.track?.let { it1 -> requestMap.put(it, it1) } - downloadList.remove(obj) - Log.i(tag, "Enqueuing Download") - }, - { - Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")} - ) + imagesList?.let{ + serviceScope.launch { + loadAllImages(it) } } + downloadObjects?.let { + total += downloadObjects.size + updateNotification() + downloadAllTracks(downloadObjects) + } + //Wake locks and misc tasks from here : return if (isServiceStarted){ + //Service Already Started START_STICKY } else{ Log.i(tag,"Starting the foreground service task") isServiceStarted = true - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply { @@ -188,9 +151,53 @@ class ForegroundService : Service(){ } } + private fun downloadAllTracks(downloadObjects: List){ + serviceScope.launch(Dispatchers.IO) { + for(downloadObj in downloadObjects){ + try { + val video = ytDownloader.getVideo(downloadObj.ytVideoId) + val format: Format? = try { + video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format + } catch (e: java.lang.IndexOutOfBoundsException) { + try { + video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format + } catch (e: java.lang.IndexOutOfBoundsException) { + try { + video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format + } catch (e: java.lang.IndexOutOfBoundsException) { + Log.i("YTDownloader", e.toString()) + null + } + } + } + format?.let { + val url: String = format.url() + Log.i("DHelper Link Found", url) + serviceScope.launch { + val request= Request(url, downloadObj.outputFile) + request.priority = Priority.NORMAL + request.networkType = NetworkType.ALL + + fetch.enqueue(request, + { + requestMap[it] = downloadObj.trackDetails + Log.i(tag, "Enqueuing Download") + }, + { + Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")} + ) + } + } + }catch (e: com.github.kiulian.downloader.YoutubeException){ + Log.i("Service YT Error", e.message.toString()) + } + } + } + } + override fun onDestroy() { super.onDestroy() - if(downloadMap.isEmpty() && converted == total){ + if(converted == total){ Handler().postDelayed({ Log.i(tag,"Service destroyed.") deleteFile(parentDirectory) @@ -200,25 +207,11 @@ class ForegroundService : Service(){ } } - private fun releaseWakeLock() { - Log.i(tag,"Releasing Wake Lock") - try { - wakeLock?.let { - if (it.isHeld) { - it.release() - } - } - } catch (e: Exception) { - Log.i(tag,"Service stopped without being started: ${e.message}") - } - isServiceStarted = false - } - - override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - if(downloadMap.isEmpty() && converted == total ){ + if(converted == total ){ Log.i(tag,"Service Removed.") + deleteFile(parentDirectory) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { stopForeground(true) } else { @@ -227,25 +220,6 @@ class ForegroundService : Service(){ } } - /** - * Deleting All Residual Files except Mp3 Files - **/ - private fun deleteFile(dir:File) { - Log.i(tag,"Starting Deletions in ${dir.path} ") - val fList = dir.listFiles() - fList?.let { - for (file in fList) { - if (file.isDirectory) { - deleteFile(file) - } else if(file.isFile) { - if(file.path.toString().substringAfterLast(".") != "mp3"){ -// Log.i(tag,"deleting ${file.path}") - file.delete() - } - } - } - } - } /** * Fetch Listener/ Responsible for Fetch Behaviour @@ -274,23 +248,23 @@ class ForegroundService : Service(){ val track = requestMap[download.request] when(notificationLine){ 0 -> { - messageList[0] = "Downloading ${track?.name}" + messageList[0] = "Downloading ${track?.title}" notificationLine = 1 } 1 -> { - messageList[1] = "Downloading ${track?.name}" + messageList[1] = "Downloading ${track?.title}" notificationLine = 2 } 2-> { - messageList[2] = "Downloading ${track?.name}" + messageList[2] = "Downloading ${track?.title}" notificationLine = 3 } 3 -> { - messageList[3] = "Downloading ${track?.name}" + messageList[3] = "Downloading ${track?.title}" notificationLine = 0 } } - Log.i(tag,"${track?.name} Download Started") + Log.i(tag,"${track?.title} Download Started") updateNotification() } @@ -309,19 +283,20 @@ class ForegroundService : Service(){ override fun onCompleted(download: Download) { val track = requestMap[download.request] for (message in messageList){ - if( message == "Downloading ${track?.name}"){ + if( message == "Downloading ${track?.title}"){ + //Remove Downloading Status from Notification messageList[messageList.indexOf(message)] = "" } } serviceScope.launch { try{ - convertToMp3(download.file, track!!) - Log.i(tag,"${track.name} Download Completed") + track?.let { convertToMp3(download.file, it) } + Log.i(tag,"${track?.title} Download Completed") }catch (e:KotlinNullPointerException ){ - Log.i(tag,"${track?.name} Download Failed! Error:Fetch!!!!") - Log.i(tag,"${track?.name} Requesting Download thru Android DM") + Log.i(tag,"${track?.title} Download Failed! Error:Fetch!!!!") + Log.i(tag,"${track?.title} Requesting Download thru Android DM") downloadUsingDM(download.request.url,download.request.file, track!!) downloaded++ requestMap.remove(download.request) @@ -348,7 +323,7 @@ class ForegroundService : Service(){ val track = requestMap[download.request] downloaded++ Log.i(tag,download.error.throwable.toString()) - Log.i(tag,"${track?.name} Requesting Download thru Android DM") + Log.i(tag,"${track?.title} Requesting Download thru Android DM") downloadUsingDM(download.request.url,download.request.file, track!!) requestMap.remove(download.request) } @@ -365,7 +340,7 @@ class ForegroundService : Service(){ downloadedBytesPerSecond: Long ) { val track = requestMap[download.request] - Log.i(tag,"${track?.name} ETA: ${etaInMilliSeconds/1000} sec") + Log.i(tag,"${track?.title} ETA: ${etaInMilliSeconds/1000} sec") speed = (downloadedBytesPerSecond/1000) updateNotification() } @@ -375,7 +350,7 @@ class ForegroundService : Service(){ /** * If fetch Fails , Android Download Manager To RESCUE!! **/ - fun downloadUsingDM(url:String, outputDir:String, track: Track){ + fun downloadUsingDM(url:String, outputDir:String, track: TrackDetails){ val uri = Uri.parse(url) val request = DownloadManager.Request(uri) .setAllowedNetworkTypes( @@ -383,14 +358,14 @@ class ForegroundService : Service(){ DownloadManager.Request.NETWORK_MOBILE ) .setAllowedOverRoaming(false) - .setTitle(track.name) + .setTitle(track.title) .setDescription("Spotify Downloader Working Up here...") .setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, outputDir.removePrefix( Environment.getExternalStorageDirectory().toString() + Environment.DIRECTORY_MUSIC + File.separator )) .setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) //Start Download - val downloadID = downloadManager?.enqueue(request) + val downloadID = downloadManager.enqueue(request) Log.i("DownloadManager", "Download Request Sent") val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { @@ -412,7 +387,7 @@ class ForegroundService : Service(){ /** *Converting Downloaded Audio (m4a) to Mp3.( Also Applying Metadata) **/ - fun convertToMp3(filePath: String, track: Track){ + fun convertToMp3(filePath: String, track: TrackDetails){ val m4aFile = File(filePath) FFmpeg.executeAsync( @@ -435,7 +410,7 @@ class ForegroundService : Service(){ } } - private fun writeMp3Tags(filePath:String, track: Track){ + private fun writeMp3Tags(filePath:String, track: TrackDetails){ var mp3File = Mp3File(filePath) mp3File = removeAllTags(mp3File) mp3File = setId3v1Tags(mp3File,track) @@ -485,62 +460,40 @@ class ForegroundService : Service(){ /** *Modifying Mp3 Tags with MetaData! **/ - private fun setId3v1Tags(mp3File: Mp3File, track: Track): Mp3File { - val id3v1Tag = ID3v1Tag() - id3v1Tag.track = track.disc_number.toString() - val artistsList = mutableListOf() - track.artists?.forEach { artistsList.add(it!!.name!!) } - id3v1Tag.artist = artistsList.joinToString() - id3v1Tag.title = track.name - id3v1Tag.album = track.album?.name - id3v1Tag.year = track.album?.release_date - id3v1Tag.comment = "Genres:${track.album?.genres?.joinToString()}" + private fun setId3v1Tags(mp3File: Mp3File, track: TrackDetails): Mp3File { + val id3v1Tag = ID3v1Tag().apply { + artist = track.artists.joinToString(",") + title = track.title + album = track.albumName + year = track.year + comment = "Genres:${track.comment}" + } mp3File.id3v1Tag = id3v1Tag return mp3File } - private fun setId3v2Tags(mp3file: Mp3File, track: Track): Mp3File { - val id3v2Tag = ID3v24Tag() - id3v2Tag.track = track.disc_number.toString() - val artistsList = mutableListOf() - track.artists?.forEach { artistsList.add(it!!.name!!) } - id3v2Tag.artist = artistsList.joinToString() - id3v2Tag.title = track.name - id3v2Tag.album = track.album?.name - id3v2Tag.year = track.album?.release_date - id3v2Tag.comment = "Genres:${track.album?.genres?.joinToString()}" - id3v2Tag.lyrics = "Gonna Implement Soon" - val copyrights = mutableListOf() - track.album?.copyrights?.forEach { copyrights.add(it!!.type!!) } - id3v2Tag.copyright = copyrights.joinToString() - id3v2Tag.url = track.href - track.ytCoverUrl?.let { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir +".Images/" + it.substringAfterLast('/',it) + ".jpeg") - Log.i("Mp3Tags editing Tags",file.path) - //init array with file length - val bytesArray = ByteArray(file.length().toInt()) - val fis = FileInputStream(file) + private fun setId3v2Tags(mp3file: Mp3File, track: TrackDetails): Mp3File { + val id3v2Tag = ID3v24Tag().apply { + artist = track.artists.joinToString(",") + title = track.title + album = track.albumName + year = track.year + comment = "Genres:${track.comment}" + lyrics = "Gonna Implement Soon" + url = track.trackUrl + } + val bytesArray = ByteArray(track.albumArt.length().toInt()) + try{ + val fis = FileInputStream(track.albumArt) fis.read(bytesArray) //read file into bytes[] fis.close() id3v2Tag.setAlbumImage(bytesArray,"image/jpeg") + }catch (e:java.io.FileNotFoundException){ + Log.i("Error","Couldn't Write Mp3 Album Art") } - track.album?.let { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir +".Images/" + (it.images?.get(0)?.url!!).substringAfterLast('/') + ".jpeg") - Log.i("Mp3Tags editing Tags",file.path) - //init array with file length - val bytesArray = ByteArray(file.length().toInt()) - val fis = FileInputStream(file) - fis.read(bytesArray) //read file into bytes[] - fis.close() - id3v2Tag.setAlbumImage(bytesArray,"image/jpeg") - } - id3v2Tag.albumImage mp3file.id3v2Tag = id3v2Tag return mp3file } + private fun removeAllTags(mp3file: Mp3File): Mp3File { if (mp3file.hasId3v1Tag()) { mp3file.removeId3v1Tag() @@ -554,4 +507,141 @@ class ForegroundService : Service(){ return mp3file } + private fun releaseWakeLock() { + Log.i(tag,"Releasing Wake Lock") + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + } catch (e: Exception) { + Log.i(tag,"Service stopped without being started: ${e.message}") + } + isServiceStarted = false + } + + /** + *Starting Service with Notification as Foreground! + **/ + private fun startForeground() { + val channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(channelId, "Downloader Service") + } else { + // If earlier version channel ID is not used + // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) + "" + } + + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.down_arrowbw) + .setNotificationSilent() + .setSubText("Total: $total Completed:$converted") + .setStyle(NotificationCompat.InboxStyle() + .setBigContentTitle("Speed: $speed KB/s") + .addLine(messageList[0]) + .addLine(messageList[1]) + .addLine(messageList[2]) + .addLine(messageList[3])) + .setContentIntent(pendingIntent) + .build() + startForeground(notificationId, notification) + } + + @Suppress("SameParameterValue") + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String{ + val chan = NotificationChannel(channelId, + channelName, NotificationManager.IMPORTANCE_DEFAULT) + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + return channelId + } + + /** + * Deleting All Residual Files except Mp3 Files + **/ + private fun deleteFile(dir:File) { + Log.i(tag,"Starting Deletions in ${dir.path} ") + val fList = dir.listFiles() + fList?.let { + for (file in fList) { + if (file.isDirectory) { + deleteFile(file) + } else if(file.isFile) { + if(file.path.toString().substringAfterLast(".") != "mp3"){ + Log.i(tag,"deleting ${file.path}") + file.delete() + } + } + } + } + } + + /** + * Function to fetch all Images for use in mp3 tags. + **/ + private suspend fun loadAllImages(urlList: ArrayList) { + /* + * Last Element of this List defines Its Source + * */ + val source = urlList.last() + for (url in urlList.subList(0,urlList.size-2)) { + val imgUri = url.toUri().buildUpon().scheme("https").build() + Glide + .with(this) + .asFile() + .load(imgUri) + .listener(object: RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + Log.i("Glide","LoadFailed") + return false + } + + override fun onResourceReady( + resource: File?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + serviceScope.launch { + withContext(Dispatchers.IO){ + try { + val file = when(source){ + "spotify" ->{ + File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + url.substringAfterLast('/') + ".jpeg" + ) + } + "youtube" ->{ + File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + url.substringBeforeLast('/',url).substringAfterLast('/',url) + ".jpeg" + ) + } + else -> File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + url.substringAfterLast('/') + ".jpeg") + } + resource?.copyTo(file) + } catch (e: IOException) { + e.printStackTrace() + } + } + } + return false + } + }).submit() + } + } + } \ No newline at end of file diff --git a/app/src/main/res/layout/spotify_fragment.xml b/app/src/main/res/layout/spotify_fragment.xml index 6b363de5..d8d584c9 100755 --- a/app/src/main/res/layout/spotify_fragment.xml +++ b/app/src/main/res/layout/spotify_fragment.xml @@ -20,7 +20,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> @@ -72,14 +72,14 @@ app:toolbarId="@+id/toolbar"> + app:layout_constraintTop_toBottomOf="@id/appbar" /> diff --git a/app/src/main/res/layout/youtube_fragment.xml b/app/src/main/res/layout/youtube_fragment.xml index 90c375e5..985b4029 100755 --- a/app/src/main/res/layout/youtube_fragment.xml +++ b/app/src/main/res/layout/youtube_fragment.xml @@ -19,14 +19,14 @@ @@ -69,14 +69,14 @@ app:toolbarId="@+id/toolbar"> + app:layout_constraintTop_toBottomOf="@id/appbar" /> From a5793cc72c22cf60c6b455cb445ad8d0cef357f1 Mon Sep 17 00:00:00 2001 From: Shabinder Date: Sun, 8 Nov 2020 13:10:46 +0530 Subject: [PATCH 03/14] YT Image URL Schema Change from HI_RES->HQ-Default --- .idea/dictionaries/shabinder.xml | 1 + .../downloadHelper/YTDownloadHelper.kt | 4 +- .../recyclerView/DownloadRecordAdapter.kt | 2 +- .../recyclerView/YoutubeTrackListAdapter.kt | 53 ++++-- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 65 +++++++- .../spotiflyer/ui/youtube/YoutubeViewModel.kt | 154 ++++++++++-------- .../shabinder/spotiflyer/utils/Provider.kt | 4 + .../com/shabinder/spotiflyer/utils/Utils.kt | 8 +- 8 files changed, 205 insertions(+), 86 deletions(-) diff --git a/.idea/dictionaries/shabinder.xml b/.idea/dictionaries/shabinder.xml index 6457524e..739b47bf 100755 --- a/.idea/dictionaries/shabinder.xml +++ b/.idea/dictionaries/shabinder.xml @@ -6,6 +6,7 @@ emoji ffmpeg flyer + hqdefault insta instagram jetbrains diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt index 02e17f8f..523447d4 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt @@ -48,8 +48,8 @@ object YTDownloadHelper { val downloadObject = DownloadObject( trackDetails = it, - ytVideoId = "https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") - .substringBeforeLast(".")}/maxresdefault.jpg", + ytVideoId = it.albumArt.absolutePath.substringAfterLast("/") + .substringBeforeLast("."), outputFile = outputFile ) diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt index 696ad40d..670abecf 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt @@ -44,7 +44,7 @@ class DownloadRecordAdapter: ListAdapter(YouTubeTrackDiffCallback()) { - - private val adapterScope = CoroutineScope(Dispatchers.Default) +class YoutubeTrackListAdapter(private val youtubeViewModel :YoutubeViewModel): ListAdapter(YouTubeTrackDiffCallback()) { override fun onCreateViewHolder( parent: ViewGroup, @@ -48,24 +51,50 @@ class YoutubeTrackListAdapter: ListAdapter { + holder.binding.btnDownload.setImageResource(R.drawable.ic_tick) + holder.binding.btnDownload.clearAnimation() + } + DownloadStatus.Downloading -> { + holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) + rotateAnim(holder.binding.btnDownload) + } + DownloadStatus.NotDownloaded -> { + holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow) + holder.binding.btnDownload.clearAnimation() + holder.binding.btnDownload.setOnClickListener{ + Toast.makeText(Provider.activity,"Processing!", Toast.LENGTH_SHORT).show() + holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) + rotateAnim(it) + item.downloaded = DownloadStatus.Downloading + youtubeViewModel.uiScope.launch { + val itemList = mutableListOf() + itemList.add(item) + YTDownloadHelper.downloadYTTracks( + youtubeViewModel.folderType, + youtubeViewModel.subFolder, + itemList + ) + } + notifyItemChanged(position)//start showing anim! + } + } + } + holder.binding.trackName.text = "${if(item.title.length > 17){"${item.title.subSequence(0,16)}..."}else{item.title}}" holder.binding.artist.text = "${item.artists.get(0)}..." holder.binding.duration.text = "${item.durationSec/60} minutes, ${item.durationSec%60} sec" - holder.binding.btnDownload.setOnClickListener{ - adapterScope.launch { -// YTDownloadHelper.downloadFile(null,"YT_Downloads",item,format) - } - } } } class YouTubeTrackDiffCallback: DiffUtil.ItemCallback(){ diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index 9801997a..7f382b40 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -17,7 +17,12 @@ package com.shabinder.spotiflyer.ui.youtube +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -52,6 +57,8 @@ class YoutubeFragment : Fragment() { private lateinit var adapter : YoutubeTrackListAdapter private val sampleDomain1 = "youtube.com" private val sampleDomain2 = "youtu.be" + private var intentFilter: IntentFilter? = null + private var updateUIReceiver: BroadcastReceiver? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -60,10 +67,11 @@ class YoutubeFragment : Fragment() { binding = DataBindingUtil.inflate(inflater,R.layout.youtube_fragment,container,false) youtubeViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - adapter = YoutubeTrackListAdapter() + adapter = YoutubeTrackListAdapter(youtubeViewModel) binding.trackList.adapter = adapter initializeLiveDataObservers() + initializeBroadcast() val args = YoutubeFragmentArgs.fromBundle(requireArguments()) val link = args.link @@ -89,6 +97,10 @@ class YoutubeFragment : Fragment() { youtubeViewModel.getYTTrack(searchId,ytDownloader) }else{showToast("Your Youtube Link is not of a Video!!")} } + + /* + * Download All Tracks + * */ binding.btnDownloadAll.setOnClickListener { binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.visibility = View.VISIBLE @@ -105,7 +117,7 @@ class YoutubeFragment : Fragment() { sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() youtubeViewModel.ytTrackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") - .substringBeforeLast(".")}/maxresdefault.jpg")} + .substringBeforeLast(".")}/hqdefault.jpg")} //Appending Source urlList.add("youtube") loadAllImages( @@ -122,7 +134,56 @@ class YoutubeFragment : Fragment() { } } } + override fun onResume() { + super.onResume() + initializeBroadcast() + } + private fun initializeBroadcast() { + intentFilter = IntentFilter() + intentFilter?.addAction("track_download_completed") + + updateUIReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + //UI update here + if (intent != null){ + val trackDetails = intent.getParcelableExtra("track") + trackDetails?.let { + val position: Int = youtubeViewModel.ytTrackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 + Log.i("Track","Download Completed Intent :$position") + if(position != -1) { + val track = youtubeViewModel.ytTrackList.value?.get(position) + track?.let{ + it.downloaded = DownloadStatus.Downloaded + youtubeViewModel.ytTrackList.value?.set(position, it) + adapter.notifyItemChanged(position) + checkIfAllDownloaded() + } + } + } + } + } + } + requireActivity().registerReceiver(updateUIReceiver, intentFilter) + } + + override fun onPause() { + super.onPause() + requireActivity().unregisterReceiver(updateUIReceiver) + } + + private fun checkIfAllDownloaded() { + if(!youtubeViewModel.ytTrackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + //All Tracks Downloaded + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.apply{ + setImageResource(R.drawable.ic_tick) + visibility = View.VISIBLE + clearAnimation() + keepScreenOn = false + } + } + } private fun initializeLiveDataObservers() { /** * CoverUrl Binding Observer! diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt index f9a7b15c..dedfb6e0 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt @@ -32,6 +32,7 @@ import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.utils.Provider import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.Provider.showToast import com.shabinder.spotiflyer.utils.finalOutputDir import com.shabinder.spotiflyer.utils.removeIllegalChars import kotlinx.coroutines.* @@ -41,10 +42,11 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO /* * YT Album Art Schema - * Normal Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + * Normal Url: https://i.ytimg.com/vi/$searchId/hqdefault.jpg" * */ - val ytTrackList = MutableLiveData>() + val ytTrackList = MutableLiveData>() val format = MutableLiveData() private val loading = "Loading" var title = MutableLiveData().apply { value = "\"Loading!\"" } @@ -56,81 +58,101 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO fun getYTPlaylist(searchId:String, ytDownloader:YoutubeDownloader){ - uiScope.launch(Dispatchers.IO) { - Log.i("YT Playlist",searchId) - val playlist = ytDownloader.getPlaylist(searchId) - val playlistDetails = playlist.details() - val name = playlistDetails.title() - subFolder = removeIllegalChars(name).toString() - val videos = playlist.videos() - coverUrl.postValue("https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg") - title.postValue( - if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} - ) - ytTrackList.postValue(videos.map { - TrackDetails( - title = it.title(), - artists = listOf(it.author().toString()), - durationSec = it.lengthSeconds(), - albumArt = File( - Environment.getExternalStorageDirectory(), - defaultDir +".Images/" + it.videoId() + ".jpeg" - ), - source = Source.YouTube, - downloaded = if(File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists()) - DownloadStatus.Downloaded - else DownloadStatus.NotDownloaded + try{ + uiScope.launch(Dispatchers.IO) { + Log.i("YT Playlist",searchId) + val playlist = ytDownloader.getPlaylist(searchId) + val playlistDetails = playlist.details() + val name = playlistDetails.title() + subFolder = removeIllegalChars(name).toString() + val videos = playlist.videos() + coverUrl.postValue("https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/hqdefault.jpg") + title.postValue( + if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} ) - }) + ytTrackList.postValue(videos.map { + TrackDetails( + title = it.title(), + artists = listOf(it.author().toString()), + durationSec = it.lengthSeconds(), + albumArt = File( + Environment.getExternalStorageDirectory(), + defaultDir + ".Images/" + it.videoId() + ".jpeg" + ), + source = Source.YouTube, + downloaded = if (File( + finalOutputDir( + itemName = it.title(), + type = folderType, + subFolder = subFolder + ) + ).exists() + ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + } + ) + }.toMutableList()) withContext(Dispatchers.IO){ - databaseDAO.insert(DownloadRecord( - type = "PlayList", - name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, - link = "https://www.youtube.com/playlist?list=$searchId", - coverUrl = "https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg", - totalFiles = videos.size, - directory = finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder), - downloaded = File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists() - )) + databaseDAO.insert(DownloadRecord( + type = "PlayList", + name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, + link = "https://www.youtube.com/playlist?list=$searchId", + coverUrl = "https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/hqdefault.jpg", + totalFiles = videos.size, + directory = finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder), + downloaded = File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists() + )) + } } + }catch (e:com.github.kiulian.downloader.YoutubeException.BadPageException){ + showToast("An Error Occurred While Processing!") } + } @SuppressLint("DefaultLocale") fun getYTTrack(searchId:String, ytDownloader:YoutubeDownloader) { - uiScope.launch(Dispatchers.IO) { - Log.i("YT Video",searchId) - val video = ytDownloader.getVideo(searchId) - coverUrl.postValue("https://i.ytimg.com/vi/$searchId/maxresdefault.jpg") - val detail = video?.details() - val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() ?: "" - Log.i("YT View Model",detail.toString()) - ytTrackList.postValue(listOf(TrackDetails( - title = name, - artists = listOf(detail?.author().toString()), - durationSec = detail?.lengthSeconds()?:0, - albumArt = File( - Environment.getExternalStorageDirectory(), - Provider.defaultDir +".Images/" + searchId + ".jpeg" - ), - source = Source.YouTube - ))) - title.postValue( - if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} - ) + try{ + uiScope.launch(Dispatchers.IO) { + Log.i("YT Video",searchId) + val video = ytDownloader.getVideo(searchId) + coverUrl.postValue("https://i.ytimg.com/vi/$searchId/hqdefault.jpg") + val detail = video?.details() + val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() ?: "" + Log.i("YT View Model",detail.toString()) + ytTrackList.postValue( + listOf(TrackDetails( + title = name, + artists = listOf(detail?.author().toString()), + durationSec = detail?.lengthSeconds()?:0, + albumArt = File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + searchId + ".jpeg" + ), + source = Source.YouTube + )).toMutableList() + ) + title.postValue( + if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} + ) - withContext(Dispatchers.IO){ - databaseDAO.insert(DownloadRecord( - type = "Track", - name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, - link = "https://www.youtube.com/watch?v=$searchId", - coverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg", - totalFiles = 1, - downloaded = false, - directory = finalOutputDir(type = "YT_Downloads") - )) + withContext(Dispatchers.IO){ + databaseDAO.insert(DownloadRecord( + type = "Track", + name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, + link = "https://www.youtube.com/watch?v=$searchId", + coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", + totalFiles = 1, + downloaded = false, + directory = finalOutputDir(type = "YT_Downloads") + )) + } } + } catch (e:com.github.kiulian.downloader.YoutubeException){ + showToast("An Error Occurred While Processing!") } } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 123a2fd2..84863e5f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -19,6 +19,7 @@ package com.shabinder.spotiflyer.utils import android.content.Context import android.os.Environment +import android.widget.Toast import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.App import com.shabinder.spotiflyer.MainActivity @@ -117,4 +118,7 @@ object Provider { return retrofit.create(YoutubeMusicApi::class.java) } + fun showToast(string: String,long:Boolean=false){ + Toast.makeText(activity,string,if(long)Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() + } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt index 8435cb1e..77c84308 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -28,7 +28,6 @@ import android.view.animation.RotateAnimation import android.widget.ImageView import androidx.core.content.ContextCompat import androidx.core.net.toUri -import androidx.databinding.BindingAdapter import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException @@ -77,8 +76,7 @@ fun rotateAnim(view: View){ view.animation = rotate } -@BindingAdapter("imageUrl") -fun bindImage(imgView: ImageView, imgUrl: String?,source: Source) { +fun bindImage(imgView: ImageView, imgUrl: String?,source: Source?) { imgUrl?.let { val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() Glide @@ -122,6 +120,10 @@ fun bindImage(imgView: ImageView, imgUrl: String?,source: Source) { defaultDir+".Images/" + imgUrl.substringBeforeLast('/',imgUrl).substringAfterLast('/',imgUrl) + ".jpeg" ) } + else -> File( + Environment.getExternalStorageDirectory(), + defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg" + ) } // the File to save , append increasing numeric counter to prevent files from getting overwritten. resource?.copyTo(file) From c489c8c84abd3fcad8d686cad31bc1cbfce15d69 Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 01:47:26 +0530 Subject: [PATCH 04/14] Network Connection Change Handling Improved,i.e,Less Crashes and Gaana Implementation Also Started --- .idea/dictionaries/shabinder.xml | 7 + app/build.gradle | 5 +- app/proguard-rules.pro | 18 ++ .../com/shabinder/spotiflyer/MainActivity.kt | 59 ++--- .../shabinder/spotiflyer/SharedViewModel.kt | 29 +-- ...ifyDownloadHelper.kt => DownloadHelper.kt} | 56 +++-- .../downloadHelper/YTDownloadHelper.kt | 6 + .../downloadHelper/YoutubeProvider.kt | 2 +- .../spotiflyer/models/DownloadObject.kt | 5 +- .../models/{YTTrack.kt => Optional.kt} | 14 +- .../spotiflyer/models/gaana/Artist.kt | 24 ++ .../spotiflyer/models/gaana/CustomArtworks.kt | 28 +++ .../spotiflyer/models/gaana/GaanaAlbum.kt | 26 +++ .../models/gaana/GaanaArtistDetails.kt | 23 ++ .../models/gaana/GaanaArtistTracks.kt | 23 ++ .../spotiflyer/models/gaana/GaanaPlaylist.kt | 28 +++ .../spotiflyer/models/gaana/GaanaSong.kt | 22 ++ .../spotiflyer/models/gaana/Genre.kt | 23 ++ .../shabinder/spotiflyer/models/gaana/Tags.kt | 23 ++ .../spotiflyer/models/gaana/Tracks.kt | 38 ++++ .../spotiflyer/models/{ => spotify}/Album.kt | 2 +- .../spotiflyer/models/{ => spotify}/Artist.kt | 2 +- .../models/{ => spotify}/Copyright.kt | 2 +- .../models/{ => spotify}/Episodes.kt | 2 +- .../models/{ => spotify}/Followers.kt | 2 +- .../spotiflyer/models/{ => spotify}/Image.kt | 2 +- .../models/{ => spotify}/LinkedTrack.kt | 2 +- .../PagingObjectPlaylistTrack.kt | 2 +- .../models/{ => spotify}/PagingObjectTrack.kt | 2 +- .../models/{ => spotify}/Playlist.kt | 2 +- .../models/{ => spotify}/PlaylistTrack.kt | 2 +- .../spotiflyer/models/spotify/Source.kt | 23 ++ .../spotiflyer/models/{ => spotify}/Token.kt | 2 +- .../spotiflyer/models/{ => spotify}/Track.kt | 6 +- .../models/{ => spotify}/UserPrivate.kt | 2 +- .../models/{ => spotify}/UserPublic.kt | 2 +- .../spotiflyer/networking/GaanaInterface.kt | 101 +++++++++ .../{utils => networking}/SpotifyInterface.kt | 37 +-- .../{utils => networking}/YoutubeMusicApi.kt | 15 +- .../recyclerView/SpotifyTrackListAdapter.kt | 50 +++-- .../recyclerView/YoutubeTrackListAdapter.kt | 13 +- .../spotiflyer/samples/response examples.txt | 2 +- .../spotiflyer/ui/gaana/GaanaFragment.kt | 54 +++++ .../spotiflyer/ui/gaana/GaanaViewModel.kt | 24 ++ .../ui/mainfragment/MainFragment.kt | 27 ++- .../spotiflyer/ui/spotify/SpotifyFragment.kt | 211 +++++++++--------- .../spotiflyer/ui/spotify/SpotifyViewModel.kt | 13 +- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 82 +++---- .../spotiflyer/ui/youtube/YoutubeViewModel.kt | 19 +- .../spotiflyer/utils/NetworkInterceptor.kt | 66 ++++++ .../shabinder/spotiflyer/utils/Provider.kt | 52 +++-- .../com/shabinder/spotiflyer/utils/Utils.kt | 62 ++++- .../spotiflyer/worker/ForegroundService.kt | 4 +- app/src/main/res/layout/main_activity.xml | 18 +- app/src/main/res/layout/main_fragment.xml | 1 + ...y_fragment.xml => track_list_fragment.xml} | 3 +- app/src/main/res/layout/track_list_item.xml | 2 +- app/src/main/res/layout/youtube_fragment.xml | 152 ------------- app/src/main/res/navigation/navigation.xml | 4 +- app/src/main/res/values/styles.xml | 11 +- build.gradle | 2 +- 61 files changed, 977 insertions(+), 564 deletions(-) rename app/src/main/java/com/shabinder/spotiflyer/downloadHelper/{SpotifyDownloadHelper.kt => DownloadHelper.kt} (79%) rename app/src/main/java/com/shabinder/spotiflyer/models/{YTTrack.kt => Optional.kt} (73%) mode change 100755 => 100644 create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/CustomArtworks.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistDetails.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/Genre.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tags.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Album.kt (96%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Artist.kt (95%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Copyright.kt (94%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Episodes.kt (96%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Followers.kt (94%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Image.kt (94%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/LinkedTrack.kt (95%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/PagingObjectPlaylistTrack.kt (95%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/PagingObjectTrack.kt (95%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Playlist.kt (96%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/PlaylistTrack.kt (95%) create mode 100644 app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Token.kt (94%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/Track.kt (88%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/UserPrivate.kt (96%) rename app/src/main/java/com/shabinder/spotiflyer/models/{ => spotify}/UserPublic.kt (95%) create mode 100644 app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt rename app/src/main/java/com/shabinder/spotiflyer/{utils => networking}/SpotifyInterface.kt (54%) rename app/src/main/java/com/shabinder/spotiflyer/{utils => networking}/YoutubeMusicApi.kt (83%) create mode 100644 app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt create mode 100644 app/src/main/java/com/shabinder/spotiflyer/utils/NetworkInterceptor.kt rename app/src/main/res/layout/{spotify_fragment.xml => track_list_fragment.xml} (98%) delete mode 100755 app/src/main/res/layout/youtube_fragment.xml diff --git a/.idea/dictionaries/shabinder.xml b/.idea/dictionaries/shabinder.xml index 739b47bf..b91cbc33 100755 --- a/.idea/dictionaries/shabinder.xml +++ b/.idea/dictionaries/shabinder.xml @@ -1,11 +1,16 @@ + albumseokey + amita + cardview cherrypick downloadrecord emoji ffmpeg flyer + gaana + gener hqdefault insta instagram @@ -19,8 +24,10 @@ musicplaceholder raleway semibold + seokey shabinder singh + snackbar spoti spotiflyer spotify diff --git a/app/build.gradle b/app/build.gradle index 236c0f2b..3382dc5f 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: 'dagger.hilt.android.plugin' -//apply plugin: 'kotlinx-serialization' +apply plugin: 'kotlinx-serialization' android { compileSdkVersion 29 @@ -90,8 +90,10 @@ dependencies { implementation 'com.google.android.material:material:1.2.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.0' + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1" implementation "androidx.room:room-runtime:2.2.5" + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' kapt "androidx.room:room-compiler:2.2.5" implementation "androidx.room:room-ktx:2.2.5" implementation "com.google.dagger:hilt-android:$hilt_version" @@ -116,7 +118,6 @@ dependencies { implementation 'com.squareup.moshi:moshi-kotlin:1.11.0' implementation "com.squareup.retrofit2:converter-moshi:2.9.0" implementation "com.squareup.retrofit2:converter-scalars:2.9.0" - implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.beust:klaxon:5.4' implementation 'me.xdrop:fuzzywuzzy:1.3.1' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..503cf7ce 100755 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,7 +11,25 @@ #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations +# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer +-keepclassmembers class kotlinx.serialization.json.* { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.* { + kotlinx.serialization.KSerializer serializer(...); +} + +# Change here com.yourcompany.yourpackage +-keep,includedescriptorclasses class com.shabinder.spotiflyer.**$$serializer { *; } # <-- change package name to your app's +-keepclassmembers class com.shabinder.spotiflyer* { # <-- change package name to your app's + *** Companion; +} +-keepclasseswithmembers class com.shabinder.spotiflyer.* { # <-- change package name to your app's + kotlinx.serialization.KSerializer serializer(...); +} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index 3b7745d6..bf6e941f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -28,6 +28,7 @@ import android.os.Bundle import android.os.PowerManager import android.provider.Settings import android.util.Log +import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.databinding.DataBindingUtil @@ -35,10 +36,12 @@ import androidx.lifecycle.ViewModelProvider import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.enums.UpdateFrom import com.shabinder.spotiflyer.databinding.MainActivityBinding +import com.shabinder.spotiflyer.networking.SpotifyService +import com.shabinder.spotiflyer.networking.SpotifyServiceTokenRequest +import com.shabinder.spotiflyer.utils.NetworkInterceptor import com.shabinder.spotiflyer.utils.Provider.activity -import com.shabinder.spotiflyer.utils.SpotifyService -import com.shabinder.spotiflyer.utils.SpotifyServiceTokenRequest import com.shabinder.spotiflyer.utils.createDirectories +import com.shabinder.spotiflyer.utils.isOnline import com.shabinder.spotiflyer.utils.startService import com.squareup.moshi.Moshi import dagger.hilt.android.AndroidEntryPoint @@ -54,35 +57,29 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity(){ private var spotifyService : SpotifyService? = null - private var isConnected: Boolean = false private var sharedPref :SharedPreferences? = null - private var token :String ="" private lateinit var binding: MainActivityBinding + lateinit var snackBarAnchor: View private lateinit var sharedViewModel: SharedViewModel - @Inject lateinit var spotifyServiceTokenRequest: SpotifyServiceTokenRequest @Inject lateinit var moshi: Moshi + @Inject lateinit var spotifyServiceTokenRequest: SpotifyServiceTokenRequest override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.main_activity) + snackBarAnchor = binding.snackBarPosition sharedViewModel = ViewModelProvider(this).get(SharedViewModel::class.java) //Enabling Dark Mode AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) sharedPref = this.getPreferences(Context.MODE_PRIVATE) - if(sharedViewModel.spotifyService.value == null){ - authenticateSpotify() - }else{ - implementSpotifyService(sharedViewModel.accessToken.value!!) - } + authenticateSpotify() requestPermission() disableDozeMode() checkIfLatestVersion() createDirectories() - isConnected = sharedViewModel.isOnline(this) - sharedViewModel.isConnected.value = isConnected - Log.i("Connection Status", isConnected.toString()) + Log.i("Connection Status", isOnline().toString()) //starting Notification and Downloader Service! startService(this) @@ -140,7 +137,7 @@ class MainActivity : AppCompatActivity(){ "Bearer $token" ).build() chain.proceed(request) - }) + }).addInterceptor(NetworkInterceptor()) val retrofit = Retrofit.Builder() .baseUrl("https://api.spotify.com/v1/") @@ -155,16 +152,12 @@ class MainActivity : AppCompatActivity(){ fun authenticateSpotify() { sharedViewModel.uiScope.launch { - if (isConnected) { - Log.i("Post Request", "Made") - token = spotifyServiceTokenRequest.getToken()!!.access_token - implementSpotifyService(token) - Log.i("Post Request", token) - sharedViewModel.accessToken.value = token - }else{ - Log.i("network", "unavailable") -// sharedViewModel.showAlertDialog(resources,this@MainActivity) + Log.i("Spotify Authentication","Started") + val token = spotifyServiceTokenRequest.getToken() + token.value?.let { + implementSpotifyService(it.access_token) } + Log.i("Spotify Token", token.value.toString()) } } @@ -189,19 +182,6 @@ class MainActivity : AppCompatActivity(){ } } - override fun onSaveInstanceState(savedInstanceState: Bundle) { - savedInstanceState.putString("token", token) - super.onSaveInstanceState(savedInstanceState) - } - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - if (savedInstanceState.getString("token") ==""){ - super.onRestoreInstanceState(savedInstanceState) - }else{ - implementSpotifyService(savedInstanceState.getString("token")!!) - super.onRestoreInstanceState(savedInstanceState) - } - } - private fun checkIfLatestVersion() { val appUpdater = AppUpdater(this) .showAppUpdated(false)//true:Show App is Update Dialog @@ -220,14 +200,7 @@ class MainActivity : AppCompatActivity(){ appUpdater.start() } - companion object{ - private var instance = MainActivity() - fun getInstance():MainActivity{ - return instance - } - } init { - instance = this activity = this } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt index 3705a905..1f9e0a45 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt @@ -17,49 +17,22 @@ package com.shabinder.spotiflyer -import android.content.Context -import android.content.res.Resources -import android.net.ConnectivityManager -import android.os.Environment import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.shabinder.spotiflyer.utils.SpotifyService +import com.shabinder.spotiflyer.networking.SpotifyService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import java.io.File class SharedViewModel : ViewModel() { var intentString = MutableLiveData().apply { value = "" } var spotifyService = MutableLiveData() - var accessToken = MutableLiveData().apply { value = "" } - var isConnected = MutableLiveData().apply { value = false } - val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator + ".Images" + File.separator - private var viewModelJob = Job() - val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) override fun onCleared() { super.onCleared() viewModelJob.cancel() } - - fun showAlertDialog(resources:Resources,context: Context){ - MaterialAlertDialogBuilder(context,R.style.AlertDialogTheme) - .setTitle(resources.getString(R.string.title)) - .setMessage(resources.getString(R.string.supporting_text)) - .setPositiveButton(resources.getString(R.string.cancel)) { _, _ -> - // Respond to neutral button press - } - .show() - } - fun isOnline(context: Context): Boolean { - val cm = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val netInfo = cm.activeNetworkInfo - return netInfo != null && netInfo.isConnectedOrConnecting - } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt similarity index 79% rename from app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt rename to app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt index 8d8a903d..106750de 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt @@ -27,7 +27,11 @@ import android.view.animation.Animation import android.widget.TextView import android.widget.Toast import com.shabinder.spotiflyer.SharedViewModel -import com.shabinder.spotiflyer.models.* +import com.shabinder.spotiflyer.models.DownloadObject +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.networking.YoutubeMusicApi +import com.shabinder.spotiflyer.networking.makeJsonBody import com.shabinder.spotiflyer.utils.* import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.Provider.defaultDir @@ -39,10 +43,10 @@ import retrofit2.Callback import retrofit2.Response import java.io.File -object SpotifyDownloadHelper { +object DownloadHelper { var statusBar:TextView? = null - var youtubeMusicApi:YoutubeMusicApi? = null + var youtubeMusicApi: YoutubeMusicApi? = null var sharedViewModel: SharedViewModel? = null private var total = 0 @@ -55,12 +59,16 @@ object SpotifyDownloadHelper { suspend fun downloadAllTracks( type:String, subFolder: String?, - trackList: List) { + trackList: List) { + resetStatusBar()// For New Download Request's Status val downloadList = ArrayList() - withContext(Dispatchers.Main){ total += trackList.size // Adding New Download List Count to StatusBar trackList.forEachIndexed { index, it -> + if(!isOnline()){ + showNoConnectionAlert() + return@withContext + } if(it.downloaded == DownloadStatus.Downloaded){//Download Already Present!! processed++ if(index == (trackList.size-1)){//LastElement @@ -74,49 +82,32 @@ object SpotifyDownloadHelper { },5000) } }else{ - val artistsList = mutableListOf() - it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } - val searchQuery = "${it.name} - ${artistsList.joinToString(",")}" - - val jsonBody = makeJsonBody(searchQuery.trim()) + val searchQuery = "${it.title} - ${it.artists.joinToString(",")}" + val jsonBody = makeJsonBody(searchQuery.trim()).toJsonString() youtubeMusicApi?.getYoutubeMusicResponse(jsonBody)?.enqueue( object : Callback{ override fun onResponse(call: Call, response: Response) { sharedViewModel?.uiScope?.launch { val videoId = sortByBestMatch( getYTTracks(response.body().toString()), - trackName = it.name.toString(), - trackArtists = artistsList, - trackDurationSec = (it.duration_ms/1000).toInt() + trackName = it.title, + trackArtists = it.artists, + trackDurationSec = it.durationSec ).keys.firstOrNull() Log.i("Spotify Helper Video ID",videoId ?: "Not Found") if(videoId.isNullOrBlank()) {notFound++ ; updateStatusBar()} else {//Found Youtube Video ID - val trackDetails = TrackDetails( - title = it.name.toString(), - artists = artistsList, - durationSec = (it.duration_ms/1000).toInt(), - albumArt = File( - Environment.getExternalStorageDirectory(), - defaultDir +".Images/" + (it.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), - albumName = it.album?.name, - year = it.album?.release_date, - comment = "Genres:${it.album?.genres?.joinToString()}", - trackUrl = it.href, - source = Source.Spotify - ) - val outputFile: String = Environment.getExternalStorageDirectory().toString() + File.separator + defaultDir + removeIllegalChars(type) + File.separator + (if (subFolder == null) { "" } else { removeIllegalChars(subFolder) + File.separator } - + removeIllegalChars(it.name!!) + ".m4a") + + removeIllegalChars(it.title) + ".m4a") val downloadObject = DownloadObject( - trackDetails = trackDetails, + trackDetails = it, ytVideoId = videoId, outputFile = outputFile ) @@ -150,6 +141,13 @@ object SpotifyDownloadHelper { } } + private fun resetStatusBar() { + total = 0 + processed = 0 + notFound = 0 + updateStatusBar() + } + private fun animateStatusBar() { val anim: Animation = AlphaAnimation(0.3f, 0.9f) anim.duration = 1500 //You can manage the blinking time with this parameter diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt index 523447d4..453903ba 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt @@ -24,7 +24,9 @@ import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.isOnline import com.shabinder.spotiflyer.utils.removeIllegalChars +import com.shabinder.spotiflyer.utils.showNoConnectionAlert import com.shabinder.spotiflyer.utils.startService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -38,6 +40,10 @@ object YTDownloadHelper { ){ val downloadList = ArrayList() tracks.forEach { + if(!isOnline()){ + showNoConnectionAlert() + return + } val outputFile: String = Environment.getExternalStorageDirectory().toString() + File.separator + defaultDir + diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt index 1ca9a5b5..743f2dd0 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt @@ -85,7 +85,7 @@ fun getYTTracks(response: String):List{ ! Songs details are ALWAYS in the following order: ! 0 - Name ! 1 - Type (Song) - ! 2 - Artist + ! 2 - com.shabinder.spotiflyer.models.gaana.Artist ! 3 - Album ! 4 - Duration (mm:ss) ! diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt index 033a6944..4fc8016f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt @@ -18,6 +18,7 @@ package com.shabinder.spotiflyer.models import android.os.Parcelable +import com.shabinder.spotiflyer.models.spotify.Source import kotlinx.android.parcel.Parcelize import java.io.File @@ -39,8 +40,8 @@ data class TrackDetails( var lyrics:String?=null, var trackUrl:String?=null, var albumArt: File, - var source:Source, - var downloaded:DownloadStatus = DownloadStatus.NotDownloaded + var source: Source, + var downloaded: DownloadStatus = DownloadStatus.NotDownloaded ):Parcelable enum class DownloadStatus{ diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/YTTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/Optional.kt old mode 100755 new mode 100644 similarity index 73% rename from app/src/main/java/com/shabinder/spotiflyer/models/YTTrack.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/Optional.kt index 703a708b..2257df5d --- a/app/src/main/java/com/shabinder/spotiflyer/models/YTTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/Optional.kt @@ -17,15 +17,7 @@ package com.shabinder.spotiflyer.models -import android.os.Parcelable -import kotlinx.android.parcel.Parcelize +import kotlinx.serialization.Serializable -@Parcelize -data class YTTrack( - var id:String?, - var title:String?, - var duration:Int?, - var author:String?, - var viewCount:Long?, - var thumbnails:List? -):Parcelable \ No newline at end of file +@Serializable +data class Optional(val value: T?) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt new file mode 100644 index 00000000..15b28e16 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class Artist ( + val popularity : Int, + val seokey : String, + val name : String, +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/CustomArtworks.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/CustomArtworks.kt new file mode 100644 index 00000000..f73b5510 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/CustomArtworks.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +import com.squareup.moshi.Json + +data class CustomArtworks ( + @Json(name = "40x40") val size_40p : String, + @Json(name = "80x80") val size_80p : String, + @Json(name = "110x110")val size_110p : String, + @Json(name = "175x175")val size_175p : String, + @Json(name = "480x480")val size_480p : String, +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt new file mode 100644 index 00000000..06cecd0f --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class GaanaAlbum ( + val tracks : List, + val count : Int, + val custom_artworks : CustomArtworks, + val release_year : Int, + val favorite_count : Int, +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistDetails.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistDetails.kt new file mode 100644 index 00000000..17d57ac3 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistDetails.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class GaanaArtistDetails( + val artist : List, + val count : Int, +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt new file mode 100644 index 00000000..c9fa3050 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class GaanaArtistTracks( + val count : Int, + val tracks : List +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt new file mode 100644 index 00000000..268121c5 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class GaanaPlaylist ( + val tags : String, + val fromCache : Int, + val modified_on : String, + val count : Int, + val created_on : String, + val favorite_count : Int, + val tracks : List, +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt new file mode 100644 index 00000000..0d7a65df --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class GaanaSong( + val tracks : List +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Genre.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Genre.kt new file mode 100644 index 00000000..0f4fcd21 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Genre.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class Genre ( + val genre_id : Int, + val name : String +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tags.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tags.kt new file mode 100644 index 00000000..c348a321 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tags.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +data class Tags ( + val tag_id : Int, + val tag_name : String +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt new file mode 100644 index 00000000..031e6687 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.gaana + +import com.squareup.moshi.Json + +data class Tracks ( + val tags : List, + val seokey : String, + val albumseokey : String, + val track_title : String, + val album_title : String, + val language : String, + @Json(name = "artwork_large") val artworkLink : String, + val artist : List, + @Json(name = "gener") val genre : List, + val lyrics_url : String, + val youtube_id : String, + val total_favourite_count : Int, + val release_date : String, + val play_ct : String, + val secondary_language : String, +) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Album.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Album.kt similarity index 96% rename from app/src/main/java/com/shabinder/spotiflyer/models/Album.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Album.kt index 025a1e71..3c06e6ad 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Album.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Album.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Artist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Artist.kt similarity index 95% rename from app/src/main/java/com/shabinder/spotiflyer/models/Artist.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Artist.kt index 59a9ac54..23b12247 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Artist.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Artist.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Copyright.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Copyright.kt similarity index 94% rename from app/src/main/java/com/shabinder/spotiflyer/models/Copyright.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Copyright.kt index d16ba35d..19761164 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Copyright.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Copyright.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Episodes.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Episodes.kt similarity index 96% rename from app/src/main/java/com/shabinder/spotiflyer/models/Episodes.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Episodes.kt index 804d438d..8fbfc49c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Episodes.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Episodes.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Followers.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Followers.kt similarity index 94% rename from app/src/main/java/com/shabinder/spotiflyer/models/Followers.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Followers.kt index 8a198e09..423f21d8 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Followers.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Followers.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Image.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Image.kt similarity index 94% rename from app/src/main/java/com/shabinder/spotiflyer/models/Image.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Image.kt index 764f59ad..11bf5242 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Image.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Image.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/LinkedTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/LinkedTrack.kt similarity index 95% rename from app/src/main/java/com/shabinder/spotiflyer/models/LinkedTrack.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/LinkedTrack.kt index 361378d4..ac00564d 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/LinkedTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/LinkedTrack.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/PagingObjectPlaylistTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/PagingObjectPlaylistTrack.kt similarity index 95% rename from app/src/main/java/com/shabinder/spotiflyer/models/PagingObjectPlaylistTrack.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/PagingObjectPlaylistTrack.kt index 3f298934..caca876d 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/PagingObjectPlaylistTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/PagingObjectPlaylistTrack.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/PagingObjectTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/PagingObjectTrack.kt similarity index 95% rename from app/src/main/java/com/shabinder/spotiflyer/models/PagingObjectTrack.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/PagingObjectTrack.kt index 004a79ec..98567afd 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/PagingObjectTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/PagingObjectTrack.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Playlist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Playlist.kt similarity index 96% rename from app/src/main/java/com/shabinder/spotiflyer/models/Playlist.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Playlist.kt index 4842ae91..1d44e64e 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Playlist.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Playlist.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import com.squareup.moshi.Json diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/PlaylistTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/PlaylistTrack.kt similarity index 95% rename from app/src/main/java/com/shabinder/spotiflyer/models/PlaylistTrack.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/PlaylistTrack.kt index 56a5d103..f5c5cac1 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/PlaylistTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/PlaylistTrack.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt new file mode 100644 index 00000000..ca609c28 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.models.spotify + +enum class Source { + Spotify, + YouTube, +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Token.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Token.kt similarity index 94% rename from app/src/main/java/com/shabinder/spotiflyer/models/Token.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Token.kt index c2fdb85b..10d35b3a 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Token.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Token.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt similarity index 88% rename from app/src/main/java/com/shabinder/spotiflyer/models/Track.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt index 01f8e984..0be692b4 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt @@ -15,9 +15,10 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable +import com.shabinder.spotiflyer.models.DownloadStatus import kotlinx.android.parcel.Parcelize @Parcelize @@ -39,5 +40,6 @@ data class Track( var album: Album? = null, var external_ids: Map? = null, var popularity: Int? = null, - var downloaded:DownloadStatus? = DownloadStatus.NotDownloaded):Parcelable + var downloaded: DownloadStatus? = DownloadStatus.NotDownloaded +):Parcelable diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/UserPrivate.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/UserPrivate.kt similarity index 96% rename from app/src/main/java/com/shabinder/spotiflyer/models/UserPrivate.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/UserPrivate.kt index 178798dd..fa090fec 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/UserPrivate.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/UserPrivate.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/UserPublic.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/UserPublic.kt similarity index 95% rename from app/src/main/java/com/shabinder/spotiflyer/models/UserPublic.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/spotify/UserPublic.kt index 06df67cd..5732dc12 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/UserPublic.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/UserPublic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.models +package com.shabinder.spotiflyer.models.spotify import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt b/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt new file mode 100644 index 00000000..34da4ad2 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.networking + +import com.shabinder.spotiflyer.models.Optional +import com.shabinder.spotiflyer.models.gaana.* +import retrofit2.http.GET +import retrofit2.http.Query + +const val gaana_token = "b2e6d7fbc136547a940516e9b77e5990" + +interface GaanaInterface { + + /* + * Api Request: http://api.gaana.com/?type=playlist&subtype=playlist_detail&seokey=gaana-dj-hindi-top-50-1&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON + * + * subtype : ["most_popular_playlist" , "playlist_home_featured" ,"playlist_detail" ,"user_playlist" ,"topCharts"] + **/ + @GET + suspend fun getGaanaPlaylist( + @Query("type") type: String = "playlist", + @Query("subtype") subtype: String = "playlist_detail", + @Query("seokey") seokey: String, + @Query("token") token: String = gaana_token, + @Query("format") format: String = "JSON", + @Query("limit") limit: Int = 2000 + ): Optional + + /* + * Api Request: http://api.gaana.com/?type=album&subtype=album_detail&seokey=kabir-singh&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON + * + * subtype : ["most_popular" , "new_release" ,"featured_album" ,"similar_album" ,"all_albums", "album" ,"album_detail" ,"album_detail_info"] + **/ + @GET + suspend fun getGaanaAlbum( + @Query("type") type: String = "album", + @Query("subtype") subtype: String = "album_detail", + @Query("seokey") seokey: String, + @Query("token") token: String = gaana_token, + @Query("format") format: String = "JSON", + @Query("limit") limit: Int = 2000 + ): Optional + + /* + * Api Request: http://api.gaana.com/?type=song&subtype=song_detail&seokey=pachtaoge&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON + * + * subtype : ["most_popular" , "hot_songs" ,"recommendation" ,"song_detail"] + **/ + @GET + suspend fun getGaanaSong( + @Query("type") type: String = "song", + @Query("subtype") subtype: String = "song_detail", + @Query("seokey") seokey: String, + @Query("token") token: String = gaana_token, + @Query("format") format: String = "JSON", + ): Optional + + /* + * Api Request: https://api.gaana.com/?type=artist&subtype=artist_details_info&seokey=neha-kakkar&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON + * + * subtype : ["most_popular" , "artist_list" ,"artist_track_listing" ,"artist_album" ,"similar_artist","artist_details" ,"artist_details_info"] + **/ + @GET + suspend fun getGaanaArtistDetails( + @Query("type") type: String = "artist", + @Query("subtype") subtype: String = "artist_details_info", + @Query("seokey") seokey: String, + @Query("token") token: String = gaana_token, + @Query("format") format: String = "JSON", + ): Optional + /* + * Api Request: http://api.gaana.com/?type=artist&subtype=artist_track_listing&seokey=neha-kakkar&limit=50&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON + * + * subtype : ["most_popular" , "artist_list" ,"artist_track_listing" ,"artist_album" ,"similar_artist","artist_details" ,"artist_details_info"] + **/ + @GET + suspend fun getGaanaArtistTracks( + @Query("type") type: String = "artist", + @Query("subtype") subtype: String = "artist_track_listing", + @Query("seokey") seokey: String, + @Query("token") token: String = gaana_token, + @Query("format") format: String = "JSON", + @Query("limit") limit: Int = 50 + ): Optional + +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/SpotifyInterface.kt b/app/src/main/java/com/shabinder/spotiflyer/networking/SpotifyInterface.kt similarity index 54% rename from app/src/main/java/com/shabinder/spotiflyer/utils/SpotifyInterface.kt rename to app/src/main/java/com/shabinder/spotiflyer/networking/SpotifyInterface.kt index 4758f80c..fcf469b5 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/SpotifyInterface.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/networking/SpotifyInterface.kt @@ -15,58 +15,41 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.utils +package com.shabinder.spotiflyer.networking -import com.shabinder.spotiflyer.models.* +import com.shabinder.spotiflyer.models.Optional +import com.shabinder.spotiflyer.models.spotify.* import retrofit2.http.* -/* -* Copyright (C) 2020 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 . -*/ - - interface SpotifyService { @GET("playlists/{playlist_id}") - suspend fun getPlaylist(@Path("playlist_id") playlistId: String?): Playlist + suspend fun getPlaylist(@Path("playlist_id") playlistId: String?): Optional @GET("playlists/{playlist_id}/tracks") suspend fun getPlaylistTracks( @Path("playlist_id") playlistId: String?, @Query("offset") offset: Int = 0, @Query("limit") limit: Int = 100 - ): PagingObjectPlaylistTrack + ): Optional @GET("tracks/{id}") - suspend fun getTrack(@Path("id") trackId: String?): Track + suspend fun getTrack(@Path("id") trackId: String?): Optional @GET("episodes/{id}") - suspend fun getEpisode(@Path("id") episodeId: String?): Track + suspend fun getEpisode(@Path("id") episodeId: String?): Optional @GET("shows/{id}") - suspend fun getShow(@Path("id") showId: String?): Track + suspend fun getShow(@Path("id") showId: String?): Optional @GET("albums/{id}") - suspend fun getAlbum(@Path("id") albumId: String?): Album + suspend fun getAlbum(@Path("id") albumId: String?): Optional } interface SpotifyServiceTokenRequest{ @POST("api/token") @FormUrlEncoded - suspend fun getToken(@Field("grant_type") grant_type:String = "client_credentials"):Token? + suspend fun getToken(@Field("grant_type") grant_type:String = "client_credentials"): Optional } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt b/app/src/main/java/com/shabinder/spotiflyer/networking/YoutubeMusicApi.kt similarity index 83% rename from app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt rename to app/src/main/java/com/shabinder/spotiflyer/networking/YoutubeMusicApi.kt index ccd72c67..e321d19a 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/YoutubeMusicApi.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/networking/YoutubeMusicApi.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.shabinder.spotiflyer.utils +package com.shabinder.spotiflyer.networking import com.beust.klaxon.JsonObject import retrofit2.Call @@ -25,21 +25,12 @@ import retrofit2.http.POST const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" -/*val body = """{ - "context": { - "client": { - "clientName": "WEB_REMIX", - "clientVersion": "0.1" - } - }, - "query": "songSearchQuery" -}"""*/ + interface YoutubeMusicApi { @Headers("Content-Type: application/json", "Referer: https://music.youtube.com/search") @POST("search?alt=json&key=$apiKey") - fun getYoutubeMusicResponse(@Body text: JsonObject): Call - + fun getYoutubeMusicResponse(@Body text: String): Call } fun makeJsonBody(query: String):JsonObject{ diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt index 277ee1f4..c469c589 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt @@ -18,6 +18,7 @@ package com.shabinder.spotiflyer.recyclerView import android.annotation.SuppressLint +import android.os.Environment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -27,23 +28,21 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.TrackListItemBinding -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadAllTracks +import com.shabinder.spotiflyer.downloadHelper.DownloadHelper.downloadAllTracks import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.Source -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.models.spotify.Track import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel +import com.shabinder.spotiflyer.utils.* import com.shabinder.spotiflyer.utils.Provider.activity -import com.shabinder.spotiflyer.utils.bindImage -import com.shabinder.spotiflyer.utils.rotateAnim import kotlinx.coroutines.launch +import java.io.File +class SpotifyTrackListAdapter(private val spotifyViewModel : SpotifyViewModel): ListAdapter(SpotifyTrackDiffCallback()) { -class SpotifyTrackListAdapter: ListAdapter(SpotifyTrackDiffCallback()) { - - var spotifyViewModel : SpotifyViewModel? = null var isAlbum:Boolean = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = TrackListItemBinding.inflate(layoutInflater,parent,false) @@ -55,9 +54,9 @@ class SpotifyTrackListAdapter: ListAdapter() - itemList.add(item) - downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList) + spotifyViewModel.uiScope.launch { + val itemList = mutableListOf() + itemList.add(item.let { track -> + val artistsList = mutableListOf() + track.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } + TrackDetails( + title = track.name.toString(), + artists = artistsList, + durationSec = (track.duration_ms/1000).toInt(), + albumArt = File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + (track.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), + albumName = track.album?.name, + year = track.album?.release_date, + comment = "Genres:${track.album?.genres?.joinToString()}", + trackUrl = track.href, + source = Source.Spotify + ) + } + ) + downloadAllTracks(spotifyViewModel.folderType,spotifyViewModel.subFolder,itemList) } notifyItemChanged(position)//start showing anim! } diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt index 0a98d88c..fc51cf8c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt @@ -20,19 +20,16 @@ package com.shabinder.spotiflyer.recyclerView import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.TrackListItemBinding import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.ui.youtube.YoutubeViewModel -import com.shabinder.spotiflyer.utils.Provider -import com.shabinder.spotiflyer.utils.bindImage -import com.shabinder.spotiflyer.utils.rotateAnim +import com.shabinder.spotiflyer.utils.* import kotlinx.coroutines.launch class YoutubeTrackListAdapter(private val youtubeViewModel :YoutubeViewModel): ListAdapter(YouTubeTrackDiffCallback()) { @@ -74,7 +71,11 @@ class YoutubeTrackListAdapter(private val youtubeViewModel :YoutubeViewModel): L holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow) holder.binding.btnDownload.clearAnimation() holder.binding.btnDownload.setOnClickListener{ - Toast.makeText(Provider.activity,"Processing!", Toast.LENGTH_SHORT).show() + if(!isOnline()){ + showNoConnectionAlert() + return@setOnClickListener + } + showMessage("Processing!") holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) rotateAnim(it) item.downloaded = DownloadStatus.Downloading diff --git a/app/src/main/java/com/shabinder/spotiflyer/samples/response examples.txt b/app/src/main/java/com/shabinder/spotiflyer/samples/response examples.txt index b181b4b1..750587d1 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/samples/response examples.txt +++ b/app/src/main/java/com/shabinder/spotiflyer/samples/response examples.txt @@ -1,5 +1,5 @@ D/Retrofit: <--- HTTP 200 https://api.spotify.com/v1/me/top/artists (7170ms) -2020-07-17 18:24:00.718 25414-25414/com.shabinder.musicforeveryone I/Network: [kaaes.spotify.webapi.android.models.Artist@4fae9ec, kaaes.spotify.webapi.android.models.Artist@aa3b1b5, kaaes.spotify.webapi.android.models.Artist@ed6004a, kaaes.spotify.webapi.android.models.Artist@870dbbb, kaaes.spotify.webapi.android.models.Artist@8a2b8d8, kaaes.spotify.webapi.android.models.Artist@aab431, kaaes.spotify.webapi.android.models.Artist@a7bd716, kaaes.spotify.webapi.android.models.Artist@3477897, kaaes.spotify.webapi.android.models.Artist@7f68a84] +2020-07-17 18:24:00.718 25414-25414/com.shabinder.musicforeveryone I/Network: [kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@4fae9ec, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@aa3b1b5, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@ed6004a, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@870dbbb, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@8a2b8d8, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@aab431, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@a7bd716, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@3477897, kaaes.spotify.webapi.android.models.com.shabinder.spotiflyer.models.gaana.Artist@7f68a84] I/Network: https://api.spotify.com/v1/artists/7vk5e3vY1uw9plTHJAMwjN diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt new file mode 100644 index 00000000..7f80f9ca --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.ui.gaana + +import android.content.BroadcastReceiver +import android.content.IntentFilter +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.shabinder.spotiflyer.R +import com.shabinder.spotiflyer.SharedViewModel +import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding +import com.shabinder.spotiflyer.networking.GaanaInterface +import com.shabinder.spotiflyer.networking.YoutubeMusicApi +import javax.inject.Inject + +class GaanaFragment : Fragment() { + + private lateinit var binding: TrackListFragmentBinding + private lateinit var sharedViewModel: SharedViewModel + @Inject lateinit var youtubeMusicApi: YoutubeMusicApi + private lateinit var viewModel: GaanaViewModel + @Inject lateinit var gaanaInterface: GaanaInterface + private var intentFilter: IntentFilter? = null + private var updateUIReceiver: BroadcastReceiver? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment, container, false) + viewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) + return binding.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt new file mode 100644 index 00000000..e133edb7 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.ui.gaana + +import androidx.hilt.lifecycle.ViewModelInject +import androidx.lifecycle.ViewModel +import com.shabinder.spotiflyer.database.DatabaseDAO + +class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : ViewModel() \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt index b2accaf3..37779f6c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt @@ -25,14 +25,18 @@ import android.text.SpannableStringBuilder import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController +import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.MainFragmentBinding +import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.utils.isOnline +import com.shabinder.spotiflyer.utils.showMessage +import com.shabinder.spotiflyer.utils.showNoConnectionAlert import com.shreyaspatil.easyupipayment.EasyUpiPayment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -55,14 +59,20 @@ class MainFragment : Fragment() { ): View? { binding = DataBindingUtil.inflate(inflater,R.layout.main_fragment,container,false) initializeAll() - binding.btnSearch.setOnClickListener { + if(!isOnline()){ + showNoConnectionAlert() + return@setOnClickListener + } val link = binding.linkSearch.text.toString() if (link.contains("spotify",true)){ + if(sharedViewModel.spotifyService.value == null){//Authentication pending!! + (activity as MainActivity).authenticateSpotify() + } findNavController().navigate(MainFragmentDirections.actionMainFragmentToSpotifyFragment(link)) }else if(link.contains("youtube.com",true) || link.contains("youtu.be",true) ){ findNavController().navigate(MainFragmentDirections.actionMainFragmentToYoutubeFragment(link)) - }else{Toast.makeText(context,"Link is Not Valid",Toast.LENGTH_SHORT).show()} + }else showMessage("Link is Not Valid",true) } handleIntent() return binding.root @@ -97,10 +107,15 @@ class MainFragment : Fragment() { sharedViewModel.intentString.observe(viewLifecycleOwner,{ if(it != ""){ sharedViewModel.uiScope.launch(Dispatchers.IO) { - while (sharedViewModel.accessToken.value == "") { - //Waiting for Authentication to Finish - Thread.sleep(1000) + if(sharedViewModel.spotifyService.value == null){ + //Not Authenticated Yet + Provider.activity.authenticateSpotify() + while (sharedViewModel.spotifyService.value == null) { + //Waiting for Authentication to Finish + Thread.sleep(1000) + } } + withContext(Dispatchers.Main){ binding.linkSearch.setText(sharedViewModel.intentString.value) binding.btnSearch.performClick() diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index 24c7d99c..3c3d9052 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -22,46 +22,43 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.ConnectivityManager import android.os.Bundle +import android.os.Environment import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.SimpleItemAnimator import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel -import com.shabinder.spotiflyer.databinding.SpotifyFragmentBinding -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding +import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.Source -import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter -import com.shabinder.spotiflyer.utils.YoutubeMusicApi -import com.shabinder.spotiflyer.utils.bindImage -import com.shabinder.spotiflyer.utils.loadAllImages -import com.shabinder.spotiflyer.utils.rotateAnim +import com.shabinder.spotiflyer.utils.* +import com.shabinder.spotiflyer.utils.Provider.defaultDir import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.File import javax.inject.Inject @Suppress("DEPRECATION") @AndroidEntryPoint class SpotifyFragment : Fragment() { - private lateinit var binding:SpotifyFragmentBinding - private lateinit var spotifyViewModel: SpotifyViewModel + private lateinit var binding:TrackListFragmentBinding private lateinit var sharedViewModel: SharedViewModel - private lateinit var adapterSpotify:SpotifyTrackListAdapter @Inject lateinit var youtubeMusicApi: YoutubeMusicApi + private lateinit var viewModel: SpotifyViewModel + private lateinit var adapter:SpotifyTrackListAdapter private var intentFilter:IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null @@ -71,8 +68,7 @@ class SpotifyFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = DataBindingUtil.inflate(inflater,R.layout.spotify_fragment,container,false) - adapterSpotify = SpotifyTrackListAdapter() + binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment,container,false) initializeAll() initializeLiveDataObservers() initializeBroadcast() @@ -88,34 +84,35 @@ class SpotifyFragment : Fragment() { if(sharedViewModel.spotifyService.value == null){//Authentication pending!! (activity as MainActivity).authenticateSpotify() } - if(!isOnline()){//Device Offline - sharedViewModel.showAlertDialog(resources,requireContext()) - }else if (type == "Error" || link == "Error") {//Incorrect Link - showToast("Please Check Your Link!") + if (type == "Error" || link == "Error") {//Incorrect Link + showMessage("Please Check Your Link!") }else if(spotifyLink.contains("open.spotify",true)){//Link Validation!! if(type == "episode" || type == "show"){//TODO Implementation - showToast("Implementing Soon, Stay Tuned!") + showMessage("Implementing Soon, Stay Tuned!") } else{ - spotifyViewModel.spotifySearch(type,link) - if(type=="album")adapterSpotify.isAlbum = true + viewModel.spotifySearch(type,link) + if(type=="album")adapter.isAlbum = true binding.btnDownloadAll.setOnClickListener { - + if(!isOnline()){ + showNoConnectionAlert() + return@setOnClickListener + } binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.visibility = View.VISIBLE rotateAnim(binding.downloadingFab) - for (track in spotifyViewModel.trackList.value!!){ + for (track in viewModel.trackList.value!!){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapterSpotify.notifyItemChanged(spotifyViewModel.trackList.value!!.indexOf(track)) + adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) } } - showToast("Processing!") + showMessage("Processing!") sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() - spotifyViewModel.trackList.value?.forEach { urlList.add(it.album?.images?.get(0)?.url.toString()) } + viewModel.trackList.value?.forEach { urlList.add(it.album?.images?.get(0)?.url.toString()) } //Appending Source urlList.add("spotify") loadAllImages( @@ -123,11 +120,29 @@ class SpotifyFragment : Fragment() { urlList ) } - spotifyViewModel.uiScope.launch { - SpotifyDownloadHelper.downloadAllTracks( - spotifyViewModel.folderType, - spotifyViewModel.subFolder, - spotifyViewModel.trackList.value!!, + viewModel.uiScope.launch { + val finalList = viewModel.trackList.value?.map{ + val artistsList = mutableListOf() + it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } + TrackDetails( + title = it.name.toString(), + artists = artistsList, + durationSec = (it.duration_ms/1000).toInt(), + albumArt = File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + (it.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), + albumName = it.album?.name, + year = it.album?.release_date, + comment = "Genres:${it.album?.genres?.joinToString()}", + trackUrl = it.href, + source = Source.Spotify + ) + } + if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") + DownloadHelper.downloadAllTracks( + viewModel.folderType, + viewModel.subFolder, + finalList ?: listOf(), ) } } @@ -136,43 +151,23 @@ class SpotifyFragment : Fragment() { return binding.root } - override fun onResume() { - super.onResume() - initializeBroadcast() + /** + * Basic Initialization + **/ + private fun initializeAll() { + sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) + viewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) + sharedViewModel.spotifyService.observe(viewLifecycleOwner, { + viewModel.spotifyService = it + }) + adapter = SpotifyTrackListAdapter(viewModel) + DownloadHelper.youtubeMusicApi = youtubeMusicApi + DownloadHelper.sharedViewModel = sharedViewModel + DownloadHelper.statusBar = binding.statusBar + binding.trackList.adapter = adapter + (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } - private fun initializeBroadcast() { - intentFilter = IntentFilter() - intentFilter?.addAction("track_download_completed") - - updateUIReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - //UI update here - if (intent != null){ - val trackDetails = intent.getParcelableExtra("track") - trackDetails?.let { - val position: Int = spotifyViewModel.trackList.value?.map { it.name }?.indexOf(trackDetails.title) ?: -1 - Log.i("Track","Download Completed Intent :$position") - if(position != -1) { - val track = spotifyViewModel.trackList.value?.get(position) - track?.let{ - it.downloaded = DownloadStatus.Downloaded - spotifyViewModel.trackList.value?.set(position, it) - adapterSpotify.notifyItemChanged(position) - checkIfAllDownloaded() - } - } - } - } - } - } - requireActivity().registerReceiver(updateUIReceiver, intentFilter) - } - - override fun onPause() { - super.onPause() - requireActivity().unregisterReceiver(updateUIReceiver) - } /** *Live Data Observers @@ -181,17 +176,17 @@ class SpotifyFragment : Fragment() { /** * CoverUrl Binding Observer! **/ - spotifyViewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.coverImage,it,Source.Spotify) + viewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it, Source.Spotify) }) /** * TrackList Binding Observer! **/ - spotifyViewModel.trackList.observe(viewLifecycleOwner, { + viewModel.trackList.observe(viewLifecycleOwner, { if (it.isNotEmpty()){ Log.i("SpotifyFragment","TrackList Updated") - adapterConfig(it) + adapter.submitList(it) checkIfAllDownloaded() } }) @@ -199,7 +194,7 @@ class SpotifyFragment : Fragment() { /** * Title Binding Observer! **/ - spotifyViewModel.title.observe(viewLifecycleOwner, { + viewModel.title.observe(viewLifecycleOwner, { binding.titleView.text = it }) @@ -213,7 +208,7 @@ class SpotifyFragment : Fragment() { } private fun checkIfAllDownloaded() { - if(!spotifyViewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ //All Tracks Downloaded binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.apply{ @@ -224,47 +219,41 @@ class SpotifyFragment : Fragment() { } } } + private fun initializeBroadcast() { + intentFilter = IntentFilter() + intentFilter?.addAction("track_download_completed") - /** - * Basic Initialization - **/ - private fun initializeAll() { - sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - spotifyViewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) - sharedViewModel.spotifyService.observe(viewLifecycleOwner, Observer { - spotifyViewModel.spotifyService = it - }) - SpotifyDownloadHelper.youtubeMusicApi = youtubeMusicApi - SpotifyDownloadHelper.sharedViewModel = sharedViewModel - SpotifyDownloadHelper.statusBar = binding.statusBar - binding.trackList.adapter = adapterSpotify - (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + updateUIReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + //UI update here + if (intent != null){ + val trackDetails = intent.getParcelableExtra("track") + trackDetails?.let { + val position: Int = viewModel.trackList.value?.map { it.name }?.indexOf(trackDetails.title) ?: -1 + Log.i("Track","Download Completed Intent :$position") + if(position != -1) { + val track = viewModel.trackList.value?.get(position) + track?.let{ + it.downloaded = DownloadStatus.Downloaded + viewModel.trackList.value?.set(position, it) + adapter.notifyItemChanged(position) + checkIfAllDownloaded() + } + } + } + } + } + } + requireActivity().registerReceiver(updateUIReceiver, intentFilter) } - /** - * Configure Recycler View Adapter - **/ - private fun adapterConfig(trackList: List){ - adapterSpotify.spotifyViewModel = spotifyViewModel - adapterSpotify.submitList(trackList) + override fun onResume() { + super.onResume() + initializeBroadcast() } - - /** - * Util. Function to create toasts! - **/ - private fun showToast(message:String){ - Toast.makeText(context,message,Toast.LENGTH_SHORT).show() + override fun onPause() { + super.onPause() + requireActivity().unregisterReceiver(updateUIReceiver) } - - /** - * Util. Function To Check Connection Status - **/ - private fun isOnline(): Boolean { - val cm = - requireActivity().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val netInfo = cm.activeNetworkInfo - return netInfo != null && netInfo.isConnectedOrConnecting - } - } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt index 2ab721a6..f93ce2b9 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt @@ -23,8 +23,9 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecord -import com.shabinder.spotiflyer.models.* -import com.shabinder.spotiflyer.utils.SpotifyService +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.spotify.* +import com.shabinder.spotiflyer.networking.SpotifyService import com.shabinder.spotiflyer.utils.finalOutputDir import kotlinx.coroutines.* import java.io.File @@ -153,19 +154,19 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO private suspend fun getTrackDetails(trackLink:String): Track?{ Log.i("Requesting","https://api.spotify.com/v1/tracks/$trackLink") - return spotifyService?.getTrack(trackLink) + return spotifyService?.getTrack(trackLink)?.value } private suspend fun getAlbumDetails(albumLink:String): Album?{ Log.i("Requesting","https://api.spotify.com/v1/albums/$albumLink") - return spotifyService?.getAlbum(albumLink) + return spotifyService?.getAlbum(albumLink)?.value } private suspend fun getPlaylistDetails(link:String): Playlist?{ Log.i("Requesting","https://api.spotify.com/v1/playlists/$link") - return spotifyService?.getPlaylist(link) + return spotifyService?.getPlaylist(link)?.value } private suspend fun getPlaylistTrackDetails(link:String,offset:Int = 0,limit:Int = 100): PagingObjectPlaylistTrack?{ Log.i("Requesting","https://api.spotify.com/v1/playlists/$link/tracks?offset=$offset&limit=$limit") - return spotifyService?.getPlaylistTracks(link, offset, limit) + return spotifyService?.getPlaylistTracks(link, offset, limit)?.value } override fun onCleared() { diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index 7f382b40..dcf3737c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -26,22 +26,19 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel -import com.shabinder.spotiflyer.databinding.YoutubeFragmentBinding +import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.recyclerView.YoutubeTrackListAdapter -import com.shabinder.spotiflyer.utils.bindImage -import com.shabinder.spotiflyer.utils.loadAllImages -import com.shabinder.spotiflyer.utils.rotateAnim +import com.shabinder.spotiflyer.utils.* import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -50,24 +47,24 @@ import javax.inject.Inject @AndroidEntryPoint class YoutubeFragment : Fragment() { - private lateinit var binding:YoutubeFragmentBinding - private lateinit var youtubeViewModel: YoutubeViewModel + private lateinit var binding: TrackListFragmentBinding + private lateinit var viewModel: YoutubeViewModel private lateinit var sharedViewModel: SharedViewModel @Inject lateinit var ytDownloader: YoutubeDownloader private lateinit var adapter : YoutubeTrackListAdapter - private val sampleDomain1 = "youtube.com" - private val sampleDomain2 = "youtu.be" private var intentFilter: IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null + private val sampleDomain2 = "youtu.be" + private val sampleDomain1 = "youtube.com" override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - binding = DataBindingUtil.inflate(inflater,R.layout.youtube_fragment,container,false) - youtubeViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) + binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment,container,false) + viewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - adapter = YoutubeTrackListAdapter(youtubeViewModel) + adapter = YoutubeTrackListAdapter(viewModel) binding.trackList.adapter = adapter initializeLiveDataObservers() @@ -84,7 +81,7 @@ class YoutubeFragment : Fragment() { if(link.contains("playlist",true) || link.contains("list",true)){ // Given Link is of a Playlist val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&") - youtubeViewModel.getYTPlaylist(playlistId,ytDownloader) + viewModel.getYTPlaylist(playlistId,ytDownloader) }else{//Given Link is of a Video var searchId = "error" if(link.contains(sampleDomain1,true) ){ @@ -94,29 +91,33 @@ class YoutubeFragment : Fragment() { searchId = link.substringAfterLast("/","error") } if(searchId != "error") { - youtubeViewModel.getYTTrack(searchId,ytDownloader) - }else{showToast("Your Youtube Link is not of a Video!!")} + viewModel.getYTTrack(searchId,ytDownloader) + }else{showMessage("Your Youtube Link is not of a Video!!")} } /* * Download All Tracks * */ binding.btnDownloadAll.setOnClickListener { + if(!isOnline()){ + showNoConnectionAlert() + return@setOnClickListener + } binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.visibility = View.VISIBLE rotateAnim(binding.downloadingFab) - for (track in youtubeViewModel.ytTrackList.value?: listOf()){ + for (track in viewModel.ytTrackList.value?: listOf()){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(youtubeViewModel.ytTrackList.value!!.indexOf(track)) + adapter.notifyItemChanged(viewModel.ytTrackList.value!!.indexOf(track)) } } - showToast("Processing!") + showMessage("Processing!") sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() - youtubeViewModel.ytTrackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") + viewModel.ytTrackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") .substringBeforeLast(".")}/hqdefault.jpg")} //Appending Source urlList.add("youtube") @@ -125,11 +126,11 @@ class YoutubeFragment : Fragment() { urlList ) } - youtubeViewModel.uiScope.launch { + viewModel.uiScope.launch { YTDownloadHelper.downloadYTTracks( - type = youtubeViewModel.folderType, - subFolder = youtubeViewModel.subFolder, - tracks = youtubeViewModel.ytTrackList.value ?: listOf() + type = viewModel.folderType, + subFolder = viewModel.subFolder, + tracks = viewModel.ytTrackList.value ?: listOf() ) } } @@ -149,13 +150,13 @@ class YoutubeFragment : Fragment() { if (intent != null){ val trackDetails = intent.getParcelableExtra("track") trackDetails?.let { - val position: Int = youtubeViewModel.ytTrackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 + val position: Int = viewModel.ytTrackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 Log.i("Track","Download Completed Intent :$position") if(position != -1) { - val track = youtubeViewModel.ytTrackList.value?.get(position) + val track = viewModel.ytTrackList.value?.get(position) track?.let{ it.downloaded = DownloadStatus.Downloaded - youtubeViewModel.ytTrackList.value?.set(position, it) + viewModel.ytTrackList.value?.set(position, it) adapter.notifyItemChanged(position) checkIfAllDownloaded() } @@ -173,7 +174,7 @@ class YoutubeFragment : Fragment() { } private fun checkIfAllDownloaded() { - if(!youtubeViewModel.ytTrackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + if(!viewModel.ytTrackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ //All Tracks Downloaded binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.apply{ @@ -188,38 +189,23 @@ class YoutubeFragment : Fragment() { /** * CoverUrl Binding Observer! **/ - youtubeViewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.coverImage,it,Source.YouTube) + viewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it, Source.YouTube) }) /** * TrackList Binding Observer! **/ - youtubeViewModel.ytTrackList.observe(viewLifecycleOwner, { - adapterConfig(it) + viewModel.ytTrackList.observe(viewLifecycleOwner, { + adapter.submitList(it) }) /** * Title Binding Observer! **/ - youtubeViewModel.title.observe(viewLifecycleOwner, { + viewModel.title.observe(viewLifecycleOwner, { binding.titleView.text = it }) } - - /** - * Configure Recycler View Adapter - **/ - private fun adapterConfig(list:List){ - adapter.submitList(list) - } - - /** - * Util. Function to create toasts! - **/ - private fun showToast(message:String){ - Toast.makeText(context,message, Toast.LENGTH_SHORT).show() - } - } diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt index dedfb6e0..35f6b11d 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt @@ -24,17 +24,15 @@ import androidx.hilt.lifecycle.ViewModelInject import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.github.kiulian.downloader.YoutubeDownloader -import com.github.kiulian.downloader.model.formats.Format import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecord import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.TrackDetails -import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.utils.Provider.defaultDir -import com.shabinder.spotiflyer.utils.Provider.showToast import com.shabinder.spotiflyer.utils.finalOutputDir import com.shabinder.spotiflyer.utils.removeIllegalChars +import com.shabinder.spotiflyer.utils.showMessage import kotlinx.coroutines.* import java.io.File @@ -47,7 +45,6 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO * */ val ytTrackList = MutableLiveData>() - val format = MutableLiveData() private val loading = "Loading" var title = MutableLiveData().apply { value = "\"Loading!\"" } var coverUrl = MutableLiveData().apply { value = loading } @@ -108,7 +105,7 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO } } }catch (e:com.github.kiulian.downloader.YoutubeException.BadPageException){ - showToast("An Error Occurred While Processing!") + showMessage("An Error Occurred While Processing!") } } @@ -124,16 +121,18 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() ?: "" Log.i("YT View Model",detail.toString()) ytTrackList.postValue( - listOf(TrackDetails( + listOf( + TrackDetails( title = name, artists = listOf(detail?.author().toString()), durationSec = detail?.lengthSeconds()?:0, albumArt = File( Environment.getExternalStorageDirectory(), - Provider.defaultDir +".Images/" + searchId + ".jpeg" + defaultDir +".Images/" + searchId + ".jpeg" ), source = Source.YouTube - )).toMutableList() + ) + ).toMutableList() ) title.postValue( if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} @@ -152,7 +151,7 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO } } } catch (e:com.github.kiulian.downloader.YoutubeException){ - showToast("An Error Occurred While Processing!") + showMessage("An Error Occurred While Processing!") } } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/NetworkInterceptor.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/NetworkInterceptor.kt new file mode 100644 index 00000000..b137c203 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/NetworkInterceptor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.utils + +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody + +const val NoInternetErrorCode = 222 + +class NetworkInterceptor: Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return if (!isOnline()){ + //No Internet Connection + showNoConnectionAlert() + //Lets Stop the Incoming Request + Response.Builder() + .code(NoInternetErrorCode) // code(200.300) = successful else = unsuccessful + .body("{}".toResponseBody(null)) // Whatever body + .protocol(Protocol.HTTP_2) + .message("No Internet Connection") + .request(chain.request()) + .build() + }else { + val response = chain.proceed(chain.request()) + val responseBody = response.body + val bodyString = responseBody?.string() + //Log.i("Network Request",bodyString) + //chain.proceed(chain.request()) + //Log.i("Network Request","{\"unchecked\":${bodyString}}") + Response.Builder() + .code(response.code) // code(200.300) = successful else = unsuccessful + .body("{\"value\":${bodyString}}".toResponseBody(responseBody?.contentType())) // Whatever body + .protocol(response.protocol) + .message(response.message) + .request(chain.request()) + .build() + } + } + /* + * Converts REQUEST's Body to String + * */ + private fun RequestBody?.bodyToString(): String { + if (this == null) return "" + val buffer = okio.Buffer() + writeTo(buffer) + return buffer.readUtf8() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 84863e5f..aad87022 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -19,12 +19,14 @@ package com.shabinder.spotiflyer.utils import android.content.Context import android.os.Environment -import android.widget.Toast import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.App import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecordDatabase +import com.shabinder.spotiflyer.networking.GaanaInterface +import com.shabinder.spotiflyer.networking.SpotifyServiceTokenRequest +import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shreyaspatil.easyupipayment.EasyUpiPayment import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory @@ -37,12 +39,12 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory import java.io.File import javax.inject.Singleton + @InstallIn(ApplicationComponent::class) @Module object Provider { @@ -66,7 +68,7 @@ object Provider { @Provides @Singleton fun provideUpi():EasyUpiPayment { - return EasyUpiPayment.Builder(MainActivity.getInstance()) + return EasyUpiPayment.Builder(activity) .setPayeeVpa("technoshab@paytm") .setPayeeName("Shabinder Singh") .setTransactionId("UNIQUE_TRANSACTION_ID") @@ -86,39 +88,59 @@ object Provider { @Provides @Singleton - fun getSpotifyTokenInterface():SpotifyServiceTokenRequest{ + fun getSpotifyTokenInterface(moshi: Moshi): SpotifyServiceTokenRequest { val httpClient2: OkHttpClient.Builder = OkHttpClient.Builder() - httpClient2.addInterceptor(Interceptor { chain -> + .addInterceptor(Interceptor { chain -> val request: Request = - chain.request().newBuilder().addHeader( + chain.request().newBuilder() + .addHeader( "Authorization", - "Basic ${android.util.Base64.encodeToString("${App.clientId}:${App.clientSecret}".toByteArray(),android.util.Base64.NO_WRAP)}" + "Basic ${ + android.util.Base64.encodeToString( + "${App.clientId}:${App.clientSecret}".toByteArray(), + android.util.Base64.NO_WRAP + ) + }" ).build() chain.proceed(request) - }) + }).addInterceptor(NetworkInterceptor()) val retrofit = Retrofit.Builder() .baseUrl("https://accounts.spotify.com/") .client(httpClient2.build()) - .addConverterFactory(MoshiConverterFactory.create(getMoshi())) + .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() return retrofit.create(SpotifyServiceTokenRequest::class.java) } @Provides @Singleton - fun getYoutubeMusicApi():YoutubeMusicApi{ + fun okHttpClient():OkHttpClient{ + return OkHttpClient.Builder() + .addInterceptor(NetworkInterceptor()) + .build() + } + @Provides + @Singleton + fun getGaanaInterface(moshi: Moshi,okHttpClient: OkHttpClient):GaanaInterface{ + val retrofit = Retrofit.Builder() + .baseUrl("http://api.gaana.com/") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + return retrofit.create(GaanaInterface::class.java) + } + + @Provides + @Singleton + fun getYoutubeMusicApi(moshi: Moshi): YoutubeMusicApi { val retrofit = Retrofit.Builder() .baseUrl("https://music.youtube.com/youtubei/v1/") .addConverterFactory(ScalarsConverterFactory.create()) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() - return retrofit.create(YoutubeMusicApi::class.java) } - fun showToast(string: String,long:Boolean=false){ - Toast.makeText(activity,string,if(long)Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() - } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt index 77c84308..e4e62be6 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -19,6 +19,9 @@ package com.shabinder.spotiflyer.utils import android.content.Context import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build import android.os.Environment import android.util.Log import android.view.View @@ -33,9 +36,12 @@ import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.Provider.defaultDir import com.shabinder.spotiflyer.worker.ForegroundService import kotlinx.coroutines.CoroutineScope @@ -64,6 +70,48 @@ fun finalOutputDir(itemName:String? = null,type:String, subFolder:String?=null,e + itemName?.let { removeIllegalChars(it) + extension}) } +/** + * Util. Function To Check Connection Status + **/ +@Suppress("DEPRECATION") +fun isOnline(): Boolean { + var result = false + val connectivityManager = + activity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? + connectivityManager?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + it.getNetworkCapabilities(connectivityManager.activeNetwork)?.apply { + result = when { + hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + else -> false + } + } + } else { + val netInfo = + (activity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo + result = netInfo != null && netInfo.isConnected + } + } + return result +} + +fun showMessage(message: String, long: Boolean = false){ + CoroutineScope(Dispatchers.Main).launch{ + Snackbar.make( + activity.snackBarAnchor, + message, + if (long) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT + ).also { snackbar -> + snackbar.setAction("Ok") { + snackbar.dismiss() + } + }.show() + } +} + + fun rotateAnim(view: View){ val rotate = RotateAnimation( 0F, 360F, @@ -76,6 +124,18 @@ fun rotateAnim(view: View){ view.animation = rotate } +fun showNoConnectionAlert(){ + CoroutineScope(Dispatchers.Main).launch { + activity.apply { + MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) + .setTitle(resources.getString(R.string.title)) + .setMessage(resources.getString(R.string.supporting_text)) + .setPositiveButton(resources.getString(R.string.cancel)) { _, _ -> + // Respond to neutral button press + }.show() + } + } +} fun bindImage(imgView: ImageView, imgUrl: String?,source: Source?) { imgUrl?.let { val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() diff --git a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt index 9dcf145e..4cddc100 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt @@ -71,7 +71,7 @@ class ForegroundService : Service(){ private lateinit var downloadManager : DownloadManager private var serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) - private val requestMap = mutableMapOf() + private val requestMap = mutableMapOf() private var speed :Long = 0 private var defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator private val parentDirectory = File(Environment.getExternalStorageDirectory(), @@ -458,7 +458,7 @@ class ForegroundService : Service(){ } /** - *Modifying Mp3 Tags with MetaData! + *Modifying Mp3 com.shabinder.spotiflyer.models.gaana.Tags with MetaData! **/ private fun setId3v1Tags(mp3File: Mp3File, track: TrackDetails): Mp3File { val id3v1Tag = ID3v1Tag().apply { diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index c90179a2..0120a3f3 100755 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -23,19 +23,15 @@ - - + type="com.shabinder.spotiflyer.models.spotify.Track" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/navigation/navigation.xml b/app/src/main/res/navigation/navigation.xml index a333ae27..6c8b8bb3 100755 --- a/app/src/main/res/navigation/navigation.xml +++ b/app/src/main/res/navigation/navigation.xml @@ -26,7 +26,7 @@ android:id="@+id/spotifyFragment" android:name="com.shabinder.spotiflyer.ui.spotify.SpotifyFragment" android:label="main_fragment" - tools:layout="@layout/spotify_fragment" > + tools:layout="@layout/track_list_fragment" > @@ -56,7 +56,7 @@ android:id="@+id/youtubeFragment" android:name="com.shabinder.spotiflyer.ui.youtube.YoutubeFragment" android:label="YoutubeFragment" - tools:layout="@layout/youtube_fragment"> + tools:layout="@layout/track_list_fragment"> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f54381ef..1801bfbb 100755 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -21,7 +21,6 @@ #000000 #FC5C7D - #000000 #FFFFFF #6A82FB #A9B200FF @@ -29,7 +28,7 @@ @style/TextAppearance.AppTheme.Headline4 - + @style/TextAppearance.MaterialComponents.Body2 @@ -38,18 +37,22 @@ @style/CutShapeAppearance @style/Alert.Button.Positive @style/Alert.Button.Neutral + 22sp + @font/amita diff --git a/build.gradle b/build.gradle index d5e5db36..ea0f8eaa 100755 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ buildscript { //safe-Args classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" -// classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } From ac423623e6b93eaa67f2446520f52449eb1efc11 Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 10:56:31 +0530 Subject: [PATCH 05/14] Clean Up --- app/build.gradle | 2 +- .../com/shabinder/spotiflyer/MainActivity.kt | 32 +++++++++++++------ .../ui/mainfragment/MainFragment.kt | 3 ++ .../spotiflyer/ui/spotify/SpotifyFragment.kt | 26 +++------------ .../spotiflyer/ui/youtube/YoutubeFragment.kt | 1 - .../shabinder/spotiflyer/utils/Provider.kt | 2 +- .../res/layout/download_record_fragment.xml | 2 ++ app/src/main/res/layout/main_activity.xml | 2 +- .../main/res/layout/track_list_fragment.xml | 1 - 9 files changed, 34 insertions(+), 37 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3382dc5f..96594a07 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -123,7 +123,7 @@ dependencies { implementation 'com.mpatric:mp3agic:0.9.1' implementation 'com.shreyaspatil:EasyUpiPayment:3.0.0' - implementation 'com.github.sealedtx:java-youtube-downloader:2.4.3' + implementation 'com.github.sealedtx:java-youtube-downloader:2.4.4' implementation "androidx.tonyodev.fetch2:xfetch2:3.1.5" implementation 'com.github.javiersantos:AppUpdater:2.7' diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index bf6e941f..2e96f354 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -21,7 +21,6 @@ import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle @@ -33,13 +32,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.findNavController import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.enums.UpdateFrom import com.shabinder.spotiflyer.databinding.MainActivityBinding import com.shabinder.spotiflyer.networking.SpotifyService import com.shabinder.spotiflyer.networking.SpotifyServiceTokenRequest import com.shabinder.spotiflyer.utils.NetworkInterceptor -import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.createDirectories import com.shabinder.spotiflyer.utils.isOnline import com.shabinder.spotiflyer.utils.startService @@ -53,25 +53,28 @@ import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Inject +/* +* This is App's God Activity +* */ @Suppress("DEPRECATION") @AndroidEntryPoint class MainActivity : AppCompatActivity(){ private var spotifyService : SpotifyService? = null - private var sharedPref :SharedPreferences? = null private lateinit var binding: MainActivityBinding lateinit var snackBarAnchor: View private lateinit var sharedViewModel: SharedViewModel + private lateinit var navController: NavController @Inject lateinit var moshi: Moshi @Inject lateinit var spotifyServiceTokenRequest: SpotifyServiceTokenRequest override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.main_activity) - snackBarAnchor = binding.snackBarPosition sharedViewModel = ViewModelProvider(this).get(SharedViewModel::class.java) + navController = findNavController(R.id.navHostFragment) + snackBarAnchor = binding.snackBarPosition //Enabling Dark Mode AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - sharedPref = this.getPreferences(Context.MODE_PRIVATE) authenticateSpotify() @@ -89,7 +92,8 @@ class MainActivity : AppCompatActivity(){ override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - Log.i("NEW INTENT", "Received") + //Return to MainFragment For Further Processing of this Intent + navController.popBackStack(R.id.mainFragment,false) handleIntentFromExternalActivity(intent) } @@ -100,9 +104,10 @@ class MainActivity : AppCompatActivity(){ this.getSystemService(Context.POWER_SERVICE) as PowerManager val isIgnoringBatteryOptimizations = pm.isIgnoringBatteryOptimizations(packageName) if (!isIgnoringBatteryOptimizations) { - val intent = Intent() - intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - intent.data = Uri.parse("package:$packageName") + val intent = Intent().apply{ + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:$packageName") + } startActivityForResult(intent, 1233) } } @@ -188,6 +193,8 @@ class MainActivity : AppCompatActivity(){ .setUpdateFrom(UpdateFrom.XML) .setUpdateXML("https://raw.githubusercontent.com/Shabinder/SpotiFlyer/master/app/src/main/res/xml/app_update.xml") .setCancelable(false) + .setButtonDoNotShowAgain("Remind Later") + .setButtonDoNotShowAgainClickListener { dialog, _ -> dialog.dismiss() } .setButtonUpdateClickListener { _, _ -> val uri: Uri = Uri.parse("http://github.com/Shabinder/SpotiFlyer/releases") @@ -200,7 +207,12 @@ class MainActivity : AppCompatActivity(){ appUpdater.start() } + companion object{ + private lateinit var instance: MainActivity + fun getInstance():MainActivity = instance + } + init { - activity = this + instance = this } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt index 37779f6c..47e631c7 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt @@ -107,6 +107,9 @@ class MainFragment : Fragment() { sharedViewModel.intentString.observe(viewLifecycleOwner,{ if(it != ""){ sharedViewModel.uiScope.launch(Dispatchers.IO) { + //Wait for any Authentication to Finish , + // this Wait prevents from multiple Authentication Requests + Thread.sleep(1000) if(sharedViewModel.spotifyService.value == null){ //Not Authenticated Yet Provider.activity.authenticateSpotify() diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index 3c3d9052..97876314 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -173,16 +173,6 @@ class SpotifyFragment : Fragment() { *Live Data Observers **/ private fun initializeLiveDataObservers() { - /** - * CoverUrl Binding Observer! - **/ - viewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.coverImage,it, Source.Spotify) - }) - - /** - * TrackList Binding Observer! - **/ viewModel.trackList.observe(viewLifecycleOwner, { if (it.isNotEmpty()){ Log.i("SpotifyFragment","TrackList Updated") @@ -191,19 +181,12 @@ class SpotifyFragment : Fragment() { } }) - /** - * Title Binding Observer! - **/ - viewModel.title.observe(viewLifecycleOwner, { - binding.titleView.text = it + viewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it, Source.Spotify) }) - sharedViewModel.intentString.observe(viewLifecycleOwner,{ - //Waiting for Authentication to Finish with Spotify()Access Token Observe - if(it != "" && it!=SpotifyFragmentArgs.fromBundle(requireArguments()).link){ - //New Intent Received , Time TO RELOAD - (activity as MainActivity).onBackPressed() - } + viewModel.title.observe(viewLifecycleOwner, { + binding.titleView.text = it }) } @@ -215,7 +198,6 @@ class SpotifyFragment : Fragment() { setImageResource(R.drawable.ic_tick) visibility = View.VISIBLE clearAnimation() - keepScreenOn = false } } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index dcf3737c..6d2a4432 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -181,7 +181,6 @@ class YoutubeFragment : Fragment() { setImageResource(R.drawable.ic_tick) visibility = View.VISIBLE clearAnimation() - keepScreenOn = false } } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index aad87022..15323e62 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -49,7 +49,7 @@ import javax.inject.Singleton @Module object Provider { - lateinit var activity: MainActivity + val activity: MainActivity = MainActivity.getInstance() val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator diff --git a/app/src/main/res/layout/download_record_fragment.xml b/app/src/main/res/layout/download_record_fragment.xml index 3e921168..ab736686 100755 --- a/app/src/main/res/layout/download_record_fragment.xml +++ b/app/src/main/res/layout/download_record_fragment.xml @@ -20,6 +20,7 @@ Date: Mon, 9 Nov 2020 11:15:13 +0530 Subject: [PATCH 06/14] DownloadRecord YT Art Fix --- .../recyclerView/DownloadRecordAdapter.kt | 10 ++++- .../downloadrecord/DownloadRecordFragment.kt | 41 +++++++++++-------- .../downloadrecord/DownloadRecordViewModel.kt | 1 + 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt index 670abecf..6648d542 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/DownloadRecordAdapter.kt @@ -25,6 +25,7 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.shabinder.spotiflyer.database.DownloadRecord import com.shabinder.spotiflyer.databinding.DownloadRecordItemBinding +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.ui.downloadrecord.DownloadRecordFragmentDirections import com.shabinder.spotiflyer.utils.bindImage import kotlinx.coroutines.CoroutineScope @@ -34,6 +35,8 @@ import kotlinx.coroutines.launch class DownloadRecordAdapter: ListAdapter(DownloadRecordDiffCallback()) { private val adapterScope = CoroutineScope(Dispatchers.Default) + //Remember To change when Submitting a Different List / Or Use New Submit List Fun + var source:Source = Source.Spotify override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val layoutInflater = LayoutInflater.from(parent.context) @@ -44,7 +47,7 @@ class DownloadRecordAdapter: ListAdapter?,source: Source) { + super.submitList(list) + this.source = source + } } class DownloadRecordDiffCallback: DiffUtil.ItemCallback(){ diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt index e6e60b89..03ac9fdb 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.ViewModelProvider import com.google.android.material.tabs.TabLayout import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.DownloadRecordFragmentBinding +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.recyclerView.DownloadRecordAdapter import dagger.hilt.android.AndroidEntryPoint @@ -48,36 +49,40 @@ class DownloadRecordFragment : Fragment() { downloadRecordViewModel.downloadRecordList.observe(viewLifecycleOwner, { if(it.isNotEmpty()){ - downloadRecordViewModel.spotifyList = mutableListOf() - downloadRecordViewModel.ytList = mutableListOf() + resetLists() for (downloadRecord in it) { - if(downloadRecord.link.contains("spotify",true)) downloadRecordViewModel.spotifyList.add(downloadRecord) - else downloadRecordViewModel.ytList.add(downloadRecord) + when{ + downloadRecord.link.contains("spotify",true) -> downloadRecordViewModel.spotifyList.add(downloadRecord) + downloadRecord.link.contains("gaana",true) -> downloadRecordViewModel.gaanaList.add(downloadRecord) + else -> downloadRecordViewModel.ytList.add(downloadRecord) + } + } + when(binding.tabLayout.selectedTabPosition){ + 0-> adapter.submitList(downloadRecordViewModel.spotifyList,Source.Spotify) + 1-> adapter.submitList(downloadRecordViewModel.ytList,Source.YouTube) } - if(binding.tabLayout.selectedTabPosition == 0) adapter.submitList(downloadRecordViewModel.spotifyList) - else adapter.submitList(downloadRecordViewModel.ytList) } }) binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { - override fun onTabSelected(tab: TabLayout.Tab?) { - if(tab?.text == "Spotify"){ - adapter.submitList(downloadRecordViewModel.spotifyList) - } else adapter.submitList(downloadRecordViewModel.ytList) - } - - override fun onTabReselected(tab: TabLayout.Tab?) { - // Handle tab reselect - } - - override fun onTabUnselected(tab: TabLayout.Tab?) { - // Handle tab unselected + when(tab?.position){ + 0-> adapter.submitList(downloadRecordViewModel.spotifyList,Source.Spotify) + 1-> adapter.submitList(downloadRecordViewModel.ytList,Source.YouTube) + } } + override fun onTabReselected(tab: TabLayout.Tab?) {} + override fun onTabUnselected(tab: TabLayout.Tab?) {} }) return binding.root } + private fun resetLists() { + downloadRecordViewModel.spotifyList = mutableListOf() + downloadRecordViewModel.ytList = mutableListOf() + downloadRecordViewModel.gaanaList = mutableListOf() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordViewModel.kt index 7f9cc1a7..8ce7a1e8 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordViewModel.kt @@ -32,6 +32,7 @@ class DownloadRecordViewModel @ViewModelInject constructor(val databaseDAO: Data private var viewModelJob = Job() private val uiScope = CoroutineScope(Dispatchers.Default + viewModelJob) var spotifyList = mutableListOf() + var gaanaList = mutableListOf() var ytList = mutableListOf() val downloadRecordList = MutableLiveData>().apply { value = mutableListOf() From 180a284a547e9ba26330dc7b63071a926b10e3de Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 14:11:22 +0530 Subject: [PATCH 07/14] Unifying All Things For Better Maintenance and Less Trouble. --- .../shabinder/spotiflyer/SharedViewModel.kt | 2 +- .../downloadHelper/DownloadHelper.kt | 10 +- .../downloadHelper/YTDownloadHelper.kt | 6 +- .../spotiflyer/models/DownloadObject.kt | 1 + .../spotiflyer/models/gaana/GaanaAlbum.kt | 2 +- .../models/gaana/GaanaArtistTracks.kt | 2 +- .../spotiflyer/models/gaana/GaanaPlaylist.kt | 14 +- .../spotiflyer/models/gaana/GaanaSong.kt | 2 +- .../models/gaana/{Tracks.kt => GaanaTrack.kt} | 4 +- .../spotiflyer/models/spotify/Track.kt | 2 +- .../recyclerView/SpotifyTrackListAdapter.kt | 125 ------------------ ...rackListAdapter.kt => TrackListAdapter.kt} | 62 +++++---- .../spotiflyer/ui/gaana/GaanaFragment.kt | 65 +++++++++ .../spotiflyer/ui/gaana/GaanaViewModel.kt | 15 ++- .../ui/mainfragment/MainFragment.kt | 91 +++++++------ .../spotiflyer/ui/spotify/SpotifyFragment.kt | 115 +++++++--------- .../spotiflyer/ui/spotify/SpotifyViewModel.kt | 58 ++++---- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 24 ++-- .../spotiflyer/ui/youtube/YoutubeViewModel.kt | 54 ++++---- .../spotiflyer/utils/BaseViewModel.kt | 43 ++++++ .../shabinder/spotiflyer/utils/Provider.kt | 4 +- .../com/shabinder/spotiflyer/utils/Utils.kt | 28 ++-- app/src/main/res/navigation/navigation.xml | 52 +++++--- 23 files changed, 403 insertions(+), 378 deletions(-) rename app/src/main/java/com/shabinder/spotiflyer/models/gaana/{Tracks.kt => GaanaTrack.kt} (89%) delete mode 100755 app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt rename app/src/main/java/com/shabinder/spotiflyer/recyclerView/{YoutubeTrackListAdapter.kt => TrackListAdapter.kt} (65%) create mode 100644 app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt diff --git a/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt index 1f9e0a45..7d8eb67f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/SharedViewModel.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job class SharedViewModel : ViewModel() { - var intentString = MutableLiveData().apply { value = "" } + var intentString = MutableLiveData() var spotifyService = MutableLiveData() private var viewModelJob = Job() diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt index 106750de..fedb4d13 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt @@ -33,8 +33,8 @@ import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.networking.makeJsonBody import com.shabinder.spotiflyer.utils.* -import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.Provider.mainActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -76,9 +76,9 @@ object DownloadHelper { //Delay is Added ,if a request is in processing it may finish Log.i("Spotify Helper","Download Request Sent") sharedViewModel?.uiScope?.launch (Dispatchers.Main){ - Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + Toast.makeText(mainActivity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() } - startService(activity,downloadList) + startService(mainActivity,downloadList) },5000) } }else{ @@ -121,9 +121,9 @@ object DownloadHelper { //Delay is Added ,if a request is in processing it may finish Log.i("Spotify Helper","Download Request Sent") sharedViewModel?.uiScope?.launch (Dispatchers.Main){ - Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + Toast.makeText(mainActivity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() } - startService(activity,downloadList) + startService(mainActivity,downloadList) },5000) } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt index 453903ba..2e6637fa 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt @@ -22,8 +22,8 @@ import android.util.Log import android.widget.Toast import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.models.TrackDetails -import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.Provider.mainActivity import com.shabinder.spotiflyer.utils.isOnline import com.shabinder.spotiflyer.utils.removeIllegalChars import com.shabinder.spotiflyer.utils.showNoConnectionAlert @@ -63,8 +63,8 @@ object YTDownloadHelper { } Log.i("YT Downloader Helper","Download Request Sent") withContext(Dispatchers.Main){ - Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() - startService(activity,downloadList) + Toast.makeText(mainActivity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + startService(mainActivity,downloadList) } } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt index 4fc8016f..f2448cbf 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt @@ -40,6 +40,7 @@ data class TrackDetails( var lyrics:String?=null, var trackUrl:String?=null, var albumArt: File, + var albumArtURL: String, var source: Source, var downloaded: DownloadStatus = DownloadStatus.NotDownloaded ):Parcelable diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt index 06cecd0f..354e9e4d 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaAlbum.kt @@ -18,7 +18,7 @@ package com.shabinder.spotiflyer.models.gaana data class GaanaAlbum ( - val tracks : List, + val tracks : List, val count : Int, val custom_artworks : CustomArtworks, val release_year : Int, diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt index c9fa3050..2f31fb01 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaArtistTracks.kt @@ -19,5 +19,5 @@ package com.shabinder.spotiflyer.models.gaana data class GaanaArtistTracks( val count : Int, - val tracks : List + val tracks : List ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt index 268121c5..0e5454b2 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt @@ -18,11 +18,11 @@ package com.shabinder.spotiflyer.models.gaana data class GaanaPlaylist ( - val tags : String, - val fromCache : Int, - val modified_on : String, - val count : Int, - val created_on : String, - val favorite_count : Int, - val tracks : List, + val tags : String, + val fromCache : Int, + val modified_on : String, + val count : Int, + val created_on : String, + val favorite_count : Int, + val tracks : List, ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt index 0d7a65df..8acbff96 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaSong.kt @@ -18,5 +18,5 @@ package com.shabinder.spotiflyer.models.gaana data class GaanaSong( - val tracks : List + val tracks : List ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt similarity index 89% rename from app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt rename to app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt index 031e6687..00feb613 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Tracks.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt @@ -17,9 +17,10 @@ package com.shabinder.spotiflyer.models.gaana +import com.shabinder.spotiflyer.models.DownloadStatus import com.squareup.moshi.Json -data class Tracks ( +data class GaanaTrack ( val tags : List, val seokey : String, val albumseokey : String, @@ -35,4 +36,5 @@ data class Tracks ( val release_date : String, val play_ct : String, val secondary_language : String, + var downloaded: DownloadStatus? = DownloadStatus.NotDownloaded ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt index 0be692b4..ecc0809e 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Track.kt @@ -40,6 +40,6 @@ data class Track( var album: Album? = null, var external_ids: Map? = null, var popularity: Int? = null, - var downloaded: DownloadStatus? = DownloadStatus.NotDownloaded + var downloaded: DownloadStatus = DownloadStatus.NotDownloaded ):Parcelable diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt deleted file mode 100755 index c469c589..00000000 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) 2020 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 . - */ - -package com.shabinder.spotiflyer.recyclerView - -import android.annotation.SuppressLint -import android.os.Environment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.databinding.TrackListItemBinding -import com.shabinder.spotiflyer.downloadHelper.DownloadHelper.downloadAllTracks -import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.TrackDetails -import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.models.spotify.Track -import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel -import com.shabinder.spotiflyer.utils.* -import com.shabinder.spotiflyer.utils.Provider.activity -import kotlinx.coroutines.launch -import java.io.File - -class SpotifyTrackListAdapter(private val spotifyViewModel : SpotifyViewModel): ListAdapter(SpotifyTrackDiffCallback()) { - - var isAlbum:Boolean = false - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = TrackListItemBinding.inflate(layoutInflater,parent,false) - return ViewHolder(binding) - } - - @SuppressLint("SetTextI18n") - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = getItem(position) - if(itemCount ==1 || isAlbum){ - holder.binding.imageUrl.visibility = View.GONE}else{ - spotifyViewModel.uiScope.launch { - //Placeholder Set - bindImage(holder.binding.imageUrl, item.album?.images?.get(0)?.url, Source.Spotify) - } - } - - holder.binding.trackName.text = "${if(item.name!!.length > 17){"${item.name!!.subSequence(0,16)}..."}else{item.name}}" - holder.binding.artist.text = "${item.artists?.get(0)?.name?:""}..." - holder.binding.duration.text = "${item.duration_ms/1000/60} minutes, ${(item.duration_ms/1000)%60} sec" - when (item.downloaded) { - DownloadStatus.Downloaded -> { - holder.binding.btnDownload.setImageResource(R.drawable.ic_tick) - holder.binding.btnDownload.clearAnimation() - } - DownloadStatus.Downloading -> { - holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) - rotateAnim(holder.binding.btnDownload) - } - DownloadStatus.NotDownloaded -> { - holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow) - holder.binding.btnDownload.clearAnimation() - holder.binding.btnDownload.setOnClickListener{ - if(!isOnline()){ - showNoConnectionAlert() - return@setOnClickListener - } - Toast.makeText(activity,"Processing!",Toast.LENGTH_SHORT).show() - holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) - rotateAnim(it) - item.downloaded = DownloadStatus.Downloading - spotifyViewModel.uiScope.launch { - val itemList = mutableListOf() - itemList.add(item.let { track -> - val artistsList = mutableListOf() - track.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } - TrackDetails( - title = track.name.toString(), - artists = artistsList, - durationSec = (track.duration_ms/1000).toInt(), - albumArt = File( - Environment.getExternalStorageDirectory(), - Provider.defaultDir +".Images/" + (track.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), - albumName = track.album?.name, - year = track.album?.release_date, - comment = "Genres:${track.album?.genres?.joinToString()}", - trackUrl = track.href, - source = Source.Spotify - ) - } - ) - downloadAllTracks(spotifyViewModel.folderType,spotifyViewModel.subFolder,itemList) - } - notifyItemChanged(position)//start showing anim! - } - } - } - } - class ViewHolder(val binding: TrackListItemBinding) : RecyclerView.ViewHolder(binding.root) -} - -class SpotifyTrackDiffCallback: DiffUtil.ItemCallback(){ - override fun areItemsTheSame(oldItem: Track, newItem: Track): Boolean { - return oldItem.name == newItem.name - } - - override fun areContentsTheSame(oldItem: Track, newItem: Track): Boolean { - return oldItem == newItem //Downloaded Check - } -} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt similarity index 65% rename from app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt rename to app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt index fc51cf8c..956bacc3 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt @@ -22,39 +22,35 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.TrackListItemBinding +import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.ui.youtube.YoutubeViewModel import com.shabinder.spotiflyer.utils.* import kotlinx.coroutines.launch -class YoutubeTrackListAdapter(private val youtubeViewModel :YoutubeViewModel): ListAdapter(YouTubeTrackDiffCallback()) { +class TrackListAdapter(private val viewModel :BaseViewModel): ListAdapter(TrackDiffCallback()) { + + var source:Source =Source.Spotify override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): SpotifyTrackListAdapter.ViewHolder { + ): ViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = TrackListItemBinding.inflate(layoutInflater,parent,false) -// val view = layoutInflater.inflate(R.layout.track_list_item,parent,false) - return SpotifyTrackListAdapter.ViewHolder(binding) + return ViewHolder(binding) } - override fun onBindViewHolder(holder: SpotifyTrackListAdapter.ViewHolder, position: Int) { + override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = getItem(position) - if(itemCount == 1){ - holder.binding.imageUrl.visibility = View.GONE}else{ - youtubeViewModel.uiScope.launch { - bindImage(holder.binding.imageUrl, - "https://i.ytimg.com/vi/${item.albumArt.absolutePath.substringAfterLast("/") - .substringBeforeLast(".")}/hqdefault.jpg" - , - Source.YouTube - ) + if(itemCount == 1){ holder.binding.imageUrl.visibility = View.GONE}else{ + viewModel.uiScope.launch { + bindImage(holder.binding.imageUrl,item.albumArtURL, source) } } @@ -79,14 +75,25 @@ class YoutubeTrackListAdapter(private val youtubeViewModel :YoutubeViewModel): L holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) rotateAnim(it) item.downloaded = DownloadStatus.Downloading - youtubeViewModel.uiScope.launch { - val itemList = mutableListOf() - itemList.add(item) - YTDownloadHelper.downloadYTTracks( - youtubeViewModel.folderType, - youtubeViewModel.subFolder, - itemList - ) + when(source){ + Source.Spotify -> { + viewModel.uiScope.launch { + DownloadHelper.downloadAllTracks( + viewModel.folderType, + viewModel.subFolder, + listOf(item) + ) + } + } + Source.YouTube -> { + viewModel.uiScope.launch { + YTDownloadHelper.downloadYTTracks( + viewModel.folderType, + viewModel.subFolder, + listOf(item) + ) + } + } } notifyItemChanged(position)//start showing anim! } @@ -97,8 +104,15 @@ class YoutubeTrackListAdapter(private val youtubeViewModel :YoutubeViewModel): L holder.binding.artist.text = "${item.artists.get(0)}..." holder.binding.duration.text = "${item.durationSec/60} minutes, ${item.durationSec%60} sec" } + + class ViewHolder(val binding: TrackListItemBinding) : RecyclerView.ViewHolder(binding.root) + + fun submitList(list: MutableList?, source: Source) { + super.submitList(list) + this.source = source + } } -class YouTubeTrackDiffCallback: DiffUtil.ItemCallback(){ +class TrackDiffCallback: DiffUtil.ItemCallback(){ override fun areItemsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean { return oldItem.title == newItem.title } diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt index 7f80f9ca..8b4f3c5a 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt @@ -20,6 +20,7 @@ package com.shabinder.spotiflyer.ui.gaana import android.content.BroadcastReceiver import android.content.IntentFilter import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -29,8 +30,14 @@ import androidx.lifecycle.ViewModelProvider import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding +import com.shabinder.spotiflyer.downloadHelper.DownloadHelper +import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.networking.GaanaInterface import com.shabinder.spotiflyer.networking.YoutubeMusicApi +import com.shabinder.spotiflyer.recyclerView.TrackListAdapter +import com.shabinder.spotiflyer.utils.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject class GaanaFragment : Fragment() { @@ -39,6 +46,7 @@ class GaanaFragment : Fragment() { private lateinit var sharedViewModel: SharedViewModel @Inject lateinit var youtubeMusicApi: YoutubeMusicApi private lateinit var viewModel: GaanaViewModel + private lateinit var adapter: TrackListAdapter @Inject lateinit var gaanaInterface: GaanaInterface private var intentFilter: IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null @@ -49,6 +57,63 @@ class GaanaFragment : Fragment() { ): View? { binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment, container, false) viewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) + adapter = TrackListAdapter(viewModel) + + val gaanaLink = GaanaFragmentArgs.fromBundle(requireArguments()).link.substringAfter("gaana.com/") + //Link Schema: https://gaana.com/type/link + val link = gaanaLink.substringAfterLast('/', "error") + val type = gaanaLink.substringBeforeLast('/', "error").substringAfterLast('/') + + Log.i("Gaana Fragment", "$type : $link") + + when{ + type == "Error" || link == "Error" -> { + showMessage("Please Check Your Link!") + Provider.mainActivity.onBackPressed() + } + + else -> { + viewModel.gaanaSearch(type,link) + + binding.btnDownloadAll.setOnClickListener { + if(!isOnline()){ + showNoConnectionAlert() + return@setOnClickListener + } + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.visibility = View.VISIBLE + + rotateAnim(binding.downloadingFab) + for (track in viewModel.trackList.value!!){ + if(track.downloaded != DownloadStatus.Downloaded){ + track.downloaded = DownloadStatus.Downloading + adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) + } + } + showMessage("Processing!") + sharedViewModel.uiScope.launch(Dispatchers.Default){ + val urlList = arrayListOf() + viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } + //Appending Source + urlList.add("spotify") + loadAllImages( + requireActivity(), + urlList + ) + } + viewModel.uiScope.launch { + val finalList = viewModel.trackList.value + if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") + DownloadHelper.downloadAllTracks( + viewModel.folderType, + viewModel.subFolder, + finalList ?: listOf(), + ) + } + } + } + } + return binding.root } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt index e133edb7..3ff35af1 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt @@ -18,7 +18,18 @@ package com.shabinder.spotiflyer.ui.gaana import androidx.hilt.lifecycle.ViewModelInject -import androidx.lifecycle.ViewModel import com.shabinder.spotiflyer.database.DatabaseDAO +import com.shabinder.spotiflyer.networking.GaanaInterface +import com.shabinder.spotiflyer.utils.BaseViewModel -class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : ViewModel() \ No newline at end of file +class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ + + override var folderType:String = "" + override var subFolder:String = "" + var gaanaInterface : GaanaInterface? = null + + fun gaanaSearch(type:String,link:String){ + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt index 47e631c7..6d5af994 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt @@ -65,19 +65,60 @@ class MainFragment : Fragment() { return@setOnClickListener } val link = binding.linkSearch.text.toString() - if (link.contains("spotify",true)){ - if(sharedViewModel.spotifyService.value == null){//Authentication pending!! - (activity as MainActivity).authenticateSpotify() + when{ + //SPOTIFY + link.contains("spotify",true) -> { + if(sharedViewModel.spotifyService.value == null){//Authentication pending!! + (activity as MainActivity).authenticateSpotify() + } + findNavController().navigate(MainFragmentDirections.actionMainFragmentToSpotifyFragment(link)) } - findNavController().navigate(MainFragmentDirections.actionMainFragmentToSpotifyFragment(link)) - }else if(link.contains("youtube.com",true) || link.contains("youtu.be",true) ){ - findNavController().navigate(MainFragmentDirections.actionMainFragmentToYoutubeFragment(link)) - }else showMessage("Link is Not Valid",true) + + //YOUTUBE + link.contains("youtube.com",true) || link.contains("youtu.be",true) -> { + findNavController().navigate(MainFragmentDirections.actionMainFragmentToYoutubeFragment(link)) + } + + //GAANA + link.contains("gaana",true) -> { + findNavController().navigate(MainFragmentDirections.actionMainFragmentToGaanaFragment(link)) + } + + else -> showMessage("Link is Not Valid",true) + } } handleIntent() return binding.root } + /** + * Handle Intent If there is any! + **/ + private fun handleIntent() { + sharedViewModel.intentString.observe(viewLifecycleOwner,{ it?.let { + sharedViewModel.uiScope.launch(Dispatchers.IO) { + //Wait for any Authentication to Finish , + // this Wait prevents from multiple Authentication Requests + Thread.sleep(1000) + if(sharedViewModel.spotifyService.value == null){ + //Not Authenticated Yet + Provider.mainActivity.authenticateSpotify() + while (sharedViewModel.spotifyService.value == null) { + //Waiting for Authentication to Finish + Thread.sleep(1000) + } + } + + withContext(Dispatchers.Main){ + binding.linkSearch.setText(sharedViewModel.intentString.value) + binding.btnSearch.performClick() + //Intent Consumed + sharedViewModel.intentString.value = null + } + } + } + }) + } private fun initializeAll() { mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java) @@ -94,44 +135,14 @@ class MainFragment : Fragment() { } } + /** + * Implementing buttons + **/ private fun historyButton() { binding.btnHistory.setOnClickListener { findNavController().navigate(MainFragmentDirections.actionMainFragmentToDownloadRecord()) } } - - /** - * Handle Intent If there is any! - **/ - private fun handleIntent() { - sharedViewModel.intentString.observe(viewLifecycleOwner,{ - if(it != ""){ - sharedViewModel.uiScope.launch(Dispatchers.IO) { - //Wait for any Authentication to Finish , - // this Wait prevents from multiple Authentication Requests - Thread.sleep(1000) - if(sharedViewModel.spotifyService.value == null){ - //Not Authenticated Yet - Provider.activity.authenticateSpotify() - while (sharedViewModel.spotifyService.value == null) { - //Waiting for Authentication to Finish - Thread.sleep(1000) - } - } - - withContext(Dispatchers.Main){ - binding.linkSearch.setText(sharedViewModel.intentString.value) - binding.btnSearch.performClick() - sharedViewModel.intentString.value = "" - } - } - } - }) - } - - /** - * Implementing buttons - **/ private fun openSpotifyButton() { val manager: PackageManager = requireActivity().packageManager try { diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index 97876314..e59600e4 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -23,7 +23,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle -import android.os.Environment import android.util.Log import android.view.LayoutInflater import android.view.View @@ -41,13 +40,12 @@ import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.networking.YoutubeMusicApi -import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter +import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* -import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.Provider.mainActivity import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.io.File import javax.inject.Inject @Suppress("DEPRECATION") @@ -58,7 +56,7 @@ class SpotifyFragment : Fragment() { private lateinit var sharedViewModel: SharedViewModel @Inject lateinit var youtubeMusicApi: YoutubeMusicApi private lateinit var viewModel: SpotifyViewModel - private lateinit var adapter:SpotifyTrackListAdapter + private lateinit var adapter:TrackListAdapter private var intentFilter:IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null @@ -73,81 +71,70 @@ class SpotifyFragment : Fragment() { initializeLiveDataObservers() initializeBroadcast() - val args = SpotifyFragmentArgs.fromBundle(requireArguments()) - val spotifyLink = args.link + val spotifyLink = SpotifyFragmentArgs.fromBundle(requireArguments()).link.substringAfter("open.spotify.com/") val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?') val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') - Log.i("Fragment", "$type : $link") + Log.i("Spotify Fragment", "$type : $link") if(sharedViewModel.spotifyService.value == null){//Authentication pending!! (activity as MainActivity).authenticateSpotify() } - if (type == "Error" || link == "Error") {//Incorrect Link - showMessage("Please Check Your Link!") - }else if(spotifyLink.contains("open.spotify",true)){//Link Validation!! - if(type == "episode" || type == "show"){//TODO Implementation - showMessage("Implementing Soon, Stay Tuned!") + + when{ + type == "Error" || link == "Error" -> { + showMessage("Please Check Your Link!") + mainActivity.onBackPressed() } - else{ - viewModel.spotifySearch(type,link) - if(type=="album")adapter.isAlbum = true - binding.btnDownloadAll.setOnClickListener { - if(!isOnline()){ - showNoConnectionAlert() - return@setOnClickListener - } - binding.btnDownloadAll.visibility = View.GONE - binding.downloadingFab.visibility = View.VISIBLE + else -> { + if(type == "episode" || type == "show"){//TODO Implementation + showMessage("Implementing Soon, Stay Tuned!") + } + else{ + viewModel.spotifySearch(type,link) - rotateAnim(binding.downloadingFab) - for (track in viewModel.trackList.value!!){ - if(track.downloaded != DownloadStatus.Downloaded){ - track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) + binding.btnDownloadAll.setOnClickListener { + if(!isOnline()){ + showNoConnectionAlert() + return@setOnClickListener } - } - showMessage("Processing!") - sharedViewModel.uiScope.launch(Dispatchers.Default){ - val urlList = arrayListOf() - viewModel.trackList.value?.forEach { urlList.add(it.album?.images?.get(0)?.url.toString()) } - //Appending Source - urlList.add("spotify") - loadAllImages( - requireActivity(), - urlList - ) - } - viewModel.uiScope.launch { - val finalList = viewModel.trackList.value?.map{ - val artistsList = mutableListOf() - it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } - TrackDetails( - title = it.name.toString(), - artists = artistsList, - durationSec = (it.duration_ms/1000).toInt(), - albumArt = File( - Environment.getExternalStorageDirectory(), - defaultDir +".Images/" + (it.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), - albumName = it.album?.name, - year = it.album?.release_date, - comment = "Genres:${it.album?.genres?.joinToString()}", - trackUrl = it.href, - source = Source.Spotify + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.visibility = View.VISIBLE + + rotateAnim(binding.downloadingFab) + for (track in viewModel.trackList.value!!){ + if(track.downloaded != DownloadStatus.Downloaded){ + track.downloaded = DownloadStatus.Downloading + adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) + } + } + showMessage("Processing!") + sharedViewModel.uiScope.launch(Dispatchers.Default){ + val urlList = arrayListOf() + viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } + //Appending Source + urlList.add("spotify") + loadAllImages( + requireActivity(), + urlList + ) + } + viewModel.uiScope.launch { + val finalList = viewModel.trackList.value + if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") + DownloadHelper.downloadAllTracks( + viewModel.folderType, + viewModel.subFolder, + finalList ?: listOf(), ) } - if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") - DownloadHelper.downloadAllTracks( - viewModel.folderType, - viewModel.subFolder, - finalList ?: listOf(), - ) } } } } + return binding.root } @@ -160,7 +147,7 @@ class SpotifyFragment : Fragment() { sharedViewModel.spotifyService.observe(viewLifecycleOwner, { viewModel.spotifyService = it }) - adapter = SpotifyTrackListAdapter(viewModel) + adapter = TrackListAdapter(viewModel) DownloadHelper.youtubeMusicApi = youtubeMusicApi DownloadHelper.sharedViewModel = sharedViewModel DownloadHelper.statusBar = binding.statusBar @@ -211,7 +198,7 @@ class SpotifyFragment : Fragment() { if (intent != null){ val trackDetails = intent.getParcelableExtra("track") trackDetails?.let { - val position: Int = viewModel.trackList.value?.map { it.name }?.indexOf(trackDetails.title) ?: -1 + val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 Log.i("Track","Download Completed Intent :$position") if(position != -1) { val track = viewModel.trackList.value?.get(position) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt index f93ce2b9..a6ed9a03 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt @@ -17,34 +17,30 @@ package com.shabinder.spotiflyer.ui.spotify +import android.os.Environment import android.util.Log import androidx.hilt.lifecycle.ViewModelInject -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecord import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.* import com.shabinder.spotiflyer.networking.SpotifyService +import com.shabinder.spotiflyer.utils.BaseViewModel +import com.shabinder.spotiflyer.utils.Provider import com.shabinder.spotiflyer.utils.finalOutputDir -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File -class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : - ViewModel(){ +class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ + + override var folderType:String = "" + override var subFolder:String = "" - var folderType:String = "" - var subFolder:String = "" - var trackList = MutableLiveData>() - private val loading = "Loading" - var title = MutableLiveData().apply { value = loading } - var coverUrl = MutableLiveData().apply { value = loading } var spotifyService : SpotifyService? = null - private var viewModelJob = Job() - val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) - - fun spotifySearch(type:String,link: String){ when (type) { "track" -> { @@ -56,7 +52,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO trackObject.downloaded = DownloadStatus.Downloaded } tempTrackList.add(trackObject) - trackList.value = tempTrackList + trackList.value = tempTrackList.toTrackDetailsList() title.value = trackObject.name coverUrl.value = trackObject.album!!.images?.get(0)!!.url!! withContext(Dispatchers.IO){ @@ -86,7 +82,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO it.album = Album(images = listOf(Image(url = albumObject.images?.get(0)?.url))) tempTrackList.add(it) } - trackList.value = tempTrackList + trackList.value = tempTrackList.toTrackDetailsList() title.value = albumObject?.name coverUrl.value = albumObject?.images?.get(0)?.url withContext(Dispatchers.IO){ @@ -129,7 +125,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO moreTracksAvailable = !moreTracks?.next.isNullOrBlank() } Log.i("Total Tracks Fetched",tempTrackList.size.toString()) - trackList.value = tempTrackList + trackList.value = tempTrackList.toTrackDetailsList() title.value = playlistObject?.name coverUrl.value = playlistObject?.images?.get(0)?.url.toString() withContext(Dispatchers.IO){ @@ -152,6 +148,26 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO } } + private fun List.toTrackDetailsList() = this.map { + val artistsList = mutableListOf() + it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } + TrackDetails( + title = it.name.toString(), + artists = artistsList, + durationSec = (it.duration_ms/1000).toInt(), + albumArt = File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + (it.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), + albumName = it.album?.name, + year = it.album?.release_date, + comment = "Genres:${it.album?.genres?.joinToString()}", + trackUrl = it.href, + downloaded = it.downloaded, + source = Source.Spotify, + albumArtURL = it.album?.images?.get(0)?.url.toString() + ) + }.toMutableList() + private suspend fun getTrackDetails(trackLink:String): Track?{ Log.i("Requesting","https://api.spotify.com/v1/tracks/$trackLink") return spotifyService?.getTrack(trackLink)?.value @@ -168,10 +184,4 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO Log.i("Requesting","https://api.spotify.com/v1/playlists/$link/tracks?offset=$offset&limit=$limit") return spotifyService?.getPlaylistTracks(link, offset, limit)?.value } - - override fun onCleared() { - super.onCleared() - viewModelJob.cancel() - } - } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index 6d2a4432..7daca1e4 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -37,7 +37,7 @@ import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.recyclerView.YoutubeTrackListAdapter +import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -51,7 +51,7 @@ class YoutubeFragment : Fragment() { private lateinit var viewModel: YoutubeViewModel private lateinit var sharedViewModel: SharedViewModel @Inject lateinit var ytDownloader: YoutubeDownloader - private lateinit var adapter : YoutubeTrackListAdapter + private lateinit var adapter : TrackListAdapter private var intentFilter: IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null private val sampleDomain2 = "youtu.be" @@ -64,7 +64,7 @@ class YoutubeFragment : Fragment() { binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment,container,false) viewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - adapter = YoutubeTrackListAdapter(viewModel) + adapter = TrackListAdapter(viewModel) binding.trackList.adapter = adapter initializeLiveDataObservers() @@ -108,16 +108,16 @@ class YoutubeFragment : Fragment() { rotateAnim(binding.downloadingFab) - for (track in viewModel.ytTrackList.value?: listOf()){ + for (track in viewModel.trackList.value?: listOf()){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(viewModel.ytTrackList.value!!.indexOf(track)) + adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) } } showMessage("Processing!") sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() - viewModel.ytTrackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") + viewModel.trackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") .substringBeforeLast(".")}/hqdefault.jpg")} //Appending Source urlList.add("youtube") @@ -130,7 +130,7 @@ class YoutubeFragment : Fragment() { YTDownloadHelper.downloadYTTracks( type = viewModel.folderType, subFolder = viewModel.subFolder, - tracks = viewModel.ytTrackList.value ?: listOf() + tracks = viewModel.trackList.value ?: listOf() ) } } @@ -150,13 +150,13 @@ class YoutubeFragment : Fragment() { if (intent != null){ val trackDetails = intent.getParcelableExtra("track") trackDetails?.let { - val position: Int = viewModel.ytTrackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 + val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 Log.i("Track","Download Completed Intent :$position") if(position != -1) { - val track = viewModel.ytTrackList.value?.get(position) + val track = viewModel.trackList.value?.get(position) track?.let{ it.downloaded = DownloadStatus.Downloaded - viewModel.ytTrackList.value?.set(position, it) + viewModel.trackList.value?.set(position, it) adapter.notifyItemChanged(position) checkIfAllDownloaded() } @@ -174,7 +174,7 @@ class YoutubeFragment : Fragment() { } private fun checkIfAllDownloaded() { - if(!viewModel.ytTrackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ //All Tracks Downloaded binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.apply{ @@ -195,7 +195,7 @@ class YoutubeFragment : Fragment() { /** * TrackList Binding Observer! **/ - viewModel.ytTrackList.observe(viewLifecycleOwner, { + viewModel.trackList.observe(viewLifecycleOwner, { adapter.submitList(it) }) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt index 35f6b11d..467442bf 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt @@ -21,38 +21,31 @@ import android.annotation.SuppressLint import android.os.Environment import android.util.Log import androidx.hilt.lifecycle.ViewModelInject -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecord import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.utils.BaseViewModel import com.shabinder.spotiflyer.utils.Provider.defaultDir import com.shabinder.spotiflyer.utils.finalOutputDir import com.shabinder.spotiflyer.utils.removeIllegalChars import com.shabinder.spotiflyer.utils.showMessage -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File -class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : ViewModel(){ - +class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ /* * YT Album Art Schema * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" * Normal Url: https://i.ytimg.com/vi/$searchId/hqdefault.jpg" * */ - val ytTrackList = MutableLiveData>() - private val loading = "Loading" - var title = MutableLiveData().apply { value = "\"Loading!\"" } - var coverUrl = MutableLiveData().apply { value = loading } - val folderType = "YT_Downloads" - var subFolder = "" - private var viewModelJob = Job() - val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) - + override var folderType = "YT_Downloads" + override var subFolder = "" fun getYTPlaylist(searchId:String, ytDownloader:YoutubeDownloader){ try{ @@ -67,7 +60,7 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO title.postValue( if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} ) - ytTrackList.postValue(videos.map { + this@YoutubeViewModel.trackList.postValue(videos.map { TrackDetails( title = it.title(), artists = listOf(it.author().toString()), @@ -77,13 +70,13 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO defaultDir + ".Images/" + it.videoId() + ".jpeg" ), source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/${it.videoId()}/hqdefault.jpg", downloaded = if (File( - finalOutputDir( - itemName = it.title(), - type = folderType, - subFolder = subFolder - ) - ).exists() + finalOutputDir( + itemName = it.title(), + type = folderType, + subFolder = subFolder + )).exists() ) DownloadStatus.Downloaded else { @@ -120,17 +113,18 @@ class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO val detail = video?.details() val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() ?: "" Log.i("YT View Model",detail.toString()) - ytTrackList.postValue( + this@YoutubeViewModel.trackList.postValue( listOf( TrackDetails( - title = name, - artists = listOf(detail?.author().toString()), - durationSec = detail?.lengthSeconds()?:0, - albumArt = File( - Environment.getExternalStorageDirectory(), - defaultDir +".Images/" + searchId + ".jpeg" - ), - source = Source.YouTube + title = name, + artists = listOf(detail?.author().toString()), + durationSec = detail?.lengthSeconds()?:0, + albumArt = File( + Environment.getExternalStorageDirectory(), + "$defaultDir.Images/$searchId.jpeg" + ), + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" ) ).toMutableList() ) diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt new file mode 100644 index 00000000..d652b171 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.utils + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.shabinder.spotiflyer.models.TrackDetails +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job + +abstract class BaseViewModel:ViewModel() { + abstract var folderType:String + abstract var subFolder:String + open val trackList = MutableLiveData>() + private val viewModelJob:CompletableJob = Job() + open val uiScope = CoroutineScope(Dispatchers.Default + viewModelJob) + + private val loading = "Loading!" + open var title = MutableLiveData().apply { value = loading } + open var coverUrl = MutableLiveData().apply { value = loading } + + override fun onCleared() { + super.onCleared() + viewModelJob.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 15323e62..3fffe3f8 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -49,7 +49,7 @@ import javax.inject.Singleton @Module object Provider { - val activity: MainActivity = MainActivity.getInstance() + val mainActivity: MainActivity = MainActivity.getInstance() val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator @@ -68,7 +68,7 @@ object Provider { @Provides @Singleton fun provideUpi():EasyUpiPayment { - return EasyUpiPayment.Builder(activity) + return EasyUpiPayment.Builder(mainActivity) .setPayeeVpa("technoshab@paytm") .setPayeeName("Shabinder Singh") .setTransactionId("UNIQUE_TRANSACTION_ID") diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt index e4e62be6..b1355efe 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -41,13 +41,12 @@ import com.google.android.material.snackbar.Snackbar import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.Provider.mainActivity import com.shabinder.spotiflyer.worker.ForegroundService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.io.File import java.io.IOException @@ -77,7 +76,7 @@ fun finalOutputDir(itemName:String? = null,type:String, subFolder:String?=null,e fun isOnline(): Boolean { var result = false val connectivityManager = - activity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? + mainActivity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? connectivityManager?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { it.getNetworkCapabilities(connectivityManager.activeNetwork)?.apply { @@ -90,7 +89,7 @@ fun isOnline(): Boolean { } } else { val netInfo = - (activity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo + (mainActivity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo result = netInfo != null && netInfo.isConnected } } @@ -100,12 +99,12 @@ fun isOnline(): Boolean { fun showMessage(message: String, long: Boolean = false){ CoroutineScope(Dispatchers.Main).launch{ Snackbar.make( - activity.snackBarAnchor, + mainActivity.snackBarAnchor, message, if (long) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT - ).also { snackbar -> - snackbar.setAction("Ok") { - snackbar.dismiss() + ).apply { + setAction("Ok") { + dismiss() } }.show() } @@ -126,7 +125,7 @@ fun rotateAnim(view: View){ fun showNoConnectionAlert(){ CoroutineScope(Dispatchers.Main).launch { - activity.apply { + mainActivity.apply { MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) .setTitle(resources.getString(R.string.title)) .setMessage(resources.getString(R.string.supporting_text)) @@ -187,13 +186,10 @@ fun bindImage(imgView: ImageView, imgUrl: String?,source: Source?) { } // the File to save , append increasing numeric counter to prevent files from getting overwritten. resource?.copyTo(file) - withContext(Dispatchers.Main){ - Glide.with(imgView) - .load(file) - .placeholder(R.drawable.ic_song_placeholder) - .into(imgView) -// Log.i("Glide","imageSaved") - } + Glide.with(imgView) + .load(file) + .placeholder(R.drawable.ic_song_placeholder) + .into(imgView) } catch (e: IOException) { e.printStackTrace() } diff --git a/app/src/main/res/navigation/navigation.xml b/app/src/main/res/navigation/navigation.xml index 6c8b8bb3..c2c0d882 100755 --- a/app/src/main/res/navigation/navigation.xml +++ b/app/src/main/res/navigation/navigation.xml @@ -22,15 +22,6 @@ android:id="@+id/navigation" app:startDestination="@id/mainFragment"> - - - + - - - + + + + + + + + + + + \ No newline at end of file From d97536ee50712905ee5576eb6dadd63e9675faeb Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 16:29:33 +0530 Subject: [PATCH 08/14] Gaana Implementation Done --- .../spotiflyer/models/gaana/Artist.kt | 3 + .../spotiflyer/models/gaana/GaanaPlaylist.kt | 3 +- .../spotiflyer/models/gaana/GaanaTrack.kt | 5 +- .../spotiflyer/models/spotify/Source.kt | 1 + .../spotiflyer/networking/GaanaInterface.kt | 10 +- .../recyclerView/TrackListAdapter.kt | 8 +- .../spotiflyer/ui/gaana/GaanaFragment.kt | 99 ++++++++++- .../spotiflyer/ui/gaana/GaanaViewModel.kt | 164 +++++++++++++++++- .../spotiflyer/ui/spotify/SpotifyFragment.kt | 2 +- .../spotiflyer/ui/spotify/SpotifyViewModel.kt | 51 +++--- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 2 +- .../spotiflyer/utils/BaseViewModel.kt | 2 +- .../shabinder/spotiflyer/utils/Provider.kt | 2 +- .../com/shabinder/spotiflyer/utils/Utils.kt | 5 + .../spotiflyer/worker/ForegroundService.kt | 6 + 15 files changed, 314 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt index 15b28e16..49c27d04 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/Artist.kt @@ -17,8 +17,11 @@ package com.shabinder.spotiflyer.models.gaana +import com.squareup.moshi.Json + data class Artist ( val popularity : Int, val seokey : String, val name : String, + @Json(name = "artwork_175x175")var artworkLink :String? ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt index 0e5454b2..38dc43ec 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaPlaylist.kt @@ -18,8 +18,7 @@ package com.shabinder.spotiflyer.models.gaana data class GaanaPlaylist ( - val tags : String, - val fromCache : Int, + val tags : String?, val modified_on : String, val count : Int, val created_on : String, diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt index 00feb613..fe5f98cc 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt @@ -21,12 +21,13 @@ import com.shabinder.spotiflyer.models.DownloadStatus import com.squareup.moshi.Json data class GaanaTrack ( - val tags : List, + val tags : List?, val seokey : String, val albumseokey : String, val track_title : String, val album_title : String, val language : String, + val duration: Int, @Json(name = "artwork_large") val artworkLink : String, val artist : List, @Json(name = "gener") val genre : List, @@ -35,6 +36,6 @@ data class GaanaTrack ( val total_favourite_count : Int, val release_date : String, val play_ct : String, - val secondary_language : String, + val secondary_language : String?, var downloaded: DownloadStatus? = DownloadStatus.NotDownloaded ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt index ca609c28..4c925217 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/spotify/Source.kt @@ -20,4 +20,5 @@ package com.shabinder.spotiflyer.models.spotify enum class Source { Spotify, YouTube, + Gaana, } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt b/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt index 34da4ad2..cc69047b 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/networking/GaanaInterface.kt @@ -31,7 +31,7 @@ interface GaanaInterface { * * subtype : ["most_popular_playlist" , "playlist_home_featured" ,"playlist_detail" ,"user_playlist" ,"topCharts"] **/ - @GET + @GET(".") suspend fun getGaanaPlaylist( @Query("type") type: String = "playlist", @Query("subtype") subtype: String = "playlist_detail", @@ -46,7 +46,7 @@ interface GaanaInterface { * * subtype : ["most_popular" , "new_release" ,"featured_album" ,"similar_album" ,"all_albums", "album" ,"album_detail" ,"album_detail_info"] **/ - @GET + @GET(".") suspend fun getGaanaAlbum( @Query("type") type: String = "album", @Query("subtype") subtype: String = "album_detail", @@ -61,7 +61,7 @@ interface GaanaInterface { * * subtype : ["most_popular" , "hot_songs" ,"recommendation" ,"song_detail"] **/ - @GET + @GET(".") suspend fun getGaanaSong( @Query("type") type: String = "song", @Query("subtype") subtype: String = "song_detail", @@ -75,7 +75,7 @@ interface GaanaInterface { * * subtype : ["most_popular" , "artist_list" ,"artist_track_listing" ,"artist_album" ,"similar_artist","artist_details" ,"artist_details_info"] **/ - @GET + @GET(".") suspend fun getGaanaArtistDetails( @Query("type") type: String = "artist", @Query("subtype") subtype: String = "artist_details_info", @@ -88,7 +88,7 @@ interface GaanaInterface { * * subtype : ["most_popular" , "artist_list" ,"artist_track_listing" ,"artist_album" ,"similar_artist","artist_details" ,"artist_details_info"] **/ - @GET + @GET(".") suspend fun getGaanaArtistTracks( @Query("type") type: String = "artist", @Query("subtype") subtype: String = "artist_track_listing", diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt index 956bacc3..00695c58 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt @@ -76,18 +76,18 @@ class TrackListAdapter(private val viewModel :BaseViewModel): ListAdapter { + Source.YouTube -> { viewModel.uiScope.launch { - DownloadHelper.downloadAllTracks( + YTDownloadHelper.downloadYTTracks( viewModel.folderType, viewModel.subFolder, listOf(item) ) } } - Source.YouTube -> { + else -> { viewModel.uiScope.launch { - YTDownloadHelper.downloadYTTracks( + DownloadHelper.downloadAllTracks( viewModel.folderType, viewModel.subFolder, listOf(item) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt index 8b4f3c5a..05cc9159 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt @@ -18,6 +18,8 @@ package com.shabinder.spotiflyer.ui.gaana import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent import android.content.IntentFilter import android.os.Bundle import android.util.Log @@ -27,19 +29,24 @@ import android.view.ViewGroup import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.SimpleItemAnimator import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.networking.GaanaInterface import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject +@AndroidEntryPoint class GaanaFragment : Fragment() { private lateinit var binding: TrackListFragmentBinding @@ -56,8 +63,9 @@ class GaanaFragment : Fragment() { savedInstanceState: Bundle? ): View? { binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment, container, false) - viewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) - adapter = TrackListAdapter(viewModel) + initializeAll() + initializeLiveDataObservers() + initializeBroadcast() val gaanaLink = GaanaFragmentArgs.fromBundle(requireArguments()).link.substringAfter("gaana.com/") //Link Schema: https://gaana.com/type/link @@ -95,7 +103,7 @@ class GaanaFragment : Fragment() { val urlList = arrayListOf() viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } //Appending Source - urlList.add("spotify") + urlList.add("gaana") loadAllImages( requireActivity(), urlList @@ -116,4 +124,89 @@ class GaanaFragment : Fragment() { return binding.root } + + /** + * Basic Initialization + **/ + private fun initializeAll() { + sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) + viewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) + viewModel.gaanaInterface = gaanaInterface + adapter = TrackListAdapter(viewModel) + DownloadHelper.youtubeMusicApi = youtubeMusicApi + DownloadHelper.sharedViewModel = sharedViewModel + DownloadHelper.statusBar = binding.statusBar + binding.trackList.adapter = adapter + (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + } + + /** + *Live Data Observers + **/ + private fun initializeLiveDataObservers() { + viewModel.trackList.observe(viewLifecycleOwner, { + if (it.isNotEmpty()){ + Log.i("GaanaFragment","TrackList Updated") + adapter.submitList(it,Source.Gaana) + checkIfAllDownloaded() + } + }) + + viewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it, Source.Gaana) + }) + + viewModel.title.observe(viewLifecycleOwner, { + binding.titleView.text = it + }) + } + + private fun checkIfAllDownloaded() { + if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + //All Tracks Downloaded + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.apply{ + setImageResource(R.drawable.ic_tick) + visibility = View.VISIBLE + clearAnimation() + } + } + } + private fun initializeBroadcast() { + intentFilter = IntentFilter() + intentFilter?.addAction("track_download_completed") + + updateUIReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + //UI update here + if (intent != null){ + val trackDetails = intent.getParcelableExtra("track") + trackDetails?.let { + val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 + Log.i("Track","Download Completed Intent :$position") + if(position != -1) { + val track = viewModel.trackList.value?.get(position) + track?.let{ + it.downloaded = DownloadStatus.Downloaded + viewModel.trackList.value?.set(position, it) + adapter.notifyItemChanged(position) + checkIfAllDownloaded() + } + } + } + } + } + } + requireActivity().registerReceiver(updateUIReceiver, intentFilter) + } + + override fun onResume() { + super.onResume() + initializeBroadcast() + } + + override fun onPause() { + super.onPause() + requireActivity().unregisterReceiver(updateUIReceiver) + } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt index 3ff35af1..22c463ce 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt @@ -17,10 +17,23 @@ package com.shabinder.spotiflyer.ui.gaana +import android.os.Environment +import android.util.Log import androidx.hilt.lifecycle.ViewModelInject import com.shabinder.spotiflyer.database.DatabaseDAO +import com.shabinder.spotiflyer.database.DownloadRecord +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.gaana.* +import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.networking.GaanaInterface import com.shabinder.spotiflyer.utils.BaseViewModel +import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.utils.finalOutputDir +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ @@ -29,7 +42,156 @@ class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) var gaanaInterface : GaanaInterface? = null fun gaanaSearch(type:String,link:String){ - + when(type){ + "song" -> { + uiScope.launch { + getGaanaSong(link)?.tracks?.firstOrNull()?.also { + folderType = "Tracks" + if(File(finalOutputDir(it.track_title,folderType,subFolder)).exists()){//Download Already Present!! + it.downloaded = DownloadStatus.Downloaded + } + trackList.value = listOf(it).toTrackDetailsList() + title.value = it.track_title + coverUrl.value = it.artworkLink + withContext(Dispatchers.IO){ + databaseDAO.insert( + DownloadRecord( + type = "Track", + name = title.value!!, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl.value!!, + totalFiles = 1, + downloaded = it.downloaded == DownloadStatus.Downloaded, + directory = finalOutputDir(it.track_title,folderType,subFolder) + ) + ) + } + } + } + } + "album" -> { + uiScope.launch { + getGaanaAlbum(link)?.also { + folderType = "Albums" + subFolder = link + it.tracks.forEach { track -> + if(File(finalOutputDir(track.track_title,folderType,subFolder)).exists()){//Download Already Present!! + track.downloaded = DownloadStatus.Downloaded + } + } + trackList.value = it.tracks.toTrackDetailsList() + title.value = link + coverUrl.value = it.custom_artworks.size_480p + withContext(Dispatchers.IO){ + databaseDAO.insert(DownloadRecord( + type = "Album", + name = title.value!!, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl.value.toString(), + totalFiles = trackList.value?.size ?: 0, + downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == trackList.value?.size, + directory = finalOutputDir(type = folderType,subFolder = subFolder) + )) + } + } + } + } + "playlist" -> { + uiScope.launch { + getGaanaPlaylist(link)?.also { + folderType = "Playlists" + subFolder = link + it.tracks.forEach {track -> + if(File(finalOutputDir(track.track_title,folderType,subFolder)).exists()){//Download Already Present!! + track.downloaded = DownloadStatus.Downloaded + } + } + trackList.value = it.tracks.toTrackDetailsList() + title.value = link + //coverUrl.value = "TODO" + withContext(Dispatchers.IO){ + databaseDAO.insert(DownloadRecord( + type = "Playlist", + name = title.value.toString(), + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl.value.toString(), + totalFiles = it.tracks.size, + downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == trackList.value?.size, + directory = finalOutputDir(type = folderType,subFolder = subFolder) + )) + } + } + } + } + "artist" -> { + uiScope.launch { + folderType = "Artist" + subFolder = link + val artistDetails = getGaanaArtistDetails(link)?.artist?.firstOrNull()?.also { + title.value = it.name + coverUrl.value = it.artworkLink + } + getGaanaArtistTracks(link)?.also { + it.tracks.forEach {track -> + if(File(finalOutputDir(track.track_title,folderType,subFolder)).exists()){//Download Already Present!! + track.downloaded = DownloadStatus.Downloaded + } + } + trackList.value = it.tracks.toTrackDetailsList() + withContext(Dispatchers.IO){ + databaseDAO.insert(DownloadRecord( + type = "Artist", + name = artistDetails?.name ?: link, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl.value.toString(), + totalFiles = trackList.value?.size ?: 0, + downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == trackList.value?.size, + directory = finalOutputDir(type = folderType,subFolder = subFolder) + )) + } + } + } + } + } } + + private fun List.toTrackDetailsList() = this.map { + TrackDetails( + title = it.track_title, + artists = it.artist.map { artist -> artist.name }, + durationSec = it.duration, + albumArt = File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + (it.artworkLink.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg"), + albumName = it.album_title, + year = it.release_date, + comment = "Genres:${it.genre.map { genre -> genre.name }.reduceOrNull { acc, s -> acc + s }}", + trackUrl = it.lyrics_url, + downloaded = it.downloaded ?: DownloadStatus.NotDownloaded, + source = Source.Gaana, + albumArtURL = it.artworkLink + ) + }.toMutableList() + + private suspend fun getGaanaSong(songLink:String): GaanaSong?{ + Log.i("Requesting","https://gaana.com/song/$songLink") + return gaanaInterface?.getGaanaSong(seokey = songLink)?.value + } + private suspend fun getGaanaAlbum(albumLink:String): GaanaAlbum?{ + Log.i("Requesting","https://gaana.com/album/$albumLink") + return gaanaInterface?.getGaanaAlbum(seokey = albumLink)?.value + } + private suspend fun getGaanaPlaylist(link:String): GaanaPlaylist?{ + Log.i("Requesting","https://gaana.com/playlist/$link") + return gaanaInterface?.getGaanaPlaylist(seokey = link)?.value + } + private suspend fun getGaanaArtistDetails(link:String): GaanaArtistDetails?{ + Log.i("Requesting","https://gaana.com/artist/$link") + return gaanaInterface?.getGaanaArtistDetails(seokey = link)?.value + } + private suspend fun getGaanaArtistTracks(link:String,limit:Int = 50): GaanaArtistTracks?{ + Log.i("Requesting","Tracks of: https://gaana.com/artist/$link") + return gaanaInterface?.getGaanaArtistTracks(seokey = link,limit = limit)?.value + } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index e59600e4..9390196a 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -163,7 +163,7 @@ class SpotifyFragment : Fragment() { viewModel.trackList.observe(viewLifecycleOwner, { if (it.isNotEmpty()){ Log.i("SpotifyFragment","TrackList Updated") - adapter.submitList(it) + adapter.submitList(it,Source.Spotify) checkIfAllDownloaded() } }) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt index a6ed9a03..07c1dfc8 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt @@ -45,26 +45,25 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO when (type) { "track" -> { uiScope.launch { - val trackObject = getTrackDetails(link) - folderType = "Tracks" - val tempTrackList = mutableListOf() - if(File(finalOutputDir(trackObject?.name!!,folderType,subFolder)).exists()){//Download Already Present!! - trackObject.downloaded = DownloadStatus.Downloaded - } - tempTrackList.add(trackObject) - trackList.value = tempTrackList.toTrackDetailsList() - title.value = trackObject.name - coverUrl.value = trackObject.album!!.images?.get(0)!!.url!! - withContext(Dispatchers.IO){ - databaseDAO.insert(DownloadRecord( - type = "Track", - name = title.value!!, - link = "https://open.spotify.com/$type/$link", - coverUrl = coverUrl.value!!, - totalFiles = tempTrackList.size, - downloaded = trackObject.downloaded == DownloadStatus.Downloaded, - directory = finalOutputDir(trackObject.name!!,folderType,subFolder) - )) + getTrackDetails(link)?.also { + folderType = "Tracks" + if(File(finalOutputDir(it.name,folderType,subFolder)).exists()){//Download Already Present!! + it.downloaded = DownloadStatus.Downloaded + } + trackList.value = listOf(it).toTrackDetailsList() + title.value = it.name + coverUrl.value = it.album!!.images?.get(0)!!.url!! + withContext(Dispatchers.IO){ + databaseDAO.insert(DownloadRecord( + type = "Track", + name = title.value!!, + link = "https://open.spotify.com/$type/$link", + coverUrl = coverUrl.value!!, + totalFiles = 1, + downloaded = it.downloaded == DownloadStatus.Downloaded, + directory = finalOutputDir(it.name,folderType,subFolder) + )) + } } } } @@ -74,15 +73,13 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO val albumObject = getAlbumDetails(link) folderType = "Albums" subFolder = albumObject?.name.toString() - val tempTrackList = mutableListOf() albumObject?.tracks?.items?.forEach { if(File(finalOutputDir(it.name!!,folderType,subFolder)).exists()){//Download Already Present!! it.downloaded = DownloadStatus.Downloaded } it.album = Album(images = listOf(Image(url = albumObject.images?.get(0)?.url))) - tempTrackList.add(it) } - trackList.value = tempTrackList.toTrackDetailsList() + trackList.value = albumObject?.tracks?.items?.toTrackDetailsList() title.value = albumObject?.name coverUrl.value = albumObject?.images?.get(0)?.url withContext(Dispatchers.IO){ @@ -91,8 +88,8 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO name = title.value!!, link = "https://open.spotify.com/$type/$link", coverUrl = coverUrl.value.toString(), - totalFiles = tempTrackList.size, - downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == tempTrackList.size, + totalFiles = trackList.value?.size ?: 0, + downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == trackList.value?.size, directory = finalOutputDir(type = folderType,subFolder = subFolder) )) } @@ -149,11 +146,9 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO } private fun List.toTrackDetailsList() = this.map { - val artistsList = mutableListOf() - it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } TrackDetails( title = it.name.toString(), - artists = artistsList, + artists = it.artists?.map { artist -> artist?.name.toString() } ?: listOf(), durationSec = (it.duration_ms/1000).toInt(), albumArt = File( Environment.getExternalStorageDirectory(), diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index 7daca1e4..bb8e261c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -196,7 +196,7 @@ class YoutubeFragment : Fragment() { * TrackList Binding Observer! **/ viewModel.trackList.observe(viewLifecycleOwner, { - adapter.submitList(it) + adapter.submitList(it,Source.YouTube) }) /** diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt index d652b171..e0f88fce 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt @@ -30,7 +30,7 @@ abstract class BaseViewModel:ViewModel() { abstract var subFolder:String open val trackList = MutableLiveData>() private val viewModelJob:CompletableJob = Job() - open val uiScope = CoroutineScope(Dispatchers.Default + viewModelJob) + open val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) private val loading = "Loading!" open var title = MutableLiveData().apply { value = loading } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 3fffe3f8..033e5515 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -125,7 +125,7 @@ object Provider { @Singleton fun getGaanaInterface(moshi: Moshi,okHttpClient: OkHttpClient):GaanaInterface{ val retrofit = Retrofit.Builder() - .baseUrl("http://api.gaana.com/") + .baseUrl("https://api.gaana.com/") .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt index b1355efe..0bd93d70 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -179,6 +179,11 @@ fun bindImage(imgView: ImageView, imgUrl: String?,source: Source?) { defaultDir+".Images/" + imgUrl.substringBeforeLast('/',imgUrl).substringAfterLast('/',imgUrl) + ".jpeg" ) } + Source.Gaana -> { + File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + (imgUrl.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg") + } else -> File( Environment.getExternalStorageDirectory(), defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg" diff --git a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt index 4cddc100..430de1c9 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt @@ -49,6 +49,7 @@ import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.Provider import com.shabinder.spotiflyer.utils.copyTo import com.tonyodev.fetch2.* import com.tonyodev.fetch2core.DownloadBlock @@ -628,6 +629,11 @@ class ForegroundService : Service(){ defaultDir +".Images/" + url.substringBeforeLast('/',url).substringAfterLast('/',url) + ".jpeg" ) } + "gaana" -> { + File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + (url.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg") + } else -> File( Environment.getExternalStorageDirectory(), defaultDir +".Images/" + url.substringAfterLast('/') + ".jpeg") From e62abca957d4be5c298ad0160591a99a9c0065ec Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 16:39:59 +0530 Subject: [PATCH 09/14] Gaana Response Null Issue Fix --- .../spotiflyer/models/gaana/GaanaTrack.kt | 20 +++++++++---------- .../spotiflyer/ui/gaana/GaanaViewModel.kt | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt index fe5f98cc..f4a0b94d 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/gaana/GaanaTrack.kt @@ -23,19 +23,19 @@ import com.squareup.moshi.Json data class GaanaTrack ( val tags : List?, val seokey : String, - val albumseokey : String, + val albumseokey : String?, val track_title : String, - val album_title : String, - val language : String, + val album_title : String?, + val language : String?, val duration: Int, @Json(name = "artwork_large") val artworkLink : String, - val artist : List, - @Json(name = "gener") val genre : List, - val lyrics_url : String, - val youtube_id : String, - val total_favourite_count : Int, - val release_date : String, - val play_ct : String, + val artist : List, + @Json(name = "gener") val genre : List?, + val lyrics_url : String?, + val youtube_id : String?, + val total_favourite_count : Int?, + val release_date : String?, + val play_ct : String?, val secondary_language : String?, var downloaded: DownloadStatus? = DownloadStatus.NotDownloaded ) \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt index 22c463ce..a7717422 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt @@ -159,14 +159,14 @@ class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) private fun List.toTrackDetailsList() = this.map { TrackDetails( title = it.track_title, - artists = it.artist.map { artist -> artist.name }, + artists = it.artist.map { artist -> artist?.name.toString() }, durationSec = it.duration, albumArt = File( Environment.getExternalStorageDirectory(), Provider.defaultDir +".Images/" + (it.artworkLink.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg"), albumName = it.album_title, year = it.release_date, - comment = "Genres:${it.genre.map { genre -> genre.name }.reduceOrNull { acc, s -> acc + s }}", + comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}", trackUrl = it.lyrics_url, downloaded = it.downloaded ?: DownloadStatus.NotDownloaded, source = Source.Gaana, From 92e699075c0e3d534db88107c57a108376427500 Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 17:01:28 +0530 Subject: [PATCH 10/14] YT Scraper issue Fix --- .../shabinder/spotiflyer/downloadHelper/DownloadHelper.kt | 1 + .../shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt | 4 ++-- .../com/shabinder/spotiflyer/worker/ForegroundService.kt | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt index fedb4d13..d764162f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt @@ -130,6 +130,7 @@ object DownloadHelper { } } override fun onFailure(call: Call, t: Throwable) { + if(t.message.toString().contains("Failed to connect")) showMessage("Failed, Check Your Internet Connection!") Log.i("YT API Req. Fail",t.message.toString()) } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt index 743f2dd0..2ffe02ea 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt @@ -128,12 +128,12 @@ fun getYTTracks(response: String):List{ ) } } - //Log.i("Text Api",availableDetails.toString()) + Log.i("Text Api",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 > 1 && availableDetails[1] in listOf("Song","Video") ){ + 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 //Has Been Giving Issues diff --git a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt index 430de1c9..edb8b844 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt @@ -630,9 +630,9 @@ class ForegroundService : Service(){ ) } "gaana" -> { - File( - Environment.getExternalStorageDirectory(), - Provider.defaultDir +".Images/" + (url.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg") + File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + (url.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg") } else -> File( Environment.getExternalStorageDirectory(), From f86972595370398c72fbace6236b5d5b14691bfc Mon Sep 17 00:00:00 2001 From: Shabinder Date: Mon, 9 Nov 2020 23:53:22 +0530 Subject: [PATCH 11/14] Fragment Code Clean , and Unification --- .../com/shabinder/spotiflyer/MainActivity.kt | 10 +- .../downloadHelper/DownloadHelper.kt | 2 +- .../spotiflyer/ui/gaana/GaanaFragment.kt | 120 ++------------ .../spotiflyer/ui/spotify/SpotifyFragment.kt | 110 ++----------- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 109 +------------ .../spotiflyer/utils/BaseFragment.kt | 148 ++++++++++++++++++ .../spotiflyer/utils/BaseViewModel.kt | 1 + .../com/shabinder/spotiflyer/utils/Utils.kt | 18 ++- build.gradle | 2 +- 9 files changed, 208 insertions(+), 312 deletions(-) create mode 100644 app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index 2e96f354..9ffe9cfc 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -69,12 +69,12 @@ class MainActivity : AppCompatActivity(){ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) binding = DataBindingUtil.setContentView(this, R.layout.main_activity) sharedViewModel = ViewModelProvider(this).get(SharedViewModel::class.java) navController = findNavController(R.id.navHostFragment) snackBarAnchor = binding.snackBarPosition //Enabling Dark Mode - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) authenticateSpotify() @@ -145,10 +145,10 @@ class MainActivity : AppCompatActivity(){ }).addInterceptor(NetworkInterceptor()) val retrofit = Retrofit.Builder() - .baseUrl("https://api.spotify.com/v1/") - .client(httpClient.build()) - .addConverterFactory(MoshiConverterFactory.create(moshi)) - .build() + .baseUrl("https://api.spotify.com/v1/") + .client(httpClient.build()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() spotifyService = retrofit.create(SpotifyService::class.java) sharedViewModel.spotifyService.value = spotifyService diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt index d764162f..78c73484 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/DownloadHelper.kt @@ -76,7 +76,7 @@ object DownloadHelper { //Delay is Added ,if a request is in processing it may finish Log.i("Spotify Helper","Download Request Sent") sharedViewModel?.uiScope?.launch (Dispatchers.Main){ - Toast.makeText(mainActivity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + showMessage("Download Started, Now You can leave the App!") } startService(mainActivity,downloadList) },5000) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt index 05cc9159..b407c4f9 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt @@ -17,55 +17,38 @@ package com.shabinder.spotiflyer.ui.gaana -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.SimpleItemAnimator -import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel -import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.networking.GaanaInterface -import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import javax.inject.Inject -@AndroidEntryPoint -class GaanaFragment : Fragment() { +class GaanaFragment : BaseFragment() { + + override lateinit var baseViewModel: BaseViewModel + override lateinit var adapter: TrackListAdapter + override var source: Source = Source.Gaana + private val viewModel:GaanaViewModel + get() = baseViewModel as GaanaViewModel - private lateinit var binding: TrackListFragmentBinding - private lateinit var sharedViewModel: SharedViewModel - @Inject lateinit var youtubeMusicApi: YoutubeMusicApi - private lateinit var viewModel: GaanaViewModel - private lateinit var adapter: TrackListAdapter - @Inject lateinit var gaanaInterface: GaanaInterface - private var intentFilter: IntentFilter? = null - private var updateUIReceiver: BroadcastReceiver? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment, container, false) + super.onCreateView(inflater, container, savedInstanceState) + initializeAll() - initializeLiveDataObservers() - initializeBroadcast() val gaanaLink = GaanaFragmentArgs.fromBundle(requireArguments()).link.substringAfter("gaana.com/") //Link Schema: https://gaana.com/type/link @@ -92,16 +75,16 @@ class GaanaFragment : Fragment() { binding.downloadingFab.visibility = View.VISIBLE rotateAnim(binding.downloadingFab) - for (track in viewModel.trackList.value!!){ + for (track in baseViewModel.trackList.value!!){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) + adapter.notifyItemChanged(baseViewModel.trackList.value!!.indexOf(track)) } } showMessage("Processing!") sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() - viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } + baseViewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } //Appending Source urlList.add("gaana") loadAllImages( @@ -109,19 +92,18 @@ class GaanaFragment : Fragment() { urlList ) } - viewModel.uiScope.launch { - val finalList = viewModel.trackList.value + baseViewModel.uiScope.launch { + val finalList = baseViewModel.trackList.value if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") DownloadHelper.downloadAllTracks( - viewModel.folderType, - viewModel.subFolder, + baseViewModel.folderType, + baseViewModel.subFolder, finalList ?: listOf(), ) } } } } - return binding.root } @@ -130,7 +112,7 @@ class GaanaFragment : Fragment() { **/ private fun initializeAll() { sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - viewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) + baseViewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) viewModel.gaanaInterface = gaanaInterface adapter = TrackListAdapter(viewModel) DownloadHelper.youtubeMusicApi = youtubeMusicApi @@ -140,73 +122,5 @@ class GaanaFragment : Fragment() { (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } - /** - *Live Data Observers - **/ - private fun initializeLiveDataObservers() { - viewModel.trackList.observe(viewLifecycleOwner, { - if (it.isNotEmpty()){ - Log.i("GaanaFragment","TrackList Updated") - adapter.submitList(it,Source.Gaana) - checkIfAllDownloaded() - } - }) - viewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.coverImage,it, Source.Gaana) - }) - - viewModel.title.observe(viewLifecycleOwner, { - binding.titleView.text = it - }) - } - - private fun checkIfAllDownloaded() { - if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ - //All Tracks Downloaded - binding.btnDownloadAll.visibility = View.GONE - binding.downloadingFab.apply{ - setImageResource(R.drawable.ic_tick) - visibility = View.VISIBLE - clearAnimation() - } - } - } - private fun initializeBroadcast() { - intentFilter = IntentFilter() - intentFilter?.addAction("track_download_completed") - - updateUIReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - //UI update here - if (intent != null){ - val trackDetails = intent.getParcelableExtra("track") - trackDetails?.let { - val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 - Log.i("Track","Download Completed Intent :$position") - if(position != -1) { - val track = viewModel.trackList.value?.get(position) - track?.let{ - it.downloaded = DownloadStatus.Downloaded - viewModel.trackList.value?.set(position, it) - adapter.notifyItemChanged(position) - checkIfAllDownloaded() - } - } - } - } - } - } - requireActivity().registerReceiver(updateUIReceiver, intentFilter) - } - - override fun onResume() { - super.onResume() - initializeBroadcast() - } - - override fun onPause() { - super.onPause() - requireActivity().unregisterReceiver(updateUIReceiver) - } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index 9390196a..093fc854 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -18,47 +18,31 @@ package com.shabinder.spotiflyer.ui.spotify import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.SimpleItemAnimator import com.shabinder.spotiflyer.MainActivity -import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.SharedViewModel -import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* import com.shabinder.spotiflyer.utils.Provider.mainActivity -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("DEPRECATION") +class SpotifyFragment : BaseFragment() { -@AndroidEntryPoint -class SpotifyFragment : Fragment() { - private lateinit var binding:TrackListFragmentBinding - private lateinit var sharedViewModel: SharedViewModel - @Inject lateinit var youtubeMusicApi: YoutubeMusicApi - private lateinit var viewModel: SpotifyViewModel - private lateinit var adapter:TrackListAdapter - private var intentFilter:IntentFilter? = null - private var updateUIReceiver: BroadcastReceiver? = null + override lateinit var baseViewModel: BaseViewModel + override lateinit var adapter: TrackListAdapter + override var source: Source = Source.Spotify + private val viewModel: SpotifyViewModel + get() = baseViewModel as SpotifyViewModel @SuppressLint("SetJavaScriptEnabled") @@ -66,13 +50,11 @@ class SpotifyFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment,container,false) + super.onCreateView(inflater, container, savedInstanceState) initializeAll() - initializeLiveDataObservers() - initializeBroadcast() val spotifyLink = SpotifyFragmentArgs.fromBundle(requireArguments()).link.substringAfter("open.spotify.com/") - + val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?') val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') @@ -142,87 +124,15 @@ class SpotifyFragment : Fragment() { * Basic Initialization **/ private fun initializeAll() { - sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - viewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) + baseViewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) + adapter = TrackListAdapter(viewModel) sharedViewModel.spotifyService.observe(viewLifecycleOwner, { viewModel.spotifyService = it }) - adapter = TrackListAdapter(viewModel) DownloadHelper.youtubeMusicApi = youtubeMusicApi DownloadHelper.sharedViewModel = sharedViewModel DownloadHelper.statusBar = binding.statusBar binding.trackList.adapter = adapter (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } - - - /** - *Live Data Observers - **/ - private fun initializeLiveDataObservers() { - viewModel.trackList.observe(viewLifecycleOwner, { - if (it.isNotEmpty()){ - Log.i("SpotifyFragment","TrackList Updated") - adapter.submitList(it,Source.Spotify) - checkIfAllDownloaded() - } - }) - - viewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.coverImage,it, Source.Spotify) - }) - - viewModel.title.observe(viewLifecycleOwner, { - binding.titleView.text = it - }) - } - - private fun checkIfAllDownloaded() { - if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ - //All Tracks Downloaded - binding.btnDownloadAll.visibility = View.GONE - binding.downloadingFab.apply{ - setImageResource(R.drawable.ic_tick) - visibility = View.VISIBLE - clearAnimation() - } - } - } - private fun initializeBroadcast() { - intentFilter = IntentFilter() - intentFilter?.addAction("track_download_completed") - - updateUIReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - //UI update here - if (intent != null){ - val trackDetails = intent.getParcelableExtra("track") - trackDetails?.let { - val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 - Log.i("Track","Download Completed Intent :$position") - if(position != -1) { - val track = viewModel.trackList.value?.get(position) - track?.let{ - it.downloaded = DownloadStatus.Downloaded - viewModel.trackList.value?.set(position, it) - adapter.notifyItemChanged(position) - checkIfAllDownloaded() - } - } - } - } - } - } - requireActivity().registerReceiver(updateUIReceiver, intentFilter) - } - - override fun onResume() { - super.onResume() - initializeBroadcast() - } - - override fun onPause() { - super.onPause() - requireActivity().unregisterReceiver(updateUIReceiver) - } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index bb8e261c..1a9baa08 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -17,43 +17,26 @@ package com.shabinder.spotiflyer.ui.youtube -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.os.Bundle -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.github.kiulian.downloader.YoutubeDownloader -import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.SharedViewModel -import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus -import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import javax.inject.Inject -@AndroidEntryPoint -class YoutubeFragment : Fragment() { +class YoutubeFragment : BaseFragment() { - private lateinit var binding: TrackListFragmentBinding - private lateinit var viewModel: YoutubeViewModel - private lateinit var sharedViewModel: SharedViewModel - @Inject lateinit var ytDownloader: YoutubeDownloader - private lateinit var adapter : TrackListAdapter - private var intentFilter: IntentFilter? = null - private var updateUIReceiver: BroadcastReceiver? = null + override lateinit var baseViewModel: BaseViewModel + override lateinit var adapter : TrackListAdapter + override var source: Source = Source.YouTube + private val viewModel: YoutubeViewModel + get() = baseViewModel as YoutubeViewModel private val sampleDomain2 = "youtu.be" private val sampleDomain1 = "youtube.com" @@ -61,15 +44,11 @@ class YoutubeFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment,container,false) - viewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) - sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) + super.onCreateView(inflater, container, savedInstanceState) + baseViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) adapter = TrackListAdapter(viewModel) binding.trackList.adapter = adapter - initializeLiveDataObservers() - initializeBroadcast() - val args = YoutubeFragmentArgs.fromBundle(requireArguments()) val link = args.link youtubeSearch(link) @@ -135,76 +114,4 @@ class YoutubeFragment : Fragment() { } } } - override fun onResume() { - super.onResume() - initializeBroadcast() - } - - private fun initializeBroadcast() { - intentFilter = IntentFilter() - intentFilter?.addAction("track_download_completed") - - updateUIReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - //UI update here - if (intent != null){ - val trackDetails = intent.getParcelableExtra("track") - trackDetails?.let { - val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 - Log.i("Track","Download Completed Intent :$position") - if(position != -1) { - val track = viewModel.trackList.value?.get(position) - track?.let{ - it.downloaded = DownloadStatus.Downloaded - viewModel.trackList.value?.set(position, it) - adapter.notifyItemChanged(position) - checkIfAllDownloaded() - } - } - } - } - } - } - requireActivity().registerReceiver(updateUIReceiver, intentFilter) - } - - override fun onPause() { - super.onPause() - requireActivity().unregisterReceiver(updateUIReceiver) - } - - private fun checkIfAllDownloaded() { - if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ - //All Tracks Downloaded - binding.btnDownloadAll.visibility = View.GONE - binding.downloadingFab.apply{ - setImageResource(R.drawable.ic_tick) - visibility = View.VISIBLE - clearAnimation() - } - } - } - private fun initializeLiveDataObservers() { - /** - * CoverUrl Binding Observer! - **/ - viewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.coverImage,it, Source.YouTube) - }) - - /** - * TrackList Binding Observer! - **/ - viewModel.trackList.observe(viewLifecycleOwner, { - adapter.submitList(it,Source.YouTube) - }) - - /** - * Title Binding Observer! - **/ - viewModel.title.observe(viewLifecycleOwner, { - binding.titleView.text = it - }) - - } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt new file mode 100644 index 00000000..b9695857 --- /dev/null +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2020 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 . + */ + +package com.shabinder.spotiflyer.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.github.kiulian.downloader.YoutubeDownloader +import com.shabinder.spotiflyer.R +import com.shabinder.spotiflyer.SharedViewModel +import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.networking.GaanaInterface +import com.shabinder.spotiflyer.networking.YoutubeMusicApi +import com.shabinder.spotiflyer.recyclerView.TrackListAdapter +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +abstract class BaseFragment : Fragment() { + + @Inject open lateinit var youtubeMusicApi: YoutubeMusicApi + @Inject open lateinit var ytDownloader: YoutubeDownloader + @Inject open lateinit var gaanaInterface: GaanaInterface + open lateinit var sharedViewModel: SharedViewModel + open lateinit var binding: TrackListFragmentBinding + abstract var baseViewModel: BaseViewModel + abstract var adapter: TrackListAdapter + abstract var source: Source + private var intentFilter: IntentFilter? = null + private var updateUIReceiver: BroadcastReceiver? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initializeLiveDataObservers() + } + /** + *Live Data Observers + **/ + open fun initializeLiveDataObservers() { + baseViewModel.trackList.observe(viewLifecycleOwner, { + if (it.isNotEmpty()){ + Log.i("GaanaFragment","TrackList Updated") + adapter.submitList(it, source) + checkIfAllDownloaded() + } + }) + + baseViewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it, source) + }) + + baseViewModel.title.observe(viewLifecycleOwner, { + binding.titleView.text = it + }) + } + + open fun initializeBroadcast() { + intentFilter = IntentFilter() + intentFilter?.addAction("track_download_completed") + + updateUIReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + //UI update here + if (intent != null){ + val trackDetails = intent.getParcelableExtra("track") + trackDetails?.let { + val position: Int = baseViewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 + Log.i("Track","Download Completed Intent :$position") + if(position != -1) { + val track = baseViewModel.trackList.value?.get(position) + track?.let{ + it.downloaded = DownloadStatus.Downloaded + baseViewModel.trackList.value?.set(position, it) + adapter.notifyItemChanged(position) + checkIfAllDownloaded() + } + } + } + } + } + } + requireActivity().registerReceiver(updateUIReceiver, intentFilter) + } + + override fun onResume() { + super.onResume() + initializeBroadcast() + } + + override fun onPause() { + super.onPause() + requireActivity().unregisterReceiver(updateUIReceiver) + } + + open fun checkIfAllDownloaded() { + if(!baseViewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + //All Tracks Downloaded + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.apply{ + setImageResource(R.drawable.ic_tick) + visibility = View.VISIBLE + clearAnimation() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt index e0f88fce..c70a2223 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt @@ -29,6 +29,7 @@ abstract class BaseViewModel:ViewModel() { abstract var folderType:String abstract var subFolder:String open val trackList = MutableLiveData>() + private val viewModelJob:CompletableJob = Job() open val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt index 0bd93d70..887fb227 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -280,4 +280,20 @@ fun createDirectories() { } fun getEmojiByUnicode(unicode: Int): String? { return String(Character.toChars(unicode)) -} \ No newline at end of file +} + +/* +internal val nullOnEmptyConverterFactory = object : Converter.Factory() { + fun converterFactory() = this + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ) = object : Converter { + val nextResponseBodyConverter = + retrofit.nextResponseBodyConverter(converterFactory(), type, annotations) + + override fun convert(value: ResponseBody) = + if (value.contentLength() != 0L) nextResponseBodyConverter.convert(value) else null + } +}*/ diff --git a/build.gradle b/build.gradle index ea0f8eaa..0c91bfef 100755 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ buildscript { ext{ kotlin_version = "1.4.10" navigationVersion = '2.3.0' - ext.hilt_version = '2.28-alpha' + ext.hilt_version = '2.29.1-alpha' } repositories { google() From b3b2e6ed51a4dd9168888ca747ecd31f36b55665 Mon Sep 17 00:00:00 2001 From: Shabinder Date: Tue, 10 Nov 2020 14:13:41 +0530 Subject: [PATCH 12/14] Set Up base TrackListFragmentBaseClass and TrackListViewModelBase --- .idea/dictionaries/shabinder.xml | 1 + app/build.gradle | 3 +- .../com/shabinder/spotiflyer/MainActivity.kt | 6 +- .../recyclerView/TrackListAdapter.kt | 2 +- .../downloadrecord/DownloadRecordFragment.kt | 4 +- .../spotiflyer/ui/gaana/GaanaFragment.kt | 32 ++-- .../spotiflyer/ui/gaana/GaanaViewModel.kt | 4 +- .../ui/mainfragment/MainFragment.kt | 3 +- .../spotiflyer/ui/spotify/SpotifyFragment.kt | 33 ++-- .../spotiflyer/ui/spotify/SpotifyViewModel.kt | 5 +- .../spotiflyer/ui/youtube/YoutubeFragment.kt | 30 ++-- .../spotiflyer/ui/youtube/YoutubeViewModel.kt | 4 +- .../{BaseFragment.kt => TrackListFragment.kt} | 47 +++--- ...BaseViewModel.kt => TrackListViewModel.kt} | 2 +- app/src/main/res/drawable/gradient.xml | 3 +- app/src/main/res/font/nunito_sans.ttf | Bin 0 -> 95760 bytes app/src/main/res/font/nunito_sans_light.ttf | Bin 0 -> 94092 bytes .../res/layout/download_record_fragment.xml | 15 +- .../main/res/layout/download_record_item.xml | 146 ++++++++-------- app/src/main/res/layout/main_activity.xml | 61 ++++--- app/src/main/res/layout/main_fragment.xml | 124 +++++--------- .../main/res/layout/track_list_fragment.xml | 10 +- app/src/main/res/layout/track_list_item.xml | 157 ++++++++---------- app/src/main/res/values/colors.xml | 6 +- app/src/main/res/values/styles.xml | 53 +++++- 25 files changed, 365 insertions(+), 386 deletions(-) rename app/src/main/java/com/shabinder/spotiflyer/utils/{BaseFragment.kt => TrackListFragment.kt} (72%) rename app/src/main/java/com/shabinder/spotiflyer/utils/{BaseViewModel.kt => TrackListViewModel.kt} (97%) create mode 100644 app/src/main/res/font/nunito_sans.ttf create mode 100644 app/src/main/res/font/nunito_sans_light.ttf diff --git a/.idea/dictionaries/shabinder.xml b/.idea/dictionaries/shabinder.xml index b91cbc33..82062cc1 100755 --- a/.idea/dictionaries/shabinder.xml +++ b/.idea/dictionaries/shabinder.xml @@ -16,6 +16,7 @@ instagram jetbrains kotlinx + linkedin mainfragment maxresdefault moshi diff --git a/app/build.gradle b/app/build.gradle index 96594a07..7efce010 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,7 +28,8 @@ android { buildToolsVersion "30.0.2" buildFeatures{ - dataBinding = true + //dataBinding = true + viewBinding = true } defaultConfig { diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index 9ffe9cfc..3b32e10c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -30,7 +30,6 @@ import android.util.Log import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.findNavController @@ -69,12 +68,13 @@ class MainActivity : AppCompatActivity(){ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + //Enabling Dark Mode AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - binding = DataBindingUtil.setContentView(this, R.layout.main_activity) + binding = MainActivityBinding.inflate(layoutInflater) + setContentView(binding.root) sharedViewModel = ViewModelProvider(this).get(SharedViewModel::class.java) navController = findNavController(R.id.navHostFragment) snackBarAnchor = binding.snackBarPosition - //Enabling Dark Mode authenticateSpotify() diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt index 00695c58..a2f4905f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/TrackListAdapter.kt @@ -33,7 +33,7 @@ import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.utils.* import kotlinx.coroutines.launch -class TrackListAdapter(private val viewModel :BaseViewModel): ListAdapter(TrackDiffCallback()) { +class TrackListAdapter(private val viewModel :TrackListViewModel): ListAdapter(TrackDiffCallback()) { var source:Source =Source.Spotify diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt index 03ac9fdb..b48311e9 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/downloadrecord/DownloadRecordFragment.kt @@ -21,11 +21,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import com.google.android.material.tabs.TabLayout -import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.DownloadRecordFragmentBinding import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.recyclerView.DownloadRecordAdapter @@ -42,7 +40,7 @@ class DownloadRecordFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - binding = DataBindingUtil.inflate(inflater,R.layout.download_record_fragment,container,false) + binding = DownloadRecordFragmentBinding.inflate(inflater,container,false) downloadRecordViewModel = ViewModelProvider(this).get(DownloadRecordViewModel::class.java) adapter = DownloadRecordAdapter() binding.downloadRecordList.adapter = adapter diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt index b407c4f9..330e741a 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaFragment.kt @@ -23,24 +23,30 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.SimpleItemAnimator import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.networking.GaanaInterface +import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import javax.inject.Inject -class GaanaFragment : BaseFragment() { +@AndroidEntryPoint +class GaanaFragment : TrackListFragment() { - override lateinit var baseViewModel: BaseViewModel + @Inject lateinit var youtubeMusicApi: YoutubeMusicApi + @Inject lateinit var gaanaInterface: GaanaInterface + override lateinit var viewModel: GaanaViewModel override lateinit var adapter: TrackListAdapter override var source: Source = Source.Gaana - private val viewModel:GaanaViewModel - get() = baseViewModel as GaanaViewModel - + override val args: GaanaFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -75,16 +81,16 @@ class GaanaFragment : BaseFragment() { binding.downloadingFab.visibility = View.VISIBLE rotateAnim(binding.downloadingFab) - for (track in baseViewModel.trackList.value!!){ + for (track in viewModel.trackList.value!!){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(baseViewModel.trackList.value!!.indexOf(track)) + adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) } } showMessage("Processing!") sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() - baseViewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } + viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } //Appending Source urlList.add("gaana") loadAllImages( @@ -92,12 +98,12 @@ class GaanaFragment : BaseFragment() { urlList ) } - baseViewModel.uiScope.launch { - val finalList = baseViewModel.trackList.value + viewModel.uiScope.launch { + val finalList = viewModel.trackList.value if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") DownloadHelper.downloadAllTracks( - baseViewModel.folderType, - baseViewModel.subFolder, + viewModel.folderType, + viewModel.subFolder, finalList ?: listOf(), ) } @@ -112,7 +118,7 @@ class GaanaFragment : BaseFragment() { **/ private fun initializeAll() { sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) - baseViewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) + viewModel = ViewModelProvider(this).get(GaanaViewModel::class.java) viewModel.gaanaInterface = gaanaInterface adapter = TrackListAdapter(viewModel) DownloadHelper.youtubeMusicApi = youtubeMusicApi diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt index a7717422..6e81a679 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/gaana/GaanaViewModel.kt @@ -27,15 +27,15 @@ import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.gaana.* import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.networking.GaanaInterface -import com.shabinder.spotiflyer.utils.BaseViewModel import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.utils.TrackListViewModel import com.shabinder.spotiflyer.utils.finalOutputDir import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ +class GaanaViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : TrackListViewModel(){ override var folderType:String = "" override var subFolder:String = "" diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt index 6d5af994..6ad9a9c0 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/mainfragment/MainFragment.kt @@ -25,7 +25,6 @@ import android.text.SpannableStringBuilder import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController @@ -57,7 +56,7 @@ class MainFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - binding = DataBindingUtil.inflate(inflater,R.layout.main_fragment,container,false) + binding = MainFragmentBinding.inflate(inflater,container,false) initializeAll() binding.btnSearch.setOnClickListener { if(!isOnline()){ diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index 093fc854..cc7299a8 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -24,26 +24,29 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.SimpleItemAnimator import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.downloadHelper.DownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* import com.shabinder.spotiflyer.utils.Provider.mainActivity +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import javax.inject.Inject -@Suppress("DEPRECATION") -class SpotifyFragment : BaseFragment() { +@AndroidEntryPoint +class SpotifyFragment : TrackListFragment() { - override lateinit var baseViewModel: BaseViewModel + @Inject lateinit var youtubeMusicApi: YoutubeMusicApi + override lateinit var viewModel: SpotifyViewModel override lateinit var adapter: TrackListAdapter override var source: Source = Source.Spotify - private val viewModel: SpotifyViewModel - get() = baseViewModel as SpotifyViewModel - + override val args: SpotifyFragmentArgs by navArgs() @SuppressLint("SetJavaScriptEnabled") override fun onCreateView( @@ -53,7 +56,7 @@ class SpotifyFragment : BaseFragment() { super.onCreateView(inflater, container, savedInstanceState) initializeAll() - val spotifyLink = SpotifyFragmentArgs.fromBundle(requireArguments()).link.substringAfter("open.spotify.com/") + val spotifyLink = args.link.substringAfter("open.spotify.com/") val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?') val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') @@ -75,7 +78,7 @@ class SpotifyFragment : BaseFragment() { showMessage("Implementing Soon, Stay Tuned!") } else{ - viewModel.spotifySearch(type,link) + this.viewModel.spotifySearch(type,link) binding.btnDownloadAll.setOnClickListener { if(!isOnline()){ @@ -86,16 +89,16 @@ class SpotifyFragment : BaseFragment() { binding.downloadingFab.visibility = View.VISIBLE rotateAnim(binding.downloadingFab) - for (track in viewModel.trackList.value!!){ + for (track in this.viewModel.trackList.value!!){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) + adapter.notifyItemChanged(this.viewModel.trackList.value!!.indexOf(track)) } } showMessage("Processing!") sharedViewModel.uiScope.launch(Dispatchers.Default){ val urlList = arrayListOf() - viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } + this@SpotifyFragment.viewModel.trackList.value?.forEach { urlList.add(it.albumArtURL) } //Appending Source urlList.add("spotify") loadAllImages( @@ -103,7 +106,7 @@ class SpotifyFragment : BaseFragment() { urlList ) } - viewModel.uiScope.launch { + this.viewModel.uiScope.launch { val finalList = viewModel.trackList.value if(finalList.isNullOrEmpty())showMessage("Not Downloading Any Song") DownloadHelper.downloadAllTracks( @@ -124,10 +127,10 @@ class SpotifyFragment : BaseFragment() { * Basic Initialization **/ private fun initializeAll() { - baseViewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) - adapter = TrackListAdapter(viewModel) + this.viewModel = ViewModelProvider(this).get(SpotifyViewModel::class.java) + adapter = TrackListAdapter(this.viewModel) sharedViewModel.spotifyService.observe(viewLifecycleOwner, { - viewModel.spotifyService = it + this.viewModel.spotifyService = it }) DownloadHelper.youtubeMusicApi = youtubeMusicApi DownloadHelper.sharedViewModel = sharedViewModel diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt index 07c1dfc8..f6f758d7 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt @@ -26,15 +26,15 @@ import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.* import com.shabinder.spotiflyer.networking.SpotifyService -import com.shabinder.spotiflyer.utils.BaseViewModel import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.utils.TrackListViewModel import com.shabinder.spotiflyer.utils.finalOutputDir import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ +class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : TrackListViewModel(){ override var folderType:String = "" override var subFolder:String = "" @@ -145,6 +145,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO } } + @Suppress("DEPRECATION") private fun List.toTrackDetailsList() = this.map { TrackDetails( title = it.name.toString(), diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index 1a9baa08..03cb626f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -22,31 +22,37 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.navArgs +import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.spotify.Source import com.shabinder.spotiflyer.recyclerView.TrackListAdapter import com.shabinder.spotiflyer.utils.* +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import javax.inject.Inject -class YoutubeFragment : BaseFragment() { +private const val sampleDomain2 = "youtu.be" +private const val sampleDomain1 = "youtube.com" - override lateinit var baseViewModel: BaseViewModel +@AndroidEntryPoint +class YoutubeFragment : TrackListFragment() { + + @Inject lateinit var ytDownloader: YoutubeDownloader + override lateinit var viewModel: YoutubeViewModel override lateinit var adapter : TrackListAdapter override var source: Source = Source.YouTube - private val viewModel: YoutubeViewModel - get() = baseViewModel as YoutubeViewModel - private val sampleDomain2 = "youtu.be" - private val sampleDomain1 = "youtube.com" + override val args: YoutubeFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { super.onCreateView(inflater, container, savedInstanceState) - baseViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) - adapter = TrackListAdapter(viewModel) + this.viewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) + adapter = TrackListAdapter(this.viewModel) binding.trackList.adapter = adapter val args = YoutubeFragmentArgs.fromBundle(requireArguments()) @@ -60,7 +66,7 @@ class YoutubeFragment : BaseFragment() { if(link.contains("playlist",true) || link.contains("list",true)){ // Given Link is of a Playlist val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&") - viewModel.getYTPlaylist(playlistId,ytDownloader) + this.viewModel.getYTPlaylist(playlistId,ytDownloader) }else{//Given Link is of a Video var searchId = "error" if(link.contains(sampleDomain1,true) ){ @@ -70,7 +76,7 @@ class YoutubeFragment : BaseFragment() { searchId = link.substringAfterLast("/","error") } if(searchId != "error") { - viewModel.getYTTrack(searchId,ytDownloader) + this.viewModel.getYTTrack(searchId,ytDownloader) }else{showMessage("Your Youtube Link is not of a Video!!")} } @@ -87,10 +93,10 @@ class YoutubeFragment : BaseFragment() { rotateAnim(binding.downloadingFab) - for (track in viewModel.trackList.value?: listOf()){ + for (track in this.viewModel.trackList.value?: listOf()){ if(track.downloaded != DownloadStatus.Downloaded){ track.downloaded = DownloadStatus.Downloading - adapter.notifyItemChanged(viewModel.trackList.value!!.indexOf(track)) + adapter.notifyItemChanged(this.viewModel.trackList.value!!.indexOf(track)) } } showMessage("Processing!") diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt index 467442bf..e3e0e242 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt @@ -27,8 +27,8 @@ import com.shabinder.spotiflyer.database.DownloadRecord import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.utils.BaseViewModel import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.TrackListViewModel import com.shabinder.spotiflyer.utils.finalOutputDir import com.shabinder.spotiflyer.utils.removeIllegalChars import com.shabinder.spotiflyer.utils.showMessage @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : BaseViewModel(){ +class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : TrackListViewModel(){ /* * YT Album Art Schema * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/TrackListFragment.kt similarity index 72% rename from app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt rename to app/src/main/java/com/shabinder/spotiflyer/utils/TrackListFragment.kt index b9695857..60e4bd56 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/TrackListFragment.kt @@ -26,35 +26,27 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.github.kiulian.downloader.YoutubeDownloader +import androidx.navigation.NavArgs import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.TrackListFragmentBinding import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.models.spotify.Source -import com.shabinder.spotiflyer.networking.GaanaInterface -import com.shabinder.spotiflyer.networking.YoutubeMusicApi import com.shabinder.spotiflyer.recyclerView.TrackListAdapter -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -@AndroidEntryPoint -abstract class BaseFragment : Fragment() { +abstract class TrackListFragment : Fragment() { - @Inject open lateinit var youtubeMusicApi: YoutubeMusicApi - @Inject open lateinit var ytDownloader: YoutubeDownloader - @Inject open lateinit var gaanaInterface: GaanaInterface - open lateinit var sharedViewModel: SharedViewModel - open lateinit var binding: TrackListFragmentBinding - abstract var baseViewModel: BaseViewModel - abstract var adapter: TrackListAdapter - abstract var source: Source + protected lateinit var sharedViewModel: SharedViewModel + protected lateinit var binding: TrackListFragmentBinding + protected abstract var viewModel: VM + protected abstract var adapter: TrackListAdapter + protected abstract var source: Source private var intentFilter: IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null + protected abstract val args:NavArgs override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -66,7 +58,7 @@ abstract class BaseFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - binding = DataBindingUtil.inflate(inflater,R.layout.track_list_fragment, container, false) + binding = TrackListFragmentBinding.inflate(inflater,container,false) return binding.root } @@ -74,11 +66,12 @@ abstract class BaseFragment : Fragment() { super.onViewCreated(view, savedInstanceState) initializeLiveDataObservers() } + /** *Live Data Observers **/ - open fun initializeLiveDataObservers() { - baseViewModel.trackList.observe(viewLifecycleOwner, { + private fun initializeLiveDataObservers() { + viewModel.trackList.observe(viewLifecycleOwner, { if (it.isNotEmpty()){ Log.i("GaanaFragment","TrackList Updated") adapter.submitList(it, source) @@ -86,16 +79,16 @@ abstract class BaseFragment : Fragment() { } }) - baseViewModel.coverUrl.observe(viewLifecycleOwner, { + viewModel.coverUrl.observe(viewLifecycleOwner, { if(it!="Loading") bindImage(binding.coverImage,it, source) }) - baseViewModel.title.observe(viewLifecycleOwner, { + viewModel.title.observe(viewLifecycleOwner, { binding.titleView.text = it }) } - open fun initializeBroadcast() { + private fun initializeBroadcast() { intentFilter = IntentFilter() intentFilter?.addAction("track_download_completed") @@ -105,13 +98,13 @@ abstract class BaseFragment : Fragment() { if (intent != null){ val trackDetails = intent.getParcelableExtra("track") trackDetails?.let { - val position: Int = baseViewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 + val position: Int = viewModel.trackList.value?.map { it.title }?.indexOf(trackDetails.title) ?: -1 Log.i("Track","Download Completed Intent :$position") if(position != -1) { - val track = baseViewModel.trackList.value?.get(position) + val track = viewModel.trackList.value?.get(position) track?.let{ it.downloaded = DownloadStatus.Downloaded - baseViewModel.trackList.value?.set(position, it) + viewModel.trackList.value?.set(position, it) adapter.notifyItemChanged(position) checkIfAllDownloaded() } @@ -133,8 +126,8 @@ abstract class BaseFragment : Fragment() { requireActivity().unregisterReceiver(updateUIReceiver) } - open fun checkIfAllDownloaded() { - if(!baseViewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ + private fun checkIfAllDownloaded() { + if(!viewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ //All Tracks Downloaded binding.btnDownloadAll.visibility = View.GONE binding.downloadingFab.apply{ diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/TrackListViewModel.kt similarity index 97% rename from app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt rename to app/src/main/java/com/shabinder/spotiflyer/utils/TrackListViewModel.kt index c70a2223..1ab33bd8 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/BaseViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/TrackListViewModel.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -abstract class BaseViewModel:ViewModel() { +abstract class TrackListViewModel:ViewModel() { abstract var folderType:String abstract var subFolder:String open val trackList = MutableLiveData>() diff --git a/app/src/main/res/drawable/gradient.xml b/app/src/main/res/drawable/gradient.xml index 6177f9f4..b152852d 100755 --- a/app/src/main/res/drawable/gradient.xml +++ b/app/src/main/res/drawable/gradient.xml @@ -16,7 +16,8 @@ ~ along with this program. If not, see . --> - + + `|MNZn4)=8Rxpk|~Id$sP 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%0DTQ`v9NEH`D^GR%-~^flPq3vA7{umfnBVA0@p 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 PNE4rDR)LYIbo~1-Zuq$< literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/nunito_sans_light.ttf b/app/src/main/res/font/nunito_sans_light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2f5d0493fcef3d63d21d4b404371293d12dcda8e GIT binary patch literal 94092 zcmd?S2bfev);C^Nx5MPr-P6N_o}TW>IS&joLz7`<7;=`JGa_c!LZkthgnQ7~;Y0CD3b|q(zGH?C8a$*<)qM_BIevdQbkxYP_iottu@K5GM9V!x z$BrK`v8Z&05N+FpNI5ieY*p=|3vV4P#KbhDXrnrT8t#IMI zS##shJG4m1s$sa_xDW{I?UoAsUW4EH3zw}v$5J$BH-6tCL_*Ti6?0}ibje4z2-){x zAuJioW}UOrG&%9Gko{i-y?gnrW%F9DoRx*&pW%Mp$`xmdhVJ? z61|MHNhFI*BW;FmWEp9TNQLBedaFniIYv50*ubfdj}>;2Yoz0ZL%2oWl6k9^i(L#~ zVYrXs0frwlJk0Q0f>JPyVVJ@YTC{43bTceuSkADPVQ+?Q37YBgz7+fv#MNV1d_txsf4arZZy=v2SSK8Wz1jHPMc{~DPT)PH-w1rbxR;TB z3BR5RJ{~xPyonlCS_}@ngw!*jz_;;<6|sRp;BKJ;pMfi?aXdr)whg%00Ok4y`dtfB zxq-h2J`L;#CR9h1_mZ#$_67ckhr)IEEbwJuCrW!6*R`S+={L}h#gIc4=v4W~+#X}9 zXQMUgDG9!w5jla!18eod8QSz513xflkYV{&wo{d2zPJK5_FL(agXCg)wR}|WS4tJD zF11?yR&7_Gs2{Z?&8^jHgS0W)TiDan*=Dl}D_+Dx6M z38tB*n@#)8nP%`P2Z&-h5Y~2(aLK_!lS6O~7qzlOq{Cj9$Szo(RPk8gVzCw1pfiWCxSsKc!L0N8;e+yL&OwVe zpq@if&!NJkx`abb6ob`dTvNqh#<+m#3rt^N`ZA^j7zK<1MlmKAm|S3TfywOwqv5^{ z_ieauI!`KGg4G;AC%h%<@Q0J8HsBYuF>QN;g`TKSeH?=wM$tLfA+Tk?%a@I}iEJgYPy<-Y7c6|Cdik%Od)hDte!+ zX4%T}xxy*DkdQs_QQ(KbA8|c{k>p{FB|8I;;nxxTJ&3z+!5aS{T);mFd;b~6uO2=+ zmPP{5eMRFSKEW~5%=jPl$AZrXzjbWz9*ty7eFPY8i;I18)q)`vQ zKG&ZxO4PGa;Qnaf3*7w%-#WkGh3N5#Gx(yqI~aozRR0DH@+<-`An%U@#|VcMazZK@ zxqL<=P~b!46NlX21Ga|p20z=0CrZ)CX_SWBdda{;ru>sUCVX@XhOxn4`W+p1mr&qc zP`ytVxcM$jiCdn`J;6F)<|5#&;4aCKo(XzQK?)s9Pa{PoaV-E3fz!{xC+4l- z@Q~;QPe%eC3G76xQteP5a7XP;T1J|v>zE{MHDrOd=B#Pnf_?-a8Qg-SqcnTbKX}UM z5kU-n>-8fpDQ_+#Kz3$`ytCcB0jJS zH4&)8%aCj>r2Ytc{2u`yp)|$`i4^G?$u&T}99m@`o~HHBu`h%0 zXYn-EkYL0|xxb>3#XG9#E@QU86H|a}2WMFXeBF%yvb69eRP`_gIrJcHD=^N#!+#5-63U4l&joCONLwHU?Q!u`A z37@m=j4mMR7h5GsBO>t>_$Ryt#Kj<)qolRCBCHlaSj&B4NZ?cONA@BY_MO_b78tS# z)O+KFNtXhC1)q<@+?m^#TTen#XieM?&_z@sN@~SeaPT?nJy{BjDj21y-|@wzP?_|p zV@M#_4|5sQ8p4MYhJK-}!?7PCi*p=~<% zgY3uT;1}ga*0=_??l3G1^~@gv{|#@}4qoX`W_M=KhJ+Ku7oxXNw$pz0Wx}%pF}d(H zN=Vy;&~MGQkkiVB7|_qYeu}@7<}KeyyCQ8<+KtZ5&dr><`Gl!EKzHK1l*@_l{?cbp zg}(|(si!%$3#pewsl7dQv~8k>AO4IhDJV%c+z`+=uh43~|pX{?$E~depR; z@A!}!5=wQRFg50+bdzv*u5CKt;!uikE4$&&vRz=ih|8eVrT=kiW49-+`vv*^7W{4D z{3vx7r+$yrpE&i`P>N)Ak?l#_P8+2z4W)G4?x?%naj)R+>-Fr|bzKy3pM)&Yp%RGU$mg&K9DxO|s|POYJUuHK!U+A+;D!oC+P~ z+!I|(s0=+t`JIph`vCA=%G^_`kyFEZz=iLIQm0U(ZoF{rT$&Afs+Y?tF1K5L-Ja#U zjI(#)*>RlL6r^TGr@}eg=i}~DzDuc9-BRcDaQEc63-QD{&V^DNLaD1msW1-u0Gghj zxT%L+!Z=Flx!i`-J)Fw}NNu%03`nW%p_Gn$E_AntRN58x7ioO6zsjkb|D)6$Cw~^U z9XSJDfeSeU-q8atd^aM6oKMP&ec#DA)$iKh0nPg?p-+%H97-KMg;ZE`Si1ZKOZOB~ zEay|83+MNvbF)M76O@WMQOc1Nq;uG97dSGB&f#_xI?6+-+7qUFgYLw4DVI~Z`@fgV zsqlPquG&!hL0n%-wR36|QWH3J8d9@Esf9hHPJUN^VmWd-&#~IE#t}}f58=Wo=&oav zPUpDMD0ewhMlSzxYW;uwL{FtrE_y9{x_crVdLC*+cR9tsy61wP?6{BbJc!hzq13jY znR*(zyzJNoxHps{+*{pn2OJ+dKI1aJLh9R4DvUeTlt22}bW>E$r<#jF2TsE&gR3Ae zJY`IfU5Y18g^qIW ziEd-43_V5porD8$e-_JSE>ah9>e3!?;kyy36L1jbsv9qyJD1iirI#CBqi)Z#{B+!P z;P1Db1AOB2ySk-*58R&!N1BuV*HG%o=oINj_q!*@rSHTOySX$C!75AEwtc-8s$+o$ErW4JS-p4Z0KGrCd(s?*Cpc zr^53|xkAivF(Pv^u$zRP9F<&tt(MtF^4zEb;=?=I76C}(XCj_Yy<)8D~&?_m0s zj9Ez-?IucVH}Tz-oc~Iq(YhGl#W|eM(tDq!!F8bUr^-%6Gj?@6|D!&qIuVh%t$r-pUe4A&r$OESW^6 z#9m)u?qd91jIUr!J!8^1ZRfO|@46Y|7NqTN!YezM?xx%{ZeOjLDbM2)?HcW`UQbVn z^_-7~bMWYCrli#)DKjWfp-U-)auX{wk|*~9aT(=?yU=jK@)Rp52hdRd%bA}0m-veD zU+Fn;o(~g;Vg+Ha`-jRJ!R6k~>ARVeyZLSgr!(lT+Cq2L7N$&P%2clPJg!$NQx0U9 zs?%@|sl<=`AEy5wzKh+?z_>U~`v}nQC@uJ|_#b*!(E79#-!T3g#{9SXlrUVo&$%w# z$HlE&qxI^1+kP9R7t%wDVnBsgWYUnA@0#+w?SZ4g{VRjJZIk zWc&sE?6-_LMt8+A=IvX;%UpiyV=gO~pUvf`a``FT6w-8#X#<{0zz$$5ewRmFwGruZ ztTSas{B^Unl%HvoX({LQoTx;`*}vd|{>h(v{ZzRp&$k-7e6qjQ;$(m4!RG3JYs8vR zI*8N1yNyZI9)ZOg^`gz(a+@iwnY3|XS&IE=iJyBN!m~g3`l&Kco^J~DAo{OfME{*g z7IfNTpc;r}w!sQ#UDOCQQpBh+YLbXoQ`8iZrcP5!u($FuwE;WrBz8*vK|P=zL8=nF zIv3+~$X1*V*@n^WN%53;L+lf8inqi+#D1Iy=`RP!fpV}MBHLw$?4(m6a+*9}ULb!Z zFO_YdTva`D~3;bhbuj)7ctXz*20avo(+`&eq^J&eq5cbhbubPG@W69dxz^ zlEK*;`8v3_;tb8-)QjpRIz>Y#XmE}|$LPOlpKY*SyHGDWUz5zWbD?%IJ=}Xwy(e7i z1NAX><$kKZ6kc^iVPAy$R(%J(!0sk~8iEo|=+zLEH&hM9{#dj&xM_!`X=wEkNOxgh za17diq=pSPZP`2%<{A{*B0hlV|gm(S~AzMGl z7KOkS;V%w%NassL1=3`J(s3RFrwd>ku;*J0Lk~8i2hYWbH&4vRUWWzvixms;XGM=# zhBWo;Snk;xG8Kpa=aG_{U&* zlEikg3wv)~5HI5-!{5d0NWTF~o+jSLpG~|YKEj@WkHu%;_H+DM*?y$6{YYo~VP(q^ z$Ce`w^&15KUFh3-ZK)=TYa+qr7Nm(M#oTDjhhjcV;Nx}FnaZ|s*;Xz)hRco>U&uI- zB@<+g$VRWO6$!FV_CcC#Ql1=zJ=l4294u2FdiX5BIdU=J5_u(PHp-1iUoEdj`Wks7 z(!askIvaZb?}bDDLE^lL+$moWPPtp|78&wyuxC!Lp^L3rwmga*>j`YjlG$2jvYpFg z&5C2qN@C6Oux6#8r?v|>YgP(tRx0b1hjq%%I+emYmBc!QUHa$=*ryMj%H^Jr#C{PF;kHSY@e-#6xrJi5g{<5{F|fe>fTwnH zU}PJKG_{sPj+SGQrZ#iP@z^_`&MoKQmUD2+#c<0xtrE>;6y1Ff`jofX$P0d;P}Q&i^!9xqKiUYdElv|zkkjJr$Z z6?p1OXsARzuR=Op!`O%#YM~lFg4`aJkBdaP4LX~^wM~ksZ4%crf$NjN^+`Y-YPfyU zn0qI;kAr!&Gp`PAA1AkrliS3}t>NSr&}Hvr&UC4#v(%l;QxbEK%p9b$yptp3?O=J^ zS>8^Tw<)5(ByfMxM>aF}7aR8%GxwLoh%wE|{l(1vCE)}%IDz|%#{DIp`-{f?C7%0> znfr@{`-_?TO9J;7D~}X0+&f|+DTmQJ$nNSEO4omlH9u14lUSyf9<-e-PK?o#WMyJm zQgJM)c$QQGODaA}C&>fT&txi$2xLF?K5FMaYLDoncJ8Bg?xPOwqjv72K_1C!o4I%D z{nE-jCUA?#8QMuaCNPf)tY<0AV;b{lV;<9)N1LHfWc}jwzQQdS!xk@|ZCxU_S|Ybv z5|5wBY~@n9)e^bY;L!@FGTw)8H+v62B6cifauD{q%G( zb~m=c!x#gPdZ9Q2R{t_Y->9xh@Gjk|H2u~8^n#XW-VQhMO7Q#PBwT_b_~b;lm8KGThGaxiikFuCctx@KuKU z7{0^seTJVfJk0PY!ygG+6~mY_&YXRQHHo2}VJ1U2!$OAT3~L$oX4uAX5X1H}&Rlth zbri!13{PV?o8dx+%Ned_cpk$w4A(Qf0(Mj3BK!Kk{VjQ3|4Sg|aqu^owmyjY zrQZ_s9IExthZ<%_Hq45MyZ`$soIqoKe+i^96448W2@L5h8SSAbXl6*URXV{$kamA4POO07L|Hj1pv2cu!iax4RZ(eY?wm*$P|dYs%^?o6zW(sg@$*isMj^^)41r&BS}hW^k$Dgiznn3dUJsG`-opsyAKviYIiWa zncYRG4GeLCR?^mmWK5U~aIK2Mv;(s`3ey`H z%nA%Hr8W~O@PgRsBY2;{5;X$%(-37-YNP0dNbp|untENmq4uda)m!QxYQK70y`$b$ z2h>4Cem~PRte==Qi)Phgv{_o*$?+(FgOCM|rXKT!3C!gtxSms8Xz6^!qvTQi~yN7fzX#Vzw~|v!c^5 zqgjpF$Q9y7$nAUi4?v$BD$L5M8dMp|PDDgILp7>$aFL|qai^DRQWfAR8R<;bTUCO$ z6r{6MA5{e|Q<2VA&8iywrXii9`l=dmZbRCoT2w9SU`IMvwW>PQ1~DP!R&A;t^-4$i z`$Z{94wsLlaDw{(3~fQ){m{Rt_eZQi@b4GY-(VnKQvU;O@4iCuq56ndC=|RO@}sE7 z3e4>N;uHMRcm+d{eu`vhXdI|W!=F++pvP}f8neFfD7in<61oYf1{1`5lkPqww#l3E z)(^Vd4>@AUP`XEfRRF>&9b2aA(U<6co+{;DMZA0g3Pd915~9THlybovMNkil!l9~4 zi7d2S64?pxhFACC*$eR}QSw8;ZNr}?z67;Nj)Z(%h+-|ln>%inHw(AC2b|^c^B(9! zz3{5R;J$+66_qT
oby&^faVJ${srpx7nH-UQhGipS#ONc7}3s6v-^rN?npkx!5 zZ02$-Tuus?kjf>ba|v!P!Gk)ORFNzvKMe7YN%-BNf6M83+l?2$xuW##7Ks1rR)~Iz z@f{Pp5IGk=DD7YVTR_)O+T227)(@f&u(D5&=zcVLtMpDkN%!79h4kk%?Oo>AC}PuE$V6S|5EIe#pmI%^HL_ z(xZqj{To^l4{yT;kE9lnEyS52`7wxIPM0$w{f&rlU4!`6orn$Zl+Plr^*8w%;!%G@ zH0lOa&j#<$i?NlWB+o);uunqxv4&`o$udip$Xr>7sOWHBiCrw0%5$-Y>P&f=d_X=d zAC!Mm4|O+$=qp<1LCt0iiqx>LR^ z4#HBNi|FQjima+XL#vh|zIg{Uu>rc%g1F6i#Hc1Cw)1Q8U+OMc!X4rntjYSY%J`i+ zs=iSF!b;;|^|ks2YmcTF>Y1;=Vp?E51tM8h=zBF-VZKUklDDFcFUprOzI+O6SPUy! z2U|HA{=`#Q5q%q@SAv$TrD<7OzE-SNYBgGecAa*+cCWTg+wO{WCA*xi99N#Jz*Xj| zay7a7x`w;Px~94oxmLK&cU|nd%5|;l2G`B5@7(e36t}~j>2|q2?m~C9yUyM09^_u> zKG%Jj`&RcI?z`Rhy8qz*llu|(R`(Nmw!HK_PhMeOrAK)zo@7s&$KlEJxIAT^VV;?u zc?G+_4g8=G&!SHvw5uAjtmhhSk$;39yo@pP6Zt)Ccqyzp)#!2cqaYg`*$o47_>+*Z^m&7I-S zcDuPoweCJ#qt)&;sL}22yHKP1-4CHgkGZ!+)yRw*rJ_c?LN%JpHKH@|GU7CI307YC z6DZcdjbFX|`=WmTCGcqAfxz8?y8^ccZVBAvU+ACWAMYRN_eD~jIQ^dO5Y0Npq0zX; zaLRjV^`VOol^^`!;IsI<;ox2&4*ugHzbWG2lSFaw7W`d)@T`OD4{Q)I?{=+I>(Z{)Hfc99{|PTQsJ#yj3V)IQeyCX*@7WH-@FQsDbJz!!17VgmQw zKXe!OcA9np?lSF;yl>hLoN?)R(^g=%62$2F|FAt&D`AbunwaToi0Cpk3^MM`8W~-V5yV?&uuK_k`nqiYl zU}vu&y9DcYsk&19Ry@zv=~?lvxD9JcQhbS>Yca6EwAz*o`MGdA47_92f>~-WX~z0)BKEALib9ztJTeJBm_wAwbTI&9 zY>mtot+EL3)vLnTnj<=7qZlshv7-eMe%U0($X4uY=__W-E-@2tVVfWah*{Y0H&y!h z?P2rfSUFBCk`u%dIY}(WE|{fqvREmni8Hb8e72k`&cX`jY;m4kAU4ajVvSrTu9N>o z7G2yaH;KFDb>c3J&wr4=6@QYqh=<^r{88Qnzv&+FINq7IRo*3DlTTw`-;?5L`A6(; z*?}E8+r%EZUA!fq7YF1^;-L5^_NRO*_o4T^AvR*?=?n5vQ7kI4vuB}LE~ml|K3^^r z?Xp4qMy|)YxfPyJocM2fsYnyYu(qw?U4sRpzbq4zm7B%= zSlNGA-YOoEw}}Vje~Sm@@5G~6fqN2fo_k9EUc8Pq(^v5x-TheUe^Wju{vmgXL-Oz9 zGwg)2!zoIaw3_oRqAA~Fp8W&-%O5fK{!UoM5qL-c!tDFsh>LxVIoemEO%@BEEEPj! zwP=)X(OY^%AL$j%GG8>wJTYCii__#Vak)H4Tq(~J7s(Z3ojhG!ELUOw>KS6KTq&-S z7l>=*g<_NZwYXNU5m(D!iRa{>#V+{>c4qxWyeJTJX+^d$U73y@gT&+^m*;kms-ogU-C4YuTa=yAyoda*-S8A=gKwYeUt=3>S^l#v4 z+@h|)TH6-(P440~T6*ism_R3Hu{+@VC8IC*LJuxM|LueIq+z0$FMVKJ-{#(p^`*u6 z?o4NTT1uP=p~C7inVOgC^g8MsxEj0-byof^a(Ej;Sh^RaK)S(52WhPIU{jr3;{WKi zO?{fL?c2BSq-*=Sa?ZN8xp`7wd`)(_Cg)sx?X@|R&$_m)?b@kr*l+&h<=UBjbMkU> z^2VR$%FWFkk72pZ+1F*OwU-Q?+c+Wsa)Q)5QzC!onwJ$D1yB0AKBD@T=((8SbKUrgjl`Qw zA->`w@ey)-HwwQjNU!z;@x&L}HYj(NS4i$bel_)4ke>OuF+~4P6#mi>{y-FdP6+=t z;Yp_g{SnRCfpv~7(Z|=AB`sPEX1f{EtTeoT81qC6Y?Y?jJ4~1eIXZ;djP+81RqD>P z{JhlE^gOFItITZidI}rr8=DM8^Li`}dwN|hY>1}bb=TDOD=8Wh(!02d%7WZ%*Rijl ze)py~R}{9S-pX27>CN?|$yKb2=)t?dHk-2{R_5C2%=bDy&b$I= z8rY7tW=65?Y^t;VH0wFN#&unsauW8FC#=eF^-zP5o~;S#$9_XUAYknt=tnYIp|7uZ z8XO$RAMp)n$vli9W-Z1%5Bgx8CahLF_H}BeX%=ZRIZT}}KKwy#a>^v;2M)eiMe?8+ z+CBcEz2!3hEq!IP|HfkfEh`4dd*ye>s$}D6b-Dj&qyN)JvQGXQjOS#7m>2F-baDW; zNnJwU*he5Ra_qn;mkv#7@YN>imJzWSvog&CPjG8+9H7G@p}o`aj6-xfoj4PhlkY9e z%eO*F1c}$1mzt-mNI_jKl*8*43{&e==D>4T_&<~>OV8@t;D3Dg_(^}*Eo;X=@aCIx z%H#plW~&`DC-)g+&loab?Xi0Y4&Lbh1TyjkzJ)*f0(w@ZuPnnE9|wC)!js&CvO8e1 zwHYR%axJZ9qUD^}b-p|Y~3vUtlio3<1qQOR;+K3HyUk{9HL83g3n9sefbneYD0 zcfA>T1eM-LA|GFp&@!i>I~J1goP=TD?u`XNW>M*Hq3; z`cK56UAuYnk2jsNOnT6ZeNp18(E6nni*X!(>pZ|c9X2Y)ZMPJZ#|qj$eI zefoP>fW_ONdFFPVOW9c`2OK+-c=i9g&i{$dtGX$~D|n=(58#75D)^*aCye@ZWe#Ct;7Jd^dSONDVZuBr0R{ta@r|7LlX+R@2W?KdUSB>X&yjC7loq={?m0K5jgAw4LKSv)rL#EoFHsCe|&jFLM? z%FalKwx*^eCnciLxcp=ok~El4vY!|nyw+2y^~+_I84daQ4H=c?XWaJ_642rn*~{|s z%Iu3SJc%emIuLH_eGxj)rjZ}cI-psDc<6pum&gw%-OyCzb4d};#RQ*2KTqcQfu3J% z1U`xVa2+2PiI33Pccbv+hwHkzCkjtCPRH*J;)!4Gt2&Vqhf zuQ?mb1w4~9TTJlgFnUkp=_GoYoM9ygmDDom_mkU?k3En4I>YmC3R(~P4mvDNs>)Lh z*Wdqq*rX&@)#T@9=aSpsfRnn0eW}QY;~%brMt-O+hy5f6!?u~gZ>%Ws(dmG_muU0#O-{lJPZo{?!d_}xJM1JJ z95cK}r4Y1{67CwtHJbY5V5UZIz4sPdTzC+;;lP+sB^~<9Bv}4r9LpCmvKoU(bFh=X8 zV&rb9bL8Q&Hsm>!e|nq0U+eVgt#U^jQmS9wv41lk33V6Y^W^21>tk+>qWXETen;~D zJJ!#GW7`dEKXm*)tg-9#T~YMAiC&kJ_zm*WDB1*!y#qeK9+F|-PlL&ZW05+wjpP9W z-G<0iE@K>*{W_wGzy~n8%U-MlI{qyKe;9aE1pPh(|1zH58G(OS$3xOWe5H}C)AQLA z#>-T;7lijB7J+ivwxqDV&?0OLt(x4Xk-q48>^1Tj8|V_ZvfOn1ZXM76r@(I_czMmB zuSP!aYGfC6`gaU^y{=>zb^HMX{~vp)994$brVcv0Oj+yI2$5fk zDhC*@gPKMqIe7MF7UtAc6Y4`+o9CF?CYM5U&uoNNO$~gHh&er*A_{yS?A|gSpMiO| z@q9{Bd>janhb5FpQ&UJV$gV5DX=UrUG0mK7r73oWavu9^^kiK=?9-ZAJ`0#{qi^VV zu8)p?(ufgIJ*hq(tiO{yUqqDL=wo3#bbLH^Sj@)wDdmH+L*pmL_{e8>2cHcL$J$Xa zPp=$>{V|mHR!z6bh>x>c%qDD&q5u}!k!UC`fyt30 zHATbRqlvt5%z+{4Ca3jQ#6~7`Wi4ubFmrcfqim?CXpy7*d*@Z0X|&tt$U8w4`w9}Q zCT#JbY;i})3r$gWISn48nMNrx%UBe)(PTzFR^R+h<#)1gy>yZ#QbYVDa?i0Rk^Y3` z1zAx39_(VEy6#rZ#4pQz9q_Tj<;#jiKm*0<4R6@FRFp<)Nu{x3O4Z(6?eE`QBPsKK zYVxtW@vZCR_uAuJ#u-$Gn4{hewbeRqbH*cHfssPDlPVJbW)P1yC!RdmZ^3OI#6$k; z*kOG@Km?J5XPsFO-YW6MJJH$kSsSAJ3;u57J2Yb5hD~^pZ+#iQ)m|qz@lTM-TtW)pnb%jnv z^7u-ScdirpM|z$1Gw&3~_!cLHUqGBLi`E99U`CM1jH$0gl!;!n(U}4>m6DZ`nT{86 zi=;ve2BUDSi6USYFN_v~qI&#RW^U~4ymHnoz>TxcUbps~v)8UY8*!%PD{h}S@is)8 zCN^!^wCR>D*IaWe)rooFcDs=4^13mG>Ui>Hbo^UUc=Ba*{Jtps;UHeKhVWhLkx>4a zGY}p!JJEA7q30kQo-=ScT5Keq=L|YNE)pLq2LbFTJdL9|zk4F_Ce6TO93?z#%-i@T zzuOcsjioS!c-RD8{{5gCMdFf<2XjVE zf~hwt(^@m^gqlIN9^Nha`mjdOlB|vgi$GHtK?8lt?jUk)1_oBxt_W&hJ`++?7&pyk zj)z5MI(f1$4wH0v$*HtrATS*03K#a9)9avoaNAT+{}3Bej|}fWXvm0-GiPiZ(RtOJ z%-YJdehp1NT@}w>r#|d7mEXLC^pUmFWRi6)Ng4hZSt++{;=UBt%Z=UilJuz`;YVn^iRU zEJ)D2E#0Byu(()-t$3k{8->_|GdH)}UF0q-E-fJA&9Oc>g+;L7q1hWoUhFJj6{P5Y zkxZ{s=QqOuP8nEQUe?qX265`3l8Q2a%P3ji)N%G&dHTAM{yk0YYU1^?z<1%$%=W5K z%HL;{(%f6F@xQ!f)0WZ4j=5Q1P!F~*zwM@vWT!}vbiIhuqlh+T{7KtC(a6J#{o1;& zkWJ$DSK)X;cUu&s4ce`cUXiY#0S;okqW|5b+1CUN&%hC64TcwEu|j|VV~iePOhs2p z%}90H)9^?#P0|ou?NwIG%W&afV_s9L*PV)`!lREqa*rD1zf8{Y|4yELY}+*({RghO z>aX|S`xhfb(4Z#zU%MU~9`$(h2=GGtdP{vp7~urgBskO?40W>inGg@}Gc^H4yJ;2N zrmyXIF;A4Ww78QxRmLwr_q=5D@8pgHt&PaJTDy zH5Qa(GHa%JSbRkP!z5LAD$GZ($l z+WN-G(WA%i*`qwOCQq4jzuIw&|H*-a2le;=OSjpr+&5`G5p_%_zm9wn=rxV!=!oc? z#vqS?1iEA)d0qMGPN%g9${SQ?HnP!O@t7ygS39C`3|Um}hf8PqZw&TB<#8>{xv(`f z#;<+E{d}kk8ZtQ>GO=Masr6N*NQw1WEcn5FGNA=vIHvIc0mqOgDGobUKC&EHhz6us zd9qT31udSb>kDmo#cUB8gK+BXL1$<9dSBZnGgq85V^U^G>ZK1%nDpkMN{|0#wPW6_ zF>~T$irnX4KYXYkvqkhHYM-;YAKjt%Bl%H}_&0-i_=&{32m5iM&X^nYO#f~WkMTE> zes>Vh<&yo@^EnX2!)J@6e}(W6ObvXqYK-G$v<}!S1M6wV93a?^G;)U50n0>bK?U2V zbXprTtY~3T4=ZC>!&L0Gy|<<|vtdccoZmKi?!OqN=8!V&#jrqotd)Krvo4(i*-Zn1lYwlq(wA-OWYEZ5%~a(#~F zs^hOiy$SC{400EbLwm7DWNyV-4IJhQa2DvIx>yQN%`Mf}oMnTJb3+Cjs#z{Ji{v0`?Ju4q~_$ zcoE!#SE$cWFrBS8*6UUFh{jpIy6FYIiiQ<-PM9)r{oJkz6FTS2Y?X4sISn;(=G4J` zr__3K>)bhAg9Z(595%3R=$wWAX8F<&b0_o}YtwD+x7bzo0(KGPP*mKOhT743J*>Xu zK(ICgV}O?3fdN3@1p~|L#Hmncjx#&67oKx??1j4F53*j^(2z$<_|_sUYNs_e<)J^z zC$1gYe%Wc$E^Al0F_)Zm(ag+>b?xgeH2ZVSzh0(Zz|3m-?UN?nx-5U~RTFEowOPXYA9!qUXc3 z2%6AHI>bD(jy}e=<4x?nn7kB!J-EW_o{WzC%9Kl$5YH#&xiM_8L~Ny`ByJd z;VXqAVhsu%O`6uyPA$^~-2!kM*CkVPk~+WPRTxdZgB6OEUiF}7tF^*zCPh;Z4mrp zETwkhu{7xGv#k4bo8^fnUws0+Ni0fZricA#Je}`pnuk2m+hC8qCHf*4q;4_x^#x~q zQB9!E9XF=DgeQ7=2&xxY^oBqbdgPLeGm}bljVWHOE224q^}u=AVEz6PB@dbp>Gh71 zAJ2#MdNZEpL&16n<1MJS6*lu$=&D6z`7$tDLAa7EC-*`GUSZ)V@QT(vr@>_*Z-&YN{mU;lnxZDk$oj^7uBr#`9E?}@@w ztU<@WMtJa!*&p^+(;5Zh`>_^GgfXO1#3#ItfcP*6Y$Y)+iSs%+TP6(u@Pl@laBSF{ zZw|#eyP4NL`+&5tsO`T zj!NXLCiuT>^&cMk&woNIuJ->Crx2F=cS@X>!8!})$+CC{4^w|++j<;+Ulg8Xq0{e) z!js>lH+^IcHf8bBom!}Zx9ce5N|>JZn>T5X>Dg1=TB>1cvVH5A{7 z3k1Hydq$wh|@Xoi@D@Zs?! z$lJkmJVkO#c!rLg9IU+;=M;I|C@srd=(WXSFhC3kK1sy-d!6nb8V*-O17g{1wB*8x zEsjz3Rb3UmFIw$rY%U*N<}8?B;~s09Ilt6bC6Amo&A)46MNQ$LlE#+If&s0)va7P( z{?6`9Jb+{OBlZga@6rVBJo%!3gUkV;k)EhDo9WJUvB68 zPW0T5q30lPEjN_ku_(MJg!f0{Blz8Z0{l~9yh@1RHxR-TzqI#^>KEoW;yLgYswd1r zST1je`NhZ{#y_Q11o_3eg$R7898EnJg?}(euO5rS|0#s0b4Pl6fM4ikQ0^?>9@_8m z5Iy9o{uQEsHVXf02>*N({>~77C*iq2^vBx^j^IseeZ*b*#K@MDjZP6OW31R^6H|eM zVW##hUnJISU?=MjVD&~=W0Y0zGnh{0V(Vf|jKow*O3Z4h_n@XgH zQI4XY>`Seztn5?S8~f!7yoJSiad7WLoA*!Di!e_Pua_6X%?fp?FDRf}RoX+hnl#Z= zTt}|J14jS47qQ*qmXyBTI~eu4ihOtm4}?2fsGU9y$@giNuZjELyV@vT=jts6iN}2@ zSRFTshI(a<$&>MMnqhdFV0dZEZX&iy2opq^AXTiUX!Ctsyfu#Y@5atZ!q(l~j$|1h zZ^qOPax+7ueNX*K7BxN26JECkW~!u5pX_W}XX`Vl&%m~p?B3Z;wbd161zvYzWU@=opp8P<#la!t-Tw|Ys+gKUTnZ zxt2G-BUW>qrDsE5>*PJ-j(m%irrQYIhm=$+^Z!PhGceX+6VYw8Ec!pplm?xr>D(qsm>`F)W6@5!hxk2 z6+DC58Zao#tgQXjimj8oMosjum@+I2!$Vo4E+4IoY?q8>TfR-fuR;GDg}6xq##lPX zvhdO5IAuo6RPX9Ja01|qVvjP17IKZy0*#Fbhh+Kky5C2Sc7)>(95S)n5WS!iFTI>7 zA~n_4Ed+rhYM3Q)_`wLAAl@!l&u(m-?dv-sy|p!cLf=8-$B*b7GqzJ6L7=enjDB@B zI8fn#v8K-7ws`vV1q)})Sb(aaW7J^0-;TEK`AkDDP1^Lyt-ITqhV)QGI(mN`ooRSw zLGL~bhty2Xb`}gS=$bmEt6*@U({);nwQW^<$Lf9_S9NaolIhbHWa9|F7i>V*%m>RF z5eN8++vI&c;nr*NHVgXI*V!0nRjJA`&ZmCBq*}BBu;Z5|21m5vZe%HlR{g z{bU1H!LT>dIN6YOGRmmf<78iaenAOuPo{7Rbo7K_ixUY+-Z8$i$8h7M`8`O^s|vd9 zSRR&e@%(O*gjk@PH37&4a@0hVxYOrsYAh|m`3Ic#uxL2#VYa04`3GGxwI?|S0U;Tu zARwghDTplJsXYXFg&&&i%fzXX+RE~xf^2M6NJ)x|v6#W3mKtxIoJjjQB79(N<=lb) zff&`7FT8N9>QrUfgHApbrIYYcq^_%5Stm*M-$~kIquYg4Ez&Mfppgv&t6QX9V4pd! z`+YQ~ZWqE1bF^JZEA*yPXc9BA$Z;M=j8auFph|Y(b210Mpm7*Gc1-7papSPz{aC+g zIL3EIXUFMnbv3g7*zyH4W-MGVea51o9(^bOt)AyS28b{uArLIvvod>Sq&sNGdK?ZB zV=TbLb3te|7RD<(JG&^m5bw+I(xDKchvR8fL~!0&$jh>b{W`HE8^zCy%k5am4o@t%_MX;VV$NnCn zcz4vym~rf-U`4slb#Wi^NDP7;t9#TK2c|~hU%{INjBx|8E*hhWp3cy;@^jnqHaFcK zeusCH9l^fREYawzvs!qEn{mh*VRB<<1k4>dFBt96ybgyW%aQ3QfWJb4vM7JWILsDw zOys-%YqTk2Ck@STOrrq6v*V0@7p!<}(#TO0 zgFMT3nP=F=Ze!iMG>4Qren*78iKN%ZK+^aA$Y&4sBlZ;o#CBh{!;Uu_RrgXc=Bfg? z&-v0~#aTCa$vC#6oBTdFfg<6~;Pgryx^Em-cg!NjYK~ce4chUsGhp-~3Yyx(hk@m{ zTG@6?UxF)j z>>A>QI);~c3ZrYVIo=(YUYXvMpWlT4)o*;m--2!9no`~4U^{bZbc3m(&29FY($X4R zTVMYj)Oy)ot^3eBG=A-phz2#riOBYe7$;N;&7qC<420UVE28~r?hRdr9z;AR-wi*9 zxp&wXI!JSGJwMF7!}xb;?rq>zBtAmVo{Pd`?rre(SQMVdHC_LBTr>J4^`Ri1`R$?J zk4NQ$xwnzevr%}=y$$^HQFzR~4gAhXy!McR_bSFCE=<1R)A~NCr^D;h=;uAq5>HXI zhL1$`DG=qN-8YnNLMqm*(lE&JA$dB3j)MwTD<6er+rS+HN=@!C`bVDDSy)(DURdTR zsP~q5cmxR9iQ}T`yt1L6f(>eF*l;A}*Oe9oH&Yc@Q+u)^RY|s-8kZ4nulC4jlM?iw z-&L5)88Uo^WQe*}!6$hebtU_UHPF+}V|2J7PY`~;;|3{eV?y0E8^H|sT$|x_B z-ge;R@({1HN-f5u(e@^|O9Xq~f?RSL_&t8_VN7*9H14hOV(2~E>rTkskUGB9ja@)TSu z7p-98&7APudm@S?Q{--cR+Wd3Ws=Ul3f>ZUywL0Pj8WHz1H%oOLNg!(L$?BiC*6u; z-BJp*F^nQkP}o!RXw}uJADy7x4p-h?ZYgi-)2iJSk5@37+ImY`WBEH07&dW*?z_=%HXp1JRbku`i0Y#^cIA1fAfKG4OV2DPw4)?b5O0?+- z%71V`@_9uM-hxZ{zo@q>*E^o&0Xzn;@$C_(rSRM^^rg+g`*?15@VUTnyw{H8NcrJh zi?J5ERldjV`H#R<^o`(sO})bP;j>cEFJqlrKPe@xZ0*A<%8oQTS0}Bo?8YG}nXtHZ zROaH=k@EUh|2?vyvZ76n^zW@i(R$mdV;IBTw0k!v1BauaB4)FbPpn#I#30CpIJ60( zBVEBGZjtMSlwibZh8}Ii!($0qagH~uW3Bal`j5~-i(_Q{nQq5~;LtL>|D+E8$Fj!w z{$Nx^)yxWvZPcb%b0xk!aykly?061pjCH`L(SDfFSjRo*KN;&HN0}Dq#6D4Do%~>I zWsRX%huGH5WFEE|V+HgIW5{9QQHOw6lI{leFp_?IkUk*gA+8HilK$`*#rkuI#~dBM zHHZ&jjEbP=F^bv>dPDdO+MnWpilC|3zfwwHn&D|?*tOEnVPyzsD)tNVA#604aTF_J z+}CAsce%N2WlQUj$*n|#4(JwrBk^cz3I|B?J)Ol7s+HpA7f5Z`nztA^{fRckcb zZfX?&uLSGGawC6QukSO2=efgo^d|G}=dyBfm?n5G3#;2boXZNw%k`~|2rp4Yqg zynzGeH#N;4&^bqL1Wi|`OpKtRROEfTZ(Ik_7NqV5Q#O6)M&RXMbSL6}|W333CV9BhGHr7SW zDYjQb#Rksn4X72R#@AN%t}1UdG)~^VbVx|!G_9d;qO*Qzl7GPobnpCbx<@**5Hiv6 z$IDR1A1^~4&oT_jocf)PeH(At@3RX%!E z>dmLayrym&@q#U>pZ6%__SAC>>rzP2df*7rm*$ID6%^W3v|>zQRVh_!w~C)7dMGt}t^v7e<)CeQIZ~ z5I)B7({ws}rC~wh`4T8!b5&lcx3C1v_pQh)%lH4iVtZ~y=lIXZRcv>c%ik^N54JO! zd$r^lsfYQdd>sDfSdO@_AJ~0p3k0`b1_Ra#bBcdU9~mgOMjX z1jj9c)gf)F|9Wlmm~q1qzOHFsoY`ycAbk`XG^bbQ{DIS!_`e!wl{Kn!p#N`U$MIT> zv4%ddt#yz-HVtTP8E8be{VT(^0)1jspi?EFuCyN3&(~rsl*6^uUU8YG>-}^9xU#z9hIlPwPIN`2SNv zGBg$yX|<4rXjl|Fz@RS_l5v4A6(z%nrNXEe+hFx3YH)~NDjb9D1D)VgesFAoOs%O4 zZX}rGZ>RN|#WSYke+gp0unYVpiX2~NEXP-{6B&zx68y0rp2}~}kQAshTCS-{c9o4B z+S-ynx+PO?GsR43^W%Sw8DCLhR_wE(7cAj^eT~tt7qIOf3jEXv{C2Ej5IyER_+Qh> z|JLb)a~^E*lU)(?&l&WX>qOB9=Q_2ZUy1)uJ($n)I(>lVIFb1qa~x3%KkQzVTOe=_ zj1HZ(^kv51-j;3&z#E=dm5{3lHV3tq$eEc+TTW zwvRaXrTbUVky@-%k(}CSuSRW+E5~HE{?uL#WAO+=#ELArMkm~>aU3P=NO)HV_G%QA z6`$H(jh}86dGEAQJ+363)^g&u8q$9J8d7ZINm?%?--xz*DkOKwvees-=4Wfe?ZNXL zy{)%~=)0omcZcYK57Cp4k9;tO@^d_Y`wss*(X-e^GUm~&OsskYSDVuzTy78;Njk|x z;R(!mvarV^e1ijBbQgAjH~S_uHP+SS=47PfB^!ufg2)L}=hPMF*STF4Io_fPqx=rkHwN$R@u~9>IGE$Z zwit^<6xu?OJlaB(1Hou`y8^~x>>!}=i?+P!YDv4gPI8}jbm0|K$k?t2lw)wtKEKII z!*m0d(|PqA!L0v7-FpDWRb2bSGqbB*^)9WtR@$O2ZCA0%s#mL8#YMJdNw#InvSSNu zh%vwh7gKrh7kHo2uUCiKfgeD2_b|4A;eyNzccr)w6bL|`G4>I zzt1qbcjwNXJ9FmDnKNfjAzh7v1WjUc?~>K4tM=|KFD=qIHA@%sc>DhScHhTU)u@EC z2(+^i?M!Doshy$J&QO0lb6AizXm>CaBk=^jG9l(x-S-;c)igA8uEnQSQno#w<{nE+ zPLlF)l8BQ(Mt=w7@&9Zm5^y*}9t5~e=}1S1?8@0^`#ZDRzMFcp+dd?_10F$vyOg1^ z%eTskW~1*fw~(voaneW#9~nqH<5so90x*~XPW1- zcUgS9a}J*YFKxaC>r^n-Z<>4A{B|^sXd0A|WIpDkCP&G~+VV@DHuEqGxsQv7W*(*m zN|x!I_wMtv?=I)-0_uE~@-ZHrShIf%`EMNScmUgMY|2%oZpiH=V@gB}5GDy?s@LGp z#cx_w<_H?x&7uyHElSsfvszfvfKBe!V)$<>4gH>h!K(7ArhYnc87#J~8s;|NYt8jb z+elvp?U(J5bpCGHcfa1Fl25W6CEu?La$$a z>Y4kA_4v>9m3)$UDD}uQeFdI~7_}Z2977N4;h%OEpfJt~5<$Baaw(UFS*WJXdvp?L zBLRpsmSzHM6QrS| zE2Pa1+JYtuM)k3@_4dz+Kr06F43vebk()IymVk2D9XNn3vR9i!f!IT3B=s8ZIpY03w@aWcVwhVM$BxF_2c$_@3Y?F!H3Ex?ajs6m|TaOCq#?8Ve(S*L{6uT)rF zE&Fm}{u4YfpJ1Ytf7qXo^@7^w&wrlpmG#g$&mU#|Uer%}$soGUo)bPw=Y&!|3f*Yz z0Z*kHjn2)=M@gCyun0@t=)mWw@ZZ&qcAu8O!>X9h@{^`7Y!dE5#;n{q&7x?DSm+)O+{2rMtPvc7Kxl+$F^YVW&oBy+U`82nc z@`ov3;(0B+PX7&-wI%F3%BmY>fJ=NGmR*Fc!6_k@U4M>N%~97~E7&C9xvF;NS5B83 z>{&60G!o1|p&+IXS=Z{J%1#^Zz`{^f2a8`~F1BJxhJhd*gfD0yNV?nsx+E|QoRt!b zR5JfB&JQ#s@GsAIt5QN*rgN&IYXj$vvPS=CR$3_Ok(?*ARx5COO2SR6%q!RdMuYV$ zVT;%gywPFgCAZiJO?S}-$s#nE$t_I@A{~H>foBmWXrnIpx^VOya0?EWy2`LbgCe^k zJxA131mlRKJefzy8X(XkJ(IC|c2?-tsFqLp0Y?xdxDJmAw zLP_;nptc#pf}7UbNIYsPz+5?MQk9LRv0tzwPe`s#)X6=6~rbcK(9+0 zR{%U7r(*VglGXd$6{X=N=|vS;9raNeMu#od;LVIG3Uj&RjLG8JvZ{kchE$h1%Mu-H z$~5Lh$N7#IS)xPZV-sL(PxL7X{dA+BVL17d9)mQXFB-HFS~Db7Xb?)63PU)e>7i7p zSrt&o;z^J1>SEtjo+1QTzPt!M;S5@q z;%D&xgz#WL&IrQ$Agy{JoI9Wk2K;o+dSZhR|7Y2~U5AcM^<93}*_SVwI`&v!PiD`) zHEXW!;g^qJywT&h?Jn=gmMtT72X1kA&bVl$qiaWP?T#+_y-x7Aw5N{8_h4c{VF;BG zRV+3EFY*MWhy|MSssiN#rdP$ab8-Q+JqZ39?5Haab>Tn-@PZpa$P2Mi@{}sBpyCa} zt}Fch{XsQ#joL90diItgrzY$UuJawc_pyK2Av4^9*#h{N!*5L{;6P)3YG1-KI!VB= zLrx0^Mt(`{ykUP}=g#fhkyZ>2u2?ZRv^@AH_uc=KpWJufL#5xo@y31oZoFZ?gak(B&%s#i{yd4K8g6!WC2l_fO|{q{WjNPpGV=0D2sS7UFl$M-D^ zk@eD2VPa|liJ-7BWHz9I&`$A|rUuaR3+f<7HAAP#3_H;jH5}2Lpe?`wT0pz_Gj%)G z)qlM(W!09>CHJ&f_API#LkkPes#~$C(#@9+)i2*rQef|IYrNdExVf^f-=1$<)mt@8 z!aV_*J%c|uf_nsmIX_v<>tO>c+TgfD?z*&6-&CqTOU=S?%ySkR3kfgzbcAXpCI!5e zz(0IejIeF0_TA5yI^lXg%40#?z`>a za~R6PyaU&zRyX^$4=?Umv9hgWCE!c=9tHU77<5$uQf!xrOcQ?l#||+AXv>UPeB8vd zTKTiS`|5ay?_BX@(eX)fDM|t_T^J{`oHr)e^ur3y5Jq@_%49%2a!Vl@$FzBhNMIWa z%=s2`PJTc@J)Bu!?e-s8(i95x3Jk9ertE}xCx5H?{O#M%Z>sUsoIN#lc8$mPhk?Gn z0ZM$$Pk;JTJ~&*vZ0*`*bt_`yhbjif#s(^e;z!!r>b%X(UZ3gmy?Za0c#LsNn;W-i z=vK?gCfYDam~BD8EWf3_>YQ-FakH3b?NiI{AIcF@3w2-dE{8_8?KBQ~pg1WupOXHc zmwtD#!{OlTdW#hLzZ5V13;jww1^rjGlji7uB1^PbXX(Fx)eoez|MG46(be^pCClxG zqRnN4W9NO#?Jiz&X`|bnmTAk(>S^oP3#|k6r4jZ+|G>BNi1r&ZLHqMS%m&jNOjHEiVXJQ(&nClt_D}fvbx%$rFKK% zCfADbMb4}HtIGOIvWz8}sm(RsMps8|X*HUhk-cnj*)m!NfCrV}pRy$UBS>R47;rubuBP2v=#&`1sAMxm5w)*PURbhR(JIGHceC&^DBLKrS}$n z;8|T=GvdZ!Z^d9s)6jCh$L0Gx6RbJlQ3raGF6l`wtpl+!Uuhl4P0PtP(mH^ko+qsX zssaZox&^KSileICYs$*Us?WZt=FC3Z`a;8S_o|JF8yf0{mNwZs3RbqwtpwZeT1E}O z^ZK-^7Fr3~R~9U`V<`as_06=4AW0KWS_>p3F?#dYf8Gv zlw&O*UN9M4jGfkkN66}TGABdWh{*}Y}U?iO!pZP~hp zhIM7NrM|xnb#x3-`q#>pgM&zXU`6fHb?cVaVp&*LUbdvTcu86LviQ;V_F83OXl-b0 zZEb95mAKs~3@|E5!;GXg9czQ?z4qj_0ZJs;uc>FI((71WI+^9c>zLI%-?kA72w>8o zIvZf+9_uF@@@V^ti)(iDD8pF2+>UXq87;-=I@=3Ywl-l%`3Lvhuh-~>g55L>xbMP=IZ=N~M z>*a6YGJfac`G_oUk-wdI-Yb7w`ed5=z?1$&K=V zThHKNclY3659WX$MklN%OtX8T^@KzVkZ93<;_q!Qwl$Kvu)Z}%irleP=4-7kU3vD^ZMU`k@PG!9%;}vIKX-#HQo*}ulwZv*M zX4}xpD!auQPV~}`V_&k5@E=Jmyn4V^4UCS{%|~j=pM0U@$vt~YmM(=S3EzJ{O>pqz zZ7R*;WJGx-4liI20Z$Dwh~eB`LY>OW4iDs5t{UL*PTpIQ5`)|-Ct&xWZ||+iS)7u* zs4U-Mx0YLSIR82JIALvzs@pS?^K8X=IrhSTlw6vD;X&X1^CtY# zeKZ-;(RLt>fPZ6k3o*NKkeUNu@LW-=Nlu2Mra9S^nGPEnQo{)3QD=7+{skp5Kpe{> zTe}vu53OvobfhGgmpQ5EK_&*h=5i>f=;t=+l>qiP#irpYk>-kzIlv*+Xz zZ>XFx5gpJm=xeHb3iKD9p~$kB&vUDY1_$tVp87=9<8Sfr7y7=zw*w#jbi)9tN`bVg zRo4$XQO3w&C#7sE0>NQ6CneElBG;N^O3Kd9mwol)?};0VK(A@B{tZP*eotFlPo5>y z+|kg`Va`0OsHm`@xTtUx|6VS&daA2EIi9d^SGKFN(v|HF`yO<`ELKbv-;dhxPZ?7` zjZ?=oa&AAN5V-Wfu~hdLbJzwouTwu?XiP22m_o28T^lP)i@LJV00)lg=jw1S^2p(= zzz73Pb0LJb^1J2@CynnB8crHk^b54x$`kp3cm^lfSW964A95cP5AhVK!bxL3%u_gQ z1i^VKl~M;exkH; z+~#13_f4pOseGSSEBRbA3u`>jME$+;Io9k2>ih2+@FrSeN~#I*kkYEBOKm3 z-)7fx7s@0NbBNFPQ$9Q&Grky7HW>F3J%KMm?ixU54;$i-@JAq*K&(Mo9{!M+G=gKC zMCcU5j+_*D=2<`KOs_Aom-IPpeGYq{eW;|zUfk;_>2cV4i2k9=4fuO4_C^|LmSA^8 z4I$1A`y$X4^2n^jKaf&p=|4ni6q@x;zwg-r6h&WV_Ool*fD#80%RFl)r(m)kh0{PB zVgt3Ul!RV6B!j;;Da~ls$b2{mi=$j#NsU%U z)g}Jap8%rY$^QrBKYyP4i{q;L`|lR< z$KM~MW>I}J9kVbBtZxoT$r|kc=hYVn146ws4g*+ija$sG=d1!5WQ+L*+PL8pdJ(V6g~~1R3e%LyqhlpmMAJi;j`5 z#i-@bA(F}M1bm&ayq8IXOi{6+F4M@%CHIu@xBljM z82=o9bKH-AzPJANH;JFX3po}_JXb%Xs-Pyp9&jHZ0HN_ff@=^9C5;&wVy9t8fIO0r zVlmQ4?PEGxhm?_2a)aQf1V_v1>FLIFV{Eb|k35u=;?!=TjRD1`G3f!ypCp?z%%-|B z$5?Sn!H&wlaquvBeirtzN2xgDd0wywnHj68?~9K$#AiSlNX5gp?HsG2om8^OG^O( zM2HOVR-`jM-hb8FBvVFFNszE4o6{|(y3+C0ICrR|(Leq`U!A2QKcle46B%3BU^C{H z=fQduEd~fd3mf?Tv>t*Ml1xa0zlU+f+~W7My%Lwm*Fxc$l$mGfMkrVG-xoH#?a`?#kn z7Q~IdjnmzkUrCo08fLvuH`buiD}}zH3m|YP=5PFcI@Vx-Yj+)gub`knzCX!+&7R~r zDDs80aS}u*ryJjM;_R@6U`PAGIFNIBR0a)NjgaTbg6#}AML`S6zdVFKS=QKuEJV>r zDS(?C$Y3CS2VQ3*ObyuSLFm0y%qN+^PB|}>BOV;3 zsExzx*(^INKg$xUDDo1n5jRX*RcaTVB&eJlH9gV5QBfO3H?(dJr5s3^d5_H_n932Y2i^ zs5!%N?5`xSrZMk;4;!k7RuOgWkS8s2e!;AcXg9@HFc6cB^f1+M(4mPz1)NR5&PgX; z!l@h1Z0WpNo}ZF8JL;PLyB^P?LPt@NqpFKwpqo;qpapSM2p<#-5P4Ff2g#TSV z-Zfz4FoZCOp#{ez-&=$lex$yI3h=*tm$8pE@9|9FntxqA1)d^yQG({Zk0pxYn|P0Z ze7f)Pk7wU&V;_si(|NBH-)rNCl^5mr{;hVlu@Ct&ZvOKADc_6!(B+c!$p4-Ys~7xT z$(Hdg@Jp4Ay)V(LarQ%5&W8LknNLDf4MeBN2fR?8{S5g!j4u@2Yz3v5U*6q zh*u%C*y3U80WI~{_8ZcT@i9WI81P!F(~=XciLyS$hm4E~EqNjO3{DDQeIw6p{k%}z ziM)__a*i4Le~YK$tbXQl;nKLkZB%;8W1~P{U|}M_^oHo9ojRHKB(FK?H4+ zEeO_$6(Oa-f?+;om!YD=|C-iaSUNZ{F<4sEotk86uyie3+6BMe6{*>pO;ZsUjj!Dk zv3ZloN|R2zSFBG1`^Q<)%!P=h;$r!T5|W;kiFoNbR&!!pNU$EU;Ughj(6lAQM@MOS zvuZ+zQ2|Ar!n!dVOc?bLQ$-Pqy|6hT8@q)cLz@hM&S!b?F&P$l0uigYOOPX~#(Sk* zj>6i2Z-kFw`a^^JPmEv@MmBfanGd^pXMR>?NsFW5+@X9AG?vOe_0cgj(Bf(uPmFY) zGwj@$^Wv@h;~rT4jj3A(#l|hchHu>&9)lrAE5MhO&m0r!;0`jOso3Fd%}4-7W*Xw7 zBZ3gNHcG+CG_vf{h}Lv4JS~y%2GNWKMRqs9hXQsMMx(LBSX@w?M;Iza8hUKlq!xtI zAEF%4esCyGlW9#Le5LLqjxA41OLphF>U#4#Gd($_E+?d5nVtE)b*^0ZB}?q~B@T!D z%eQnF6m%OsxgK|VN?C4c!@xj&S#DWMx*J)(TV2cC?qx3JFUHo1`DVn}mavW9*bHdG zWoLy1Yca&o*5`8wtp&F^g)zhe4Aw+LZZTyH)44&-gyReCZbFv}Pc9+P2tuqe>6Vx{ zc{-}%=}9)Ap4pkIOjKzuDvVznE^9cmD8;j(wtuCqsitGN)-zgDx@dh)%D`E@t8gIN zI#e||5wdAgV=J(iP;Mo#dy96sOMvg1o%0O%6h=`Eo;9)_dzoSf9u zocx^pyxe)-mnm)nKRxfudMvua-CmX0H8C`?$| z=@EkaNippMf!+pOU`cxCDr<^$YuWk>Vy7mAn3#&)y{>XYeu{OKy=!&U0}l)S$b%WT z5P{BfUcAd;jEyxq&bb(2Q1gn{uf@;oXvZsP2WQ1I%_1Li$zFqZ2D4_7Ut})}k38>^v8*c# z3vK1$qvu>4m35_f#_6c=+I>aWLkqYHawx>djlAoYa0sbS~A4rCJuTVemx*OH{VmaWXaxr*4oR2KtW_ ztH}Hq&=*Z%t*w=BSHZUE=C>;n+Kc)7?qzkpcW|in0I61fIyO)O>c?3bXhS||LpJDs zG6=m^1E)_X2EYALbgCZHi+q{hjS#th+c~ePgO+Hh|pMb z4u(Rye}zuk7p`ZeZWHf%%{Zzq;`VWKO72MUh8;ULG<4p8uxC%jrD+1zak~YGa_bdW ze9!nt!g-917h@B{O1y>8mcV=mXF(VbJwrHmSWBzZtdl=P_JZ=78c{O~>Q@9)Yzv$S zjK;G>y6^YAq22dH)x<;<_qIcO&I|*chj^Xu5O1;L_Ho$iC_YhfG9%-+nplgs5#WTa z02JPJp#Y~YXgrK-!|99(?n-B4;!5E?ZL)w9rtB;uZasO zC{55)ilIaIQc<}4J6+Y(o~lFmSCCUPQQWsImJcqfsMuIhQE~pN_2m;AC(0*$4}|6x zQF{QlWZJBSD9+<9$QUzm2GK5nRx61-%GyFKH(|bLwzrDXZYE^_x{F%xqWx&6qY*`Pg;Q| z$;|7m3g*EfXU-JltGf;WkN@nrW88l1_%mYBF*FC=T`}`dE%|69 zPfp#u&l}OyD8hup4zE)OisoT>B@EaU8mh)+jS7LhA#xRqj701t zy4NATL%XEe0PHA1_ ziSS%$b2-|a2njSKQ6Q0URE8BLXk1G(9)c;bBB7!BVVI^!>I2u|P%KMB93_NuMx0^! zFC6}y<0K87opLmmB$mpNz7I^MyB~T;Htp!9v(MRZ+jj?+-g7fA9=Bg}jeXp~M>ein zvB}ZVwjXWcpfF6-$+-s`g9z;P0Rv)Puu5T=VHOK^9bj;jBJ?BOWr?~Uxg$2M=i>t@ zxe3>`T%BI!zT%A^YJ6w&^OL^yhA0wOWY;@-#=?Rv#`ljFnLJlwvg14cH9|(QfB;*I zz$OYSCRc`81}t!8h>26y1bCSOs{j{mrwxF3PANcxE%)WHL&Um7I;^XMO~Q{1G8{WTgBv4b*Ez*79L2MJsf zF|t0(wR)8+Yhq1Ae`i)z%`*40n#!zBg%*O1RcPUdBKkOzq=%qcXycH?lWOmx1kOS@ zxDuvU5Cq;upuYr91>iHo@bzCUNVHne^amM!SyfZe)p`V*C zhc08e!YIfRhctO9j#WR!_{A`TC!o;mBx}`5X>=G1lPy<7t)#pGB<`~@385q_`GK@lVr9&D29af_tDa&pbKT} zCiLZ6`8`;M5Z4BBz{WAo5IOXdx*DW20NP@CC^A<&cAR8W%lVv2mmZn7KDFJVSKWh&G_CgE4JPA>1@U>u}CJv%# zk}#A#eYo@}<|Fl;sr81!wklY!0V`Rc)*XYLpi=+!zHxZNHauc~r0n_|d|A}J1>S}G zGQtD-EqWK<8|T-fd3d$#hU?27vHNb5ug<IhGjd=q~>$379=9-$78_CO8QDe+TwN>U!*^cYbj+xS?u)xSn-&-G%gUPS zH`U~t|7%mN$>=-c_HM}8>}|iG%aAyhHI}oXs^fwV>)H&VF;z|0x|SKMDktkp`m=nO z!<`mnPuGe({$23)WJw+RlU#FJ5_XZW;gc&h#@TNwNoyH)usS+SqRlPL?2M|m9GdOq z?=&=2m>Uxl8_gB7cfWThCb~1s&_v@#{K=3%`FGpP%Gz`B&wqWx>TsAH7K?+D+2Mei z56$KCao+Sg))%tP3sz_+eV8J90Hevq4=XHxy#PuI9fbvUTW>qI(6)->&vtaV?d77P zqg$!xf7?FsfBSo7us?uh3swRD8bd+{Bw?)ZT?P$;W0z{Gk3FS112FwRwe{4^ltHfY zDJ)F=-{K|>MgyyLlkZQC>xVDA{8RDiu{!>|(>J7pu``F^ z4kY$cD5UzLAa#;t(ojqDKa!Rcs*7e=-OI;{tU8{>n_^;N-vigaH0=mkzoHeWU{y1J z6RF~J%z+%xzKkSLR>FUE0!Udmh1DarlDwhLmko2*Wy>$v&^T4&-Bi1^JmK)|yR)aHq{k^f?|tE}+ivupJuvX~n&tb>DGoYbP`a_bc15vsthr&-393>F-K155 z^q-=j>yg8pY{*Nr1*}bw9;8mobQ-O09h?f0a{~>Co=}X4)8K86f+srU++2#F zt3?3eTm*qs4C<8Fx!NdgWNtWCVb}s281+cy#ARpH*?g*e9$x}gtmAJD4Rv-64Rw{e zaoX*6d-$Hg!LBYO`cdj~l}cqT--=BeNuzw@CekSH=^YsC@!dN~8Tf;&fgY)34!qD) zzqNm!*Y6tE&ZfK@)xL+&$&MCVCXJTjrXdnBLYV~iJ|-+$L%sx}^pP3~0k7BP@;Db)Xd9hDr@iy&*?gh16ZPVRjV&Pe6M* zELm6ua-42#)=ddHrf)VKdcSS)UmkCMsyj74E;2kUI65|1pA;64=y~DcVIgs_#!m^m zKCL(r|MMeP9Krt=S0%>AMh0ng2>BfzuMalF#zx_nUayZxKoIbjmJ3>1&{yZo2F*I0 z!+KdKTk0J!#)_a&$fLjnVRN(#YEnU1or8n;dZvfeUmr3G9!UzwaBL!DBZP)Z`EzDy zM{O-*wVkz#TbfW?O{~>wiMLp+RyqdI%AV3hq5h$R5TyIpLb+T+XG)_UkIg3ukg+*Y zQursAEX0>SzHaJ?GtYZqdF3*@r;>}LE=NtTdv%SyC6tG|ZJMmC%$kD5H-2sX_g4?y zv31}AQ**%&r*w<+7C9a5xs`?cH$1uPf@dZ-et-G+E>8*cmzMX|?`X4GT72)tMP;WI zZ&c%E8N9R-CW}xFI!YoHR7&mr17nQOz|@4U1i{SuauqTjn3jiTU?Z$ zWB>_)qX3l}n`x(`YG4tO!D$48!)QgxT=_vqqgnHcX&~2!;O)fl%B22KP+H*0wc@K8 zNd+d-yoU}Afs!VZ!ucqKNN*sR=^;Q+^f8DeL2j56pKX^skW7H*6+r)|HjDi=7_{h(xp;~kQsDuI#b>}uh8#%eZZn61Q0!U9E|T~JVwQWIz!snDC1FDUfeKoe1C zAXG$=96~ySfgedvi5~C&+Tx^wj*`l{BF{iyV@6Ws+WumAIc{mJsx6?LGcr?wi?x|4 zj+&nRwT7&s&SCO;Tvb+BnOcyyWRa=Pkcm8VddZ^7^1_O=!h*gEo6%BWDa$H^`->8D zS#}|C%rWyXaUAn93KG(Cwo>x!eI9pSF2w8TDL{dp4~#dZrO7%5!Mh%lo7Oe#S_g4~ zc{F*=<6TB0MfNb38_S&bq5`WqIx{*WDG?P%TFGZ9G504<&uWr;Spf>_c%oA!Q~tv) zqRyC>(8Vxu*Y{Sfylrgkdn;GoHac`gU*8o&Ls$0oT{$$capQ`-uCxSWWmbJ_YkgLw zF#+a9L94ze%hKOfxl68Gx|9mz?~=9CW24jQDFrD>u$&r7!c98SQtdoAIN)Pvy@bTD zg9qWj0V`S%lom9*aHhxLHV^>OGYIxWpvD;tzofxI>)>~XHZ{2TcQr02!K0$gxyZGs zh>rTu(Eyl4s4$r`?N^wV6M2}JFjMMRD->MYTMXvMm&hr%CD8A)z_%7$$9KPk; z^T~(4SNQF|*9q2w?tTH zEiEYUP+K@#4ZXG-(LaA&IOPZn%7b`{BLb-u1f95;)l~;IXB@kf+8OYUo+M8v>K{|% zp;otmJS7elX=G(pReT%o^xc2_xAYh7h-bI+9GoeKF{yz(E3!pMAW&)?t&zAEPv^u~ zQcerVlc3;%ty@dBZWTY@k?Y&ZFU;M6qG;#WXFlQ&0?z4@N6yI(As$%^twKm?X4y$S z06I6(G=45Afd@HVromEYQAtI4ye>5(Atxy#qtu+$oDiFo99iqS#B1E}*Tr3ntSuLG zq=Y?WwI(Lzq-FBkw{$0F*%#eLu=V#Tj^SK+At@tPq>y2G45X#r!Dp-#($OCZsjNt% zNz6DaVx*qRzAC-_L+*|px%i{>7qm>%f&Qjqw;ud}DGjH_)L%cIBqn4*&of@iLzLA% zjub7WP(-IE2w ztptax_)J1rPtcQK9k}CQ@H`s$jiRjqyh>pem%+{C$ylm4|Kx&nG#@W_5-O9V9-|?e zaCUZLB6LeFFoiTFW+ft)Fe#Lic?230Y9_i9xNE?HT8uoOb*f=jBnD_O!!=bM9kq3f z7uWe-tg!iNE9~Ou1^M~;g@ySS@~Am)M!x?LL&s~x(&OJ3Pv&_X&eBq+z0@~EdWOIO z2fGYB?lfe3S#qXBC7Ul&lw;qc}XG|48BbhFHJSwW@e6d>@H*Ge_ZTkLt6? z9>G!&JO_h9A=-(!bojNHSdLkV77&>9>3tuyx8L60F79n@ZIykRsT1QfSHaJ`h8bl| zI4_}!sJ{?g&)SI)Xh_(8sJk0M3*k2CRbibuAASHeEK|M*mj;~e2LH@S=haWOD^@cZX|tZH=a!^n*Z`GwD;WNvBh``55!F7>^pjvF5ilk z-NYy9y@0-I@TLLj@oc--f<)h?I$$p(v@<`DbT+`=02-6ZS4kSN)m$yKkD;vZ2>SOi z+Bl0dq$>!Vp_Zzjl%8GuZN*8_(dwBUgXcb zB>s%4X*i9wZ~?1v<%6^2eBMg#?@Gd2hm*(;?KgRR9CDyJJy^=w=M=}sMMN0l(~Xgq{6bfJd~B?+rpKqphR244M}+6bSkeutF_DT*gphOc0XeVq zEMK-FQ^7Tb;!H0wzaxg6nUNd}V-^_z^{qozwp>ZeclPCGf0r)F1s2S-p4_BcCFjA9V# z&__g`5Vl~o7yBOI>l^69P4FBM!yK{?IZe>qn{{76n_mf- z9rTUYXO_?SX7-*wT?$g^((QB-i1*psGd>=1`tMuR_unol!TSODru(rx>--W-lDMw1 z%pBEh!Z)#2lNSR5{erv+9k>VZC7Mkw5Y)+eR;Mw|T&3B9I!L7mL_{_(sL&soecM5(&;aJtkzrZ$`2B6<5nPCK*j17k7YWWG!x)*9Q{*xvMn{X|(J_gk!D0H4P;d^W^tgm@ePjsD*F$LA zK^3?2y*cydt7ILp4OoD8pb15M!;v-B&7C9FO%l=f1J7U3gaP070532$3cfAOGx!ls zsH(5T{A35>+nx<^gl}^#q9GmuIIN#sVl$@c zHH|Gr^|qvR6V=5g#3J?>;x5nf9jCXBsuopbUjCTEcI1i={cHzu7n7nMc7k7-pNX5} zXP#Jyp8?0RmFIsI{*>fnLS;_@yJOft-F;g7wKxpvxq_L^~^)_2eD zHL*9FVeE%9ccGVG%HxqL;clz7|1QF*-NMCwD2|-YdvVHp;>ZEK=LEj&2OW)I(3Ac$ z*q#K}v_yid<}#v;MMNuAxPHd|DeKJjT22q9C&QEIujb)d+FJ~p$XR$IvO*!g1iSK? ze~4zdd;TTVYo7Gyv2eiMNN^WVD0%2-BkXTO|8Mb_{$u%*dCa#@o5w`|=lb>dY+p0P zIvz>#7(~2QVZx@MVReY)Du~$UhtupF6p#4xSi2uT#bdL1vvXY>p3T$2{`h(Dwi@yB zY#vNoFn9ipyr=wmIISCi4eC?Kd)l9exvOQbk=#QZQu09aUl3WK(d5e~io6Vl!zzwP zkkC)e?vWs)Ae%_qv}#rZg@mSCt0j#93!P;d3W!zF2Cxd}6^^#~J}D?Ncq|r=p{Q`1 zxNPq~=Lq=xZ$dv9-$cATd{f;c?}iXtV0Sk6QSA5SM3&@gU-pJ*6ED#o2gqHCOO!M2b@ zgwPVhZilqQWViy2wu_E1Wf2Wo{V!YwTbz`X6ibS^2+K5WaZbHV2lfF=c6?;`?2?_B z7#(xzye>t@#0Bd@^r6b~9TOX-4-c81>!csP*-{q-@q8_2MDo<(tPo)Ki&6kloVnr* zO|RDtk_sT1S?Tm$O1yFl?7(6z(w<6TmofzH$5gROv7iG(lRr4B*vK8J)g{#wYBcxV-gi66z2%+da z<^sYP*lLg)+IR|l0UtZCzhvYh0Rl(|jUj==I?9 zdpArdpD!h&$Jy_Hg7L8a@6ai0isdWODV@nrsM52Py+uMGp_1J~6jGv8A)#S7mQ9O| z3(<#%ZVT+Fe|-N5T9wPbI<11Q->*umu$4UxVnxk=%1^CI{5RLnZ#(w!pjU*Op|qn8 zq*qu|O%w^w^+MpWuN)p<$qEI*nepi!9|XKzDG45J1qlW;Ghj zHQis8nBg43Ps{>tRbr-jK@u@PG0Tkoo@^oQ4G1Y}4~E`yHiB@}PK>`2))0e)fuc?aTOG|394aQ2#4v->K3^Y&jNS zoxs(f*vB|Gmucor_VLVzNH62JP*X3ieaQPSTy3~gnRjuo!SyX(I`a=o;Qg1&lz{r% zagXas>F;V@1~dj*P!37_9&kxiu5^E5(mWQB_X+y-|AB<|dcjOBmx1*denp~R|4T`; zlzAzMD%c;t$?T^}cy1T)J1?(vuO_Vx_mTgjse~_L!J-!5I90N=`VNomiiubQZUXIbdq{|A!hn`j4cK}~kvM|SBlHRIZdREpGs5<{#UX%A8>QZ-U3(f~NN zDmBlg=3Ry>C4c#)xNk(NL|Thfht!8e&nw|8*NQZZl!s(S%0+S_J{pn`F>}K!<|9~&Le=r+A6W>vj zzb$GT7#jn>_B)ktdk$CpL!I?VtC5P3XbhZ4loy3mjMR%%rvA3$O3&nT?Y(S;nongD zkmxzxqh9PrhS=3e^e!bDXaD`FuJpeDnHKjYNaqvYN!+7nz(eHeBpwd2D$Vt*GAKvl zl0#gD{mgY}{}sT~a#$;evqDV?bI5cl;5iA6(Bo`1CD8tO)bn+^La^r0=3;#ug5Dm+ zMUD^ieFRB!9b>*%aqUCOM8c_%?}(aSz;8Y7UqX5v_eNY%)<^kXB%D~z)Z;hRL%-qO z-}i>{oXX30kU#sKU!n|sCl7f?)U?ny4&j;n25@FV;!Pn0hlPmNQ3(7gWc!iMMcOS> zZD1aF#X=U2)TmxFaGg@$ZO1+8k+_I@5S0~m(YMt9j`--mmrnf6=--t3-_7g*dk8*W zUt>qu`|LBAp+`eFRmjVD1MlW5;JJF5pT{pl#L0I>rMN--KztxR*EDG^(d^gUs!h_~ zs=Y`1u=aiJv7ox3&Y*#yH9=EByMpcsdN}CWpkL@bx<_@->0Z@7~WrXF2RfIK# zO^4kUc5m40VMoJ02>U!-7akj)5xyjREPPA&&hSIw&xgMrel+}pa9>1dL}EmCL~%q# zM0dp6h^-O3A})yxjZBQpjx3I>h-`}Nja(VIA@aP){gEF;^+v6X+7NYK)c&XgQ6EKH zqsOA}js7%7#6-rV#8_h-G1W0`F}KFt6LUD`)tGlUE@baUsgPP#qP_xIqQRLbN0>I2eKc^KAin( z_V2RaGZ{^5O+f=mIkue2oR*wR za`xxknsZOi!#U6Ayqbxa+2l6BHQ}V6(mHEB->+|0(C@wg!;MIc93w4FDg&BqUg`UE?!p_2*3hyj@qVUDS zqeVN5_7v?ay1D2;(WAw#;yuM5maH!MsKjTBwx!u}ZCh+RZF_9{Y&Y8u*dDSSw!Lc4 zwY%(Idxw37>4!9n29d^CuI^ufY^_g4i zj&`THbKNet*WKYBa=+*P*mINT&eGu0_)=r3tMmt@M@xNW8D#@yJIih@d%Ij$-c){F z`O6jA6}u{Kt?(^cw&?6d*DZRxQdikfc~j+URo1EttA0=&UOiboUA?pV!s^Sa_f_9e zeOvW`>Vwsf*63;qYbI+B)qLo!^!~v6T&=bCs@hv?pR4^%U3Fb&-O9SDx?Od9>u##M zr|wYQ%XM$peNgACkF3wAFRrhy@2+26zoq`X`bXil;2d@w4!Oc>8a+}W>51#^K|o7&G$4v-~4WiXvt`?wiLHiv^2FWYnf^} zyXB&mt6CmzdAk*kO#=SwZG91uLmqDTv_INm?pWXP++xq-t&5-SjO=XdysGO!x30Up z`@-($dQy5;_DuDh*K?rf(VjPYV|%N6$9m81y`uM?-WU5=Ut8bazI*$QEooaawd9T^ zZ!h_{Kfb@Xe^>vZfr^16gKThM@WY|=hVEIKz4VS{j%6#B-MZ}2W$!MJUf#WY&+?a7 z^sYE_#Z@aFUU6i_vEh_q*YLpb8S66>H5;{^i(lD}eWO`)($ib0cjQG}6uGz6>-;resKKI_;cgG82{bc#I;M- zKD#b^UFW)8>yE51T)%$(4<^zk&Yrk!;x`-WHe5}TBTX21qEIAQDl;>4vrDmBTpE!G zTLF?#rE!V^p!~yHtKM_yanz~za4ij|tx6dUWVgNQy%sumo7DRtoMXcRPFACX^!`%y zKA45G>(%=Z+<#xa4};w9$Lf7J?q5{zBiI$#cPaHmvJ`#>Nkg?zSYa3PZ>jg3Ws5lV zUa&|({!*xnhIvH2dap&9v()<_$i_%_lgjA8M?aw62ZOKvm3kk7dy1<-Wx~L(kfRg2 z5669}dLO}!nhEtjlDV|sRPUo%BGku~@-Zw_`^);xTh7`#v2Oh|LP6U-#wF{=jh$yq zO-ye#c5mIhVSIGjSbN6w`psLn8FNQBPfd@EPJ6eFOl{k|E@vYk?HcuJlJ13UL!znzg9UIxSE%1K*^z@dB zl9KJ)w-?WE$|)Z#SwFGO*o5XA`!=tg-afK*+=z_HiP7^}f~) zW7n4PDW!0SQlQZ2Z>GK2UJMY;y-U@MoH;TvIkIMQ+_-&WdcASP*i_qN9GR{#s?FIp zx^-g9G&J5OCW|+3U02f8)N$%3tR7lcTi97_E1Q7&%6c}X&! zEVT(o`15P;Q_I2$-)x(D@y0rQWfIS}qJ70^A+>Q4g#Yc(T<*kOU@iVSiUR5gd~Sru zQfX1(R|fDE>cNC;J;A~MoxpefZwA(_yg8-5SBtI82&VrwxXPv)PtGSePXq21Fl8-a z+vR`7UkIhI{sxWR1nM-hCUrEZ*PGc|e0jTsBaN(5ViMO;IV#)mcMSP7-ea=$MwC~u zpbr6lJMgRvEvFF-teM7Yu7*N9I}w_8K$DV!RSSLB-xDtM@1H8NNe#gJ{P9Pr4;m{nO`p+q5 z9su$z@DJ+^soe`uL^Jz2&ReuNN!F3MJ^ZwX@K7Gc!+8XcWNUa7yBoWc7#_>x*eKkX z-@~rp36R)Xu|J$)V~C_=;7PFBOlJR$s7$Fm4IU3OF!jcHCiXO0JexhoP29{a+{$x! zE_kdUYrPk-`+SL4vX^-k z`*WU>sb+RV8y(VmGCAGNepl0ZHWHZ0eQ?(?D9Jy3F*R)ss|Ao`}h*x zkI0dOKs*O~l@GE1Kpe+qd^z@8!+a%M$5-*ye1!dyui>M7jF0oRd>wXT6MO^T$S3(G zKEsdLg;5YCa`AzWbvj{SbKSP@F2)~8j%D=~NVC@SpML_|Mr^{ycwyzsO(WFY{N}HvTIA z5B4?w3;s*~8vhl4o&TEuhW{4Rw2l3fzro++zvFK~PT3AO6-W4g@}ofeU$bfcU+hds zcDM6)_`Cd%>U=QUDMCc32xDi9a1kLQ5g9yM z#E4iCC*nndNE8N<#BPB^?;MdLQbiiO59H|sEIIv%d7FWluSSt2vV{p!JF~E`cc7PX zOyr1Mkq5uX1?-z_5WZcDM6oD=FIu~BupzcfIN5o^#m*OQ=;e7tsVEb0UnCY`H(w>H zMUC)^T2UwJMFU&PmWf8uB${y+v7D`7?~7K%(r6RyqC+eeouUi&h~4auqDS9-v zArFj&JnV;TC8SZ+%nPnB931KW;!bfF&Up@qyAfaFPIibr&Yoch*#qoB_7iqk+#`M@ z?iKfm`^AsNL3SZ{$;V*9_MrF)yMz5hJS2W99u|*?N5x~}ka%1?A)aL4fL`8y@w7P1 zBH3Trhmg$}*|%60%VghX``Fdsh)39M@PU05yFolFeun4~*RtE;|KS=I%${XG6+dSt z7Q*(hi@;M~f+*&@VRZ8zy8s@4pAs*K7g?=%NxUpx5wD8>5Wf(=6t9V2iPy!i#c#xK z#T(*H@jLOBcw78l91;I1j*9;he-Q78cf}vYd*Z*vpTwWV`{FN<0R2^bDE=ls5`PyT zi%-O-;veEO@lWx&_?I{aFAY91104sbP_fI|o9sVvQt}(N7jn#c_IvgQ-d@Dda%Rx#@a^ZTIW%( zHudUIuP)_U>%Z2kzt!@!W$N!*y4oBz_5E7^Rn~)ihrV{x$mrJ1Q~KJ?>o!k~Zw#&7 zIx)3wWb};baeZy0a+OW8m4(%hPHY`LW7FEn@g1S{W1GRAgQcC8U$ED^bPb~;s0f#> zn@41>z0sz&%O?9{v)8+Ijk24%Mp;;CokMM{T`gUw_P$>2O}*OcdbRiUve#(6E3|Q5 zGwh9ZYE^aqj(hZtY6oR6>}r*Em%dTyumY&MdR?<@QCRa_C&QbuzQ>RZ*Owkl22wN6h=j*W-4 z&D9*<9?&ESJ$rqru0xKKu0!@9yd$89wgatJn&XsR#~7&XaH`*TDY#&-uhn%b^>ixr zbOzL;RN<0P>xn#QSh>gVg!XJxvs+1}b48`LptP`}ooj$MOVMT62Bhk_wCheI7O zr+=(fT&Qmh>cWO_O8H2G+U7=oZEC3o6{d~0h_3Z#0Drfhv1xMT4B2x)wo%^|hy-r+ zZMXcE4bv^GYun_=w)Fw%?ow-ZDVXbU)a!Z`==3Vk={*5DHiunpx?Amv-T#?JU0*=W z9u)!}Pgq~T!1kR`pS{te*5^^{@u;9ISDIumYtk+O0_v8?1}USVlto)qKzY<|cp7#6 zv!jE@Y8x8;S5Hv?9F!Z?4>hR`X!Ju}t*o&zqW@$lV`}vWLb=|tP(RTWUBAR; zWgtt-bb}HmVS{rp2pJrk7~eX+ZDN~laNX9CGsnZ0&OKD#D^mx$$)#H=izwMmDmFP) zP&Jk5mdWZvmz^+c%N^0J^2w^U;>pdUlbsz6x{+DP02P{)8tY2ct6jZ1)vKGXm_=$s zYUOXd*PuMFRo|;ESAWat0UVU|0MGr;)p`-lOrg+D{fG*^5tR~;%u(Wz@c;y}m4}Va z!6kHbwuid0Sxf-wRU7pGbarjsZ5>ruvM$!ex7=K)N_*53n_wNOx>%Ccx`};`B$K*; z<0K%plopG1_&laQBovRAnWvpyU)v{zIz5him|?2wvIb zG3Zu-DXA?`Q05n^!6Ro?z@~Ip25m8LG>mRVtyNVit_-5BgjK5JM^8qVcAY!AMa)vw z_=v0Vk(1V$O0g8YBNOh<(D(2iryAdp)%f0UP-fnOTHtCvc+XvpRTkw`h^nRFeHI_N ze`+I_a#L*{dH20xHBNQA=}Z*YF5E#C=3p@%<7?b<)kT;^H<57@IX97a2rA!9OxZ)1 zoKY0>AuCmO_lR59a>+enIs{dFCMN7b2wh{@6CxyI)aLLxT+li+9pV(BP6)}SjtM3r zd{!`=93C4?M{QnciLVY%y=Z$FbI#gaX+Chhy}fHoN*-Y8oN0ydT)@-E2^@C1TTF%T zw+=c?G6VI84)4gG$l)y%Lmmuob&d`cYJVX6Lf!UV?uvBr$g?M5i(bx`jC*aRh)lu7 zneQQJc?Y27Qywkn7h1l>pymC8mTv=SIS;c)4v9zy^N|kblR6km9n8o&2+6nKn5((p|zq(N6>V^X(7jAVAaV&}d(%1P zbS_;C^KQn`u@UAfMu=vD#~kN#f_hT+4qJd;xo|o4FqBi40B-! zWz(|-GMMTwxtiLvyDdc&nV4{#mZamko7Hp^SvO(1iGrJ0W@5sLgnMkBiL)nS;vTQM zh)l*!NaY-Ytjwr%c5sCq+y>?C5dG@9AzzqCXD!Y|w48}(7dR5&8*0d!6(+*G-V{0+ z#&iMU%eZjbg-b4+QyA6X;U_%cCp_RMoSTwM!}3|Rt0R57qjb6>=k8#gr#nu_VC|S*Wap%`Ixv= zZ)|D3n>8^qWB(DKoZ#8q)Hh=BxbW14$I{F6xsX=FObV|dw8mCaj2K!|6OYz}fA+28 zOWuf8*{q0Qr4|>ynfm(MwYVOS^=g{HN1MjQQaz@L1wsq;y4IK6c2itJm!dS0rtvg! zK3c13u%>5gBC=L%phu&z2%!R@LL=6|_d&&kH&L(a!n0Ou)$2*&Gqj2({4+Kz3s`G4 z5ztK$)J^zN7v4rv_zfL)Y0Z9M!_;VmERIPF=%?^CDx1PL7ssHb^|T%o^wWVEIC-ts zSc}=~^_pIf*EO+py@s(Exu*0dMZgfFR^k}%b8)1CAazqm#C6jaBO6=7y9p~qU@j>} z4UJ62D~q4|JsV(wu}h6Qu{6qTYREVqje4w#ITv?gA2v>|MMO&RCSZaEzZ+Vmr`xnH z94}9dVk)#4j2u{r)zR&;R1tl~ba4^0Fxhdq85g!&irpN1Z@uV0x`XeJ1 z(X`7+F=4;~O%r3*8{`*&Zq~&(QR|?_K_$f`#7uEWG(=z%W{U}{q4gS?m_X2yV#-*( zQS1Ag<@!Z2-qP>6=i)l14KT8S#%T(+0*%(;$qMXWLGQ_>dd33w@hj#! zLTm}oj%6SDm4@`TEL7u zGEWX?4K2_1=D_EzJXsA;WS(js^?Rm2<%6btSzqX9y%)&2c?19!KXpz6yTV>bioB6} zZZRneU&e|I-UNFQYvP%n(Nfw9?R>=YYOlAVub|S@a73W8q8t?P9hc`3(gl>LX)y&h zKdR;ovx%^kX!TOMrY-g$uXK_{OG#P6k4fOvM1zXP(zV*d5zVK?9*)fVp0Ar!NJFT} z&qlfKgbtS=I0Nb`hMdz~F5Z(F*U;5U z!QP~JeV~n_ua>GoSAf$l50>{b3Pp;dSQS%NZ7qf)Q(LU3`YG>uY|twwM%QC&Cq|b} zj1K0y<(;0fY>0(~n_)0%pwKi#DbYjfQGWHXrJrRR7BnSNFwtU{DSZcPLTRaEU!}0m zq#I?_9ULK)>zwERe-f`eEv3Fn-cp3AL@nwIvA8Qk;&o+X1+{e{F^EGIR4EasRia2J zPaLOL;PeH#!ExQdUOtx+^VrYden$UQi1VI5C-UHV!w~qqX|YOit02Hy73b<8Qg0fR z!eSMH{f=?$@st6+3*aU2J>wW3rwp(Lz&+Q9r-Gh!;vsOIcnI7e9s)Owhp2nYK;H-Q zGWvmmug7Cl1Co7p;`0*UTg1ocZQ^6}L*irf4(YVO_9N0s;9b&5pg}qb*u+x>*dQJP zo5Vw)NjwBvY?leN#dZljGCXW. --> - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/download_record_item.xml b/app/src/main/res/layout/download_record_item.xml index d301d54e..2324b2de 100755 --- a/app/src/main/res/layout/download_record_item.xml +++ b/app/src/main/res/layout/download_record_item.xml @@ -17,89 +17,81 @@ ~ along with this program. If not, see . --> - - - - + - + - + - - - + - + - + - - + diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 101b59cd..ee29bb7f 100755 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -16,39 +16,36 @@ ~ along with this program. If not, see . --> - + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/mainActivity" + android:layout_width="match_parent" + android:layout_height="match_parent"> + - - + android:layout_height="0dp" + app:defaultNavHost="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:navGraph="@navigation/navigation" + tools:ignore="FragmentTagUsage" /> - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/main_fragment.xml b/app/src/main/res/layout/main_fragment.xml index 40240e42..07f71c93 100755 --- a/app/src/main/res/layout/main_fragment.xml +++ b/app/src/main/res/layout/main_fragment.xml @@ -15,47 +15,40 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + + app:layout_constraintTop_toBottomOf="@+id/linkSearch" + tools:ignore="UnusedAttribute" /> + android:contentDescription="Open Download History Button" + app:srcCompat="@drawable/ic_history"/> + app:layout_constraintTop_toBottomOf="@id/appLogo" + /> - - - + android:padding="6dp" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintStart_toStartOf="@+id/appSubTitle" + app:layout_constraintTop_toBottomOf="@+id/appSubTitle"/> + android:contentDescription="Open Github App Button" + app:layout_constraintVertical_chainStyle="packed"/> + android:contentDescription="Open LinkedIN App Button" + app:layout_constraintTop_toBottomOf="@+id/btn_github_spotify"/> - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/track_list_fragment.xml b/app/src/main/res/layout/track_list_fragment.xml index 7ea750a8..dfdfc714 100755 --- a/app/src/main/res/layout/track_list_fragment.xml +++ b/app/src/main/res/layout/track_list_fragment.xml @@ -16,16 +16,16 @@ ~ along with this program. If not, see . --> - - + diff --git a/app/src/main/res/layout/track_list_item.xml b/app/src/main/res/layout/track_list_item.xml index aa9de8b6..ba3a0c59 100755 --- a/app/src/main/res/layout/track_list_item.xml +++ b/app/src/main/res/layout/track_list_item.xml @@ -16,97 +16,84 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> + - - - - - - - + android:contentDescription="Track Image" + android:scaleType="centerInside" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/artist" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_song_placeholder" /> - + - - - + - + - + - - - - + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2824d54a..a4088d8b 100755 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -20,10 +20,12 @@ #FC5C7D #CE1CFF - #799BFF + #9AB3FF #FFFFFF #99FFFFFF #000000 - + #121212 + #4BB543 + #FF9494 \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 1801bfbb..c3b734c0 100755 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -19,12 +19,22 @@ + + + + + -