YT Scraping instead of API(bcuz of Limited Quota),

YT Video Support Added(Limited Metadata),
Service Fixes(Will Hopefully work in Idle Mode),
Interrupted Download Lists Handling Improved!
This commit is contained in:
shabinder 2020-08-02 15:51:01 +05:30
parent 3df41fbe87
commit a47a3865c2
16 changed files with 721 additions and 360 deletions

View File

@ -5,6 +5,7 @@
<w>flyer</w>
<w>insta</w>
<w>instagram</w>
<w>maxresdefault</w>
<w>moshi</w>
<w>musicforeveryone</w>
<w>musicplaceholder</w>
@ -15,6 +16,8 @@
<w>spotify</w>
<w>spotifydownloader</w>
<w>spotifyler</w>
<w>thru</w>
<w>youtu</w>
</words>
</dictionary>
</component>

View File

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectPlainTextFileTypeManager">
<file url="file://$PROJECT_DIR$/app/src/main/java/com/shabinder/spotiflyer/testing/YoutubeInterface.kt.backup" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>

View File

@ -20,7 +20,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: 'kotlinx-serialization'
//apply plugin: 'kotlinx-serialization'
android {
compileSdkVersion 29
@ -34,17 +34,28 @@ android {
applicationId 'com.shabinder.spotiflyer'
minSdkVersion 22
targetSdkVersion 29
versionCode 2
versionName "1.1"
versionCode 3
versionName "1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/ASL2.0'
exclude("META-INF/*.kotlin_module")
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_8
@ -65,6 +76,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.browser:browser:1.2.0'
implementation 'androidx.webkit:webkit:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
@ -86,9 +98,14 @@ dependencies {
}
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'com.google.apis:google-api-services-youtube:v3-rev180-1.22.0'
implementation 'com.google.oauth-client:google-oauth-client:1.22.0'
// Authentication Way Changed!
// implementation ('com.google.apis:google-api-services-youtube:v3-rev180-1.22.0'){
// exclude module: 'httpclient'
// }
// //noinspection GradleDependency
// implementation ('com.google.oauth-client:google-oauth-client:1.22.0'){
// exclude module: 'httpclient'
// }
// implementation 'com.spotify.android:auth:1.1.0'
implementation 'com.squareup.okhttp3:okhttp:4.8.0'
@ -97,9 +114,6 @@ dependencies {
implementation "com.squareup.moshi:moshi-kotlin:1.9.3"
implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" // or "kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0" // JVM dependency
implementation 'com.mpatric:mp3agic:0.9.1'
implementation 'com.arthenica:mobile-ffmpeg-audio:4.4.LTS'

View File

@ -27,6 +27,8 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />-->

View File

@ -18,6 +18,7 @@
package com.shabinder.spotiflyer
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@ -25,6 +26,8 @@ import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
@ -36,7 +39,6 @@ import com.shabinder.spotiflyer.databinding.MainActivityBinding
import com.shabinder.spotiflyer.downloadHelper.DownloadHelper
import com.shabinder.spotiflyer.utils.SpotifyService
import com.shabinder.spotiflyer.utils.SpotifyServiceToken
import com.shabinder.spotiflyer.utils.YoutubeInterface
import com.shabinder.spotiflyer.utils.createDirectory
import com.shreyaspatil.EasyUpiPayment.EasyUpiPayment
import com.squareup.moshi.Moshi
@ -71,6 +73,8 @@ class MainActivity : AppCompatActivity(){
binding = DataBindingUtil.setContentView(this,R.layout.main_activity)
sharedViewModel = ViewModelProvider(this).get(SharedViewModel::class.java)
sharedPref = this.getPreferences(Context.MODE_PRIVATE)
//starting Notification and Downloader Service!
DownloadHelper.startService(this)
/* if(sharedPref?.contains("token")!! && (sharedPref?.getLong("time",System.currentTimeMillis()/1000/60/60)!! < (System.currentTimeMillis()/1000/60/60)) ){
val savedToken = sharedPref?.getString("token","error")!!
@ -88,6 +92,7 @@ class MainActivity : AppCompatActivity(){
}
requestPermission()
disableDozeMode()
checkIfLatestVersion()
createDir()
setUpi()
@ -98,12 +103,42 @@ class MainActivity : AppCompatActivity(){
//Object to download From Youtube {"https://github.com/sealedtx/java-youtube-downloader"}
ytDownloader = YoutubeDownloader()
sharedViewModel.ytDownloader = ytDownloader
//Initialing Communication with Youtube
YoutubeInterface.youtubeConnector()
handleIntentFromExternalActivity()
}
@SuppressLint("BatteryLife")
fun disableDozeMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm =
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")
startActivityForResult(intent, 1233)
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 1233) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm =
getSystemService(Context.POWER_SERVICE) as PowerManager
val isIgnoringBatteryOptimizations =
pm.isIgnoringBatteryOptimizations(packageName)
if (isIgnoringBatteryOptimizations) {
// Ignoring battery optimization
} else {
disableDozeMode()//Again Ask For Permission!!
}
}
}
}
/**
* Adding my own new Spotify Web Api Requests!
* */
@ -253,6 +288,7 @@ class MainActivity : AppCompatActivity(){
createDirectory(DownloadHelper.defaultDir+"Tracks/")
createDirectory(DownloadHelper.defaultDir+"Albums/")
createDirectory(DownloadHelper.defaultDir+"Playlists/")
createDirectory(DownloadHelper.defaultDir+"YT_Downloads/")
}
private fun checkIfLatestVersion() {

View File

@ -17,28 +17,44 @@
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.util.Log
import android.view.View
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.webkit.ValueCallback
import android.webkit.WebView
import android.webkit.WebViewClient
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.SharedViewModel
import com.shabinder.spotiflyer.fragments.MainFragment
import com.shabinder.spotiflyer.models.DownloadObject
import com.shabinder.spotiflyer.models.Track
import com.shabinder.spotiflyer.utils.YoutubeInterface
import com.shabinder.spotiflyer.worker.ForegroundService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
object DownloadHelper {
var webView:WebView? = null
var context : Context? = null
var statusBar:TextView? = null
val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator
private var downloadList = arrayListOf<DownloadObject>()
var sharedViewModel:SharedViewModel? = null
private var isBrowserLoading = false
private var total = 0
private var Processed = 0
var youtubeList = mutableListOf<YoutubeRequest>()
/**
* Function To Download All Tracks Available in a List
@ -47,108 +63,179 @@ object DownloadHelper {
type:String,
subFolder: String?,
trackList: List<Track>, ytDownloader: YoutubeDownloader?) {
withContext(Dispatchers.Main){
var size = trackList.size
total += size
animateStatusBar()
trackList.forEach {
size--
if(size == 0){
downloadTrack(null,type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it ,0 )
val outputFile:String = Environment.getExternalStorageDirectory().toString() + File.separator +
defaultDir + removeIllegalChars(type) + File.separator + (if(subFolder == null){""}else{ removeIllegalChars(subFolder) + File.separator} + removeIllegalChars(it.name!!)+".mp3")
if(File(outputFile).exists()){//Download Already Present!!
Processed++
updateStatusBar()
}else{
downloadTrack(null,type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it )
if(isBrowserLoading){
if(size == 0){
youtubeList.add(YoutubeRequest(null,type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it ,0 ))
}else{
youtubeList.add(YoutubeRequest(null,type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it))
}
}else{
if(size == 0){
getYTLink(null,type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it ,0 )
}else{
getYTLink(null,type,subFolder,ytDownloader,"${it.name} ${it.artists?.get(0)?.name ?:""}", it)
}
}
}
}
}
}
suspend fun downloadTrack(
mainFragment: MainFragment? = null,
//TODO CleanUp here and there!!
@SuppressLint("SetJavaScriptEnabled")
suspend fun getYTLink(mainFragment: MainFragment? = null,
type:String,
subFolder:String?,
ytDownloader: YoutubeDownloader?,
searchQuery: String,
track: Track,
index: Int? = null
) {
index: Int? = null){
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){
isBrowserLoading = true
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"
,object :ValueCallback<String>{
override fun onReceiveValue(value: String?) {
Log.i("YT-id",value.toString().replace("\"",""))
val id = value!!.substringAfterLast("=", "error").replace("\"","")
Log.i("YT-id",id)
if(id !="error"){//Link extracting error
mainFragment?.showToast("Starting Download")
Processed++
updateStatusBar()
downloadFile(subFolder, type, track, index,ytDownloader,id)
}
if(youtubeList.isNotEmpty()){
val request = youtubeList[0]
sharedViewModel!!.uiScope.launch {
getYTLink(request.mainFragment,request.type,request.subFolder,request.ytDownloader,request.searchQuery,request.track,request.index)
}
youtubeList.remove(request)
if(youtubeList.size == 0){//list processing completed , webView is free again!
isBrowserLoading = false
}
}
}
} )
}
}
}
}
@SuppressLint("SetJavaScriptEnabled")
fun applyWebViewSettings(webView: WebView) {
val desktopUserAgent =
"Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.4) Gecko/20100101 Firefox/4.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.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
}
}
private fun updateStatusBar() {
statusBar!!.visibility = View.VISIBLE
statusBar?.text = "Total: $total Processed: $Processed"
}
fun downloadFile(subFolder: String?, type: String, track:Track, index:Int? = null,ytDownloader: YoutubeDownloader?,id: String) {
sharedViewModel!!.uiScope.launch {
withContext(Dispatchers.IO) {
val data: YoutubeInterface.VideoItem = YoutubeInterface.search(searchQuery)?.get(0)!!
//Fetching a Video Object.
try {
val audioUrl = getDownloadLink(AudioQuality.medium, ytDownloader, data)
withContext(Dispatchers.Main) {
mainFragment?.showToast("Starting Download")
}
downloadFile(audioUrl, searchQuery, subFolder, type, track, index,mainFragment)
val video = ytDownloader?.getVideo(id)
val format:Format? =try {
video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format
}catch (e:java.lang.IndexOutOfBoundsException){
try {
val audioUrl = getDownloadLink(AudioQuality.high, ytDownloader, data)
withContext(Dispatchers.Main) {
mainFragment?.showToast("Starting Download")
}
downloadFile(audioUrl, searchQuery, subFolder, type, track, index,mainFragment)
video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format
}catch (e:java.lang.IndexOutOfBoundsException){
try{
val audioUrl = getDownloadLink(AudioQuality.low, ytDownloader, data)
withContext(Dispatchers.Main) {
mainFragment?.showToast("Starting Download")
}
downloadFile(audioUrl, searchQuery, subFolder, type, track, index,mainFragment)
video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format
}catch (e:java.lang.IndexOutOfBoundsException){
Log.i("Catch", e.toString())
Log.i("YTDownloader",e.toString())
null
}
}
}
format?.let {
val url:String = format.url()
}
}
Log.i("DHelper Link Found", url)
private fun getDownloadLink(quality: AudioQuality ,ytDownloader: YoutubeDownloader?,data:YoutubeInterface.VideoItem): String {
val video = ytDownloader?.getVideo(data.id)
val format: Format =
video?.findAudioWithQuality(quality)?.get(0) as Format
Log.i("Format", video.findAudioWithQuality(AudioQuality.medium)?.get(0)!!.mimeType())
val audioUrl:String = format.url()
Log.i("DHelper Link Found", audioUrl)
return audioUrl
}
private suspend fun downloadFile(url: String, title: String, subFolder: String?, type: String, track:Track, index:Int? = null,mainFragment: MainFragment? = null) {
withContext(Dispatchers.IO) {
val outputFile:String = Environment.getExternalStorageDirectory().toString() + File.separator +
DownloadHelper.defaultDir + removeIllegalChars(type) + File.separator + (if(subFolder == null){""}else{ removeIllegalChars(subFolder) + File.separator} + removeIllegalChars(track.name!!)+".m4a")
defaultDir + removeIllegalChars(type) + File.separator + (if(subFolder == null){""}else{ removeIllegalChars(subFolder) + File.separator} + removeIllegalChars(track.name!!)+".m4a")
if(!File(removeIllegalChars(outputFile.substringBeforeLast('.')) +".mp3").exists()){
val downloadObject = DownloadObject(
track = track,
url = url,
outputDir = outputFile
)
Log.i("DH",outputFile)
if(index==null){
startService(context!!, downloadObject)
/*if(index==null){
downloadList.add(downloadObject)
}else{
downloadList.add(downloadObject)
startService(context!!, downloadList)
Log.i("DH No of Songs", downloadList.size.toString())
downloadList = arrayListOf()
}
}else{withContext(Dispatchers.Main){
mainFragment?.showToast("${track.name} is already Downloaded")
}*/
// downloadList.add(downloadObject)
// downloadList = arrayListOf()
}
}
}
}
private fun startService(context:Context,list: ArrayList<DownloadObject>) {
fun startService(context:Context,obj:DownloadObject? = null ) {
val serviceIntent = Intent(context, ForegroundService::class.java)
serviceIntent.putParcelableArrayListExtra("list",list)
serviceIntent.putExtra("object",obj)
ContextCompat.startForegroundService(context, serviceIntent)
}
/**
* Removing Illegal Chars from File Name
* **/
private fun removeIllegalChars(fileName: String): String? {
fun removeIllegalChars(fileName: String): String? {
val illegalCharArray = charArrayOf(
'/',
'\n',
@ -165,7 +252,6 @@ object DownloadHelper {
'|',
'\"',
'.',
':',
'-',
'\''
)
@ -180,6 +266,29 @@ object DownloadHelper {
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
anim.startOffset = 20
anim.repeatMode = Animation.REVERSE
anim.repeatCount = Animation.INFINITE
statusBar?.animation = anim
}
}
data class YoutubeRequest(
val mainFragment: MainFragment? = null,
val type:String,
val subFolder:String?,
val ytDownloader: YoutubeDownloader?,
val searchQuery: String,
val track: Track,
val index: Int? = null
)

View File

@ -17,6 +17,7 @@
package com.shabinder.spotiflyer.fragments
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@ -29,6 +30,9 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.ValueCallback
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.core.net.toUri
import androidx.databinding.DataBindingUtil
@ -45,6 +49,7 @@ import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.SharedViewModel
import com.shabinder.spotiflyer.databinding.MainFragmentBinding
import com.shabinder.spotiflyer.downloadHelper.DownloadHelper
import com.shabinder.spotiflyer.downloadHelper.DownloadHelper.applyWebViewSettings
import com.shabinder.spotiflyer.downloadHelper.DownloadHelper.downloadAllTracks
import com.shabinder.spotiflyer.models.Track
import com.shabinder.spotiflyer.recyclerView.TrackListAdapter
@ -67,23 +72,25 @@ class MainFragment : Fragment() {
private var type:String = ""
private var spotifyLink = ""
private var i: Intent? = null
private var webView: WebView? = null
@SuppressLint("SetJavaScriptEnabled")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater,R.layout.main_fragment,container,false)
webView = binding.webView
DownloadHelper.webView = binding.webView
DownloadHelper.context = requireContext()
sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java)
mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java)
spotifyService = sharedViewModel.spotifyService
DownloadHelper.sharedViewModel = sharedViewModel
DownloadHelper.statusBar = binding.StatusBar
val spanStringBuilder = SpannableStringBuilder()
spanStringBuilder.append(getText(R.string.d_one)).append("\n")
spanStringBuilder.append(getText(R.string.d_two)).append("\n")
spanStringBuilder.append(getText(R.string.d_three)).append("\n")
binding.usage.text = spanStringBuilder
setUpUsageText()
openSpotifyButton()
openGithubButton()
openInstaButton()
@ -93,6 +100,72 @@ class MainFragment : Fragment() {
}
binding.btnSearch.setOnClickListener {
val link = binding.linkSearch.text.toString()
if(link.contains("open.spotify",true)){
spotifySearch()
}
if(link.contains("youtube.com",true) || link.contains("youtu.be",true) ){
youtubeSearch()
}
}
handleIntent()
//Handling Device Configuration Change
if(savedInstanceState != null && savedInstanceState["searchLink"].toString() != ""){
binding.linkSearch.setText(savedInstanceState["searchLink"].toString())
binding.btnSearch.performClick()
setUiVisibility()
}
return binding.root
}
private fun youtubeSearch() {
val youtubeLink = binding.linkSearch.text.toString()
var title = ""
val link = youtubeLink.removePrefix("https://").removePrefix("http://")
val sampleDomain1 = "youtube.com"
val sampleDomain2 = "youtu.be"
if(!link.contains("playlist",true)){
var searchId = "error"
if(link.contains(sampleDomain1,true) ){
searchId = link.substringAfterLast("=","error")
}
if(link.contains(sampleDomain2,true) && !link.contains("playlist",true) ){
searchId = link.substringAfterLast("/","error")
}
if(searchId != "error"){
val coverLink = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg"
applyWebViewSettings(webView!!)
sharedViewModel.uiScope.launch {
webView!!.loadUrl(youtubeLink)
webView!!.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
view?.evaluateJavascript(
"document.getElementsByTagName(\"h1\")[0].textContent"
,object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
title = DownloadHelper.removeIllegalChars(value.toString()).toString()
Log.i("YT-id", title)
Log.i("YT-id", value)
Log.i("YT-id", coverLink)
setUiVisibility()
bindImage(binding.imageView,coverLink)
binding.btnDownloadAll.setOnClickListener {
showToast("Starting Download in Few Seconds")
//TODO Clean This Code!
DownloadHelper.downloadFile(null,"YT_Downloads",Track(name = value,ytCoverUrl = coverLink),0,sharedViewModel.ytDownloader,searchId)
}
}
})
}
}
}
}
}else(showToast("Your Youtube Link is not of a Video!!"))
}
private fun spotifySearch(){
spotifyLink = binding.linkSearch.text.toString()
val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?')
@ -137,13 +210,12 @@ class MainFragment : Fragment() {
binding.btnDownloadAll.setOnClickListener {
showToast("Starting Download in Few Seconds")
sharedViewModel.uiScope.launch {
withContext(Dispatchers.IO) {
downloadAllTracks(
"Tracks",
null,
trackList,
sharedViewModel.ytDownloader)
}
sharedViewModel.ytDownloader
)
}
}
@ -164,13 +236,13 @@ class MainFragment : Fragment() {
binding.btnDownloadAll.setOnClickListener {
showToast("Starting Download in Few Seconds")
sharedViewModel.uiScope.launch {
withContext(Dispatchers.IO) {
loadAllImages(trackList)
downloadAllTracks(
"Albums",
albumObject.name,
trackList,
sharedViewModel.ytDownloader)
}
sharedViewModel.ytDownloader
)
}
}
}
@ -191,20 +263,18 @@ class MainFragment : Fragment() {
binding.btnDownloadAll.setOnClickListener {
showToast("Starting Download in Few Seconds")
sharedViewModel.uiScope.launch {
withContext(Dispatchers.IO) {
loadAllImages(trackList)
downloadAllTracks(
"Playlists",
playlistObject.name,
trackList,
sharedViewModel.ytDownloader)
sharedViewModel.ytDownloader
)
}
}
}
}
}
"episode" -> {
showToast("Implementation Pending")
}
@ -215,15 +285,6 @@ class MainFragment : Fragment() {
}
}
}
handleIntent()
//Handling Device Configuration Change
if(savedInstanceState != null && savedInstanceState["searchLink"].toString() != ""){
binding.linkSearch.setText(savedInstanceState["searchLink"].toString())
binding.btnSearch.performClick()
setUiVisibility()
}
return binding.root
}
/**
* Function to fetch all Images for using in mp3 tag.
@ -351,6 +412,15 @@ class MainFragment : Fragment() {
})
}
private fun setUpUsageText() {
val spanStringBuilder = SpannableStringBuilder()
spanStringBuilder.append(getText(R.string.d_one)).append("\n")
spanStringBuilder.append(getText(R.string.d_two)).append("\n")
spanStringBuilder.append(getText(R.string.d_three)).append("\n")
binding.usage.text = spanStringBuilder
}
/**
* Util. Function to create toasts!
**/

View File

@ -17,9 +17,10 @@
package com.shabinder.spotiflyer.models
import kotlinx.serialization.Serializable
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Serializable
@Parcelize
data class Followers(
var href: String? = null,
var total: Int? = null):java.io.Serializable
var total: Int? = null):Parcelable

View File

@ -39,4 +39,5 @@ data class Track(
var uri: String? = null,
var album: Album? = null,
var external_ids: Map<String?, String?>? = null,
var popularity: Int? = null):Parcelable
var popularity: Int? = null,
var ytCoverUrl:String? = null):Parcelable

View File

@ -26,7 +26,7 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.SharedViewModel
import com.shabinder.spotiflyer.downloadHelper.DownloadHelper.downloadTrack
import com.shabinder.spotiflyer.downloadHelper.DownloadHelper.getYTLink
import com.shabinder.spotiflyer.fragments.MainFragment
import com.shabinder.spotiflyer.models.Track
import com.shabinder.spotiflyer.utils.bindImage
@ -63,7 +63,7 @@ class TrackListAdapter:RecyclerView.Adapter<TrackListAdapter.ViewHolder>() {
holder.duration.text = "${item.duration_ms/1000/60} minutes, ${(item.duration_ms/1000)%60} sec"
holder.downloadBtn.setOnClickListener{
sharedViewModel.uiScope.launch {
downloadTrack(mainFragment,"Tracks",null,sharedViewModel.ytDownloader,"${item.name} ${item.artists?.get(0)!!.name?:""}",track = item,index = 0)
getYTLink(mainFragment,"Tracks",null,sharedViewModel.ytDownloader,"${item.name} ${item.artists?.get(0)!!.name?:""}",track = item,index = 0)
}
}

View File

@ -72,7 +72,7 @@ fun bindImage(imgView: ImageView, imgUrl: String?) {
try {
val file = File(
Environment.getExternalStorageDirectory(),
DownloadHelper.defaultDir+".Images/" + imgUrl.substringAfterLast('/') + ".jpeg"
DownloadHelper.defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg"
) // the File to save , append increasing numeric counter to prevent files from getting overwritten.
val options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.ARGB_8888

View File

@ -1,84 +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 <https://www.gnu.org/licenses/>.
*/
package com.shabinder.spotiflyer.utils
import android.util.Log
import com.google.api.client.http.HttpRequestInitializer
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.jackson2.JacksonFactory
import com.google.api.services.youtube.YouTube
import java.io.IOException
object YoutubeInterface {
private var youtube: YouTube? = null
private var query:YouTube.Search.List? = null
private var apiKey:String = "AIzaSyDuRmMA_2mF56BjlhhNpa0SIbjMgjjFaEI"
private var apiKey2:String = "AIzaSyCotyqgqmz5qw4-IH0tiezIrIIDHLI2yNs"
fun youtubeConnector() {
youtube =
YouTube.Builder(NetHttpTransport(), JacksonFactory(), HttpRequestInitializer { })
.setApplicationName("spotifyler").build()
try {
query = youtube?.search()?.list("id,snippet")
query?.key = apiKey
query?.maxResults = 1
query?.type = "video"
query?.fields =
"items(id/videoId,snippet/title,snippet/thumbnails/default/url)"
} catch (e: IOException) {
Log.i("YI", "Could not initialize: $e")
}
}
fun search(keywords: String?): List<VideoItem>? {
Log.i("YI searched for",keywords.toString())
if (youtube == null){youtubeConnector()}
query!!.q= keywords
return try {
val response = query!!.execute()
val results =
response.items
val items = mutableListOf<VideoItem>()
for (result in results) {
val item = VideoItem(
id = result.id.videoId,
title = result.snippet.title,
// description = result.snippet.description,
thumbnailUrl = result.snippet.thumbnails.default.url
)
items.add(item)
Log.i("YI links received",item.id)
}
items
} catch (e: IOException) {
Log.d("YI", "Could not search: $e")
if(query?.key == apiKey2){return null}
query?.key = apiKey2
search(keywords)
}
}
data class VideoItem(
val id:String,
val title:String,
// val description: String,
val thumbnailUrl:String
)
}

View File

@ -18,11 +18,16 @@
package com.shabinder.spotiflyer.worker
import android.app.*
import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
@ -41,22 +46,41 @@ import com.shabinder.spotiflyer.models.Track
import com.tonyodev.fetch2.*
import com.tonyodev.fetch2core.DownloadBlock
import com.tonyodev.fetch2core.Func
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
class ForegroundService : Service(){
private val tag = "Foreground Service"
private val channelId = "ForegroundDownloaderService"
private val notificationId = 101
private var total = 0 //Total Downloads Requested
private var converted = 0//Total Files Converted
private var downloaded = 0//Total Files downloaded
private var fetch:Fetch? = null
private var downloadManager : DownloadManager? = null
private var downloadList = mutableListOf<DownloadObject>()
private var serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val requestMap = mutableMapOf<Request,Track>()
private val downloadMap = mutableMapOf<String,Track>()
private var speed :Long = 0
private var defaultDirectory = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator
private val parentDirectory = File(Environment.getExternalStorageDirectory(),
defaultDirectory+File.separator
)
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private var messageSnippet1 = ""
private var messageSnippet2 = ""
private var messageSnippet3 = ""
private var messageSnippet4 = ""
var notificationLine = 1
override fun onBind(intent: Intent): IBinder? {
return null
@ -69,26 +93,22 @@ class ForegroundService : Service(){
this,
0, notificationIntent, 0
)
val notification = NotificationCompat.Builder(this, channelId)
.setContentTitle("SpotiFlyer: Downloading Your Music")
.setSubText("Speed: $speed KB/s ")
.setNotificationSilent()
.setOnlyAlertOnce(true)
.setContentText("Total: $total Downloaded: ${total - requestMap.keys.size} Converted:$converted ")
.setSmallIcon(R.drawable.down_arrowbw)
.build()
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val fetchConfiguration =
FetchConfiguration.Builder(this)
.setDownloadConcurrentLimit(4)
.build()
Fetch.Impl.setDefaultInstanceConfiguration(fetchConfiguration)
fetch = Fetch.getDefaultInstance()
fetch = Fetch.Impl.getInstance(fetchConfiguration)
// fetch?.enableLogging(true)
fetch?.addListener(fetchListener)
startForeground()
}
/**
*Starting Service with Notification as Foreground!
**/
private fun startForeground() {
val channelId =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -105,10 +125,15 @@ class ForegroundService : Service(){
.setSubText("Speed: $speed KB/s ")
.setNotificationSilent()
.setOnlyAlertOnce(true)
.setContentText("Total: $total Downloaded: ${total - requestMap.keys.size} Converted:$converted ")
.setContentText("Total: $total Downloaded: $downloaded Completed:$converted ")
.setSmallIcon(R.drawable.down_arrowbw)
.setStyle(NotificationCompat.InboxStyle()
.addLine(messageSnippet1)
.addLine(messageSnippet2)
.addLine(messageSnippet3)
.addLine(messageSnippet4))
.build()
startForeground(101, notification)
startForeground(notificationId, notification)
}
@RequiresApi(Build.VERSION_CODES.O)
@ -127,51 +152,106 @@ class ForegroundService : Service(){
//do heavy work on a background thread
//val list = intent.getSerializableExtra("list") as List<Any?>
val list = intent.getParcelableArrayListExtra<DownloadObject>("list") ?: intent.extras?.getParcelableArrayList<DownloadObject>("list")
Log.i(tag,"Intent List Size: ${list!!.size}")
total += list.size
list.forEach { downloadList.add(it as DownloadObject) }
// val list = intent.getParcelableArrayListExtra<DownloadObject>("list") ?: intent.extras?.getParcelableArrayList<DownloadObject>("list")
// Log.i(tag,"Intent List Size: ${list!!.size}")
val obj = intent.getParcelableExtra<DownloadObject>("object") ?: intent.extras?.getParcelable<DownloadObject>("object")
obj?.let {
total ++
// Log.i(tag,"Intent List Size: ${list!!.size}")
updateNotification()
serviceScope.launch {
withContext(Dispatchers.IO){
for (downloadObject in downloadList) {
val request= Request(downloadObject.url, downloadObject.outputDir)
val request= Request(obj.url, obj.outputDir)
request.priority = Priority.NORMAL
request.networkType = NetworkType.ALL
fetch?.enqueue(request,
fetch!!.enqueue(request,
Func {
Log.i("DownloadManager", "Download Request Sent")
requestMap[it] = downloadObject.track
downloadList.remove(downloadObject) },
requestMap[it] = obj.track
downloadList.remove(obj)
Log.i(tag, "Enqueuing Download")
},
Func {
Log.i("DownloadManager", "Download Request Error:${it.throwable.toString()}")}
Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")}
)
}
}
//Wake locks and misc tasks from here :
return if (isServiceStarted){
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 {
acquire()
}
}
return START_NOT_STICKY
START_STICKY
}
}
override fun onDestroy() {
super.onDestroy()
if(downloadMap.isEmpty() && converted == total){
Log.i(tag,"Service destroyed.")
fetch?.close()
deleteFile(parentDirectory)
releaseWakeLock()
stopForeground(true)
}
}
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 ){
Log.i(tag,"Service destroyed.")
Log.i(tag,"Service Removed.")
fetch?.close()
stopSelf()
}
}
/**
* 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) {
Log.i(tag,"Cleaning ${file.path} Directory")
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
**/
private var fetchListener: FetchListener = object : FetchListener {
override fun onQueued(
download: Download,
@ -194,7 +274,32 @@ class ForegroundService : Service(){
totalBlocks: Int
) {
val track = requestMap[download.request]
when(notificationLine){
1 -> {
messageSnippet1 = "Downloading ${track?.name}"
notificationLine = 2
}
2 -> {
messageSnippet2 = "Downloading ${track?.name}"
notificationLine = 3
}
3-> {
messageSnippet3 = "Downloading ${track?.name}"
notificationLine = 4
}
4 -> {
messageSnippet4 = "Downloading ${track?.name}"
notificationLine = 1
}
}
Log.i(tag,"${track?.name} Download Started")
updateNotification()
val link = "https://m.youtube.com/watch?v=shCX5YgU9yc"
var result = ""
result = link.removePrefix("https://")
result = link.removePrefix("http://")
}
override fun onWaitingNetwork(download: Download) {
@ -211,12 +316,20 @@ class ForegroundService : Service(){
override fun onCompleted(download: Download) {
val track = requestMap[download.request]
speed = 0
serviceScope.launch {
try{
convertToMp3(download.file, track!!)
Log.i(tag,"${track.name} Download Completed")
}catch (e:KotlinNullPointerException
){
Log.i(tag,"${track?.name} Download Failed! Error:Fetch!!!!")
Log.i(tag,"${track?.name} Requesting Download thru Android DM")
downloadUsingDM(download.request.url,download.request.file, track!!)
}
Log.i(tag,"${track?.name} Download Completed")
}
downloaded++
requestMap.remove(download.request)
if(requestMap.keys.toList().isEmpty()) speed = 0
updateNotification()
}
@ -233,7 +346,13 @@ class ForegroundService : Service(){
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
val track = requestMap[download.request]
Log.i(tag,download.error.throwable.toString())
Log.i(tag,"${track?.name} Requesting Download thru Android DM")
downloadUsingDM(download.request.url,download.request.file, track!!)
downloaded++
requestMap.remove(download.request)
updateNotification()
}
override fun onPaused(download: Download) {
@ -253,12 +372,51 @@ class ForegroundService : Service(){
}
/**
* If fetch Fails , Android Download Manager To RESCUE!!
**/
fun downloadUsingDM(url:String,outputDir:String,track: Track){
val uri = Uri.parse(url)
val request = DownloadManager.Request(uri)
.setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_WIFI or
DownloadManager.Request.NETWORK_MOBILE
)
.setAllowedOverRoaming(false)
.setTitle(track.name)
.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)
Log.i("DownloadManager", "Download Request Sent")
val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
//Fetching the download id received with the broadcast
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
//Checking if the received broadcast is for our enqueued download by matching download id
if (downloadID == id) {
convertToMp3(outputDir,track)
converted++
//Unregister this broadcast Receiver
this@ForegroundService.unregisterReceiver(this)
}
}
}
registerReceiver(onDownloadComplete,IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
/**
*Converting Downloaded Audio (m4a) to Mp3.( Also Applying Metadata)
**/
fun convertToMp3(filePath: String,track: Track){
val m4aFile = File(filePath)
val executionId = FFmpeg.executeAsync(
"-i $filePath -vn ${filePath.substringBeforeLast('.') + ".mp3"}"
FFmpeg.executeAsync(
"-i $filePath -b:a 160k -vn ${filePath.substringBeforeLast('.') + ".mp3"}"
) { _, returnCode ->
when (returnCode) {
RETURN_CODE_SUCCESS -> {
@ -292,11 +450,11 @@ class ForegroundService : Service(){
updateNotification()
//All tasks completed (REST IN PEACE)
if(converted == total){
stopForeground(false)
stopSelf()
onDestroy()
}
}
/**
* This is the method that can be called to update the Notification
*/
@ -305,15 +463,23 @@ class ForegroundService : Service(){
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notification = NotificationCompat.Builder(this, channelId)
.setContentTitle("SpotiFlyer: Downloading Your Music")
.setContentText("Total: $total Downloaded: ${total - requestMap.keys.size} Converted:$converted ")
.setContentText("Total: $total Completed:$converted ")
.setSubText("Speed: $speed KB/s ")
.setNotificationSilent()
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.down_arrowbw)
.setStyle(NotificationCompat.InboxStyle()
.addLine(messageSnippet1)
.addLine(messageSnippet2)
.addLine(messageSnippet3)
.addLine(messageSnippet4))
.build()
mNotificationManager.notify(101, notification)
mNotificationManager.notify(notificationId, notification)
}
/**
*Modifying Mp3 Tags with MetaData!
**/
private fun setId3v1Tags(mp3File: Mp3File, track: Track): Mp3File {
val id3v1Tag = ID3v1Tag()
id3v1Tag.track = track.disc_number.toString()
@ -342,10 +508,22 @@ class ForegroundService : Service(){
track.album?.copyrights?.forEach { copyrights.add(it!!.type!!) }
id3v2Tag.copyright = copyrights.joinToString()
id3v2Tag.url = track.href
track.let {
track.ytCoverUrl?.let {
val file = File(
Environment.getExternalStorageDirectory(),
DownloadHelper.defaultDir +".Images/" + (it.album!!.images?.get(0)?.url!!).substringAfterLast('/') + ".jpeg")
DownloadHelper.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)
fis.read(bytesArray) //read file into bytes[]
fis.close()
id3v2Tag.setAlbumImage(bytesArray,"image/jpeg")
}
track.album?.let {
val file = File(
Environment.getExternalStorageDirectory(),
DownloadHelper.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())

View File

@ -68,7 +68,6 @@
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/text_background_accented"
android:ems="10"
android:hint="Link From Spotify"
@ -78,7 +77,6 @@
android:textColor="@color/white"
android:textColorHint="@color/grey"
android:textSize="19sp"
app:layout_constraintBottom_toTopOf="@+id/image_view"
app:layout_constraintEnd_toStartOf="@+id/btn_search"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -103,11 +101,11 @@
android:id="@+id/image_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="6dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="3dp"
android:contentDescription="Album Cover"
android:foreground="@drawable/gradient"
android:padding="20dp"
android:layout_marginBottom="3dp"
android:paddingBottom="10dp"
android:src="@drawable/spotify_download"
android:visibility="visible"
@ -115,7 +113,29 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btn_search" />
app:layout_constraintTop_toBottomOf="@+id/linkSearch" />
<TextView
android:id="@+id/StatusBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/text_background_accented"
android:fontFamily="@font/raleway_semibold"
android:foreground="@drawable/rounded_gradient"
android:gravity="center"
android:paddingLeft="12dp"
android:paddingTop="1dp"
android:paddingRight="12dp"
android:paddingBottom="1dp"
android:text="Total: 100 Processed: 50"
android:textAlignment="center"
android:textColor="@color/grey"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@+id/btn_search"
app:layout_constraintStart_toStartOf="@+id/linkSearch"
app:layout_constraintTop_toBottomOf="@+id/linkSearch" />
</androidx.constraintlayout.widget.ConstraintLayout>
@ -312,7 +332,15 @@
app:layout_constraintStart_toEndOf="@id/heart"
app:layout_constraintTop_toBottomOf="@+id/developer_insta" />
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="300dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -18,8 +18,8 @@
<AppUpdater>
<update>
<latestVersion>1.1</latestVersion>
<latestVersionCode>2</latestVersionCode>
<latestVersion>1.2</latestVersion>
<latestVersionCode>3</latestVersionCode>
<url>https://github.com/Shabinder/SpotiFlyer/releases</url>
</update>
</AppUpdater>

View File

@ -31,7 +31,7 @@ buildscript {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
//safe-Args
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
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
}