ID3 Tags and File Save for web-app.

This commit is contained in:
shabinder 2021-03-13 01:48:03 +05:30
parent 601d8135db
commit a47d9f52a0
34 changed files with 733 additions and 146 deletions

View File

@ -7,9 +7,10 @@ kotlin {
jvm("desktop")
android()
//ios()
js {
js() {
browser()
nodejs()
//nodejs()
binaries.executable()
}
sourceSets {
named("commonTest") {

View File

@ -14,9 +14,10 @@ plugins {
kotlin {
jvm("desktop")
android()
js {
js() {
browser()
nodejs()
//nodejs()
binaries.executable()
}
sourceSets {
named("commonMain") {

View File

@ -15,6 +15,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.shabinder.common.di.Picture
import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
@ -67,7 +68,7 @@ fun SpotiFlyerListContent(
fun TrackCard(
track: TrackDetails,
downloadTrack:()->Unit,
loadImage:suspend (String)-> ImageBitmap?
loadImage:suspend (String)-> Picture
) {
Row(verticalAlignment = Alignment.CenterVertically,modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
ImageLoad(
@ -120,7 +121,7 @@ fun CoverImage(
title: String,
coverURL: String,
scope: CoroutineScope,
loadImage: suspend (String) -> ImageBitmap?,
loadImage: suspend (String) -> Picture,
modifier: Modifier = Modifier,
) {
Column(

View File

@ -28,10 +28,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.shabinder.common.di.Picture
import com.shabinder.common.di.giveDonation
import com.shabinder.common.di.openPlatform
import com.shabinder.common.di.shareApp
import com.shabinder.common.di.showPopUpMessage
import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
import com.shabinder.common.models.DownloadRecord
@ -303,7 +303,7 @@ fun AboutColumn(modifier: Modifier = Modifier) {
@Composable
fun HistoryColumn(
list: List<DownloadRecord>,
loadImage:suspend (String)-> ImageBitmap?,
loadImage:suspend (String)-> Picture,
onItemClicked: (String) -> Unit
) {
Crossfade(list){
@ -335,7 +335,7 @@ fun HistoryColumn(
@Composable
fun DownloadRecordItem(
item: DownloadRecord,
loadImage:suspend (String)-> ImageBitmap?,
loadImage:suspend (String)-> Picture,
onItemClicked:(String)->Unit
) {
Row(verticalAlignment = Alignment.CenterVertically,modifier = Modifier.fillMaxWidth().padding(end = 8.dp)) {

View File

@ -50,8 +50,11 @@ kotlin {
}
jsMain {
dependencies {
implementation(Ktor.clientJs)
implementation(project(":common:data-models"))
implementation(Ktor.clientJs)
implementation(npm("browser-id3-writer","4.4.0"))
implementation(npm("file-saver","2.0.4"))
//implementation(npm("@types/file-saver","2.0.1",generateExternals = true))
}
}
}

View File

@ -4,15 +4,12 @@ import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.core.content.ContextCompat
import com.github.kiulian.downloader.model.YoutubeVideo
import com.github.kiulian.downloader.model.formats.Format
import com.github.kiulian.downloader.model.quality.AudioQuality
import com.razorpay.Checkout
import com.shabinder.common.database.activityContext
import com.shabinder.common.database.appContext
import com.shabinder.common.di.worker.ForegroundService
import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.Dispatchers
@ -87,8 +84,8 @@ actual fun queryActiveTracks() {
actual suspend fun downloadTracks(
list: List<TrackDetails>,
getYTIDBestMatch:suspend (String,TrackDetails)->String?,
saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit
fetcher: FetchPlatformQueryResult,
dir: Dir
){
if(!list.isNullOrEmpty()){
val serviceIntent = Intent(activityContext, ForegroundService::class.java)

View File

@ -15,8 +15,8 @@ expect val isInternetAvailable:Boolean
expect suspend fun downloadTracks(
list: List<TrackDetails>,
getYTIDBestMatch:suspend (String,TrackDetails)->String?,
saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit
fetcher: FetchPlatformQueryResult,
dir: Dir
)
expect fun queryActiveTracks()

View File

@ -12,7 +12,7 @@ import kotlinx.coroutines.withContext
class FetchPlatformQueryResult(
private val gaanaProvider: GaanaProvider,
private val spotifyProvider: SpotifyProvider,
val spotifyProvider: SpotifyProvider,
val youtubeProvider: YoutubeProvider,
val youtubeMusic: YoutubeMusic,
val youtubeMp3: YoutubeMp3,

View File

@ -19,6 +19,7 @@ package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit
import com.shabinder.common.di.*
import com.shabinder.common.di.spotify.SpotifyRequests
import com.shabinder.common.di.spotify.authenticateSpotify
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.spotify.Album
@ -41,11 +42,13 @@ class SpotifyProvider(
init {
logger.d { "Creating Spotify Provider" }
//GlobalScope.launch(Dispatchers.Default) {authenticateSpotify()}
GlobalScope.launch(Dispatchers.Default) {
authenticateSpotifyClient()
}
}
override suspend fun authenticateSpotify(): HttpClient?{
val token = tokenStore.getToken()
override suspend fun authenticateSpotifyClient(override:Boolean): HttpClient?{
val token = if(override) authenticateSpotify() else tokenStore.getToken()
return if(token == null) {
logger.d{ "Please Check your Network Connection" }
null
@ -69,7 +72,7 @@ class SpotifyProvider(
suspend fun query(fullLink: String): PlatformQueryResult?{
if(!this::httpClient.isInitialized){
authenticateSpotify()
authenticateSpotifyClient()
}
var spotifyLink =

View File

@ -249,8 +249,7 @@ class YoutubeMusic constructor(
return httpClient.post("https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey"){
contentType(ContentType.Application.Json)
headers{
//append("Content-Type"," application/json")
append("Referer"," https://music.youtube.com/search")
append("referer","https://music.youtube.com/search")
}
body = buildJsonObject {
putJsonObject("context"){

View File

@ -13,7 +13,7 @@ interface SpotifyRequests {
val httpClient:HttpClient
suspend fun authenticateSpotify():HttpClient?
suspend fun authenticateSpotifyClient(override:Boolean = false):HttpClient?
suspend fun getPlaylist(playlistID: String): Playlist {
return httpClient.get("$BASE_URL/playlists/$playlistID")

View File

@ -1,9 +1,5 @@
package com.shabinder.common.di
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import com.github.kiulian.downloader.YoutubeDownloader
import com.github.kiulian.downloader.model.YoutubeVideo
import com.github.kiulian.downloader.model.formats.Format
@ -62,20 +58,20 @@ val DownloadProgressFlow: MutableSharedFlow<HashMap<String,DownloadStatus>> = Mu
actual suspend fun downloadTracks(
list: List<TrackDetails>,
getYTIDBestMatch:suspend (String,TrackDetails)->String?,
saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit
fetcher: FetchPlatformQueryResult,
dir: Dir
){
list.forEach {
if (!it.videoID.isNullOrBlank()) {//Video ID already known!
downloadTrack(it.videoID!!, it,saveFileWithMetaData)
downloadTrack(it.videoID!!, it,dir::saveFileWithMetadata)
} else {
val searchQuery = "${it.title} - ${it.artists.joinToString(",")}"
val videoId = getYTIDBestMatch(searchQuery,it)
val videoId = fetcher.youtubeMusic.getYTIDBestMatch(searchQuery,it)
if (videoId.isNullOrBlank()) {
DownloadProgressFlow.emit(DownloadProgressFlow.replayCache.getOrElse(0
) { hashMapOf() }.apply { set(it.title,DownloadStatus.Failed) })
} else {//Found Youtube Video ID
downloadTrack(videoId, it,saveFileWithMetaData)
downloadTrack(videoId, it,dir::saveFileWithMetadata)
}
}
}

View File

@ -0,0 +1,26 @@
@file:JsModule("file-saver")
@file:JsNonModule
package com.shabinder.common.di
import org.w3c.files.Blob
external interface FileSaverOptions {
var autoBom: Boolean
}
external fun saveAs(data: Blob, filename: String = definedExternally, options: FileSaverOptions = definedExternally)
external fun saveAs(data: Blob)
external fun saveAs(data: Blob, filename: String = definedExternally)
external fun saveAs(data: String, filename: String = definedExternally, options: FileSaverOptions = definedExternally)
external fun saveAs(data: String)
external fun saveAs(data: String, filename: String = definedExternally)
external fun saveAs(data: Blob, filename: String = definedExternally, disableAutoBOM: Boolean = definedExternally)
external fun saveAs(data: String, filename: String = definedExternally, disableAutoBOM: Boolean = definedExternally)

View File

@ -0,0 +1,15 @@
package com.shabinder.common.di
import org.khronos.webgl.ArrayBuffer
import org.w3c.files.Blob
@JsModule("browser-id3-writer")
@JsNonModule
external class ID3Writer(a: ArrayBuffer) {
fun setFrame(frameName:String,frameValue:Any):ID3Writer
fun removeTag()
fun addTag():ArrayBuffer
fun getBlob():Blob
fun getURL():String
fun revokeURL()
}

View File

@ -1,10 +1,13 @@
package com.shabinder.common.di
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import io.ktor.client.request.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import org.khronos.webgl.ArrayBuffer
actual fun openPlatform(packageID:String, platformLink:String){
//TODO
@ -50,13 +53,40 @@ val DownloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = M
actual suspend fun downloadTracks(
list: List<TrackDetails>,
getYTIDBestMatch:suspend (String, TrackDetails)->String?,
saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit
){/*
list.forEach {
if (!it.videoID.isNullOrBlank()) {//Video ID already known!
} else {
fetcher: FetchPlatformQueryResult,
dir: Dir
){
withContext(Dispatchers.Default){
list.forEach {
if (!it.videoID.isNullOrBlank()) {//Video ID already known!
downloadTrack(it.videoID!!, it, fetcher, dir)
} else {
val searchQuery = "${it.title} - ${it.artists.joinToString(",")}"
val videoID = fetcher.youtubeMusic.getYTIDBestMatch(searchQuery,it)
if (videoID.isNullOrBlank()) {
} else {//Found Youtube Video ID
downloadTrack(videoID, it, fetcher, dir)
}
}
}
}*/
}
}
suspend fun downloadTrack(videoID: String, track: TrackDetails, fetcher:FetchPlatformQueryResult,dir:Dir) {
val url = fetcher.youtubeMp3.getMp3DownloadLink(videoID)
if(url == null){
// TODO Handle
println("No URL to Download")
}else {
downloadFile(url).collect {
when(it){
is DownloadResult.Success -> {
println("Download Completed")
dir.saveFileWithMetadata(it.byteArray, track)
}
is DownloadResult.Error -> println("Download Error: ${track.title}")
is DownloadResult.Progress -> println("Download Progress: ${it.progress} : ${track.title}")
}
}
}
}

View File

@ -1,9 +1,20 @@
package com.shabinder.common.di
import co.touchlab.kermit.Kermit
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.database.Database
import kotlinext.js.Object
import kotlinext.js.asJsObject
import kotlinext.js.js
import kotlinext.js.jsObject
import kotlinx.coroutines.flow.collect
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.ImageBitmap
import org.khronos.webgl.Int8Array
actual class Dir actual constructor(
private val logger: Kermit,
@ -13,9 +24,10 @@ actual class Dir actual constructor(
/*init {
createDirectories()
}*/
/*
* TODO
* */
/*
* TODO
* */
actual fun fileSeparator(): String = "/"
actual fun imageCacheDir(): String = "TODO" +
@ -26,12 +38,9 @@ actual class Dir actual constructor(
actual fun isPresent(path: String): Boolean = false
actual fun createDirectory(dirPath:String){
actual fun createDirectory(dirPath:String){}
}
actual suspend fun clearCache() {
}
actual suspend fun clearCache() {}
actual suspend fun cacheImage(image: Any,path:String) {}
@ -40,6 +49,41 @@ actual class Dir actual constructor(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails
) {
val writer = ID3Writer(mp3ByteArray.toArrayBuffer())
val albumArt = downloadFile(trackDetails.albumArtURL)
albumArt.collect {
when(it){
is DownloadResult.Success -> {
println("Album Art Downloaded Success")
val albumArtObj = js {
this["type"] = 3
this["data"] = it.byteArray.toArrayBuffer()
this["description"] = "Cover Art"
}
writeTagsAndSave(writer, albumArtObj as Object,trackDetails)
}
is DownloadResult.Error -> {
println("Album Art Downloading Error")
writeTagsAndSave(writer,null,trackDetails)
}
is DownloadResult.Progress -> println("Album Art Downloading: ${it.progress}")
}
}
}
private suspend fun writeTagsAndSave(writer:ID3Writer, albumArt:Object?, trackDetails: TrackDetails){
writer.apply {
setFrame("TIT2", trackDetails.title)
setFrame("TPE1", trackDetails.artists)
setFrame("TALB", trackDetails.albumName?:"")
try{trackDetails.year?.substring(0,4)?.toInt()?.let { setFrame("TYER", it) }} catch(e:Exception){}
setFrame("TPE2", trackDetails.artists.joinToString(","))
setFrame("WOAS", trackDetails.source.toString())
setFrame("TLEN", trackDetails.durationSec)
albumArt?.let { setFrame("APIC", it) }
}
writer.addTag()
saveAs(writer.getBlob(), "${removeIllegalChars(trackDetails.title)}.mp3")
}
actual fun addToLibrary(path:String){}
@ -48,14 +92,14 @@ actual class Dir actual constructor(
return Picture(url)
}
private fun loadCachedImage(cachePath: String): ImageBitmap? {
return null
}
private fun loadCachedImage(cachePath: String): ImageBitmap? = null
private suspend fun freshImage(url:String): ImageBitmap?{
return null
}
private suspend fun freshImage(url:String): ImageBitmap? = null
actual val db: Database?
get() = database
}
fun ByteArray.toArrayBuffer():ArrayBuffer{
return this.unsafeCast<Int8Array>().buffer
}

View File

@ -60,7 +60,7 @@ internal class SpotiFlyerListStoreProvider(
val finalList =
intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
if (finalList.isNullOrEmpty()) showPopUpMessage("All Songs are Processed")
else downloadTracks(finalList,fetchQuery.youtubeMusic::getYTIDBestMatch,dir::saveFileWithMetadata)
else downloadTracks(finalList,fetchQuery,dir)
val list = intent.trackList.map {
if (it.downloaded == DownloadStatus.NotDownloaded)
@ -70,7 +70,7 @@ internal class SpotiFlyerListStoreProvider(
dispatch(Result.UpdateTrackList(list.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0){ hashMapOf()})))
}
is Intent.StartDownload -> {
downloadTracks(listOf(intent.track),fetchQuery.youtubeMusic::getYTIDBestMatch,dir::saveFileWithMetadata)
downloadTracks(listOf(intent.track),fetchQuery,dir)
dispatch(Result.UpdateTrackItem(intent.track.copy(downloaded = DownloadStatus.Queued)))
}
is Intent.RefreshTracksStatuses -> queryActiveTracks()

View File

@ -16,6 +16,7 @@ dependencies {
implementation(kotlin("stdlib-js"))
implementation(Decompose.decompose)
implementation(Koin.core)
implementation(Ktor.clientJs)
implementation(MVIKotlin.mvikotlin)
implementation(MVIKotlin.coroutines)
implementation(MVIKotlin.mvikotlinMain)
@ -33,7 +34,8 @@ dependencies {
}
kotlin {
js {
js() {
//useCommonJs()
browser {
webpackTask {
cssSupport.enabled = true

View File

@ -18,7 +18,8 @@ external interface AppProps : RProps {
var dependencies: AppDependencies
}
fun RBuilder.app(attrs: AppProps.() -> Unit): ReactElement {
@Suppress("FunctionName")
fun RBuilder.App(attrs: AppProps.() -> Unit): ReactElement {
return child(App::class){
this.attrs(attrs)
}

View File

@ -5,15 +5,16 @@ import com.shabinder.common.di.initKoin
import react.dom.render
import kotlinx.browser.document
import kotlinx.browser.window
import navbar.navBar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
fun main() {
window.onload = {
render(document.getElementById("root")) {
navBar {}
app {
App {
dependencies = AppDependencies
}
}
@ -21,6 +22,7 @@ fun main() {
}
object AppDependencies : KoinComponent {
val appScope = CoroutineScope(Dispatchers.Default)
val logger: Kermit
val directories: Dir
val fetchPlatformQueryResult: FetchPlatformQueryResult
@ -29,5 +31,8 @@ object AppDependencies : KoinComponent {
directories = get()
logger = get()
fetchPlatformQueryResult = get()
appScope.launch {
//fetchPlatformQueryResult.spotifyProvider.authenticateSpotifyClient(true)
}
}
}

View File

@ -3,12 +3,7 @@ package home
import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.State
import extras.RenderableComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.css.*
import react.*
import styled.css
@ -23,17 +18,6 @@ class HomeScreen(
override val stateFlow: Flow<SpotiFlyerMain.State> = model.models
override fun componentDidMount() {
if(!scope.isActive)
scope = CoroutineScope(Dispatchers.Default)
scope.launch {
stateFlow.collect {
println("Updating State = $it")
setState { data = it }
}
}
}
override fun RBuilder.render() {
println("Rendering New State = \"${state.data}\" ")
styledDiv{
@ -50,7 +34,6 @@ class HomeScreen(
}
SearchBar {
println("Search Props ${state.data.link}")
link = state.data.link
search = model::onLinkSearch
onLinkChange = model::onInputLinkChanged

View File

@ -33,7 +33,6 @@ val searchbar = functionalComponent<SearchbarProps>("SearchBar"){ props ->
onChangeFunction = {
val target = it.target as HTMLInputElement
props.onLinkChange(target.value)
println(target.value)
}
value = props.link
}

View File

@ -2,9 +2,7 @@ package list
import kotlinx.css.*
import kotlinx.html.id
import react.RProps
import react.rFunction
import react.useState
import react.*
import styled.css
import styled.styledDiv
import styled.styledH1
@ -16,19 +14,25 @@ external interface CoverImageProps : RProps {
var coverName: String
}
val CoverImage = rFunction<CoverImageProps>("CoverImage"){ props ->
val (coverURL,setCoverURL) = useState(props.coverImageURL)
val (coverName,setCoverName) = useState(props.coverName)
@Suppress("FunctionName")
fun RBuilder.CoverImage(handler: CoverImageProps.() -> Unit): ReactElement {
return child(coverImage){
attrs {
handler()
}
}
}
private val coverImage = functionalComponent<CoverImageProps>("CoverImage"){ props ->
styledDiv {
styledImg(src=coverURL){
styledImg(src= props.coverImageURL){
css {
height = 300.px
width = 300.px
height = 220.px
width = 220.px
}
}
styledH1 {
+coverName
+props.coverName
css {
textAlign = TextAlign.center
}
@ -40,6 +44,7 @@ val CoverImage = rFunction<CoverImageProps>("CoverImage"){ props ->
display = Display.flex
alignItems = Align.center
flexDirection = FlexDirection.column
marginTop = 12.px
}
}
}

View File

@ -0,0 +1,58 @@
package list
import kotlinx.css.*
import kotlinx.html.id
import react.*
import styled.css
import styled.styledDiv
import styled.styledH5
import styled.styledImg
external interface DownloadAllButtonProps : RProps {
var isActive:Boolean
}
@Suppress("FunctionName")
fun RBuilder.DownloadAllButton(handler: DownloadAllButtonProps.() -> Unit): ReactElement {
return child(downloadAllButton){
attrs {
handler()
}
}
}
private val downloadAllButton = functionalComponent<DownloadAllButtonProps>("DownloadAllButton") { props->
styledDiv {
styledDiv {
styledImg(src = "download.svg",alt = "Download All Button") {
css {
classes = mutableListOf("download-all-icon")
height = 32.px
}
}
styledH5 {
attrs {
id = "download-all-text"
}
+ "Download All"
css {
whiteSpace = WhiteSpace.nowrap
fontSize = 15.px
}
}
css {
classes = mutableListOf("download-icon")
display = Display.flex
alignItems = Align.center
}
}
css {
classes = mutableListOf("download-button")
display = if(props.isActive) Display.flex else Display.none
alignItems = Align.center
}
}
}

View File

@ -1,40 +1,58 @@
package list
import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.list.SpotiFlyerList.State
import extras.RenderableComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.css.*
import kotlinx.html.id
import react.RBuilder
import react.RState
import styled.css
import styled.styledDiv
class ListScreen(
props: Props<SpotiFlyerList>,
) : RenderableComponent<SpotiFlyerList, ListScreen.State>(props,initialState = State(SpotiFlyerList.State())) {
) : RenderableComponent<SpotiFlyerList, State>(props,initialState = State()) {
override val stateFlow: Flow<State> = model.models.map { State(it) }
override val stateFlow: Flow<SpotiFlyerList.State> = model.models
override fun RBuilder.render() {
val result = state.data.queryResult
styledDiv {
attrs {
id = "list-screen-div"
}
if(result == null){
LoadingAnim { }
}else{
CoverImage {
coverImageURL = result.coverUrl
coverName = result.title
}
DownloadAllButton {
isActive = state.data.trackList.isNotEmpty()
}
state.data.trackList.forEachIndexed{ index, trackDetails ->
TrackItem {
details = trackDetails
downloadTrack = model::onDownloadClicked
}
}
}
css {
classes = mutableListOf("list-screen")
display = Display.flex
flexDirection = FlexDirection.column
flexGrow = 1.0
justifyContent = JustifyContent.center
alignItems = Align.center
backgroundColor = Color.white
alignItems = Align.stretch
}
}
}
class State(
var data: SpotiFlyerList.State
):RState
}

View File

@ -0,0 +1,37 @@
package list
import kotlinx.css.*
import react.*
import styled.css
import styled.styledDiv
@Suppress("FunctionName")
fun RBuilder.LoadingAnim(handler: RProps.() -> Unit): ReactElement {
return child(loadingAnim){
attrs {
handler()
}
}
}
private val loadingAnim = functionalComponent<RProps>("Loading Animation") {
styledDiv {
styledDiv { css { classes = mutableListOf("sk-cube sk-cube1") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube2") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube3") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube4") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube5") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube6") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube7") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube8") } }
styledDiv { css { classes = mutableListOf("sk-cube sk-cube9") } }
css {
classes = mutableListOf("sk-cube-grid")
height = 60.px
width = 60.px
}
}
}

View File

@ -1,13 +1,107 @@
package list
import react.RProps
import react.rFunction
import com.shabinder.common.models.TrackDetails
import kotlinx.css.*
import kotlinx.html.id
import kotlinx.html.js.onClickFunction
import react.*
import styled.*
external interface TrackItemProps : RProps {
var coverImageURL: String
var coverName: String
var details:TrackDetails
var downloadTrack:(TrackDetails)->Unit
}
val trackItem = rFunction<TrackItemProps>("Track-Item"){
@Suppress("FunctionName")
fun RBuilder.TrackItem(handler: TrackItemProps.() -> Unit): ReactElement {
return child(trackItem){
attrs {
handler()
}
}
}
private val trackItem = functionalComponent<TrackItemProps>("Track-Item"){ props ->
val details = props.details
styledDiv {
styledImg(src = details.albumArtURL) {
css {
height = 90.px
width = 90.px
}
}
styledDiv {
attrs {
id = "text-details"
}
styledDiv {
styledH3 {
+ details.title
css {
padding(8.px)
}
}
css {
height = 40.px
display =Display.flex
alignItems = Align.center
}
}
styledDiv {
styledH4 {
+ details.artists.joinToString(",")
css {
flexGrow = 1.0
padding(8.px)
}
}
styledH4 {
+ "${details.durationSec} sec"
css {
flexGrow = 1.0
padding(8.px)
textAlign = TextAlign.right
}
}
css {
height = 40.px
display =Display.flex
alignItems = Align.center
}
}
css {
display = Display.flex
flexGrow = 1.0
flexDirection = FlexDirection.column
margin(8.px)
}
}
styledDiv {
styledImg(src = "download-gradient.svg") {
attrs {
onClickFunction = {
props.downloadTrack(details)
}
}
css {
margin(8.px)
}
}
css {
classes = mutableListOf("glow-button")
borderRadius = 100.px
width = 65.px
}
}
css {
alignItems = Align.center
display =Display.flex
flexDirection = FlexDirection.row
flexGrow = 1.0
color = Color.white
}
}
}

View File

@ -6,50 +6,59 @@ import react.*
import styled.*
fun RBuilder.navBar(attrs: RProps.() -> Unit): ReactElement{
return child(NavBar::class){
this.attrs(attrs)
@Suppress("FunctionName")
fun RBuilder.NavBar(handler: NavBarProps.() -> Unit): ReactElement{
return child(navBar){
attrs {
handler()
}
}
}
@OptIn(ExperimentalJsExport::class)
@JsExport
class NavBar : RComponent<RProps, RState>() {
external interface NavBarProps:RProps{
var isBackVisible: Boolean
}
override fun RBuilder.render() {
styledNav {
private val navBar = functionalComponent<NavBarProps>("NavBar") { props ->
styledNav {
css {
+NavBarStyles.nav
}
styledImg(src = "left-arrow.svg",alt = "Back Arrow"){
css {
+NavBarStyles.nav
height = 42.px
width = 42.px
display = if(props.isBackVisible) Display.inline else Display.none
filter = "invert(100)"
marginRight = 12.px
}
styledImg {
attrs {
src = "spotiflyer.svg"
}
}
styledImg(src = "spotiflyer.svg",alt = "Logo") {
css {
height = 42.px
width = 42.px
}
}
styledH1 {
+"SpotiFlyer"
attrs {
id = "appName"
}
css{
fontSize = 46.px
margin(horizontal = 14.px)
}
}
styledA(href = "https://github.com/Shabinder/SpotiFlyer/"){
styledImg(src = "github.svg"){
css {
height = 42.px
width = 42.px
}
}
styledH1 {
+"SpotiFlyer"
attrs {
id = "appName"
}
css{
fontSize = 46.px
margin(horizontal = 14.px)
}
}
styledA(href = "https://github.com/Shabinder/SpotiFlyer/"){
styledImg(src = "github.svg"){
css {
height = 42.px
width = 42.px
}
}
css {
marginLeft = LinearDimension.auto
}
css {
marginLeft = LinearDimension.auto
}
}
}

View File

@ -6,7 +6,9 @@ import com.shabinder.common.root.SpotiFlyerRoot.*
import extras.RenderableRootComponent
import extras.renderableChild
import home.HomeScreen
import kotlinx.coroutines.launch
import list.ListScreen
import navbar.NavBar
import react.RBuilder
import react.RState
@ -18,6 +20,9 @@ class RootR(props: Props<SpotiFlyerRoot>) : RenderableRootComponent<SpotiFlyerRo
get() = model.routerState.value.activeChild.component
override fun RBuilder.render() {
NavBar {
isBackVisible = (component is Child.List)
}
when(component){
is Child.Main -> renderableChild(HomeScreen::class, (component as Child.Main).component)
is Child.List -> renderableChild(ListScreen::class, (component as Child.List).component)

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.525879e-005" y1="258" x2="512" y2="258" gradientTransform="matrix(1 0 0 -1 0 514)">
<stop offset="0" style="stop-color:#80D8FF"/>
<stop offset="0.16" style="stop-color:#88D1FF"/>
<stop offset="0.413" style="stop-color:#9FBEFE"/>
<stop offset="0.725" style="stop-color:#C4A0FD"/>
<stop offset="1" style="stop-color:#EA80FC"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M462.622,512H49.378C22.151,512,0,489.85,0,462.623v-118.49c0-11.046,8.954-20,20-20
s20,8.954,20,20v118.49c0,5.171,4.207,9.377,9.378,9.377h413.244c5.171,0,9.378-4.207,9.378-9.377v-118.49c0-11.046,8.954-20,20-20
c11.046,0,20,8.954,20,20v118.49C512,489.85,489.849,512,462.622,512z M270.548,425.426l146.888-155.701
c5.479-5.807,6.979-14.316,3.816-21.646c-3.163-7.33-10.381-12.079-18.364-12.079h-68.133V20c0-11.046-8.954-20-20-20h-117.51
c-11.046,0-20,8.954-20,20v216h-68.133c-7.983,0-15.202,4.748-18.364,12.078s-1.662,15.839,3.816,21.646l146.888,155.701
c3.778,4.005,9.041,6.275,14.548,6.275C261.507,431.7,266.77,429.431,270.548,425.426z M197.245,276c11.046,0,20-8.954,20-20V40
h77.51v216c0,11.046,8.954,20,20,20h41.77L256,382.556L155.476,276H197.245z"/>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 471.2 471.2" style="enable-background:new 0 0 471.2 471.2;" xml:space="preserve">
<g>
<g>
<path d="M457.7,230.15c-7.5,0-13.5,6-13.5,13.5v122.8c0,33.4-27.2,60.5-60.5,60.5H87.5c-33.4,0-60.5-27.2-60.5-60.5v-124.8
c0-7.5-6-13.5-13.5-13.5s-13.5,6-13.5,13.5v124.8c0,48.3,39.3,87.5,87.5,87.5h296.2c48.3,0,87.5-39.3,87.5-87.5v-122.8
C471.2,236.25,465.2,230.15,457.7,230.15z"/>
<path d="M226.1,346.75c2.6,2.6,6.1,4,9.5,4s6.9-1.3,9.5-4l85.8-85.8c5.3-5.3,5.3-13.8,0-19.1c-5.3-5.3-13.8-5.3-19.1,0l-62.7,62.8
V30.75c0-7.5-6-13.5-13.5-13.5s-13.5,6-13.5,13.5v273.9l-62.8-62.8c-5.3-5.3-13.8-5.3-19.1,0c-5.3,5.3-5.3,13.8,0,19.1
L226.1,346.75z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path d="M468.662,212.66H147.974l52.851-52.851c16.896-16.897,16.897-44.393,0-61.293c-8.187-8.186-19.07-12.694-30.647-12.694
c-11.576,0-22.46,4.508-30.646,12.694L12.697,225.351C4.509,233.536,0,244.42,0,256s4.509,22.465,12.695,30.647l126.837,126.839
c8.186,8.185,19.069,12.693,30.646,12.693c11.577,0,22.462-4.508,30.646-12.694c8.179-8.177,12.683-19.06,12.683-30.647
s-4.504-22.47-12.682-30.647l-52.851-52.851h320.687C492.557,299.341,512,279.898,512,256S492.558,212.659,468.662,212.66z
M468.659,278.942h-345.31c-4.126,0-7.844,2.486-9.422,6.296c-1.579,3.811-0.706,8.198,2.21,11.115l70.264,70.263
c4.324,4.324,6.706,10.086,6.706,16.222c0,6.136-2.382,11.897-6.706,16.222H186.4c-4.333,4.333-10.093,6.719-16.222,6.719
c-6.128,0-11.889-2.387-16.222-6.719L27.118,272.221c-4.333-4.332-6.719-10.092-6.719-16.221s2.387-11.889,6.721-16.222
L153.957,112.94c4.334-4.334,10.093-6.72,16.221-6.72s11.89,2.387,16.223,6.718c8.945,8.947,8.945,23.501,0,32.446l-70.263,70.263
c-2.916,2.917-3.789,7.304-2.21,11.115c1.578,3.81,5.296,6.296,9.422,6.296h345.311c12.649,0,22.941,10.291,22.941,22.942
S481.31,278.943,468.659,278.942z"/>
</g>
</g>
<g>
<g>
<path d="M124.477,326.835l-2.022-2.023c-3.985-3.982-10.441-3.982-14.425,0c-3.983,3.984-3.983,10.442,0,14.425l2.023,2.023
c1.992,1.991,4.601,2.987,7.212,2.987c2.61,0,5.22-0.996,7.213-2.987C128.46,337.275,128.46,330.817,124.477,326.835z"/>
</g>
</g>
<g>
<g>
<path d="M102.707,305.064L49.431,251.79c-3.985-3.982-10.441-3.982-14.425,0c-3.983,3.984-3.983,10.442,0,14.425l53.276,53.275
c1.992,1.991,4.601,2.987,7.212,2.987c2.61,0,5.22-0.995,7.213-2.987C106.69,315.505,106.69,309.047,102.707,305.064z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -2,19 +2,21 @@
font-family: pristine;
src: url("pristine_script.ttf");
}
html {
background-image: url("header-dark.jpg");
font-family: Lora,Helvetica Neue,Helvetica,Arial,sans-serif;
}
body, html {
width: 100%;
height: 100%;
margin: 0;
font-family: Lora,Helvetica Neue,Helvetica,Arial,sans-serif;
background-repeat: no-repeat;
background-size: 100% 100%;
background-attachment: fixed;
position: relative;
color: #fff;
caret-color: crimson;
background-color: black;
/*background-image: linear-gradient(180deg,rgba(221,0,221,0.50),rgba(221,0,221,0.32),rgba(221,0,221,0.16),rgba(221,0,221,0.08),rgba(221,0,221,0.04),rgba(0,0,0,0));*/
background-image: url("header.png");
background-repeat: no-repeat;
background-size: 100% 100%;
}
#appName{
font-family: pristine, cursive;
@ -90,7 +92,105 @@ body, html {
line-height: 40px;
width: 0px;
}
#download-all-text {
color: black;
display: none;
}
.download-button {
font-size: 1.5rem;
border: 2px solid white;
border-radius: 100px;
width: 40px;
height: 40px;
padding: 5px;
margin: 12px auto;
transition: 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
justify-content: center;
}
.download-button:hover {
width: 150px;
background-color: white;
box-shadow: 0px 5px 5px rgba(0, 0, 0, 0.2);
color: black;
transition: 0.3s;
justify-content: flex-start;
}
.download-button:hover #download-all-text {
display: inline;
color: black;
}
.download-button:hover .download-all-icon {
filter: none;
margin-right: 8px;
}
.download-button:not(hover) .download-all-icon {
filter: invert(100);
}
.sk-cube-grid {
width: 40px;
height: 40px;
margin: 100px auto;
}
.sk-cube-grid .sk-cube {
width: 33%;
height: 33%;
background-color: rgb(240, 90, 220);
float: left;
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
}
.sk-cube-grid .sk-cube1 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s; }
.sk-cube-grid .sk-cube2 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s; }
.sk-cube-grid .sk-cube3 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s; }
.sk-cube-grid .sk-cube4 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s; }
.sk-cube-grid .sk-cube5 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s; }
.sk-cube-grid .sk-cube6 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s; }
.sk-cube-grid .sk-cube7 {
-webkit-animation-delay: 0s;
animation-delay: 0s; }
.sk-cube-grid .sk-cube8 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s; }
.sk-cube-grid .sk-cube9 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s; }
@-webkit-keyframes sk-cubeGridScaleDelay {
0%, 70%, 100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
} 35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
@keyframes sk-cubeGridScaleDelay {
0%, 70%, 100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
} 35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
@media screen and (max-width: 620px) {
.searchBox:hover > .searchInput {
width: 150px;