diff --git a/buildSrc/src/main/kotlin/multiplatform-setup-test.gradle.kts b/buildSrc/src/main/kotlin/multiplatform-setup-test.gradle.kts index 6f128d50..28d8ccc2 100644 --- a/buildSrc/src/main/kotlin/multiplatform-setup-test.gradle.kts +++ b/buildSrc/src/main/kotlin/multiplatform-setup-test.gradle.kts @@ -7,9 +7,10 @@ kotlin { jvm("desktop") android() //ios() - js { + js() { browser() - nodejs() + //nodejs() + binaries.executable() } sourceSets { named("commonTest") { diff --git a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts index 68d7a67d..b91a8f22 100644 --- a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts @@ -14,9 +14,10 @@ plugins { kotlin { jvm("desktop") android() - js { + js() { browser() - nodejs() + //nodejs() + binaries.executable() } sourceSets { named("commonMain") { diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerListUi.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerListUi.kt index 75d8033f..6603a385 100644 --- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerListUi.kt +++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerListUi.kt @@ -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( diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt index 32309738..b9fcb8f1 100644 --- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt +++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt @@ -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, - 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)) { diff --git a/common/dependency-injection/build.gradle.kts b/common/dependency-injection/build.gradle.kts index d34854e1..3a146355 100644 --- a/common/dependency-injection/build.gradle.kts +++ b/common/dependency-injection/build.gradle.kts @@ -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)) } } } diff --git a/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt index a086de76..e08c0164 100644 --- a/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt +++ b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt @@ -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, - 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) diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt index 79ae1be1..4f3c9218 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt @@ -15,8 +15,8 @@ expect val isInternetAvailable:Boolean expect suspend fun downloadTracks( list: List, - getYTIDBestMatch:suspend (String,TrackDetails)->String?, - saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit + fetcher: FetchPlatformQueryResult, + dir: Dir ) expect fun queryActiveTracks() \ No newline at end of file diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/FetchPlatformQueryResult.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/FetchPlatformQueryResult.kt index d2be47d7..eac9a316 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/FetchPlatformQueryResult.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/FetchPlatformQueryResult.kt @@ -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, diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt index 3e3de8ce..c99708c0 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt @@ -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 = diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt index 2e265701..10b3531e 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt @@ -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"){ diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt index 49c95c3a..c4e94bf1 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt @@ -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") diff --git a/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt index 06c209cf..42f8cc25 100644 --- a/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt +++ b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt @@ -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> = Mu actual suspend fun downloadTracks( list: List, - 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) } } } diff --git a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/FileSave.kt b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/FileSave.kt new file mode 100644 index 00000000..5f120f80 --- /dev/null +++ b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/FileSave.kt @@ -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) \ No newline at end of file diff --git a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/ID3Writer.kt b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/ID3Writer.kt new file mode 100644 index 00000000..cd8419d5 --- /dev/null +++ b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/ID3Writer.kt @@ -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() +} \ No newline at end of file diff --git a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt index c8236534..520e106d 100644 --- a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt +++ b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt @@ -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> = M actual suspend fun downloadTracks( list: List, - 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}") + } + } + } } diff --git a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebDir.kt b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebDir.kt index 6b523f76..e9d66437 100644 --- a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebDir.kt +++ b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebDir.kt @@ -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().buffer +} \ No newline at end of file diff --git a/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt b/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt index 5cdd4b3a..964fad92 100644 --- a/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt +++ b/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt @@ -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() diff --git a/web-app/build.gradle.kts b/web-app/build.gradle.kts index cb001435..51cce1b5 100644 --- a/web-app/build.gradle.kts +++ b/web-app/build.gradle.kts @@ -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 diff --git a/web-app/src/main/kotlin/App.kt b/web-app/src/main/kotlin/App.kt index 08ce4c41..3b3ffaa6 100644 --- a/web-app/src/main/kotlin/App.kt +++ b/web-app/src/main/kotlin/App.kt @@ -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) } diff --git a/web-app/src/main/kotlin/client.kt b/web-app/src/main/kotlin/client.kt index 4c93919d..0f0ddc45 100644 --- a/web-app/src/main/kotlin/client.kt +++ b/web-app/src/main/kotlin/client.kt @@ -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) + } } } \ No newline at end of file diff --git a/web-app/src/main/kotlin/home/HomeScreen.kt b/web-app/src/main/kotlin/home/HomeScreen.kt index 012c09a8..a51ab5d3 100644 --- a/web-app/src/main/kotlin/home/HomeScreen.kt +++ b/web-app/src/main/kotlin/home/HomeScreen.kt @@ -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 = 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 diff --git a/web-app/src/main/kotlin/home/Searchbar.kt b/web-app/src/main/kotlin/home/Searchbar.kt index 8d31ed62..126581e9 100644 --- a/web-app/src/main/kotlin/home/Searchbar.kt +++ b/web-app/src/main/kotlin/home/Searchbar.kt @@ -33,7 +33,6 @@ val searchbar = functionalComponent("SearchBar"){ props -> onChangeFunction = { val target = it.target as HTMLInputElement props.onLinkChange(target.value) - println(target.value) } value = props.link } diff --git a/web-app/src/main/kotlin/list/CoverImage.kt b/web-app/src/main/kotlin/list/CoverImage.kt index 7a5294e0..ead5f3a3 100644 --- a/web-app/src/main/kotlin/list/CoverImage.kt +++ b/web-app/src/main/kotlin/list/CoverImage.kt @@ -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("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("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("CoverImage"){ props -> display = Display.flex alignItems = Align.center flexDirection = FlexDirection.column + marginTop = 12.px } } } \ No newline at end of file diff --git a/web-app/src/main/kotlin/list/DownloadAllButton.kt b/web-app/src/main/kotlin/list/DownloadAllButton.kt new file mode 100644 index 00000000..489760aa --- /dev/null +++ b/web-app/src/main/kotlin/list/DownloadAllButton.kt @@ -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("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 + } + } +} diff --git a/web-app/src/main/kotlin/list/ListScreen.kt b/web-app/src/main/kotlin/list/ListScreen.kt index 34ae286c..8a5aa508 100644 --- a/web-app/src/main/kotlin/list/ListScreen.kt +++ b/web-app/src/main/kotlin/list/ListScreen.kt @@ -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, -) : RenderableComponent(props,initialState = State(SpotiFlyerList.State())) { +) : RenderableComponent(props,initialState = State()) { - override val stateFlow: Flow = model.models.map { State(it) } + override val stateFlow: Flow = 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 } \ No newline at end of file diff --git a/web-app/src/main/kotlin/list/LoadingAnim.kt b/web-app/src/main/kotlin/list/LoadingAnim.kt new file mode 100644 index 00000000..dedab437 --- /dev/null +++ b/web-app/src/main/kotlin/list/LoadingAnim.kt @@ -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("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 + } + } +} \ No newline at end of file diff --git a/web-app/src/main/kotlin/list/TrackItem.kt b/web-app/src/main/kotlin/list/TrackItem.kt index 269e9198..a0ec5739 100644 --- a/web-app/src/main/kotlin/list/TrackItem.kt +++ b/web-app/src/main/kotlin/list/TrackItem.kt @@ -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("Track-Item"){ - +@Suppress("FunctionName") +fun RBuilder.TrackItem(handler: TrackItemProps.() -> Unit): ReactElement { + return child(trackItem){ + attrs { + handler() + } + } +} + +private val trackItem = functionalComponent("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 + } + } } diff --git a/web-app/src/main/kotlin/navbar/NavBar.kt b/web-app/src/main/kotlin/navbar/NavBar.kt index 3a74e4b3..4d0c0d75 100644 --- a/web-app/src/main/kotlin/navbar/NavBar.kt +++ b/web-app/src/main/kotlin/navbar/NavBar.kt @@ -6,51 +6,60 @@ 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() { +external interface NavBarProps:RProps{ + var isBackVisible: Boolean +} - override fun RBuilder.render() { - styledNav { + +private val navBar = functionalComponent("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 } } } -} +} \ No newline at end of file diff --git a/web-app/src/main/kotlin/root/RootR.kt b/web-app/src/main/kotlin/root/RootR.kt index 1467c018..279e4220 100644 --- a/web-app/src/main/kotlin/root/RootR.kt +++ b/web-app/src/main/kotlin/root/RootR.kt @@ -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) : RenderableRootComponent renderableChild(HomeScreen::class, (component as Child.Main).component) is Child.List -> renderableChild(ListScreen::class, (component as Child.List).component) diff --git a/web-app/src/main/resources/download-gradient.svg b/web-app/src/main/resources/download-gradient.svg new file mode 100644 index 00000000..d74dc881 --- /dev/null +++ b/web-app/src/main/resources/download-gradient.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-app/src/main/resources/download.svg b/web-app/src/main/resources/download.svg new file mode 100644 index 00000000..1822dce0 --- /dev/null +++ b/web-app/src/main/resources/download.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-app/src/main/resources/header-dark.jpg b/web-app/src/main/resources/header-dark.jpg new file mode 100644 index 00000000..48607e87 Binary files /dev/null and b/web-app/src/main/resources/header-dark.jpg differ diff --git a/web-app/src/main/resources/left-arrow.svg b/web-app/src/main/resources/left-arrow.svg new file mode 100644 index 00000000..da079752 --- /dev/null +++ b/web-app/src/main/resources/left-arrow.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-app/src/main/resources/styles.css b/web-app/src/main/resources/styles.css index 356e2828..9ddd95ef 100644 --- a/web-app/src/main/resources/styles.css +++ b/web-app/src/main/resources/styles.css @@ -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;