Youtube Playlist and more support

This commit is contained in:
Shabinder 2020-11-08 01:25:47 +05:30
parent 099a103e98
commit c0e3a35898
21 changed files with 977 additions and 789 deletions

View File

@ -1,6 +1,7 @@
<component name="ProjectDictionaryState"> <component name="ProjectDictionaryState">
<dictionary name="shabinder"> <dictionary name="shabinder">
<words> <words>
<w>cherrypick</w>
<w>downloadrecord</w> <w>downloadrecord</w>
<w>emoji</w> <w>emoji</w>
<w>ffmpeg</w> <w>ffmpeg</w>

View File

@ -118,6 +118,7 @@ dependencies {
implementation "com.squareup.retrofit2:converter-scalars:2.9.0" implementation "com.squareup.retrofit2:converter-scalars:2.9.0"
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.beust:klaxon:5.4' implementation 'com.beust:klaxon:5.4'
implementation 'me.xdrop:fuzzywuzzy:1.3.1'
implementation 'com.mpatric:mp3agic:0.9.1' implementation 'com.mpatric:mp3agic:0.9.1'
implementation 'com.shreyaspatil:EasyUpiPayment:3.0.0' implementation 'com.shreyaspatil:EasyUpiPayment:3.0.0'

View File

@ -35,10 +35,11 @@ import androidx.lifecycle.ViewModelProvider
import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.AppUpdater
import com.github.javiersantos.appupdater.enums.UpdateFrom import com.github.javiersantos.appupdater.enums.UpdateFrom
import com.shabinder.spotiflyer.databinding.MainActivityBinding 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.SpotifyService
import com.shabinder.spotiflyer.utils.SpotifyServiceTokenRequest import com.shabinder.spotiflyer.utils.SpotifyServiceTokenRequest
import com.shabinder.spotiflyer.utils.createDirectories import com.shabinder.spotiflyer.utils.createDirectories
import com.shabinder.spotiflyer.utils.startService
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -84,7 +85,7 @@ class MainActivity : AppCompatActivity(){
Log.i("Connection Status", isConnected.toString()) Log.i("Connection Status", isConnected.toString())
//starting Notification and Downloader Service! //starting Notification and Downloader Service!
SpotifyDownloadHelper.startService(this) startService(this)
handleIntentFromExternalActivity() handleIntentFromExternalActivity()
} }
@ -227,5 +228,6 @@ class MainActivity : AppCompatActivity(){
} }
init { init {
instance = this instance = this
activity = this
} }
} }

View File

@ -17,25 +17,20 @@
package com.shabinder.spotiflyer.downloadHelper package com.shabinder.spotiflyer.downloadHelper
import android.content.Context import android.annotation.SuppressLint
import android.content.Intent
import android.os.Environment import android.os.Environment
import android.os.Handler
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.animation.AlphaAnimation import android.view.animation.AlphaAnimation
import android.view.animation.Animation import android.view.animation.Animation
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import android.widget.Toast
import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.SharedViewModel
import com.github.kiulian.downloader.model.formats.Format import com.shabinder.spotiflyer.models.*
import com.github.kiulian.downloader.model.quality.AudioQuality import com.shabinder.spotiflyer.utils.*
import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.utils.Provider.activity
import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.utils.Provider.defaultDir
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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -45,13 +40,13 @@ import retrofit2.Response
import java.io.File import java.io.File
object SpotifyDownloadHelper { object SpotifyDownloadHelper {
var context : Context? = null
var statusBar:TextView? = null var statusBar:TextView? = null
var youtubeMusicApi:YoutubeMusicApi? = null var youtubeMusicApi:YoutubeMusicApi? = null
val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator var sharedViewModel: SharedViewModel? = null
var spotifyViewModel: SpotifyViewModel? = null
var total = 0 private var total = 0
var Processed = 0 private var processed = 0
var notFound = 0 var notFound = 0
/** /**
@ -60,16 +55,94 @@ object SpotifyDownloadHelper {
suspend fun downloadAllTracks( suspend fun downloadAllTracks(
type:String, type:String,
subFolder: String?, subFolder: String?,
trackList: List<Track>, ytDownloader: YoutubeDownloader?) { trackList: List<Track>) {
val downloadList = ArrayList<DownloadObject>()
withContext(Dispatchers.Main){ withContext(Dispatchers.Main){
total += trackList.size // Adding New Download List Count to StatusBar total += trackList.size // Adding New Download List Count to StatusBar
trackList.forEach { trackList.forEachIndexed { index, it ->
if(it.downloaded == "Downloaded"){//Download Already Present!! if(it.downloaded == DownloadStatus.Downloaded){//Download Already Present!!
Processed++ 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{ }else{
val artistsList = mutableListOf<String>() val artistsList = mutableListOf<String>()
it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } 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<String>{
override fun onResponse(call: Call<String>, response: Response<String>) {
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<String>, t: Throwable) {
Log.i("YT API Req. Fail",t.message.toString())
}
}
)
} }
updateStatusBar() 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<String>{
override fun onResponse(call: Call<String>, response: Response<String>) {
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<String>, 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() { private fun animateStatusBar() {
val anim: Animation = AlphaAnimation(0.0f, 0.9f) val anim: Animation = AlphaAnimation(0.3f, 0.9f)
anim.duration = 650 //You can manage the blinking time with this parameter anim.duration = 1500 //You can manage the blinking time with this parameter
anim.startOffset = 20 anim.startOffset = 20
anim.repeatMode = Animation.REVERSE anim.repeatMode = Animation.REVERSE
anim.repeatCount = Animation.INFINITE anim.repeatCount = Animation.INFINITE
statusBar?.animation = anim statusBar?.animation = anim
} }
@SuppressLint("SetTextI18n")
fun updateStatusBar() {
statusBar!!.visibility = View.VISIBLE
statusBar?.text = "Total: $total ${getEmojiByUnicode(0x2705)}: $processed ${getEmojiByUnicode(0x274C)}: $notFound"
}
} }

View File

@ -17,49 +17,48 @@
package com.shabinder.spotiflyer.downloadHelper package com.shabinder.spotiflyer.downloadHelper
import android.content.Context
import android.content.Intent
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import android.view.View import android.widget.Toast
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.github.kiulian.downloader.model.formats.Format
import com.shabinder.spotiflyer.models.DownloadObject import com.shabinder.spotiflyer.models.DownloadObject
import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.models.TrackDetails
import com.shabinder.spotiflyer.worker.ForegroundService 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 import java.io.File
object YTDownloadHelper { object YTDownloadHelper {
var context : Context? = null suspend fun downloadYTTracks(
var statusBar: TextView? = null type:String,
subFolder: String?,
fun downloadFile(subFolder: String?, type: String,ytTrack: Track,format: Format?) { tracks:List<TrackDetails>,
format?.let { ){
val url:String = format.url() val downloadList = ArrayList<DownloadObject>()
// Log.i("DHelper Link Found", url) tracks.forEach {
val outputFile:String = Environment.getExternalStorageDirectory().toString() + File.separator + val outputFile: String =
SpotifyDownloadHelper.defaultDir + SpotifyDownloadHelper.removeIllegalChars(type) + File.separator + (if(subFolder == null){""}else{ SpotifyDownloadHelper.removeIllegalChars(subFolder) + File.separator} + SpotifyDownloadHelper.removeIllegalChars( Environment.getExternalStorageDirectory().toString() + File.separator +
ytTrack.name!! defaultDir +
) +".m4a") removeIllegalChars(type) + File.separator +
(if (subFolder == null) { "" }
else { removeIllegalChars(subFolder) + File.separator }
+ removeIllegalChars(it.title) + ".m4a")
val downloadObject = DownloadObject( val downloadObject = DownloadObject(
track = ytTrack, trackDetails = it,
url = url, ytVideoId = "https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/")
outputDir = outputFile .substringBeforeLast(".")}/maxresdefault.jpg",
outputFile = outputFile
) )
Log.i("DH",outputFile)
startService(context!!, downloadObject) downloadList.add(downloadObject)
statusBar?.visibility= View.VISIBLE }
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)
}
} }

View File

@ -17,34 +17,26 @@
package com.shabinder.spotiflyer.downloadHelper package com.shabinder.spotiflyer.downloadHelper
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import com.beust.klaxon.JsonArray import com.beust.klaxon.JsonArray
import com.beust.klaxon.JsonObject import com.beust.klaxon.JsonObject
import com.beust.klaxon.Parser 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 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, fun getYTTracks(response: String):List<YoutubeTrack>{
subFolder:String?,
ytDownloader: YoutubeDownloader?,
response: String,
track: Track
){
//TODO Download File
val youtubeTracks = mutableListOf<YoutubeTrack>() val youtubeTracks = mutableListOf<YoutubeTrack>()
val parser: Parser = Parser.default()
val stringBuilder: StringBuilder = StringBuilder(response) 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<JsonObject>("contents") val contentBlocks = responseObj.obj("contents")?.obj("sectionListRenderer")?.array<JsonObject>("contents")
val resultBlocks = mutableListOf<JsonArray<JsonObject>>() val resultBlocks = mutableListOf<JsonArray<JsonObject>>()
if (contentBlocks != null) { if (contentBlocks != null) {
Log.i("Total Content Blocks:", contentBlocks.size.toString())
for (cBlock in contentBlocks){ for (cBlock in contentBlocks){
/** /**
*Ignore user-suggestion *Ignore user-suggestion
@ -109,8 +101,6 @@ fun getYTLink(type:String,
! we do so only if their Type is 'Song' or 'Video ! we do so only if their Type is 'Song' or 'Video
*/ */
val simplifiedResults = mutableListOf<JsonObject>()
for(result in resultBlocks){ for(result in resultBlocks){
// Blindly gather available details // 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 ! other constituents of a result block will lead to errors, hence the 'in
! result[:-1] ,i.e., skip last element in array ' ! 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(detail.obj("musicResponsiveListItemFlexColumnRenderer")?.size!! < 2) continue
// if not a dummy, collect All Variables // 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 ! Filter Out non-Song/Video results and incomplete results here itself
! From what we know about detail order, note that [1] - indicate result type ! 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") ){ if ( availableDetails.size > 1 && availableDetails[1] in listOf("Song","Video") ){
// skip if result is in hours instead of minutes (no song is that long) // skip if result is in hours instead of minutes (no song is that long)
// if(availableDetails[4].split(':').size != 2) continue TODO if(availableDetails[4].split(':').size != 2) continue //Has Been Giving Issues
/*
! 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 ! grab Video ID
@ -168,23 +151,86 @@ fun getYTLink(type:String,
name = availableDetails[0], name = availableDetails[0],
type = availableDetails[1], type = availableDetails[1],
artist = availableDetails[2], artist = availableDetails[2],
duration = availableDetails[4],
videoId = videoId videoId = videoId
) )
youtubeTracks.add(ytTrack) 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++ return youtubeTracks
else downloadFile( }
subFolder,
type, @SuppressLint("DefaultLocale")
track, fun sortByBestMatch(ytTracks:List<YoutubeTrack>,
ytDownloader, trackName:String,
id = youtubeTracks[0].videoId.toString() trackArtists:List<String>,
) trackDurationSec:Int,
Log.i("DHelper YT ID", youtubeTracks.firstOrNull()?.videoId ?: "Not Found") ):Map<String,Int>{
SpotifyDownloadHelper.updateStatusBar() /*
* "linksWithMatchValue" is map with Youtube VideoID and its rating/match with 100 as Max Value
**/
val linksWithMatchValue = mutableMapOf<String,Int>()
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()
} }

View File

@ -19,11 +19,32 @@ package com.shabinder.spotiflyer.models
import android.os.Parcelable import android.os.Parcelable
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import java.io.File
@Parcelize @Parcelize
data class DownloadObject( data class DownloadObject(
var ytVideo: YTTrack?=null, var trackDetails: TrackDetails,
var track: Track?=null, var ytVideoId:String,
var url:String, var outputFile:String
var outputDir:String ):Parcelable
):Parcelable
@Parcelize
data class TrackDetails(
var title:String,
var artists:List<String>,
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
}

View File

@ -31,7 +31,6 @@ data class Track(
var explicit: Boolean? = null, var explicit: Boolean? = null,
var external_urls: Map<String?, String?>? = null, var external_urls: Map<String?, String?>? = null,
var href: String? = null, var href: String? = null,
var id: String? = null,
var name: String? = null, var name: String? = null,
var preview_url: String? = null, var preview_url: String? = null,
var track_number: Int = 0, var track_number: Int = 0,
@ -40,5 +39,5 @@ data class Track(
var album: Album? = null, var album: Album? = null,
var external_ids: Map<String?, String?>? = null, var external_ids: Map<String?, String?>? = null,
var popularity: Int? = null, var popularity: Int? = null,
var ytCoverUrl:String? = null, var downloaded:DownloadStatus? = DownloadStatus.NotDownloaded):Parcelable
var downloaded:String? = "notDownloaded"):Parcelable

View File

@ -25,5 +25,6 @@ data class YoutubeTrack(
var name: String? = null, var name: String? = null,
var type: String? = null, // Song / Video var type: String? = null, // Song / Video
var artist: String? = null, var artist: String? = null,
var duration:String? = null,
var videoId: String? = null var videoId: String? = null
):Parcelable ):Parcelable

View File

@ -17,6 +17,7 @@
package com.shabinder.spotiflyer.recyclerView package com.shabinder.spotiflyer.recyclerView
import android.annotation.SuppressLint
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -24,13 +25,14 @@ import android.widget.Toast
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.kiulian.downloader.YoutubeDownloader
import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.databinding.TrackListItemBinding import com.shabinder.spotiflyer.databinding.TrackListItemBinding
import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.context
import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadAllTracks 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.models.Track
import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel 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.bindImage
import com.shabinder.spotiflyer.utils.rotateAnim import com.shabinder.spotiflyer.utils.rotateAnim
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -40,7 +42,6 @@ class SpotifyTrackListAdapter: ListAdapter<Track,SpotifyTrackListAdapter.ViewHol
var spotifyViewModel : SpotifyViewModel? = null var spotifyViewModel : SpotifyViewModel? = null
var isAlbum:Boolean = false var isAlbum:Boolean = false
var ytDownloader: YoutubeDownloader? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@ -49,13 +50,14 @@ class SpotifyTrackListAdapter: ListAdapter<Track,SpotifyTrackListAdapter.ViewHol
return ViewHolder(binding) return ViewHolder(binding)
} }
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position) val item = getItem(position)
if(itemCount ==1 || isAlbum){ if(itemCount ==1 || isAlbum){
holder.binding.imageUrl.visibility = View.GONE}else{ holder.binding.imageUrl.visibility = View.GONE}else{
spotifyViewModel!!.uiScope.launch { spotifyViewModel!!.uiScope.launch {
//Placeholder Set //Placeholder Set
bindImage(holder.binding.imageUrl, item.album!!.images?.get(0)?.url) bindImage(holder.binding.imageUrl, item.album?.images?.get(0)?.url,Source.Spotify)
} }
} }
@ -63,26 +65,26 @@ class SpotifyTrackListAdapter: ListAdapter<Track,SpotifyTrackListAdapter.ViewHol
holder.binding.artist.text = "${item.artists?.get(0)?.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" holder.binding.duration.text = "${item.duration_ms/1000/60} minutes, ${(item.duration_ms/1000)%60} sec"
when (item.downloaded) { when (item.downloaded) {
"Downloaded" -> { DownloadStatus.Downloaded -> {
holder.binding.btnDownload.setImageResource(R.drawable.ic_tick) holder.binding.btnDownload.setImageResource(R.drawable.ic_tick)
holder.binding.btnDownload.clearAnimation() holder.binding.btnDownload.clearAnimation()
} }
"Downloading" -> { DownloadStatus.Downloading -> {
holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh)
rotateAnim(holder.binding.btnDownload) rotateAnim(holder.binding.btnDownload)
} }
"notDownloaded" -> { DownloadStatus.NotDownloaded -> {
holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow) holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow)
holder.binding.btnDownload.clearAnimation() holder.binding.btnDownload.clearAnimation()
holder.binding.btnDownload.setOnClickListener{ 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) holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh)
rotateAnim(it) rotateAnim(it)
item.downloaded = "Downloading" item.downloaded = DownloadStatus.Downloading
spotifyViewModel!!.uiScope.launch { spotifyViewModel!!.uiScope.launch {
val itemList = mutableListOf<Track>() val itemList = mutableListOf<Track>()
itemList.add(item) itemList.add(item)
downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList,ytDownloader) downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList)
} }
notifyItemChanged(position)//start showing anim! notifyItemChanged(position)//start showing anim!
} }

View File

@ -22,18 +22,16 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import com.github.kiulian.downloader.model.formats.Format
import com.shabinder.spotiflyer.databinding.TrackListItemBinding import com.shabinder.spotiflyer.databinding.TrackListItemBinding
import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper import com.shabinder.spotiflyer.models.Source
import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.models.TrackDetails
import com.shabinder.spotiflyer.utils.bindImage import com.shabinder.spotiflyer.utils.bindImage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class YoutubeTrackListAdapter: ListAdapter<Track,SpotifyTrackListAdapter.ViewHolder>(YouTubeTrackDiffCallback()) { class YoutubeTrackListAdapter: ListAdapter<TrackDetails,SpotifyTrackListAdapter.ViewHolder>(YouTubeTrackDiffCallback()) {
var format:Format? = null
private val adapterScope = CoroutineScope(Dispatchers.Default) private val adapterScope = CoroutineScope(Dispatchers.Default)
override fun onCreateViewHolder( override fun onCreateViewHolder(
@ -51,26 +49,31 @@ class YoutubeTrackListAdapter: ListAdapter<Track,SpotifyTrackListAdapter.ViewHol
if(itemCount == 1){ if(itemCount == 1){
holder.binding.imageUrl.visibility = View.GONE}else{ holder.binding.imageUrl.visibility = View.GONE}else{
adapterScope.launch { adapterScope.launch {
bindImage(holder.binding.imageUrl, item.ytCoverUrl) bindImage(holder.binding.imageUrl,
"https://i.ytimg.com/vi/${item.albumArt.absolutePath.substringAfterLast("/")
.substringBeforeLast(".")}/maxresdefault.jpg"
,
Source.YouTube
)
} }
} }
holder.binding.trackName.text = "${if(item.name!!.length > 17){"${item.name!!.subSequence(0,16)}..."}else{item.name}}" 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)?.name?:""}..." holder.binding.artist.text = "${item.artists.get(0)}..."
holder.binding.duration.text = "${item.duration_ms/1000/60} minutes, ${(item.duration_ms/1000)%60} sec" holder.binding.duration.text = "${item.durationSec/60} minutes, ${item.durationSec%60} sec"
holder.binding.btnDownload.setOnClickListener{ holder.binding.btnDownload.setOnClickListener{
adapterScope.launch { adapterScope.launch {
YTDownloadHelper.downloadFile(null,"YT_Downloads",item,format) // YTDownloadHelper.downloadFile(null,"YT_Downloads",item,format)
} }
} }
} }
} }
class YouTubeTrackDiffCallback: DiffUtil.ItemCallback<Track>(){ class YouTubeTrackDiffCallback: DiffUtil.ItemCallback<TrackDetails>(){
override fun areItemsTheSame(oldItem: Track, newItem: Track): Boolean { override fun areItemsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean {
return oldItem.name == newItem.name return oldItem.title == newItem.title
} }
override fun areContentsTheSame(oldItem: Track, newItem: Track): Boolean { override fun areContentsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View File

@ -24,41 +24,33 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.SimpleItemAnimator 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.MainActivity
import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.SharedViewModel
import com.shabinder.spotiflyer.databinding.SpotifyFragmentBinding import com.shabinder.spotiflyer.databinding.SpotifyFragmentBinding
import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper 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.Track
import com.shabinder.spotiflyer.models.TrackDetails
import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter
import com.shabinder.spotiflyer.utils.YoutubeMusicApi import com.shabinder.spotiflyer.utils.YoutubeMusicApi
import com.shabinder.spotiflyer.utils.bindImage 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 com.shabinder.spotiflyer.utils.rotateAnim
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@ -69,7 +61,6 @@ class SpotifyFragment : Fragment() {
private lateinit var spotifyViewModel: SpotifyViewModel private lateinit var spotifyViewModel: SpotifyViewModel
private lateinit var sharedViewModel: SharedViewModel private lateinit var sharedViewModel: SharedViewModel
private lateinit var adapterSpotify:SpotifyTrackListAdapter private lateinit var adapterSpotify:SpotifyTrackListAdapter
@Inject lateinit var ytDownloader:YoutubeDownloader
@Inject lateinit var youtubeMusicApi: YoutubeMusicApi @Inject lateinit var youtubeMusicApi: YoutubeMusicApi
private var intentFilter:IntentFilter? = null private var intentFilter:IntentFilter? = null
private var updateUIReceiver: BroadcastReceiver? = null private var updateUIReceiver: BroadcastReceiver? = null
@ -109,30 +100,34 @@ class SpotifyFragment : Fragment() {
spotifyViewModel.spotifySearch(type,link) spotifyViewModel.spotifySearch(type,link)
if(type=="album")adapterSpotify.isAlbum = true if(type=="album")adapterSpotify.isAlbum = true
binding.btnDownloadAllSpotify.setOnClickListener { binding.btnDownloadAll.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.visibility = View.GONE
binding.downloadingFab.visibility = View.VISIBLE
rotateAnim(binding.downloadingFabSpotify) rotateAnim(binding.downloadingFab)
for (track in spotifyViewModel.trackList.value!!){ 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)) adapterSpotify.notifyItemChanged(spotifyViewModel.trackList.value!!.indexOf(track))
} }
} }
showToast("Starting Download in Few Seconds") showToast("Processing!")
spotifyViewModel.uiScope.launch(Dispatchers.Default){loadAllImages(spotifyViewModel.trackList.value!!)} sharedViewModel.uiScope.launch(Dispatchers.Default){
val urlList = arrayListOf<String>()
spotifyViewModel.trackList.value?.forEach { urlList.add(it.album?.images?.get(0)?.url.toString()) }
//Appending Source
urlList.add("spotify")
loadAllImages(
requireActivity(),
urlList
)
}
spotifyViewModel.uiScope.launch { spotifyViewModel.uiScope.launch {
SpotifyDownloadHelper.downloadAllTracks( SpotifyDownloadHelper.downloadAllTracks(
spotifyViewModel.folderType, spotifyViewModel.folderType,
spotifyViewModel.subFolder, spotifyViewModel.subFolder,
spotifyViewModel.trackList.value!!, spotifyViewModel.trackList.value!!,
ytDownloader
) )
} }
} }
@ -154,15 +149,18 @@ class SpotifyFragment : Fragment() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
//UI update here //UI update here
if (intent != null){ if (intent != null){
val track = intent.getParcelableExtra<Track?>("track") val trackDetails = intent.getParcelableExtra<TrackDetails?>("track")
track?.let { trackDetails?.let {
val position: Int = spotifyViewModel.trackList.value?.indexOf(track)!! val position: Int = spotifyViewModel.trackList.value?.map { it.name }?.indexOf(trackDetails.title) ?: -1
Log.i("Track","Download Completed Intent :$position") Log.i("Track","Download Completed Intent :$position")
track.downloaded = "Downloaded"
if(position != -1) { if(position != -1) {
spotifyViewModel.trackList.value?.set(position, track) val track = spotifyViewModel.trackList.value?.get(position)
adapterSpotify.notifyItemChanged(position) track?.let{
checkIfAllDownloaded() it.downloaded = DownloadStatus.Downloaded
spotifyViewModel.trackList.value?.set(position, it)
adapterSpotify.notifyItemChanged(position)
checkIfAllDownloaded()
}
} }
} }
} }
@ -184,7 +182,7 @@ class SpotifyFragment : Fragment() {
* CoverUrl Binding Observer! * CoverUrl Binding Observer!
**/ **/
spotifyViewModel.coverUrl.observe(viewLifecycleOwner, { 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! * Title Binding Observer!
**/ **/
spotifyViewModel.title.observe(viewLifecycleOwner, { spotifyViewModel.title.observe(viewLifecycleOwner, {
binding.titleViewSpotify.text = it binding.titleView.text = it
}) })
sharedViewModel.intentString.observe(viewLifecycleOwner,{ sharedViewModel.intentString.observe(viewLifecycleOwner,{
@ -215,10 +213,10 @@ class SpotifyFragment : Fragment() {
} }
private fun checkIfAllDownloaded() { private fun checkIfAllDownloaded() {
if(!spotifyViewModel.trackList.value!!.any { it.downloaded != "Downloaded" }){ if(!spotifyViewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){
//All Tracks Downloaded //All Tracks Downloaded
binding.btnDownloadAllSpotify.visibility = View.GONE binding.btnDownloadAll.visibility = View.GONE
binding.downloadingFabSpotify.apply{ binding.downloadingFab.apply{
setImageResource(R.drawable.ic_tick) setImageResource(R.drawable.ic_tick)
visibility = View.VISIBLE visibility = View.VISIBLE
clearAnimation() clearAnimation()
@ -236,69 +234,17 @@ class SpotifyFragment : Fragment() {
sharedViewModel.spotifyService.observe(viewLifecycleOwner, Observer { sharedViewModel.spotifyService.observe(viewLifecycleOwner, Observer {
spotifyViewModel.spotifyService = it spotifyViewModel.spotifyService = it
}) })
SpotifyDownloadHelper.context = requireContext()
SpotifyDownloadHelper.youtubeMusicApi = youtubeMusicApi SpotifyDownloadHelper.youtubeMusicApi = youtubeMusicApi
SpotifyDownloadHelper.spotifyViewModel = spotifyViewModel SpotifyDownloadHelper.sharedViewModel = sharedViewModel
SpotifyDownloadHelper.statusBar = binding.StatusBarSpotify SpotifyDownloadHelper.statusBar = binding.statusBar
binding.trackListSpotify.adapter = adapterSpotify binding.trackList.adapter = adapterSpotify
(binding.trackListSpotify.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
/**
* Function to fetch all Images for using in mp3 tag.
**/
private suspend fun loadAllImages(trackList: List<Track>) {
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<File> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<File>?,
isFirstResource: Boolean
): Boolean {
Log.i("Glide","LoadFailed")
return false
}
override fun onResourceReady(
resource: File?,
model: Any?,
target: Target<File>?,
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()
}
}
} }
/** /**
* Configure Recycler View Adapter * Configure Recycler View Adapter
**/ **/
private fun adapterConfig(trackList: List<Track>){ private fun adapterConfig(trackList: List<Track>){
adapterSpotify.ytDownloader = ytDownloader
adapterSpotify.spotifyViewModel = spotifyViewModel adapterSpotify.spotifyViewModel = spotifyViewModel
adapterSpotify.submitList(trackList) adapterSpotify.submitList(trackList)
} }
@ -320,4 +266,5 @@ class SpotifyFragment : Fragment() {
val netInfo = cm.activeNetworkInfo val netInfo = cm.activeNetworkInfo
return netInfo != null && netInfo.isConnectedOrConnecting return netInfo != null && netInfo.isConnectedOrConnecting
} }
} }

View File

@ -52,7 +52,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO
folderType = "Tracks" folderType = "Tracks"
val tempTrackList = mutableListOf<Track>() val tempTrackList = mutableListOf<Track>()
if(File(finalOutputDir(trackObject?.name!!,folderType,subFolder)).exists()){//Download Already Present!! if(File(finalOutputDir(trackObject?.name!!,folderType,subFolder)).exists()){//Download Already Present!!
trackObject.downloaded = "Downloaded" trackObject.downloaded = DownloadStatus.Downloaded
} }
tempTrackList.add(trackObject) tempTrackList.add(trackObject)
trackList.value = tempTrackList trackList.value = tempTrackList
@ -65,7 +65,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO
link = "https://open.spotify.com/$type/$link", link = "https://open.spotify.com/$type/$link",
coverUrl = coverUrl.value!!, coverUrl = coverUrl.value!!,
totalFiles = tempTrackList.size, totalFiles = tempTrackList.size,
downloaded = trackObject.downloaded =="Downloaded", downloaded = trackObject.downloaded == DownloadStatus.Downloaded,
directory = finalOutputDir(trackObject.name!!,folderType,subFolder) directory = finalOutputDir(trackObject.name!!,folderType,subFolder)
)) ))
} }
@ -80,7 +80,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO
val tempTrackList = mutableListOf<Track>() val tempTrackList = mutableListOf<Track>()
albumObject?.tracks?.items?.forEach { albumObject?.tracks?.items?.forEach {
if(File(finalOutputDir(it.name!!,folderType,subFolder)).exists()){//Download Already Present!! 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))) it.album = Album(images = listOf(Image(url = albumObject.images?.get(0)?.url)))
tempTrackList.add(it) tempTrackList.add(it)
@ -112,7 +112,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO
playlistObject?.tracks?.items?.forEach { playlistObject?.tracks?.items?.forEach {
it.track?.let { it.track?.let {
it1 -> if(File(finalOutputDir(it1.name!!,folderType,subFolder)).exists()){//Download Already Present!! it1 -> if(File(finalOutputDir(it1.name!!,folderType,subFolder)).exists()){//Download Already Present!!
it1.downloaded = "Downloaded" it1.downloaded = DownloadStatus.Downloaded
} }
tempTrackList.add(it1) tempTrackList.add(it1)
} }
@ -130,13 +130,13 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO
Log.i("Total Tracks Fetched",tempTrackList.size.toString()) Log.i("Total Tracks Fetched",tempTrackList.size.toString())
trackList.value = tempTrackList trackList.value = tempTrackList
title.value = playlistObject?.name title.value = playlistObject?.name
coverUrl.value = playlistObject?.images?.get(0)!!.url!! coverUrl.value = playlistObject?.images?.get(0)?.url.toString()
withContext(Dispatchers.IO){ withContext(Dispatchers.IO){
databaseDAO.insert(DownloadRecord( databaseDAO.insert(DownloadRecord(
type = "Playlist", type = "Playlist",
name = title.value!!, name = title.value.toString(),
link = "https://open.spotify.com/$type/$link", link = "https://open.spotify.com/$type/$link",
coverUrl = coverUrl.value!!, coverUrl = coverUrl.value.toString(),
totalFiles = tempTrackList.size, totalFiles = tempTrackList.size,
downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == tempTrackList.size, downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == tempTrackList.size,
directory = finalOutputDir(type = folderType,subFolder = subFolder) directory = finalOutputDir(type = folderType,subFolder = subFolder)

View File

@ -24,17 +24,22 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.github.kiulian.downloader.YoutubeDownloader import com.github.kiulian.downloader.YoutubeDownloader
import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.SharedViewModel
import com.shabinder.spotiflyer.databinding.YoutubeFragmentBinding import com.shabinder.spotiflyer.databinding.YoutubeFragmentBinding
import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper 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.recyclerView.YoutubeTrackListAdapter
import com.shabinder.spotiflyer.utils.bindImage import com.shabinder.spotiflyer.utils.bindImage
import com.shabinder.spotiflyer.utils.loadAllImages
import com.shabinder.spotiflyer.utils.rotateAnim
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -43,10 +48,10 @@ class YoutubeFragment : Fragment() {
private lateinit var binding:YoutubeFragmentBinding private lateinit var binding:YoutubeFragmentBinding
private lateinit var youtubeViewModel: YoutubeViewModel private lateinit var youtubeViewModel: YoutubeViewModel
private lateinit var sharedViewModel: SharedViewModel private lateinit var sharedViewModel: SharedViewModel
@Inject lateinit var ytDownloader: YoutubeDownloader
private lateinit var adapter : YoutubeTrackListAdapter private lateinit var adapter : YoutubeTrackListAdapter
private val sampleDomain1 = "youtube.com" private val sampleDomain1 = "youtube.com"
private val sampleDomain2 = "youtu.be" private val sampleDomain2 = "youtu.be"
@Inject lateinit var ytDownloader: YoutubeDownloader
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
@ -56,9 +61,7 @@ class YoutubeFragment : Fragment() {
youtubeViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) youtubeViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java)
sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java)
adapter = YoutubeTrackListAdapter() adapter = YoutubeTrackListAdapter()
YTDownloadHelper.context = requireContext() binding.trackList.adapter = adapter
YTDownloadHelper.statusBar = binding.StatusBarYoutube
binding.trackListYoutube.adapter = adapter
initializeLiveDataObservers() initializeLiveDataObservers()
@ -70,7 +73,11 @@ class YoutubeFragment : Fragment() {
private fun youtubeSearch(linkSearch:String) { private fun youtubeSearch(linkSearch:String) {
val link = linkSearch.removePrefix("https://").removePrefix("http://") 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" var searchId = "error"
if(link.contains(sampleDomain1,true) ){ if(link.contains(sampleDomain1,true) ){
searchId = link.substringAfterLast("=","error") searchId = link.substringAfterLast("=","error")
@ -79,41 +86,63 @@ class YoutubeFragment : Fragment() {
searchId = link.substringAfterLast("/","error") searchId = link.substringAfterLast("/","error")
} }
if(searchId != "error") { if(searchId != "error") {
youtubeViewModel.getYTTrack(searchId,ytDownloader) youtubeViewModel.getYTTrack(searchId,ytDownloader)
binding.btnDownloadAllYoutube.setOnClickListener {
YTDownloadHelper.downloadFile(null,"YT_Downloads",
youtubeViewModel.ytTrack.value!!,youtubeViewModel.format.value)
}
}else{showToast("Your Youtube Link is not of a Video!!")} }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<String>()
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() { private fun initializeLiveDataObservers() {
/** /**
* CoverUrl Binding Observer! * CoverUrl Binding Observer!
**/ **/
youtubeViewModel.coverUrl.observe(viewLifecycleOwner, Observer { youtubeViewModel.coverUrl.observe(viewLifecycleOwner, {
if(it!="Loading") bindImage(binding.youtubeCoverImage,it) if(it!="Loading") bindImage(binding.coverImage,it,Source.YouTube)
}) })
/** /**
* TrackList Binding Observer! * TrackList Binding Observer!
**/ **/
youtubeViewModel.ytTrack.observe(viewLifecycleOwner, Observer { youtubeViewModel.ytTrackList.observe(viewLifecycleOwner, {
val list = mutableListOf<Track>() adapterConfig(it)
list.add(it)
adapterConfig(list)
})
youtubeViewModel.format.observe(viewLifecycleOwner, Observer {
adapter.format = it
}) })
/** /**
* Title Binding Observer! * Title Binding Observer!
**/ **/
youtubeViewModel.title.observe(viewLifecycleOwner, Observer { youtubeViewModel.title.observe(viewLifecycleOwner, {
binding.titleViewYoutube.text = it binding.titleView.text = it
}) })
} }
@ -121,7 +150,7 @@ class YoutubeFragment : Fragment() {
/** /**
* Configure Recycler View Adapter * Configure Recycler View Adapter
**/ **/
private fun adapterConfig(list:List<Track>){ private fun adapterConfig(list:List<TrackDetails>){
adapter.submitList(list) adapter.submitList(list)
} }

View File

@ -17,78 +17,119 @@
package com.shabinder.spotiflyer.ui.youtube package com.shabinder.spotiflyer.ui.youtube
import android.annotation.SuppressLint
import android.os.Environment
import android.util.Log import android.util.Log
import androidx.hilt.lifecycle.ViewModelInject import androidx.hilt.lifecycle.ViewModelInject
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.github.kiulian.downloader.YoutubeDownloader import com.github.kiulian.downloader.YoutubeDownloader
import com.github.kiulian.downloader.model.formats.Format 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.DatabaseDAO
import com.shabinder.spotiflyer.database.DownloadRecord import com.shabinder.spotiflyer.database.DownloadRecord
import com.shabinder.spotiflyer.models.Artist import com.shabinder.spotiflyer.models.DownloadStatus
import com.shabinder.spotiflyer.models.Track 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.finalOutputDir
import com.shabinder.spotiflyer.utils.removeIllegalChars
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File
class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : ViewModel(){
ViewModel(){
val ytTrack = MutableLiveData<Track>() /*
* YT Album Art Schema
* Normal Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg"
* */
val ytTrackList = MutableLiveData<List<TrackDetails>>()
val format = MutableLiveData<Format>() val format = MutableLiveData<Format>()
private val loading = "Loading" private val loading = "Loading"
var title = MutableLiveData<String>().apply { value = "\"Loading!\"" } var title = MutableLiveData<String>().apply { value = "\"Loading!\"" }
var coverUrl = MutableLiveData<String>().apply { value = loading } var coverUrl = MutableLiveData<String>().apply { value = loading }
val folderType = "YT_Downloads"
var subFolder = ""
private var viewModelJob = Job() private var viewModelJob = Job()
val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
fun getYTTrack(searchId:String,ytDownloader:YoutubeDownloader) { fun getYTPlaylist(searchId:String, ytDownloader:YoutubeDownloader){
uiScope.launch { uiScope.launch(Dispatchers.IO) {
withContext(Dispatchers.IO){ Log.i("YT Playlist",searchId)
Log.i("YT View Model",searchId) val playlist = ytDownloader.getPlaylist(searchId)
val video = ytDownloader.getVideo(searchId) val playlistDetails = playlist.details()
val detail = video?.details() val name = playlistDetails.title()
val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() subFolder = removeIllegalChars(name).toString()
Log.i("YT View Model",detail.toString()) val videos = playlist.videos()
ytTrack.postValue( coverUrl.postValue("https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg")
Track( title.postValue(
id = searchId, if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}
name = name, )
artists = listOf<Artist>(Artist(name = detail?.author())), ytTrackList.postValue(videos.map {
duration_ms = detail?.lengthSeconds()?.times(1000)?.toLong()?:0, TrackDetails(
ytCoverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" title = it.title(),
)) artists = listOf(it.author().toString()),
coverUrl.postValue("https://i.ytimg.com/vi/$searchId/maxresdefault.jpg") durationSec = it.lengthSeconds(),
title.postValue( albumArt = File(
if(name?.length!! > 17){"${name.subSequence(0,16)}..."}else{name} 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){ withContext(Dispatchers.IO){
databaseDAO.insert(DownloadRecord( databaseDAO.insert(DownloadRecord(
type = "Track", type = "PlayList",
name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name}, name = if(name.length > 17){"${name.subSequence(0,16)}..."}else{name},
link = "https://www.youtube.com/watch?v=$searchId", link = "https://www.youtube.com/playlist?list=$searchId",
coverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg", coverUrl = "https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg",
totalFiles = 1, totalFiles = videos.size,
downloaded = false, directory = finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder),
directory = finalOutputDir(type = "YT_Downloads") 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")
))
} }
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<File>{
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<File>?,
isFirstResource: Boolean
): Boolean {
Log.i("Glide","LoadFailed")
return false
}
override fun onResourceReady(
resource: File?,
model: Any?,
target: Target<File>?,
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")}
}

View File

@ -18,6 +18,7 @@
package com.shabinder.spotiflyer.utils package com.shabinder.spotiflyer.utils
import android.content.Context import android.content.Context
import android.os.Environment
import com.github.kiulian.downloader.YoutubeDownloader import com.github.kiulian.downloader.YoutubeDownloader
import com.shabinder.spotiflyer.App import com.shabinder.spotiflyer.App
import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.MainActivity
@ -38,18 +39,28 @@ import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory
import java.io.File
import javax.inject.Singleton import javax.inject.Singleton
@InstallIn(ApplicationComponent::class) @InstallIn(ApplicationComponent::class)
@Module @Module
object Provider { object Provider {
lateinit var activity: MainActivity
val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator
@Provides @Provides
fun databaseDAO(@ApplicationContext appContext: Context):DatabaseDAO{ fun databaseDAO(@ApplicationContext appContext: Context):DatabaseDAO{
return DownloadRecordDatabase.getInstance(appContext).databaseDAO return DownloadRecordDatabase.getInstance(appContext).databaseDAO
} }
@Provides
@Singleton
fun getYTDownloader():YoutubeDownloader{
return YoutubeDownloader()
}
@Provides @Provides
@Singleton @Singleton
@ -72,12 +83,6 @@ object Provider {
.build() .build()
} }
@Provides
@Singleton
fun getYTDownloader():YoutubeDownloader{
return YoutubeDownloader()
}
@Provides @Provides
@Singleton @Singleton
fun getSpotifyTokenInterface():SpotifyServiceTokenRequest{ fun getSpotifyTokenInterface():SpotifyServiceTokenRequest{

202
app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt Normal file → Executable file
View File

@ -17,15 +17,203 @@
package com.shabinder.spotiflyer.utils 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<String>? = 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<DownloadObject>? = 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<File>{
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<File>?,
isFirstResource: Boolean
): Boolean {
Log.i("Glide","LoadFailed")
return false
}
override fun onResourceReady(
resource: File?,
model: Any?,
target: Target<File>?,
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() { fun createDirectories() {
createDirectory(SpotifyDownloadHelper.defaultDir) createDirectory(defaultDir)
createDirectory(SpotifyDownloadHelper.defaultDir + ".Images/") createDirectory(defaultDir + ".Images/")
createDirectory(SpotifyDownloadHelper.defaultDir + "Tracks/") createDirectory(defaultDir + "Tracks/")
createDirectory(SpotifyDownloadHelper.defaultDir + "Albums/") createDirectory(defaultDir + "Albums/")
createDirectory(SpotifyDownloadHelper.defaultDir + "Playlists/") createDirectory(defaultDir + "Playlists/")
createDirectory(SpotifyDownloadHelper.defaultDir + "YT_Downloads/") createDirectory(defaultDir + "YT_Downloads/")
} }
fun getEmojiByUnicode(unicode: Int): String? { fun getEmojiByUnicode(unicode: Int): String? {
return String(Character.toChars(unicode)) return String(Character.toChars(unicode))

View File

@ -29,27 +29,36 @@ import android.os.*
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import com.arthenica.mobileffmpeg.Config import com.arthenica.mobileffmpeg.Config
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS
import com.arthenica.mobileffmpeg.FFmpeg 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.ID3v1Tag
import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.ID3v24Tag
import com.mpatric.mp3agic.Mp3File import com.mpatric.mp3agic.Mp3File
import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.MainActivity
import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper
import com.shabinder.spotiflyer.models.DownloadObject 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.fetch2.*
import com.tonyodev.fetch2core.DownloadBlock import com.tonyodev.fetch2core.DownloadBlock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.IOException
import java.util.*
@Suppress("DEPRECATION")
class ForegroundService : Service(){ class ForegroundService : Service(){
private val tag = "Foreground Service" private val tag = "Foreground Service"
private val channelId = "ForegroundDownloaderService" private val channelId = "ForegroundDownloaderService"
@ -57,22 +66,21 @@ class ForegroundService : Service(){
private var total = 0 //Total Downloads Requested private var total = 0 //Total Downloads Requested
private var converted = 0//Total Files Converted private var converted = 0//Total Files Converted
private var downloaded = 0//Total Files downloaded private var downloaded = 0//Total Files downloaded
private var fetch:Fetch? = null private lateinit var fetch:Fetch
private var downloadManager : DownloadManager? = null private lateinit var ytDownloader: YoutubeDownloader
private var downloadList = mutableListOf<DownloadObject>() private lateinit var downloadManager : DownloadManager
private var serviceJob = Job() private var serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val requestMap = mutableMapOf<Request,Track>() private val requestMap = mutableMapOf<Request,TrackDetails>()
private val downloadMap = mutableMapOf<String,Track>()
private var speed :Long = 0 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(), private val parentDirectory = File(Environment.getExternalStorageDirectory(),
defaultDirectory+File.separator defaultDir +File.separator
) )
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false private var isServiceStarted = false
var notificationLine = 0 var notificationLine = 0
val messageList = mutableListOf<String>("","","","") val messageList = mutableListOf("","","","")
private var pendingIntent:PendingIntent? = null private var pendingIntent:PendingIntent? = null
@ -89,7 +97,7 @@ class ForegroundService : Service(){
0, notificationIntent, 0 0, notificationIntent, 0
) )
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
ytDownloader = YoutubeDownloader()
val fetchConfiguration = val fetchConfiguration =
FetchConfiguration.Builder(this) FetchConfiguration.Builder(this)
.setDownloadConcurrentLimit(4) .setDownloadConcurrentLimit(4)
@ -98,86 +106,41 @@ class ForegroundService : Service(){
Fetch.setDefaultInstanceConfiguration(fetchConfiguration) Fetch.setDefaultInstanceConfiguration(fetchConfiguration)
fetch = Fetch.getDefaultInstance() fetch = Fetch.getDefaultInstance()
// fetch?.enableLogging(true) fetch.addListener(fetchListener)
fetch?.addListener(fetchListener)
//clearing all not completed Downloads //clearing all not completed Downloads
//Starting fresh //Starting fresh
fetch?.removeAll() fetch.removeAll()
startForeground() 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") @SuppressLint("WakelockTimeout")
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
// Send a notification that service is started // Send a notification that service is started
Log.i(tag,"Service Started.") Log.i(tag,"Service Started.")
startForeground() startForeground()
val obj:DownloadObject? = intent.getParcelableExtra("object") ?: intent.extras?.getParcelable("object") val downloadObjects: ArrayList<DownloadObject>? = (intent.getParcelableArrayListExtra("object") ?: intent.extras?.getParcelableArrayList("object"))
obj?.let { val imagesList: ArrayList<String>? = (intent.getStringArrayListExtra("imagesList") ?: intent.extras?.getStringArrayList("imagesList"))
total ++
updateNotification()
serviceScope.launch {
val request= Request(obj.url, obj.outputDir)
request.priority = Priority.NORMAL
request.networkType = NetworkType.ALL
fetch!!.enqueue(request, imagesList?.let{
{ serviceScope.launch {
obj.track?.let { it1 -> requestMap.put(it, it1) } loadAllImages(it)
downloadList.remove(obj)
Log.i(tag, "Enqueuing Download")
},
{
Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")}
)
} }
} }
downloadObjects?.let {
total += downloadObjects.size
updateNotification()
downloadAllTracks(downloadObjects)
}
//Wake locks and misc tasks from here : //Wake locks and misc tasks from here :
return if (isServiceStarted){ return if (isServiceStarted){
//Service Already Started
START_STICKY START_STICKY
} else{ } else{
Log.i(tag,"Starting the foreground service task") Log.i(tag,"Starting the foreground service task")
isServiceStarted = true isServiceStarted = true
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply {
@ -188,9 +151,53 @@ class ForegroundService : Service(){
} }
} }
private fun downloadAllTracks(downloadObjects: List<DownloadObject>){
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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if(downloadMap.isEmpty() && converted == total){ if(converted == total){
Handler().postDelayed({ Handler().postDelayed({
Log.i(tag,"Service destroyed.") Log.i(tag,"Service destroyed.")
deleteFile(parentDirectory) 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?) { override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
if(downloadMap.isEmpty() && converted == total ){ if(converted == total ){
Log.i(tag,"Service Removed.") Log.i(tag,"Service Removed.")
deleteFile(parentDirectory)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
stopForeground(true) stopForeground(true)
} else { } 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 * Fetch Listener/ Responsible for Fetch Behaviour
@ -274,23 +248,23 @@ class ForegroundService : Service(){
val track = requestMap[download.request] val track = requestMap[download.request]
when(notificationLine){ when(notificationLine){
0 -> { 0 -> {
messageList[0] = "Downloading ${track?.name}" messageList[0] = "Downloading ${track?.title}"
notificationLine = 1 notificationLine = 1
} }
1 -> { 1 -> {
messageList[1] = "Downloading ${track?.name}" messageList[1] = "Downloading ${track?.title}"
notificationLine = 2 notificationLine = 2
} }
2-> { 2-> {
messageList[2] = "Downloading ${track?.name}" messageList[2] = "Downloading ${track?.title}"
notificationLine = 3 notificationLine = 3
} }
3 -> { 3 -> {
messageList[3] = "Downloading ${track?.name}" messageList[3] = "Downloading ${track?.title}"
notificationLine = 0 notificationLine = 0
} }
} }
Log.i(tag,"${track?.name} Download Started") Log.i(tag,"${track?.title} Download Started")
updateNotification() updateNotification()
} }
@ -309,19 +283,20 @@ class ForegroundService : Service(){
override fun onCompleted(download: Download) { override fun onCompleted(download: Download) {
val track = requestMap[download.request] val track = requestMap[download.request]
for (message in messageList){ for (message in messageList){
if( message == "Downloading ${track?.name}"){ if( message == "Downloading ${track?.title}"){
//Remove Downloading Status from Notification
messageList[messageList.indexOf(message)] = "" messageList[messageList.indexOf(message)] = ""
} }
} }
serviceScope.launch { serviceScope.launch {
try{ try{
convertToMp3(download.file, track!!) track?.let { convertToMp3(download.file, it) }
Log.i(tag,"${track.name} Download Completed") Log.i(tag,"${track?.title} Download Completed")
}catch (e:KotlinNullPointerException }catch (e:KotlinNullPointerException
){ ){
Log.i(tag,"${track?.name} Download Failed! Error:Fetch!!!!") Log.i(tag,"${track?.title} Download Failed! Error:Fetch!!!!")
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!!) downloadUsingDM(download.request.url,download.request.file, track!!)
downloaded++ downloaded++
requestMap.remove(download.request) requestMap.remove(download.request)
@ -348,7 +323,7 @@ class ForegroundService : Service(){
val track = requestMap[download.request] val track = requestMap[download.request]
downloaded++ downloaded++
Log.i(tag,download.error.throwable.toString()) 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!!) downloadUsingDM(download.request.url,download.request.file, track!!)
requestMap.remove(download.request) requestMap.remove(download.request)
} }
@ -365,7 +340,7 @@ class ForegroundService : Service(){
downloadedBytesPerSecond: Long downloadedBytesPerSecond: Long
) { ) {
val track = requestMap[download.request] 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) speed = (downloadedBytesPerSecond/1000)
updateNotification() updateNotification()
} }
@ -375,7 +350,7 @@ class ForegroundService : Service(){
/** /**
* If fetch Fails , Android Download Manager To RESCUE!! * 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 uri = Uri.parse(url)
val request = DownloadManager.Request(uri) val request = DownloadManager.Request(uri)
.setAllowedNetworkTypes( .setAllowedNetworkTypes(
@ -383,14 +358,14 @@ class ForegroundService : Service(){
DownloadManager.Request.NETWORK_MOBILE DownloadManager.Request.NETWORK_MOBILE
) )
.setAllowedOverRoaming(false) .setAllowedOverRoaming(false)
.setTitle(track.name) .setTitle(track.title)
.setDescription("Spotify Downloader Working Up here...") .setDescription("Spotify Downloader Working Up here...")
.setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, outputDir.removePrefix( .setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, outputDir.removePrefix(
Environment.getExternalStorageDirectory().toString() + Environment.DIRECTORY_MUSIC + File.separator Environment.getExternalStorageDirectory().toString() + Environment.DIRECTORY_MUSIC + File.separator
)) ))
.setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
//Start Download //Start Download
val downloadID = downloadManager?.enqueue(request) val downloadID = downloadManager.enqueue(request)
Log.i("DownloadManager", "Download Request Sent") Log.i("DownloadManager", "Download Request Sent")
val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() {
@ -412,7 +387,7 @@ class ForegroundService : Service(){
/** /**
*Converting Downloaded Audio (m4a) to Mp3.( Also Applying Metadata) *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) val m4aFile = File(filePath)
FFmpeg.executeAsync( 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) var mp3File = Mp3File(filePath)
mp3File = removeAllTags(mp3File) mp3File = removeAllTags(mp3File)
mp3File = setId3v1Tags(mp3File,track) mp3File = setId3v1Tags(mp3File,track)
@ -485,62 +460,40 @@ class ForegroundService : Service(){
/** /**
*Modifying Mp3 Tags with MetaData! *Modifying Mp3 Tags with MetaData!
**/ **/
private fun setId3v1Tags(mp3File: Mp3File, track: Track): Mp3File { private fun setId3v1Tags(mp3File: Mp3File, track: TrackDetails): Mp3File {
val id3v1Tag = ID3v1Tag() val id3v1Tag = ID3v1Tag().apply {
id3v1Tag.track = track.disc_number.toString() artist = track.artists.joinToString(",")
val artistsList = mutableListOf<String>() title = track.title
track.artists?.forEach { artistsList.add(it!!.name!!) } album = track.albumName
id3v1Tag.artist = artistsList.joinToString() year = track.year
id3v1Tag.title = track.name comment = "Genres:${track.comment}"
id3v1Tag.album = track.album?.name }
id3v1Tag.year = track.album?.release_date
id3v1Tag.comment = "Genres:${track.album?.genres?.joinToString()}"
mp3File.id3v1Tag = id3v1Tag mp3File.id3v1Tag = id3v1Tag
return mp3File return mp3File
} }
private fun setId3v2Tags(mp3file: Mp3File, track: Track): Mp3File { private fun setId3v2Tags(mp3file: Mp3File, track: TrackDetails): Mp3File {
val id3v2Tag = ID3v24Tag() val id3v2Tag = ID3v24Tag().apply {
id3v2Tag.track = track.disc_number.toString() artist = track.artists.joinToString(",")
val artistsList = mutableListOf<String>() title = track.title
track.artists?.forEach { artistsList.add(it!!.name!!) } album = track.albumName
id3v2Tag.artist = artistsList.joinToString() year = track.year
id3v2Tag.title = track.name comment = "Genres:${track.comment}"
id3v2Tag.album = track.album?.name lyrics = "Gonna Implement Soon"
id3v2Tag.year = track.album?.release_date url = track.trackUrl
id3v2Tag.comment = "Genres:${track.album?.genres?.joinToString()}" }
id3v2Tag.lyrics = "Gonna Implement Soon" val bytesArray = ByteArray(track.albumArt.length().toInt())
val copyrights = mutableListOf<String>() try{
track.album?.copyrights?.forEach { copyrights.add(it!!.type!!) } val fis = FileInputStream(track.albumArt)
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)
fis.read(bytesArray) //read file into bytes[] fis.read(bytesArray) //read file into bytes[]
fis.close() fis.close()
id3v2Tag.setAlbumImage(bytesArray,"image/jpeg") 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 mp3file.id3v2Tag = id3v2Tag
return mp3file return mp3file
} }
private fun removeAllTags(mp3file: Mp3File): Mp3File { private fun removeAllTags(mp3file: Mp3File): Mp3File {
if (mp3file.hasId3v1Tag()) { if (mp3file.hasId3v1Tag()) {
mp3file.removeId3v1Tag() mp3file.removeId3v1Tag()
@ -554,4 +507,141 @@ class ForegroundService : Service(){
return mp3file 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<String>) {
/*
* 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<File> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<File>?,
isFirstResource: Boolean
): Boolean {
Log.i("Glide","LoadFailed")
return false
}
override fun onResourceReady(
resource: File?,
model: Any?,
target: Target<File>?,
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()
}
}
} }

View File

@ -20,7 +20,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_youtube" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginTop="25dp" android:layout_marginTop="25dp"
@ -28,7 +28,7 @@
tools:context=".ui.spotify.SpotifyFragment"> tools:context=".ui.spotify.SpotifyFragment">
<androidx.appcompat.widget.AppCompatButton <androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_download_all_spotify" android:id="@+id/btn_download_all"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="44dp" android:layout_height="44dp"
android:background="@drawable/btn_design" android:background="@drawable/btn_design"
@ -39,11 +39,11 @@
android:textColor="@color/black" android:textColor="@color/black"
android:textSize="16sp" android:textSize="16sp"
android:visibility="visible" android:visibility="visible"
app:layout_anchor="@+id/appbar_spotify" app:layout_anchor="@+id/appbar"
app:layout_anchorGravity="bottom|center" /> app:layout_anchorGravity="bottom|center" />
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/downloading_fab_spotify" android:id="@+id/downloading_fab"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:keepScreenOn="true" android:keepScreenOn="true"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -51,7 +51,7 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:visibility="gone" android:visibility="gone"
app:borderWidth="0dp" app:borderWidth="0dp"
app:layout_anchor="@+id/appbar_spotify" app:layout_anchor="@+id/appbar"
app:layout_anchorGravity="bottom|center" app:layout_anchorGravity="bottom|center"
app:maxImageSize="38dp" app:maxImageSize="38dp"
app:rippleColor="@color/colorPrimaryDark" app:rippleColor="@color/colorPrimaryDark"
@ -59,7 +59,7 @@
app:tint="@null" /> app:tint="@null" />
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_spotify" android:id="@+id/appbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="280dp"> android:layout_height="280dp">
@ -72,14 +72,14 @@
app:toolbarId="@+id/toolbar"> app:toolbarId="@+id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/TopLayout_spotify" android:id="@+id/topLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:foreground="@drawable/gradient" android:foreground="@drawable/gradient"
android:layout_height="match_parent"> android:layout_height="match_parent">
<ImageView <ImageView
android:id="@+id/spotify_cover_image" android:id="@+id/cover_image"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="28dp" android:layout_marginTop="28dp"
@ -89,13 +89,13 @@
android:src="@drawable/spotify_download" android:src="@drawable/spotify_download"
android:visibility="visible" android:visibility="visible"
app:layout_collapseMode="parallax" app:layout_collapseMode="parallax"
app:layout_constraintBottom_toTopOf="@id/title_view_spotify" app:layout_constraintBottom_toTopOf="@id/title_view"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/StatusBar_spotify" android:id="@+id/statusBar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="2dp" android:layout_marginBottom="2dp"
@ -113,11 +113,11 @@
android:textColor="@color/grey" android:textColor="@color/grey"
android:textSize="16sp" android:textSize="16sp"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/spotify_cover_image" app:layout_constraintBottom_toTopOf="@+id/cover_image"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<TextView <TextView
android:id="@+id/title_view_spotify" android:id="@+id/title_view"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
@ -142,7 +142,7 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/track_list_spotify" android:id="@+id/track_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="26dp" android:paddingTop="26dp"
@ -152,7 +152,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar_spotify" /> app:layout_constraintTop_toBottomOf="@id/appbar" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout> </layout>

View File

@ -19,14 +19,14 @@
<layout xmlns:android="http://schemas.android.com/apk/res/android" <layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_youtube" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginTop="25dp" android:layout_marginTop="25dp"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
<androidx.appcompat.widget.AppCompatButton <androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_download_all_youtube" android:id="@+id/btn_download_all"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="44dp" android:layout_height="44dp"
android:background="@drawable/btn_design" android:background="@drawable/btn_design"
@ -38,17 +38,17 @@
android:textColor="@color/black" android:textColor="@color/black"
android:textSize="16sp" android:textSize="16sp"
android:visibility="visible" android:visibility="visible"
app:layout_anchor="@+id/appbar_youtube" app:layout_anchor="@+id/appbar"
app:layout_anchorGravity="bottom|center" /> app:layout_anchorGravity="bottom|center" />
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/downloading_fab_youtube" android:id="@+id/downloading_fab"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:backgroundTint="@color/black" android:backgroundTint="@color/black"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:visibility="gone" android:visibility="gone"
app:borderWidth="0dp" app:borderWidth="0dp"
app:layout_anchor="@+id/appbar_youtube" app:layout_anchor="@+id/appbar"
app:layout_anchorGravity="bottom|center" app:layout_anchorGravity="bottom|center"
app:maxImageSize="38dp" app:maxImageSize="38dp"
app:rippleColor="@color/colorPrimaryDark" app:rippleColor="@color/colorPrimaryDark"
@ -56,7 +56,7 @@
app:tint="@null" /> app:tint="@null" />
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_youtube" android:id="@+id/appbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="230dp"> android:layout_height="230dp">
@ -69,14 +69,14 @@
app:toolbarId="@+id/toolbar"> app:toolbarId="@+id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/TopLayout_youtube" android:id="@+id/topLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:foreground="@drawable/gradient" android:foreground="@drawable/gradient"
> >
<ImageView <ImageView
android:id="@+id/youtube_cover_image" android:id="@+id/cover_image"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="23dp" android:layout_marginTop="23dp"
@ -86,13 +86,13 @@
android:src="@drawable/spotify_download" android:src="@drawable/spotify_download"
android:visibility="visible" android:visibility="visible"
app:layout_collapseMode="parallax" app:layout_collapseMode="parallax"
app:layout_constraintBottom_toTopOf="@id/title_view_youtube" app:layout_constraintBottom_toTopOf="@id/title_view"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/StatusBar_youtube" android:id="@+id/statusBar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="2dp" android:layout_marginBottom="2dp"
@ -109,11 +109,11 @@
android:textColor="@color/grey" android:textColor="@color/grey"
android:textSize="16sp" android:textSize="16sp"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/youtube_cover_image" app:layout_constraintBottom_toTopOf="@+id/cover_image"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<TextView <TextView
android:id="@+id/title_view_youtube" android:id="@+id/title_view"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
@ -136,7 +136,7 @@
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/track_list_youtube" android:id="@+id/track_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="26dp" android:paddingTop="26dp"
@ -146,7 +146,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar_youtube" /> app:layout_constraintTop_toBottomOf="@id/appbar" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout> </layout>