Repository: Shabinder/SpotiFlyer Branch: main Commit: 42b2d542e731 Files: 448 Total size: 980.6 KB Directory structure: gitextract_3r9508kv/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── Release.yml │ ├── build-and-publish-kjs.yml │ ├── build-release-binaries.yml │ ├── maintenance.yml │ └── tf-refresh.yml ├── .gitignore ├── .gitmodules ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── android/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── shabinder/ │ │ └── spotiflyer/ │ │ ├── App.kt │ │ ├── MainActivity.kt │ │ ├── di/ │ │ │ └── AppModule.kt │ │ ├── service/ │ │ │ ├── ForegroundService.kt │ │ │ ├── Message.kt │ │ │ ├── TrackStatusFlowMap.kt │ │ │ └── Utils.kt │ │ ├── ui/ │ │ │ ├── AnalyticsDialog.kt │ │ │ ├── NetworkDialog.kt │ │ │ ├── PermissionDialog.kt │ │ │ └── SplashScreenActivity.kt │ │ └── utils/ │ │ ├── SignatureVerification.kt │ │ ├── UtilFunctions.kt │ │ └── autoclear/ │ │ ├── AutoClear.kt │ │ ├── AutoClearFragment.kt │ │ ├── LifecycleAutoInitializer.kt │ │ └── lifecycleobservers/ │ │ ├── LifecycleCreateAndDestroyObserver.kt │ │ ├── LifecycleResumeAndPauseObserver.kt │ │ └── LifecycleStartAndStopObserver.kt │ └── res/ │ ├── drawable/ │ │ └── ic_splash.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ └── values/ │ ├── colors.xml │ ├── ic_launcher_background.xml │ └── themes.xml ├── build.gradle.kts ├── buildSrc/ │ ├── build.gradle.kts │ ├── buildSrc/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ └── Versions.kt │ ├── deps.versions.toml │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ ├── android-setup.gradle.kts │ ├── compiler-args.gradle.kts │ ├── ktlint-setup.gradle.kts │ ├── multiplatform-compose-setup.gradle.kts │ ├── multiplatform-setup-test.gradle.kts │ └── multiplatform-setup.gradle.kts ├── common/ │ ├── compose/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── uikit/ │ │ │ ├── AndroidDialog.kt │ │ │ ├── AndroidImageLoad.kt │ │ │ ├── AndroidImages.kt │ │ │ ├── AndroidScrollBars.kt │ │ │ └── configurations/ │ │ │ └── AndroidTypography.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── uikit/ │ │ │ ├── ExpectDialog.kt │ │ │ ├── ExpectImageLoad.kt │ │ │ ├── ExpectImages.kt │ │ │ ├── ScrollBars.kt │ │ │ ├── Toast.kt │ │ │ ├── configurations/ │ │ │ │ ├── Color.kt │ │ │ │ ├── Shape.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── dialogs/ │ │ │ │ ├── Donation.kt │ │ │ │ └── ErrorInfoDialog.kt │ │ │ ├── screens/ │ │ │ │ ├── SpotiFlyerListUi.kt │ │ │ │ ├── SpotiFlyerMainUi.kt │ │ │ │ ├── SpotiFlyerPreferenceUi.kt │ │ │ │ ├── SpotiFlyerRootUi.kt │ │ │ │ └── splash/ │ │ │ │ └── Splash.kt │ │ │ └── utils/ │ │ │ ├── Colors.kt │ │ │ └── GradientScrim.kt │ │ └── desktopMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── shabinder/ │ │ └── common/ │ │ └── uikit/ │ │ ├── DesktopDialog.kt │ │ ├── DesktopImageLoad.kt │ │ ├── DesktopImages.kt │ │ ├── DesktopScrollBar.kt │ │ ├── DesktopToast.kt │ │ └── configurations/ │ │ └── DesktopTypography.kt │ ├── core-components/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── core_components/ │ │ │ ├── AndroidNetworkObserver.kt │ │ │ ├── LiveDataExt.kt │ │ │ ├── analytics/ │ │ │ │ └── AndroidAnalyticsManager.kt │ │ │ ├── file_manager/ │ │ │ │ └── AndroidFileManager.kt │ │ │ ├── media_converter/ │ │ │ │ ├── AndroidMediaConverter.kt │ │ │ │ └── AudioTagging.kt │ │ │ ├── picture/ │ │ │ │ ├── AndroidPicture.kt │ │ │ │ └── Picture.kt │ │ │ └── utils/ │ │ │ └── AndroidHttpClient.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.core_components/ │ │ │ ├── CoreComponentsModule.kt │ │ │ ├── analytics/ │ │ │ │ ├── AnalyticsManager.kt │ │ │ │ ├── Events.kt │ │ │ │ └── Views.kt │ │ │ ├── file_manager/ │ │ │ │ └── FileManager.kt │ │ │ ├── media_converter/ │ │ │ │ └── MediaConverter.kt │ │ │ ├── parallel_executor/ │ │ │ │ └── ParallelExecutor.kt │ │ │ ├── picture/ │ │ │ │ └── Picture.kt │ │ │ ├── preference_manager/ │ │ │ │ └── PreferenceManager.kt │ │ │ └── utils/ │ │ │ ├── NetworkingExt.kt │ │ │ └── StoreExt.kt │ │ ├── desktopMain/ │ │ │ └── kotlin/ │ │ │ ├── com/ │ │ │ │ └── shabinder/ │ │ │ │ └── common/ │ │ │ │ └── core_components/ │ │ │ │ └── utils/ │ │ │ │ └── DesktopHttpClient.kt │ │ │ └── com.shabinder.common.core_components/ │ │ │ ├── analytics/ │ │ │ │ └── DesktopAnalyticsManager.kt │ │ │ ├── file_manager/ │ │ │ │ └── DesktopFileManager.kt │ │ │ ├── media_converter/ │ │ │ │ ├── DesktopMediaConverter.kt │ │ │ │ └── ID3Tagging.kt │ │ │ └── picture/ │ │ │ └── Picture.kt │ │ ├── iosMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.core_components/ │ │ │ ├── IOSDeps.kt │ │ │ ├── IOSDir.kt │ │ │ ├── IOSTagging.kt │ │ │ ├── IOSUtils.kt │ │ │ └── picture/ │ │ │ └── IOSPicture.kt │ │ └── jsMain/ │ │ └── kotlin/ │ │ └── com.shabinder.common.core_components/ │ │ ├── FileSave.kt │ │ ├── ID3Writer.kt │ │ ├── analytics/ │ │ │ └── WebAnalyticsManager.kt │ │ ├── file_manager/ │ │ │ └── WebFileManager.kt │ │ ├── media_converter/ │ │ │ └── WebMediaConverter.kt │ │ ├── picture/ │ │ │ └── Picture.kt │ │ └── utils/ │ │ └── WebHttpClient.kt │ ├── data-models/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin/ │ │ │ │ └── com.shabinder.common.models/ │ │ │ │ ├── AndroidAtomicReference.kt │ │ │ │ ├── AndroidDispatcher.kt │ │ │ │ └── AndroidPlatformActions.kt │ │ │ └── res/ │ │ │ └── drawable/ │ │ │ └── jio_saavn.xml │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ ├── caching/ │ │ │ │ ├── Cache.kt │ │ │ │ ├── FakeTimeSource.kt │ │ │ │ ├── RealCache.kt │ │ │ │ └── ReorderingIsoMutableSet.kt │ │ │ ├── models/ │ │ │ │ ├── Actions.kt │ │ │ │ ├── AudioFormat.kt │ │ │ │ ├── AudioQuality.kt │ │ │ │ ├── Consumer.kt │ │ │ │ ├── CorsProxy.kt │ │ │ │ ├── Dispatcher.kt │ │ │ │ ├── DownloadObject.kt │ │ │ │ ├── DownloadRecord.kt │ │ │ │ ├── DownloadResult.kt │ │ │ │ ├── NativeAtomicReference.kt │ │ │ │ ├── PlatformActions.kt │ │ │ │ ├── PlatformQueryResult.kt │ │ │ │ ├── SpotiFlyerException.kt │ │ │ │ ├── Status.kt │ │ │ │ ├── YoutubeTrack.kt │ │ │ │ ├── event/ │ │ │ │ │ ├── Event.kt │ │ │ │ │ ├── Factory.kt │ │ │ │ │ ├── Validation.kt │ │ │ │ │ └── coroutines/ │ │ │ │ │ ├── SuspendableEvent.kt │ │ │ │ │ └── SuspendedValidation.kt │ │ │ │ ├── gaana/ │ │ │ │ │ ├── Artist.kt │ │ │ │ │ ├── CustomArtworks.kt │ │ │ │ │ ├── GaanaAlbum.kt │ │ │ │ │ ├── GaanaArtistDetails.kt │ │ │ │ │ ├── GaanaArtistTracks.kt │ │ │ │ │ ├── GaanaPlaylist.kt │ │ │ │ │ ├── GaanaSong.kt │ │ │ │ │ ├── GaanaTrack.kt │ │ │ │ │ ├── Genre.kt │ │ │ │ │ └── Tags.kt │ │ │ │ ├── saavn/ │ │ │ │ │ ├── MoreInfo.kt │ │ │ │ │ ├── SaavnAlbum.kt │ │ │ │ │ ├── SaavnPlaylist.kt │ │ │ │ │ ├── SaavnSearchResult.kt │ │ │ │ │ └── SaavnSong.kt │ │ │ │ ├── soundcloud/ │ │ │ │ │ ├── Badges.kt │ │ │ │ │ ├── CreatorSubscription.kt │ │ │ │ │ ├── Format.kt │ │ │ │ │ ├── Media.kt │ │ │ │ │ ├── Product.kt │ │ │ │ │ ├── PublisherMetadata.kt │ │ │ │ │ ├── Transcoding.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── Visual.kt │ │ │ │ │ ├── Visuals.kt │ │ │ │ │ └── resolvemodel/ │ │ │ │ │ └── SoundCloudResolveResponseBase.kt │ │ │ │ ├── spotify/ │ │ │ │ │ ├── Album.kt │ │ │ │ │ ├── Artist.kt │ │ │ │ │ ├── Copyright.kt │ │ │ │ │ ├── Episodes.kt │ │ │ │ │ ├── Followers.kt │ │ │ │ │ ├── Image.kt │ │ │ │ │ ├── LinkedTrack.kt │ │ │ │ │ ├── PagingObjectPlaylistTrack.kt │ │ │ │ │ ├── PagingObjectTrack.kt │ │ │ │ │ ├── Playlist.kt │ │ │ │ │ ├── PlaylistTrack.kt │ │ │ │ │ ├── Source.kt │ │ │ │ │ ├── SpotifyCredentials.kt │ │ │ │ │ ├── TokenData.kt │ │ │ │ │ ├── Track.kt │ │ │ │ │ ├── UserPrivate.kt │ │ │ │ │ └── UserPublic.kt │ │ │ │ └── wynk/ │ │ │ │ ├── AlbumRefWynk.kt │ │ │ │ ├── HtDataWynk.kt │ │ │ │ ├── ItemWynk.kt │ │ │ │ ├── ShortURLWynk.kt │ │ │ │ └── SingerWynk.kt │ │ │ └── utils/ │ │ │ ├── Ext.kt │ │ │ ├── JsonUtils.kt │ │ │ └── Utils.kt │ │ ├── desktopMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── models/ │ │ │ ├── DesktopAtomicReference.kt │ │ │ ├── DesktopDispacthers.kt │ │ │ └── DesktopPlatformActions.kt │ │ ├── iosMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.models/ │ │ │ └── IOSPlatformActions.kt │ │ ├── jsMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.models/ │ │ │ ├── JSPlatformActions.kt │ │ │ ├── WebActual.kt │ │ │ └── WebAtomicReference.kt │ │ └── main/ │ │ └── res/ │ │ └── drawable/ │ │ ├── ic_arrow.xml │ │ ├── ic_download_arrow.xml │ │ ├── ic_error.xml │ │ ├── ic_gaana.xml │ │ ├── ic_github.xml │ │ ├── ic_heart.xml │ │ ├── ic_indian_rupee.xml │ │ ├── ic_instagram.xml │ │ ├── ic_jio_saavn_logo.xml │ │ ├── ic_linkedin.xml │ │ ├── ic_mug.xml │ │ ├── ic_musicplaceholder.xml │ │ ├── ic_opencollective_icon.xml │ │ ├── ic_paypal_logo.xml │ │ ├── ic_refreshgradient.xml │ │ ├── ic_round_cancel_24.xml │ │ ├── ic_share_open.xml │ │ ├── ic_song_placeholder.xml │ │ ├── ic_soundcloud.xml │ │ ├── ic_spotiflyer_logo.xml │ │ ├── ic_spotify_logo.xml │ │ ├── ic_tick.xml │ │ ├── ic_youtube.xml │ │ ├── ic_youtube_music_logo.xml │ │ ├── music.xml │ │ └── soundbound_app_logo.xml │ ├── database/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── database/ │ │ │ └── ActualAndroid.kt │ │ ├── commonMain/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── shabinder/ │ │ │ │ └── common/ │ │ │ │ └── database/ │ │ │ │ ├── Expect.kt │ │ │ │ └── SpotiFlyerDatabase.kt │ │ │ └── sqldelight/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── database/ │ │ │ ├── DownloadRecordDatabase.sq │ │ │ └── TokenDB.sq │ │ ├── desktopMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── database/ │ │ │ └── ActualDesktop.kt │ │ ├── iosMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.database/ │ │ │ └── ActualIos.kt │ │ └── jsMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── shabinder/ │ │ └── common/ │ │ └── database/ │ │ └── ActualJs.kt │ ├── dependency-injection/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── shabinder/ │ │ └── common/ │ │ └── di/ │ │ ├── ApplicationInit.kt │ │ └── DI.kt │ ├── list/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── shabinder/ │ │ └── common/ │ │ └── list/ │ │ ├── SpotiFlyerList.kt │ │ ├── integration/ │ │ │ └── SpotiFlyerListImpl.kt │ │ └── store/ │ │ ├── InstanceKeeperExt.kt │ │ ├── SpotiFlyerListStore.kt │ │ └── SpotiFlyerListStoreProvider.kt │ ├── main/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── shabinder/ │ │ └── common/ │ │ └── main/ │ │ ├── SpotiFlyerMain.kt │ │ ├── integration/ │ │ │ └── SpotiFlyerMainImpl.kt │ │ └── store/ │ │ ├── InstanceKeeperExt.kt │ │ ├── SpotiFlyerMainStore.kt │ │ └── SpotiFlyerMainStoreProvider.kt │ ├── preference/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── AndroidManifest.xml │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── shabinder/ │ │ └── common/ │ │ └── preference/ │ │ ├── SpotiFlyerPreference.kt │ │ ├── integration/ │ │ │ └── SpotiFlyerPreferenceImpl.kt │ │ └── store/ │ │ ├── InstanceKeeperExt.kt │ │ ├── SpotiFlyerPreferenceStore.kt │ │ └── SpotiFlyerPreferenceStoreProvider.kt │ ├── providers/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── providers/ │ │ │ ├── AndroidActual.kt │ │ │ └── saavn/ │ │ │ └── requests/ │ │ │ └── decryptURL.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.providers/ │ │ │ ├── Expect.kt │ │ │ ├── FetchPlatformQueryResult.kt │ │ │ ├── ProvidersModule.kt │ │ │ ├── gaana/ │ │ │ │ ├── GaanaProvider.kt │ │ │ │ └── requests/ │ │ │ │ └── GaanaRequests.kt │ │ │ ├── saavn/ │ │ │ │ ├── SaavnProvider.kt │ │ │ │ └── requests/ │ │ │ │ ├── JioSaavnRequests.kt │ │ │ │ └── JioSaavnUtils.kt │ │ │ ├── sound_cloud/ │ │ │ │ ├── SoundCloudProvider.kt │ │ │ │ └── requests/ │ │ │ │ └── SoundCloudRequests.kt │ │ │ ├── spotify/ │ │ │ │ ├── SpotifyProvider.kt │ │ │ │ ├── requests/ │ │ │ │ │ ├── SpotifyAuth.kt │ │ │ │ │ └── SpotifyRequests.kt │ │ │ │ └── token_store/ │ │ │ │ └── TokenStore.kt │ │ │ ├── youtube/ │ │ │ │ └── YoutubeProvider.kt │ │ │ ├── youtube_music/ │ │ │ │ └── YoutubeMusic.kt │ │ │ └── youtube_to_mp3/ │ │ │ └── requests/ │ │ │ ├── YoutubeMp3.kt │ │ │ └── Yt1sMp3.kt │ │ ├── commonTest/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── providers/ │ │ │ ├── TestSpotifyTrackMatching.kt │ │ │ ├── placeholders/ │ │ │ │ ├── FileManager.kt │ │ │ │ ├── MediaConverter.kt │ │ │ │ └── PreferenceManager.kt │ │ │ └── utils/ │ │ │ ├── CommonUtils.kt │ │ │ └── SpotifyUtils.kt │ │ ├── desktopMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── shabinder/ │ │ │ └── common/ │ │ │ └── providers/ │ │ │ ├── DesktopActual.kt │ │ │ └── saavn/ │ │ │ └── requests/ │ │ │ └── decryptURL.kt │ │ ├── iosMain/ │ │ │ └── kotlin/ │ │ │ └── com.shabinder.common.providers/ │ │ │ ├── IOSActual.kt │ │ │ └── saavn.requests/ │ │ │ └── decryptURL.kt │ │ └── jsMain/ │ │ └── kotlin/ │ │ └── com.shabinder.common.providers/ │ │ ├── WebActual.kt │ │ └── saavn/ │ │ └── requests/ │ │ └── decryptURL.kt │ └── root/ │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── AndroidManifest.xml │ └── commonMain/ │ └── kotlin/ │ └── com/ │ └── shabinder/ │ └── common/ │ └── root/ │ ├── SpotiFlyerRoot.kt │ ├── callbacks/ │ │ └── SpotiFlyerRootCallBacks.kt │ └── integration/ │ └── SpotiFlyerRootImpl.kt ├── console-app/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ ├── common/ │ │ ├── Common.kt │ │ └── Parameters.kt │ ├── main.kt │ └── utils/ │ ├── Exceptions.kt │ ├── Ext.kt │ └── TestClass.kt ├── desktop/ │ ├── build.gradle.kts │ └── src/ │ └── jvmMain/ │ ├── kotlin/ │ │ └── Main.kt │ └── resources/ │ └── drawable/ │ └── spotiflyer.icns ├── fastlane/ │ ├── Appfile │ ├── Fastfile │ └── metadata/ │ └── android/ │ └── en-US/ │ ├── changelogs/ │ │ ├── 20.txt │ │ ├── 21.txt │ │ ├── 22.txt │ │ ├── 24.txt │ │ ├── 25.txt │ │ ├── 26.txt │ │ ├── 27.txt │ │ ├── 28.txt │ │ ├── 29.txt │ │ ├── 30.txt │ │ ├── 31.txt │ │ └── 32.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── ffmpeg/ │ ├── android-ffmpeg/ │ │ ├── .gitignore │ │ ├── build.gradle.bak │ │ ├── build.gradle.kts │ │ ├── gradle.properties │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── nl/ │ │ └── bravobit/ │ │ └── ffmpeg/ │ │ ├── CommandResult.java │ │ ├── CpuArch.java │ │ ├── CpuArchHelper.java │ │ ├── ExecuteBinaryResponseHandler.java │ │ ├── FFbinaryContextProvider.java │ │ ├── FFbinaryInterface.java │ │ ├── FFbinaryObserver.java │ │ ├── FFcommandExecuteAsyncTask.java │ │ ├── FFcommandExecuteResponseHandler.java │ │ ├── FFmpeg.kt │ │ ├── FFprobe.java │ │ ├── FFtask.kt │ │ ├── FileUtils.kt │ │ ├── Log.kt │ │ ├── ResponseHandler.kt │ │ ├── ShellCommand.kt │ │ └── Util.kt │ └── copy-ffmpeg-executables.sh ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── infra/ │ ├── .terraform.lock.hcl │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── maintenance-tasks/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ ├── common/ │ │ ├── Common.kt │ │ ├── ContentUpdation.kt │ │ ├── Date.kt │ │ ├── GithubService.kt │ │ ├── HCTIService.kt │ │ └── Secrets.kt │ ├── main.kt │ ├── models/ │ │ ├── github/ │ │ │ ├── Asset.kt │ │ │ ├── Author.kt │ │ │ ├── GithubFileContent.kt │ │ │ ├── GithubReleaseInfoItem.kt │ │ │ ├── GithubReleasesInfo.kt │ │ │ ├── Reactions.kt │ │ │ └── Uploader.kt │ │ └── matomo/ │ │ ├── MatomoDownloads.kt │ │ └── MatomoDownloadsItem.kt │ ├── scripts/ │ │ ├── UpdateAnalyticsImage.kt │ │ └── UpdateDownloadCards.kt │ └── utils/ │ ├── Exceptions.kt │ ├── Ext.kt │ └── TestClass.kt ├── scripts/ │ └── build-ffmpeg.sh ├── settings.gradle.kts ├── translations/ │ ├── Strings_cn.properties.xml │ ├── Strings_cro.properties │ ├── Strings_cs.properties │ ├── Strings_de.properties │ ├── Strings_en.properties │ ├── Strings_es.properties │ ├── Strings_fa.properties │ ├── Strings_fr.properties │ ├── Strings_hi.properties │ ├── Strings_id.properties │ ├── Strings_it.properties │ ├── Strings_ja.properties.xml │ ├── Strings_jp.properties │ ├── Strings_ml.properties │ ├── Strings_ne.properties │ ├── Strings_nl.properties │ ├── Strings_pl.properties │ ├── Strings_pt.properties │ ├── Strings_pt_BR.properties │ ├── Strings_ro.properties │ ├── Strings_ru.properties │ ├── Strings_tl.properties │ ├── Strings_tr.properties │ ├── Strings_tw.properties │ ├── Strings_uk.properties │ └── Strings_ur.properties.xml └── web-app/ ├── build.gradle.kts └── src/ └── main/ ├── kotlin/ │ ├── App.kt │ ├── Styles.kt │ ├── client.kt │ ├── extras/ │ │ ├── RenderableComponent.kt │ │ ├── UniqueId.kt │ │ └── Utils.kt │ ├── home/ │ │ ├── HomeScreen.kt │ │ ├── IconList.kt │ │ ├── Message.kt │ │ └── Searchbar.kt │ ├── list/ │ │ ├── CircularProgressBar.kt │ │ ├── CoverImage.kt │ │ ├── DownloadAllButton.kt │ │ ├── DownloadButton.kt │ │ ├── ListScreen.kt │ │ ├── LoadingAnim.kt │ │ ├── LoadingSpinner.kt │ │ └── TrackItem.kt │ ├── navbar/ │ │ ├── NavBar.kt │ │ └── NavBarStyles.kt │ └── root/ │ └── RootR.kt └── resources/ ├── css-circular-prog-bar.css ├── index.html └── styles.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username ko_fi: shabinder # Replace with a single Ko-fi username open_collective: spotiflyer # Replace with a single Open Collective username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: ["https://paypal.me/shabinder","https://rzp.io/l/9J3UBYiU"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.md ================================================ --- name: Bug Report about: Create a bug report to help us improve title: "[BUG] : " labels: bug assignees: '' --- **Describe the bug:** **Media Links Used:** **Expected behavior** **Screenshots:** **StackTrace:** ``` Paste Stacktrace here if available ``` **Device Info (please complete the following information):** - Device: [e.g. iPhone6, Samsung J2, PocoF1] - OS: [e.g. iOS, Android 10, WinOS, MacOS] - Version: [e.g. 3.2.1] - Country: [eg: India, will help in detecting if service is accessible to u] **Additional context:** ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "[Feature-Request]: " labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/.github/workflows" schedule: interval: "daily" - package-ecosystem: "gradle" directory: "/" schedule: interval: "daily" - package-ecosystem: "terraform" directory: "/infra" schedule: interval: "daily" ================================================ FILE: .github/workflows/Release.yml ================================================ name: Release on: [workflow_dispatch] # workflow_dispatch: # release: # types: [ created ] jobs: build: name: Build App runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Restore Gradle cache id: cache uses: actions/cache@v2.1.4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: 8 - name: Build Web App run: ./gradlew :web-app:build - name: Upload Static Web App Artifact uses: actions/upload-artifact@v2 id: upload with: path: web-app/build/distributions name: static-web-app if-no-files-found: error deploy-Infrastructure: runs-on: ubuntu-latest name: Deploy Main Infrastructure needs: [ build ] env: ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USER }} ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }} ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }} ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }} ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }} steps: - name: Checkout uses: actions/checkout@v2 - name: Setup Terraform environment uses: little-core-labs/install-terraform@v2.0.0 with: version: 0.14.5 - name: Terraform init run: terraform init working-directory: infra - name: Terraform apply run: terraform apply -auto-approve working-directory: infra env: TF_VAR_docker_registry_username: ${{ secrets.GH_PKG_USER }} TF_VAR_docker_registry_password: ${{ secrets.GH_PKG_PASSWORD }} TF_VAR_cors_anywhere_allow_list: "" TF_VAR_cors_anywhere_rate_limit: "" deploy-StaticWebApp: runs-on: ubuntu-latest name: Deploy Static Web App needs: [ build ] steps: - uses: actions/checkout@v2 - name: Download Static Web App Artifact uses: actions/download-artifact@v2 with: name: static-web-app path: dist/ - name: Deploy uses: JamesIves/github-pages-deploy-action@4.1.0 with: BRANCH: gh-pages # The branch the action should deploy to. FOLDER: dist/ # The folder the action should deploy. CLEAN: true # Automatically remove deleted files from the deploy branch # - name: Deploy Azure Static Web App # uses: Azure/static-web-apps-deploy@v0.0.1-preview # with: # azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_AMBITIOUS_WATER_0BC116E03 }} # repo_token: ${{ github.token }} # Used for Github integrations (i.e. PR comments) # action: "upload" # ###### Repository/Build Configurations - These values can be configured to match you app requirements. ###### # # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig # app_location: "/dist" # ###### End of Repository/Build Configurations ###### ================================================ FILE: .github/workflows/build-and-publish-kjs.yml ================================================ name: Build and Publish on: [ pull_request ] jobs: build: name: Test and Build runs-on: ubuntu-latest steps: # Setup Java 1.8 environment for the next steps - name: Setup Java uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: 15 # Check out current repository - name: Fetch Sources uses: actions/checkout@v2.3.1 # Build Android application - name: Android App run: ./gradlew :android:build # Build Desktop application - name: Desktop App run: ./gradlew :desktop:build # Build Web application - name: Web App run: ./gradlew :web-app:build ================================================ FILE: .github/workflows/build-release-binaries.yml ================================================ name: Build Release Binaries on: [ workflow_dispatch ] jobs: create-linux-package: runs-on: ubuntu-latest name: Create Deb Package steps: # Setup Java environment for the next steps - name: Setup Java uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: 15 # Check out current repository - name: Fetch Sources uses: actions/checkout@v2.3.1 # Build Desktop Uber Jar application - name: Desktop Uber Jar run: ./gradlew :desktop:packageUberJarForCurrentOS # Build Desktop Packaged application - name: Desktop App Package run: ./gradlew :desktop:packageDeb # Create a Draft Release - name: Draft Release uses: ncipollo/release-action@v1 with: draft: true allowUpdates: true tag: "v3.6.4" artifacts: "desktop/build/compose/jars/*.jar,desktop/build/compose/binaries/main/*/*" token: ${{ secrets.GH_TOKEN }} # Windows Package create-win-package: runs-on: windows-latest name: Create Windows Package steps: # Setup Java environment for the next steps - name: Setup Java uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: 15 # Check out current repository - name: Fetch Sources uses: actions/checkout@v2.3.1 # Build Desktop Uber Jar application - name: Desktop Uber Jar run: ./gradlew :desktop:packageUberJarForCurrentOS # Build Desktop Packaged application - name: Desktop App Package run: ./gradlew :desktop:packageMsi # Build Android App - name: Generate Release APK run: ./gradlew :android:assembleRelease # Sign Android Apk - name: Sign APK uses: r0adkll/sign-android-release@v1 id: sign_app with: releaseDirectory: android/build/outputs/apk/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} alias: ${{ secrets.ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} # Create a Draft Release - name: Draft Release uses: ncipollo/release-action@v1 with: draft: true allowUpdates: true tag: "v3.6.4" artifacts: "desktop/build/compose/jars/*.jar,desktop/build/compose/binaries/main/*/*,android/build/outputs/apk/release/*signed.apk" token: ${{ secrets.GH_TOKEN }} create-mac-package: runs-on: macos-latest name: Create Mac Package steps: # Setup Java environment for the next steps - name: Setup Java uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: 15 # Check out current repository - name: Fetch Sources uses: actions/checkout@v2.3.1 # Build Desktop Uber Jar application - name: Desktop Uber Jar run: ./gradlew :desktop:packageUberJarForCurrentOS # Build Desktop Packaged application - name: Desktop App Package run: ./gradlew :desktop:packageDmg # Create a Draft Release - name: Draft Release uses: ncipollo/release-action@v1 with: draft: true allowUpdates: true tag: "v3.6.4" artifacts: "desktop/build/compose/jars/*.jar,desktop/build/compose/binaries/main/*/*" token: ${{ secrets.GH_TOKEN }} ================================================ FILE: .github/workflows/maintenance.yml ================================================ name: Maintenance on: [ pull_request ] #workflow_dispatch: #schedule: # - cron: '0 0 * * *' #once a day jobs: build: name: Maintenance Time runs-on: ubuntu-latest steps: # Setup Java environment for the next steps - name: Setup Java uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: 15 # Check out current repository - name: Fetch Sources uses: actions/checkout@v2.3.1 # Run Maintenance Tasks - name: Maintenance Run run: ./gradlew :maintenance-tasks:run env: GH_TOKEN: ${{ secrets.GH_TOKEN }} OWNER_NAME: 'Shabinder' REPO_NAME: 'SpotiFlyer' BRANCH_NAME: 'main' FILE_PATH: 'README.md' IMAGE_DESCRIPTION: 'Analytics' COMMIT_MESSAGE: 'Analytics & Download Card Updated' TAG_NAME: 'HTI' ================================================ FILE: .github/workflows/tf-refresh.yml ================================================ name: Refresh Terraform State on: workflow_dispatch: schedule: - cron: '0 0 * * 0' jobs: refresh-Infrastructure: runs-on: ubuntu-latest name: Refresh Main Infrastructure env: ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USER }} ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }} ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }} ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }} ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }} steps: - name: Checkout uses: actions/checkout@v2 - name: Setup Terraform environment uses: little-core-labs/install-terraform@v2.0.0 with: version: 0.14.5 - name: Terraform init run: terraform init working-directory: infra - name: Terraform refresh run: terraform refresh working-directory: infra env: TF_VAR_docker_registry_username: ${{ secrets.GH_PKG_USER }} TF_VAR_docker_registry_password: ${{ secrets.GH_PKG_PASSWORD }} ================================================ FILE: .gitignore ================================================ .idea/ local.properties /android/release/ /android/google-services.json build/ .gradle/ terraform.tfvars .terraform/ /spotiflyer-ios/Pods/ /fastlane/report.xml /fastlane/README.md Gemfile Gemfile.lock /maintenance-tasks/build/ /android/.cxx/Debug/5k2s1t1p/x86/ /ffmpeg/ffmpeg-kit-android-lib/.cxx/Debug/ ================================================ FILE: .gitmodules ================================================ [submodule "spotiflyer-ios"] path = spotiflyer-ios url = https://github.com/Shabinder/spotiflyer-ios [submodule "ffmpeg/ffmpeg-android-maker"] path = ffmpeg/ffmpeg-android-maker url = https://github.com/Shabinder/ffmpeg-android-maker/ ================================================ FILE: CONTRIBUTING.md ================================================ ### Contributing Translations: - **Fork** this repo (Button at top Right Corner) - Create **`Strings_{language-code}.properties.xml`** in **`/translations/`** - Refer to the **Default file** - *English **(en)*** => [`Strings_en.properties`](https://github.com/Shabinder/SpotiFlyer/blob/main/translations/Strings_en.properties) - For example, if you want to translate the app to **French** , create **`Strings_fr.properties`** - **Copy** all Key-Value pairs from **`Strings_en.properties`** to your newly created **`Strings_fr.properties`** and Translate all Values. - **Commit** your Changes to your Fork and then **Create a Pull Request** to this Repo. - **Sit Back and Relax**... and **kudos** for your contibution! Your effort and contribution will benefit lots of people. 🙏😊 If You Need any Help with Anything, just create an issue asking about the same. ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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 . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ We will from now on publish **[PLUGINS](https://gitlab.com/shabinder/soundbound#-disclaimer)** for **[Soundbound](https://soundbound.app)**, which users can install **explicitly** on their own responsibility [(*disclaimer*)](https://gitlab.com/shabinder/soundbound#-disclaimer). - [Soundbound Installation Guide.](https://soundbound.app/install) - [Wiki](https://soundbound.app/wiki) - [Telegram](https://soundbound.app/telegram) ![Feature Soundbound](art/soundbound-feature.jpg) # Soundbound - [Get Now!](https://soundbound.app/) --- > Please refer above for latest updates. > - Spotiflyer itself won't be getting any more updates, > We all thank you for your support and hope to see you in [Soundbound Telegram Community](https://soundbound.app/telegram). --- ![Web Cover](art/cover-web.jpg) ![Android Cover](art/cover-android.jpg) ![Desktop Cover](art/cover-desktop.jpg) # [SpotiFlyer](https://spotiflyer.in/) - **Kotlin Multiplatform** Music Downloader ,supports **Spotify, Youtube, Gaana, Jio-Saavn and SoundCloud**. Supports- Playlist, Albums, Tracks. _(If You know Any Source for Episodes/Podcasts create an Issue sharing It.)_ **Currently running on:** - [Android (Jetpack Compose)](https://github.com/Shabinder/SpotiFlyer#-install) - [Desktop (Compose for Desktop) β](https://github.com/Shabinder/SpotiFlyer#-install) - [Web (Kotlin/JS + React Wrapper) β](https://shabinder.github.io/SpotiFlyer/) - [_(WIP)_ IOS SOON (SWIFTUI)](https://github.com/Shabinder/spotiflyer-ios) [![GitHub stars](https://img.shields.io/github/stars/Shabinder/SpotiFlyer?style=social)](https://github.com/Shabinder/SpotiFlyer/stargazers) [![GitHub forks](https://img.shields.io/github/forks/Shabinder/SpotiFlyer?style=social)](https://github.com/Shabinder/SpotiFlyer/network/members) [![GitHub watchers](https://img.shields.io/github/watchers/Shabinder/SpotiFlyer?style=social)](https://github.com/Shabinder/SpotiFlyer/watchers) ***Encourage this repo by giving it a Star⭐ .*** [SpotiFlyer](https://app.spotiflyer.in/) is an **App**(Written in **Kotlin**), which **aims** to work as: - **Downloads**: Albums, Tracks and Playlists,etc - **Save your Data** ,by not **_Streaming_** your Fav Songs Online again & again(Just Download Them!) - **No ADS!** - **Works straight out of the box** and does not require you to generate or mess with your API keys (already included). ### Supported Platforms: - Spotify - Gaana - Youtube - Youtube Music - Jio-Saavn - SoundCloud - _(more coming soon)_ ## 💻 Install | Platform | Download | Status | |----------|----------|--------| | Android |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=7885FF&label=Android-Apk&logo=android&style=for-the-badge)](https://github.com/Shabinder/SpotiFlyer/releases/download/v3.6.3/SpotiFlyer-3.6.3.apk)| ✅ Stable | | Windows |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=00A8E8&label=Windows-msi&logo=windows&style=for-the-badge)](https://github.com/Shabinder/SpotiFlyer/releases/download/v3.6.3/SpotiFlyer-3.6.3.msi)| ✅ Stable | | Windows-JAR |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=00719c&label=Windows-jar&logo=windows&style=for-the-badge)](https://github.com/Shabinder/SpotiFlyer/releases/download/v3.6.3/SpotiFlyer-windows-x64-3.6.3.jar)| ✅ Stable | | MacOS-JAR |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=5F85CE&label=MacOS-jar&logo=apple&style=for-the-badge)](https://github.com/Shabinder/SpotiFlyer/releases/download/v3.6.3/SpotiFlyer-macos-x64-3.6.3.jar) | ✅ Stable | | Linux-DEB |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=D0074E&label=Linux-deb&logo=debian&style=for-the-badge)](https://github.com/Shabinder/SpotiFlyer/releases/download/v3.6.3/spotiflyer_3.6.3-1_amd64.deb)| ✅ Stable | | Linux-JAR |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=EBA201&label=Linux-jar&logo=linux&style=for-the-badge)](https://github.com/Shabinder/SpotiFlyer/releases/download/v3.6.3/SpotiFlyer-linux-x64-3.6.3.jar)| ✅ Stable | | Web |[![Download Button](https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=FF7139&label=SpotiFlyer&logo=firefox&style=for-the-badge)](https://shabinder.github.io/SpotiFlyer/) | ⚠️ Beta | - To run the `jar` version, you need JAVA installed. - MacOs DMG is not notarized and signed using a certificate , so Use jar in mac for now. Get it on F-Droid ### Want to Contribute 🙋‍♂️? Want to contribute? Great! All contributions are welcome, from code to documentation to graphics to design suggestions to bug reports. Please use GitHub to its fullest-- contribute Pull Requests, contribute tutorials or other wiki content-- whatever you have to offer, we can use it! - For **Translations** , read [Contributing.md](https://github.com/Shabinder/SpotiFlyer/blob/main/CONTRIBUTING.md) **Please Donate to support me and my work!**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R84CS1D)
[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://paypal.me/shabinder) ### Want to discuss? 💬 Have any questions, doubts or want to present your opinions, views? You're always welcome. You can [start discussions](https://github.com/Shabinder/SpotiFlyer/discussions). ### Todos 📄 - Write **Tests**. - Support for Podcasts/Episodes and Shows. - Build a Media Player into this app. [#113](https://github.com/Shabinder/SpotiFlyer/issues/113) ### Note The availability of YouTube Music / JioSaavn in your country is important for this app to work. The reason behind this is, we use YouTube Music / JioSaavn to filter out our search results and get the desired song downloaded from Youtube Music OR other providers we may use(like Jio Saavn). To check if YouTube Music is available in your country, visit [YouTube Music](https://music.youtube.com). I am hosting a **server for WEB APP on my own personal device** , so expect some downtimes, If you have a **server** available and would like to share , open an issue or ping me wherever you can get a hold of me. ### Permissions Info: - **NETWORK**- *(INTERNET, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE)*: to access the online streaming services, and Confirm Network Connectivity. - **STORAGE**- *READ_STORAGE_PERMISSION, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE, MANAGE_EXTERNAL_STORAGE)*: to save Downloaded Media Files. - **QUERY_ALL_PACKAGES**- in order to check if Youtube Music, Spotify, Gaana, JioSaavn, etc apps are installed and if they are, user can directly open them. - **REQUEST_IGNORE_BATTERY_OPTIMIZATIONS**: User Allows App to Run in Background at runtime in Permission Dialog. - **Wake Lock**: Don't let Wifi/Internet Sleep in screen off / dozing state when Songs are being downloaded. - **Foreground Service**: Service responsible to download and save songs to storage even after app is exited/background. - **NOTE**: Analytics and Crashlytics are **OPT-IN** *(Disabled by Default)* and are **Self-Hosted**. License ![GPL-License](https://img.shields.io/github/license/Shabinder/SpotiFlyer?style=flat-square) ---- **GPL-3.0 License** 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. ***Free Software, Hell Yeah!*** Credits ---- - Some Logos are Based on Logos by [Freepik](https://www.freepik.com/). Disclaimer ---- Downloading copyright songs may be illegal in your country. This tool is for educational purposes only and was created only to show how Music Platform's Apis like Spotify's API can be exploited to download music. Please support the artists by buying their music. ================================================ FILE: android/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.compose.compose plugins { id("com.android.application") kotlin("android") id("kotlin-parcelize") id("org.jetbrains.compose") id("ktlint-setup") } group = "com.shabinder" version = Versions.versionName repositories { google() mavenCentral() } android { val props = gradleLocalProperties(rootDir) if (props.containsKey("storeFileDir")) { signingConfigs { create("release") { storeFile = file(props.getProperty("storeFileDir")) storePassword = props.getProperty("storePassword") keyAlias = props.getProperty("keyAlias") keyPassword = props.getProperty("keyPassword") } } } compileSdk = Versions.compileSdkVersion buildToolsVersion = "30.0.3" defaultConfig { applicationId = "com.shabinder.spotiflyer" minSdk = Versions.minSdkVersion targetSdk = Versions.targetSdkVersion versionCode = Versions.versionCode versionName = Versions.versionName ndkVersion = "21.4.7075529" } buildTypes { getByName("release") { isMinifyEnabled = true // isShrinkResources = true if (props.containsKey("storeFileDir")) { signingConfig = signingConfigs.getByName("release") } proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } kotlinOptions { jvmTarget = "1.8" } compileOptions { // Flag to enable support for the new language APIs isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } packagingOptions { exclude("META-INF/*") } } dependencies { implementation(compose.material) implementation(compose.materialIconsExtended) implementation(deps.androidx.activity) // Project's SubModules implementation(project(":common:database")) implementation(project(":common:compose")) implementation(project(":common:root")) implementation(project(":common:dependency-injection")) implementation(project(":common:data-models")) implementation(project(":common:core-components")) implementation(project(":common:providers")) with(deps) { // Koin with(koin) { implementation(androidx.compose) implementation(android) } // DECOMPOSE with(decompose) { implementation(dep) implementation(extensions.compose) } implementation(countly.android) implementation(android.app.notifier) implementation(storage.chooser) with(bundles) { implementation(ktor) implementation(mviKotlin) implementation(androidx.lifecycle) implementation(accompanist.inset) } // Test testImplementation(junit) androidTestImplementation(androidx.junit) androidTestImplementation(androidx.expresso) // Desugar coreLibraryDesugaring(androidx.desugar) // Debug debugImplementation(leak.canary) } } ================================================ FILE: android/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations -keepattributes SourceFile,LineNumberTable # Keep file names and line numbers. -keep public class * extends java.lang.Exception # Optional: Keep custom exceptions. # kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer -keepclassmembers class kotlinx.serialization.json.** { *** Companion; } -keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); } -keep class com.shabinder.** { *; } -keep class com.mpatric.** { *; } -keep,includedescriptorclasses class com.shabinder.**$$serializer { *; } # <-- change package name to your app's -keepclassmembers class com.shabinder.** { # <-- change package name to your app's *** Companion; } -keepclasseswithmembers class com.shabinder.** { # <-- change package name to your app's kotlinx.serialization.KSerializer serializer(...); } # Ktor -keep class io.ktor.** { *; } -keep class kotlinx.coroutines.** { *; } ================================================ FILE: android/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/App.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.spotiflyer import android.app.Application import com.shabinder.common.di.initKoin import com.shabinder.spotiflyer.di.appModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.component.KoinComponent import org.koin.core.logger.Level class App : Application(), KoinComponent { companion object { const val SIGNATURE_HEX = "53304f6d75736a2f30484230334c454b714753525763724259444d3d0a" } override fun onCreate() { super.onCreate() val loggingEnabled = true // KOIN - DI initKoin(loggingEnabled) { androidLogger(Level.NONE) // No virtual method elapsedNow androidContext(this@App) modules(appModule(loggingEnabled)) } } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/MainActivity.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.spotiflyer import android.annotation.SuppressLint import android.content.* import android.content.pm.PackageManager import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Bundle import android.os.IBinder import android.os.PowerManager import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.defaultComponentContext import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import com.codekidlabs.storagechooser.R import com.codekidlabs.storagechooser.StorageChooser import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsPadding import com.google.accompanist.insets.statusBarsHeight import com.google.accompanist.insets.statusBarsPadding import com.shabinder.common.core_components.ConnectionLiveData import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.di.ApplicationInit import com.shabinder.common.di.observeAsState import com.shabinder.common.models.* import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey import com.shabinder.common.providers.FetchPlatformQueryResult import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.configurations.SpotiFlyerTheme import com.shabinder.common.uikit.configurations.colorOffWhite import com.shabinder.common.uikit.screens.SpotiFlyerRootContent import com.shabinder.spotiflyer.service.ForegroundService import com.shabinder.spotiflyer.ui.AnalyticsDialog import com.shabinder.spotiflyer.ui.NetworkDialog import com.shabinder.spotiflyer.ui.PermissionDialog import com.shabinder.spotiflyer.utils.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.core.parameter.parametersOf import java.io.File @OptIn(ExperimentalAnimationApi::class) class MainActivity : ComponentActivity() { private val fetcher: FetchPlatformQueryResult by inject() private val fileManager: FileManager by inject() private val preferenceManager: PreferenceManager by inject() private val analyticsManager: AnalyticsManager by inject { parametersOf(this) } private val applicationInit: ApplicationInit by inject() private val callBacks: SpotiFlyerRootCallBacks get() = this.rootComponent.callBacks private val trackStatusFlow = MutableSharedFlow>(1) private var permissionGranted = mutableStateOf(true) private val internetAvailability by lazy { ConnectionLiveData(applicationContext) } private lateinit var rootComponent: SpotiFlyerRoot // private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance // Variable for storing instance of our service class var foregroundService: ForegroundService? = null // Boolean to check if our activity is bound to service or not var isServiceBound: Boolean? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) preferenceManager.analyticsManager = analyticsManager // This app draws behind the system bars, so we want to handle fitting system windows WindowCompat.setDecorFitsSystemWindows(window, false) this.rootComponent = spotiFlyerRoot(defaultComponentContext()) setContent { SpotiFlyerTheme { Surface(contentColor = colorOffWhite) { ProvideWindowInsets { permissionGranted = remember { mutableStateOf(true) } val view = LocalView.current Box { SpotiFlyerRootContent( this@MainActivity.rootComponent, Modifier.statusBarsPadding().navigationBarsPadding(), showSplash = false // We already show System-Native Splash ) Spacer( Modifier .statusBarsHeight() .fillMaxWidth() .background(MaterialTheme.colors.background.copy(alpha = 0.65f)) ) } NetworkDialog(isInternetAvailableState()) PermissionDialog( permissionGranted.value, { requestStoragePermission() }, { disableDozeMode(disableDozeCode) }, ) var askForAnalyticsPermission by remember { mutableStateOf(false) } AnalyticsDialog( askForAnalyticsPermission, enableAnalytics = { preferenceManager.toggleAnalytics(true) preferenceManager.firstLaunchDone() }, dismissDialog = { askForAnalyticsPermission = false preferenceManager.firstLaunchDone() } ) LaunchedEffect(view) { permissionGranted.value = checkPermissions() if (preferenceManager.isFirstLaunch) { delay(2500) // Ask For Analytics Permission on first Dialog askForAnalyticsPermission = true } } } } } } initialise() } private fun initialise() { val isGithubRelease = checkAppSignature(this) /* * Only Send an `Update Notification` on Github Release Builds * and Track Downloads for all other releases like F-Droid, * for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer * */ if (isGithubRelease) { checkIfLatestVersion() } // TODO Track Download Event handleIntentFromExternalActivity() initForegroundService() } /*START: Foreground Service Handlers*/ private fun initForegroundService() { // Start and then Bind to the Service ContextCompat.startForegroundService( this@MainActivity, Intent(this, ForegroundService::class.java) ) bindService() } /** * Interface for getting the instance of binder from our service class * So client can get instance of our service class and can directly communicate with it. */ private val serviceConnection = object : ServiceConnection { val tag = "Service Connection" override fun onServiceConnected(className: ComponentName, iBinder: IBinder) { Log.d(tag, "connected to service.") // We've bound to MyService, cast the IBinder and get MyBinder instance val binder = iBinder as ForegroundService.DownloadServiceBinder foregroundService = binder.service isServiceBound = true lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { foregroundService?.trackStatusFlowMap?.statusFlow?.let { trackStatusFlow.emitAll(it.conflate().flowOn(Dispatchers.Default)) } } } } override fun onServiceDisconnected(arg0: ComponentName) { Log.d(tag, "disconnected from service.") isServiceBound = false } } /*Used to bind to our service class*/ private fun bindService() { Intent(this, ForegroundService::class.java).also { intent -> bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) } } /*Used to unbind from our service class*/ private fun unbindService() { Intent(this, ForegroundService::class.java).also { unbindService(serviceConnection) } } /*END: Foreground Service Handlers*/ @Composable private fun isInternetAvailableState(): State { return internetAvailability.observeAsState() } private fun showPopUpMessage(string: String, long: Boolean = false) { runOnUiThread { android.widget.Toast.makeText( applicationContext, string, if (long) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT ).show() } Log.i("Toasting", string) } @Suppress("DEPRECATION") override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) permissionGranted.value = checkPermissions() } private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot = SpotiFlyerRoot( componentContext, dependencies = object : SpotiFlyerRoot.Dependencies { override val storeFactory = LoggingStoreFactory(DefaultStoreFactory()) override val database = this@MainActivity.fileManager.db override val fetchQuery = this@MainActivity.fetcher override val fileManager: FileManager = this@MainActivity.fileManager override val preferenceManager = this@MainActivity.preferenceManager override val analyticsManager: AnalyticsManager = this@MainActivity.analyticsManager override val downloadProgressFlow: MutableSharedFlow> = trackStatusFlow override val appInit: ApplicationInit = applicationInit override val actions = object : Actions { override val platformActions = object : PlatformActions { override val imageCacheDir: String = applicationContext.cacheDir.absolutePath + File.separator override val sharedPreferences = applicationContext.getSharedPreferences( SharedPreferencesKey, MODE_PRIVATE ) override fun addToLibrary(path: String) { MediaScannerConnection.scanFile( applicationContext, listOf(path).toTypedArray(), null, null ) } override fun sendTracksToService(array: List) { for (chunk in array.chunked(25)) { if (foregroundService == null) initForegroundService() foregroundService?.downloadAllTracks(chunk) } } } override fun showPopUpMessage(string: String, long: Boolean) = this@MainActivity.showPopUpMessage(string, long) override fun setDownloadDirectoryAction(callBack: (String) -> Unit) = setUpOnPrefClickListener(callBack) override fun queryActiveTracks() = this@MainActivity.queryActiveTracks() override fun giveDonation() { openPlatform( "", platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button" ) } override fun shareApp() { val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, Strings.shareMessage()) type = "text/plain" } val shareIntent = Intent.createChooser(sendIntent, null) startActivity(shareIntent) } override fun copyToClipboard(text: String) { val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("SpotiFlyer Selection", text) clipboard.setPrimaryClip(clip) showPopUpMessage("Text Copied to Clipboard.") } override fun openPlatform(packageID: String, platformLink: String) { val manager: PackageManager = applicationContext.packageManager try { val intent = manager.getLaunchIntentForPackage(packageID) ?: throw PackageManager.NameNotFoundException() intent.addCategory(Intent.CATEGORY_LAUNCHER) startActivity(intent) } catch (e: PackageManager.NameNotFoundException) { val uri: Uri = Uri.parse(platformLink) val intent = Intent(Intent.ACTION_VIEW, uri) startActivity(intent) } } override fun writeMp3Tags(trackDetails: TrackDetails) { /*IMPLEMENTED*/ } override val isInternetAvailable get() = internetAvailability.value ?: true } } ) private fun queryActiveTracks() { lifecycleScope.launch { foregroundService?.trackStatusFlowMap?.let { tracksStatus -> trackStatusFlow.emit(tracksStatus) } } } override fun onResume() { super.onResume() queryActiveTracks() } @Suppress("DEPRECATION") private fun setUpOnPrefClickListener(callBack: (String) -> Unit) { // Initialize Builder val chooser = StorageChooser.Builder() .withActivity(this) .withFragmentManager(fragmentManager) .withMemoryBar(true) .setTheme( StorageChooser.Theme(applicationContext).apply { scheme = applicationContext.resources.getIntArray(R.array.default_dark) } ) .setDialogTitle(Strings.setDownloadDirectory()) .allowCustomPath(true) .setType(StorageChooser.DIRECTORY_CHOOSER) .build() // get path that the user has chosen chooser.setOnSelectListener { path -> Log.d("Setting Base Path", path) val f = File(path) if (f.canWrite()) { // hell yeah :) preferenceManager.setDownloadDirectory(path) callBack(path) showPopUpMessage(Strings.downloadDirectorySetTo("\n${fileManager.defaultDir()}")) } else { showPopUpMessage(Strings.noWriteAccess("\n$path ")) } } // Show dialog whenever you want by chooser.show() } @Suppress("DEPRECATION") @SuppressLint("ObsoleteSdkInt") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == disableDozeCode) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = getSystemService(Context.POWER_SERVICE) as PowerManager val isIgnoringBatteryOptimizations = pm.isIgnoringBatteryOptimizations(packageName) if (isIgnoringBatteryOptimizations) { // Ignoring battery optimization permissionGranted.value = true } else { disableDozeMode(disableDozeCode) // Again Ask For Permission!! } } } } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) handleIntentFromExternalActivity(intent) } private fun handleIntentFromExternalActivity(intent: Intent? = getIntent()) { if (intent?.action == Intent.ACTION_SEND) { if ("text/plain" == intent.type) { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { val filterLinkRegex = """http.+\w""".toRegex() val string = it.replace("\n".toRegex(), " ") val link = filterLinkRegex.find(string)?.value.toString() Log.i("Intent", link) lifecycleScope.launch { while (!this@MainActivity::rootComponent.isInitialized) { delay(100) } if (Actions.instance.isInternetAvailable) callBacks.searchLink(link) } } } } } override fun onDestroy() { super.onDestroy() unbindService() } override fun onStart() { super.onStart() analyticsManager.onStart() } override fun onStop() { super.onStop() analyticsManager.onStop() } companion object { const val disableDozeCode = 1223 } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/di/AppModule.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.spotiflyer.di import org.koin.dsl.module fun appModule(enableLogging: Boolean = false) = module {} ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/service/ForegroundService.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.spotiflyer.service import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Binder import android.os.Build import android.os.IBinder import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.downloadFile import com.shabinder.common.core_components.parallel_executor.ParallelExecutor import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.failure import com.shabinder.common.providers.FetchPlatformQueryResult import com.shabinder.common.translations.Strings import com.shabinder.spotiflyer.R import io.ktor.client.HttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import java.io.File class ForegroundService : LifecycleService() { private lateinit var downloadService: ParallelExecutor val trackStatusFlowMap = TrackStatusFlowMap( MutableSharedFlow(replay = 1), lifecycleScope ) private val fetcher: FetchPlatformQueryResult by inject() private val logger: Kermit by inject() private val dir: FileManager by inject() private val httpClient: HttpClient by inject() private var messageList = java.util.Collections.synchronizedList(MutableList(5) { emptyMessage }) private var wakeLock: PowerManager.WakeLock? = null private var isServiceStarted = false private val cancelIntent: PendingIntent by lazy { val intent = Intent(this, ForegroundService::class.java).apply { action = "kill" } val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE } else { PendingIntent.FLAG_UPDATE_CURRENT } PendingIntent.getService(this, 0, intent, flags) } /* Variables Holding Download State */ private var total = 0 private var converted = 0 private var downloaded = 0 private var failed = 0 private val isFinished get() = converted + failed == total private var isSingleDownload = false inner class DownloadServiceBinder : Binder() { val service get() = this@ForegroundService } private val myBinder: IBinder = DownloadServiceBinder() override fun onBind(intent: Intent): IBinder { super.onBind(intent) return myBinder } override fun onCreate() { super.onCreate() downloadService = ParallelExecutor(Dispatchers.IO) trackStatusFlowMap.scope = lifecycleScope createNotificationChannel(CHANNEL_ID, "Downloader Service") } @SuppressLint("WakelockTimeout") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) downloadService.reviveIfClosed() // Send a notification that service is started Log.i(TAG, "Foreground Service Started.") startForeground(NOTIFICATION_ID, createNotification()) intent?.let { when (it.action) { "kill" -> killService() } } // Wake locks and misc tasks from here : return if (isServiceStarted) { // Service Already Started START_STICKY } else { isServiceStarted = true Log.i(TAG, "Starting the foreground service task") wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply { acquire() } } START_STICKY } } /** * Function To Download All Tracks Available in a List **/ fun downloadAllTracks(trackList: List) { downloadService.reviveIfClosed() trackList.size.also { size -> total += size isSingleDownload = (size == 1) updateNotification() } for (track in trackList) { trackStatusFlowMap[track.title] = DownloadStatus.Queued lifecycleScope.launch { downloadService.executeSuspending { fetcher.findBestDownloadLink(track).fold( success = { res -> enqueueDownload(res.first, track.apply { audioQuality = res.second }) }, failure = { error -> failed++ updateNotification() trackStatusFlowMap[track.title] = DownloadStatus.Failed(error) } ) } } } } private suspend fun enqueueDownload(url: String, track: TrackDetails) { // Initiating Download addToNotification(Message(track.title, DownloadStatus.Downloading())) trackStatusFlowMap[track.title] = DownloadStatus.Downloading() // Enqueueing Download httpClient.downloadFile(url).collect { when (it) { is DownloadResult.Error -> { logger.d(TAG) { it.message } failed++ trackStatusFlowMap[track.title] = DownloadStatus.Failed(it.cause ?: Exception(it.message)) removeFromNotification(Message(track.title, DownloadStatus.Downloading())) } is DownloadResult.Progress -> { trackStatusFlowMap[track.title] = DownloadStatus.Downloading(it.progress) // updateProgressInNotification(Message(track.title,DownloadStatus.Downloading(it.progress))) } is DownloadResult.Success -> { coroutineScope { SuspendableEvent { // Save File and Embed Metadata val job = launch(Dispatchers.Default) { dir.saveFileWithMetadata( it.byteArray, track ).fold( failure = { throwable -> throwable.printStackTrace() throw throwable }, success = {} ) } // Send Converting Status trackStatusFlowMap[track.title] = DownloadStatus.Converting addToNotification(Message(track.title, DownloadStatus.Converting)) // All Processing Completed for this Track job.invokeOnCompletion { throwable -> if (throwable != null /*&& throwable !is CancellationException*/) { // handle error failed++ trackStatusFlowMap[track.title] = DownloadStatus.Failed(throwable) removeFromNotification( Message( track.title, DownloadStatus.Converting ) ) return@invokeOnCompletion } converted++ trackStatusFlowMap[track.title] = DownloadStatus.Downloaded removeFromNotification( Message( track.title, DownloadStatus.Converting ) ) } logger.d(TAG) { "${track.title} Download Completed" } downloaded++ }.failure { error -> error.printStackTrace() // Download Failed failed++ trackStatusFlowMap[track.title] = DownloadStatus.Failed(error) } removeFromNotification(Message(track.title, DownloadStatus.Downloading())) } } } } } private fun releaseWakeLock() { logger.d(TAG) { "Releasing Wake Lock" } try { wakeLock?.let { if (it.isHeld) { it.release() } } } catch (e: Exception) { logger.d(TAG) { "Service stopped without being started: ${e.message}" } } isServiceStarted = false } @Suppress("SameParameterValue") private fun createNotificationChannel(channelId: String, channelName: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT ) channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager service.createNotificationChannel(channel) } } /* * Time To Wrap UP * - `Clean Up` and `Stop this Foreground Service` * */ private fun killService() { lifecycleScope.launch { logger.d(TAG) { "Killing Self" } resetVar() messageList = messageList.getEmpty().apply { set(index = 0, Message(Strings.cleaningAndExiting(), DownloadStatus.NotDownloaded)) } downloadService.close() updateNotification() trackStatusFlowMap.apply { clear() scope = null } cleanFiles(File(dir.defaultDir())) // cleanFiles(File(dir.imageCacheDir())) messageList = messageList.getEmpty() releaseWakeLock() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { stopForeground(true) stopSelf() } else { stopSelf() } } } private fun resetVar() { total = 0 downloaded = 0 failed = 0 converted = 0 } private fun createNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID).run { setSmallIcon(R.drawable.ic_download_arrow) setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed") setSilent(true) setProgress(total, failed + converted, false) setStyle( NotificationCompat.InboxStyle().run { addLine(messageList[messageList.size - 1].asString()) addLine(messageList[messageList.size - 2].asString()) addLine(messageList[messageList.size - 3].asString()) addLine(messageList[messageList.size - 4].asString()) addLine(messageList[messageList.size - 5].asString()) } ) addAction(R.drawable.ic_round_cancel_24, Strings.exit(), cancelIntent) build() } private fun addToNotification(message: Message) { synchronized(messageList) { messageList.add(message) } updateNotification() } private fun removeFromNotification(message: Message) { synchronized(messageList) { messageList.removeAll { it.title == message.title } } updateNotification() } @Suppress("unused") private fun updateProgressInNotification(message: Message) { synchronized(messageList) { val index = messageList.indexOfFirst { it.title == message.title } messageList[index] = message } updateNotification() } // Update Notification only if Service is Still Active private fun updateNotification() { if (!downloadService.isClosed.value) { val mNotificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager mNotificationManager.notify(NOTIFICATION_ID, createNotification()) } else { // Service is Inactive so clear residual status resetVar() } } override fun onDestroy() { super.onDestroy() if (isFinished) { killService() } } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) if (isFinished) { killService() } } companion object { private const val TAG: String = "Foreground Service" private const val CHANNEL_ID = "ForegroundDownloaderService" private const val NOTIFICATION_ID = 101 } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/service/Message.kt ================================================ package com.shabinder.spotiflyer.service import com.shabinder.common.models.DownloadStatus import com.shabinder.common.translations.Strings typealias Message = Pair val Message.title: String get() = first val Message.downloadStatus: DownloadStatus get() = second val Message.progress: String get() = when (downloadStatus) { is DownloadStatus.Downloading -> "-> ${(downloadStatus as DownloadStatus.Downloading).progress}%" is DownloadStatus.Converting -> "-> 100%" is DownloadStatus.Downloaded -> "-> ${Strings.downloadDone}" is DownloadStatus.Failed -> "-> ${Strings.failed()}" is DownloadStatus.Queued -> "-> ${Strings.queued()}" is DownloadStatus.NotDownloaded -> "" } val emptyMessage = Message("", DownloadStatus.NotDownloaded) // `Progress` is not being shown because we don't get get consistent Updates from Download Fun , // all Progress data is emitted all together from fun fun Message.asString(): String { val statusString = when (downloadStatus) { is DownloadStatus.Downloading -> Strings.downloading() is DownloadStatus.Converting -> Strings.processing() else -> "" } return "$statusString $title ${""/*progress*/}".trim() } fun List.getEmpty(): MutableList = java.util.Collections.synchronizedList(MutableList(size) { emptyMessage }) ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/service/TrackStatusFlowMap.kt ================================================ package com.shabinder.spotiflyer.service import com.shabinder.common.models.DownloadStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch class TrackStatusFlowMap( val statusFlow: MutableSharedFlow>, var scope: CoroutineScope? ) : HashMap() { override fun put(key: String, value: DownloadStatus): DownloadStatus? { synchronized(this) { val res = super.put(key, value) emitValue(this) return res } } override fun clear() { synchronized(this) { // Reset Statuses this.forEach { (title, status) -> if(status !is DownloadStatus.Failed && status !is DownloadStatus.Downloaded) { super.put(title,DownloadStatus.NotDownloaded) } } emitValue(this) super.clear() emitValue(this) } } private fun emitValue(map: HashMap) { scope?.launch { statusFlow.emit(map) } } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/service/Utils.kt ================================================ package com.shabinder.spotiflyer.service import android.util.Log import java.io.File /** * Cleaning All Residual Files except Mp3 Files **/ fun cleanFiles(dir: File) { try { Log.d("File Cleaning", "Starting Cleaning in ${dir.path} ") val fList = dir.listFiles() fList?.let { for (file in fList) { if (file.isDirectory) { cleanFiles(file) } else if (file.isFile) { val filePath = file.path.toString() if (filePath.substringAfterLast(".") != "mp3" || filePath.isTempFile()) { Log.d("Files Cleaning", "Cleaning $filePath") file.delete() } } } } } catch (e: Exception) { e.printStackTrace() } } private fun String.isTempFile(): Boolean { return substringBeforeLast(".").takeLast(5) == ".temp" } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/ui/AnalyticsDialog.kt ================================================ package com.shabinder.spotiflyer.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.AlertDialog import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Insights import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.configurations.SpotiFlyerShapes import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorPrimary @ExperimentalAnimationApi @Composable fun AnalyticsDialog( isVisible: Boolean, enableAnalytics: () -> Unit, dismissDialog: () -> Unit, ) { // Analytics Permission Dialog AnimatedVisibility(isVisible) { AlertDialog( onDismissRequest = dismissDialog, title = { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly) { Icon(Icons.Rounded.Insights, Strings.analytics(), Modifier.size(42.dp)) Spacer(Modifier.padding(horizontal = 8.dp)) Text(Strings.grantAnalytics(), style = SpotiFlyerTypography.h5, textAlign = TextAlign.Center) } }, backgroundColor = Color.DarkGray, buttons = { Column { OutlinedButton( onClick = dismissDialog, Modifier.padding(horizontal = 8.dp).fillMaxWidth() .background(Color.DarkGray, shape = SpotiFlyerShapes.medium) .padding(horizontal = 8.dp), shape = SpotiFlyerShapes.medium, colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF303030)) ) { Text(Strings.no(), color = colorPrimary, fontSize = 18.sp, textAlign = TextAlign.Center) } Spacer(Modifier.padding(vertical = 4.dp)) TextButton( onClick = { dismissDialog() enableAnalytics() }, Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp).fillMaxWidth() .background(colorPrimary, shape = SpotiFlyerShapes.medium) .padding(horizontal = 8.dp), shape = SpotiFlyerShapes.medium ) { Text(Strings.yes(), color = Color.Black, fontSize = 18.sp, textAlign = TextAlign.Center) } } }, text = { Text(Strings.analyticsDescription(), style = SpotiFlyerTypography.body2, textAlign = TextAlign.Center) }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) ) } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/ui/NetworkDialog.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.spotiflyer.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.AlertDialog import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.configurations.SpotiFlyerShapes import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorOffWhite import kotlinx.coroutines.delay @ExperimentalAnimationApi @Composable fun NetworkDialog( networkAvailability: State ) { var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(2600) visible = true } AnimatedVisibility( networkAvailability.value == false && visible ) { AlertDialog( onDismissRequest = {}, buttons = { /* TextButton({ //Retry Network Connection }, Modifier.padding(bottom = 16.dp,start = 16.dp,end = 16.dp).fillMaxWidth().background(Color(0xFFFC5C7D),shape = RoundedCornerShape(size = 8.dp)).padding(horizontal = 8.dp), ){ Text("Retry",color = Color.Black,fontSize = 18.sp,textAlign = TextAlign.Center) Icon(Icons.Rounded.SyncProblem,"Check Network Connection Again") } */ }, title = { Text( Strings.noInternetConnection(), style = SpotiFlyerTypography.h5, textAlign = TextAlign.Center ) }, backgroundColor = Color.DarkGray, text = { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Spacer(modifier = Modifier.padding(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) ) { Image( Icons.Rounded.CloudOff, Strings.noInternetConnection(), Modifier.size(42.dp), colorFilter = ColorFilter.tint( colorOffWhite ) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Text( text = Strings.checkInternetConnection(), style = SpotiFlyerTypography.subtitle1 ) } } }, shape = SpotiFlyerShapes.medium ) } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/ui/PermissionDialog.kt ================================================ package com.shabinder.spotiflyer.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.AlertDialog import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.SdStorage import androidx.compose.material.icons.rounded.SystemSecurityUpdate import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.configurations.SpotiFlyerShapes import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorPrimary import kotlinx.coroutines.delay @ExperimentalAnimationApi @Composable fun PermissionDialog( permissionGranted: Boolean, requestStoragePermission: () -> Unit, disableDozeMode: () -> Unit, ) { var askForPermission by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(2600) askForPermission = true } AnimatedVisibility( askForPermission && !permissionGranted ) { AlertDialog( onDismissRequest = {}, buttons = { TextButton( { requestStoragePermission() disableDozeMode() }, Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp).fillMaxWidth() .background(colorPrimary, shape = SpotiFlyerShapes.medium) .padding(horizontal = 8.dp), ) { Text(Strings.grantPermissions(), color = Color.Black, fontSize = 18.sp, textAlign = TextAlign.Center) } }, title = { Text(Strings.requiredPermissions(), style = SpotiFlyerTypography.h5, textAlign = TextAlign.Center) }, backgroundColor = Color.DarkGray, text = { Column { Spacer(modifier = Modifier.padding(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) ) { Icon(Icons.Rounded.SdStorage, Strings.storagePermission()) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = Strings.storagePermission(), style = SpotiFlyerTypography.h6.copy(fontWeight = FontWeight.SemiBold) ) Text( text = Strings.storagePermissionReason(), style = SpotiFlyerTypography.subtitle2, ) } } Row( modifier = Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.SystemSecurityUpdate, Strings.backgroundRunning()) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = Strings.backgroundRunning(), style = SpotiFlyerTypography.h6.copy(fontWeight = FontWeight.SemiBold) ) Text( text = Strings.backgroundRunningReason(), style = SpotiFlyerTypography.subtitle2, ) } } /*Row( modifier = Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.Insights,"Analytics") Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = "Analytics", style = SpotiFlyerTypography.h6.copy(fontWeight = FontWeight.SemiBold) ) Text( text = "Share Analytics Data (optional) with App Devs (Self-Hosted), It will never be used/shared/sold to any third party service.", style = SpotiFlyerTypography.subtitle2, ) } }*/ } } ) } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/ui/SplashScreenActivity.kt ================================================ package com.shabinder.spotiflyer.ui import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.ExperimentalAnimationApi import androidx.lifecycle.lifecycleScope import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.di.ApplicationInit import com.shabinder.spotiflyer.MainActivity import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.scope.ScopeActivity import org.koin.core.parameter.parametersOf class SplashScreenActivity : AppCompatActivity() { private val applicationInit: ApplicationInit by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) applicationInit.init() lifecycleScope.launch { delay(SPLASH_DELAY) startActivity(Intent(this@SplashScreenActivity, MainActivity::class.java)) finish() } } companion object { private const val SPLASH_DELAY = 2000L } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/SignatureVerification.kt ================================================ package com.shabinder.spotiflyer.utils import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.util.Base64 import com.shabinder.spotiflyer.App import java.security.MessageDigest @Suppress("DEPRECATION") @SuppressLint("PackageManagerGetSignatures") fun checkAppSignature(context: Context): Boolean { try { val packageInfo: PackageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES) for (signature in packageInfo.signatures) { val md: MessageDigest = MessageDigest.getInstance("SHA") md.update(signature.toByteArray()) val currentSignature: String = Base64.encodeToString(md.digest(), Base64.DEFAULT) // Log.d("REMOVE_ME", "Include this string as a value for SIGNATURE:$currentSignature") // Log.d("REMOVE_ME HEX", "Include this string as a value for SIGNATURE Hex:${currentSignature.toByteArray().toHEX()}") // compare signatures if (App.SIGNATURE_HEX == currentSignature.toByteArray().toHEX()) { return true } } } catch (e: Exception) { e.printStackTrace() // assumes an issue in checking signature., but we let the caller decide on what to do. } return false } fun ByteArray.toHEX(): String { val builder = StringBuilder() for (aByte in this) { builder.append(String.format("%02x", aByte)) } return builder.toString() } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/UtilFunctions.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.spotiflyer.utils import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.PowerManager import android.provider.Settings import androidx.core.content.ContextCompat import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.enums.Display import com.github.javiersantos.appupdater.enums.UpdateFrom fun Activity.checkIfLatestVersion() { AppUpdater(this, 0).run { setDisplay(Display.NOTIFICATION) showAppUpdated(false) // true:Show App is Updated Dialog setUpdateFrom(UpdateFrom.XML) setUpdateXML("https://raw.githubusercontent.com/Shabinder/SpotiFlyer/Compose/app/src/main/res/xml/app_update.xml") setCancelable(false) start() } } fun Activity.checkPermissions(): Boolean = ContextCompat .checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE ) == PackageManager.PERMISSION_GRANTED && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ContextCompat.checkSelfPermission( this, Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS ) == PackageManager.PERMISSION_GRANTED } else true @SuppressLint("BatteryLife", "ObsoleteSdkInt") fun Activity.disableDozeMode(requestCode: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = this.getSystemService(Context.POWER_SERVICE) as PowerManager val isIgnoringBatteryOptimizations = pm.isIgnoringBatteryOptimizations(packageName) if (!isIgnoringBatteryOptimizations) { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS data = Uri.parse("package:$packageName") } startActivityForResult(intent, requestCode) } } } @SuppressLint("ObsoleteSdkInt") fun Activity.requestStoragePermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { this.requestPermissions( arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 786 ) } } /* fun Activity.requestBroaderStoragePermission() { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) startActivity(intent) }*/ ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/autoclear/AutoClear.kt ================================================ package com.shabinder.spotiflyer.utils.autoclear import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.shabinder.spotiflyer.utils.autoclear.AutoClear.Companion.TRIGGER import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleCreateAndDestroyObserver import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleResumeAndPauseObserver import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleStartAndStopObserver import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty class AutoClear( lifecycle: Lifecycle, private val initializer: (() -> T)?, private val trigger: TRIGGER = TRIGGER.ON_CREATE, ) : ReadWriteProperty { companion object { enum class TRIGGER { ON_CREATE, ON_START, ON_RESUME } } private var _value: T? get() = observer.value set(value) { observer.value = value } val value: T get() = _value ?: initializer?.invoke() ?: throw IllegalStateException("The value has not yet been set or no default initializer provided") fun getOrNull(): T? = _value private val observer: LifecycleAutoInitializer by lazy { when (trigger) { TRIGGER.ON_CREATE -> LifecycleCreateAndDestroyObserver(initializer) TRIGGER.ON_START -> LifecycleStartAndStopObserver(initializer) TRIGGER.ON_RESUME -> LifecycleResumeAndPauseObserver(initializer) } } init { lifecycle.addObserver(observer) } override fun getValue(thisRef: LifecycleOwner, property: KProperty<*>): T { if (_value != null) { return value } // If for Some Reason Initializer is not invoked even after Initialisation, invoke it after checking state if (thisRef.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { return initializer?.invoke().also { _value = it } ?: throw IllegalStateException("The value has not yet been set or no default initializer provided") } else { throw IllegalStateException("Activity might have been destroyed or not initialized yet") } } override fun setValue(thisRef: LifecycleOwner, property: KProperty<*>, value: T?) { this._value = value } fun reset() { this._value = null } } fun LifecycleOwner.autoClear( trigger: TRIGGER = TRIGGER.ON_CREATE, initializer: () -> T ): AutoClear { return AutoClear(this.lifecycle, initializer, trigger) } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/autoclear/AutoClearFragment.kt ================================================ package com.shabinder.spotiflyer.utils.autoclear import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty class AutoClearFragment( fragment: Fragment, private val initializer: (() -> T)? ) : ReadWriteProperty { private var _value: T? = null init { fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { val viewLifecycleOwnerObserver = Observer { viewLifecycleOwner -> viewLifecycleOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { _value = null } }) } override fun onCreate(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerObserver) } override fun onDestroy(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerObserver) } } ) } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { val value = _value if (value != null) { return value } if (thisRef.viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { return initializer?.invoke().also { _value = it } ?: throw IllegalStateException("The value has not yet been set or no default initializer provided") } else { throw IllegalStateException("Fragment might have been destroyed or not initialized yet") } } override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T?) { _value = value } } fun Fragment.autoClear(initializer: () -> T): AutoClearFragment { return AutoClearFragment(this, initializer) } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/autoclear/LifecycleAutoInitializer.kt ================================================ package com.shabinder.spotiflyer.utils.autoclear import androidx.lifecycle.DefaultLifecycleObserver interface LifecycleAutoInitializer : DefaultLifecycleObserver { var value: T? } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/autoclear/lifecycleobservers/LifecycleCreateAndDestroyObserver.kt ================================================ package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers import androidx.lifecycle.LifecycleOwner import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer class LifecycleCreateAndDestroyObserver( private val initializer: (() -> T)? ) : LifecycleAutoInitializer { override var value: T? = null override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) value = initializer?.invoke() } override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) value = null } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/autoclear/lifecycleobservers/LifecycleResumeAndPauseObserver.kt ================================================ package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers import androidx.lifecycle.LifecycleOwner import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer class LifecycleResumeAndPauseObserver( private val initializer: (() -> T)? ) : LifecycleAutoInitializer { override var value: T? = null override fun onResume(owner: LifecycleOwner) { super.onResume(owner) value = initializer?.invoke() } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) value = null } } ================================================ FILE: android/src/main/java/com/shabinder/spotiflyer/utils/autoclear/lifecycleobservers/LifecycleStartAndStopObserver.kt ================================================ package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers import androidx.lifecycle.LifecycleOwner import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer class LifecycleStartAndStopObserver( private val initializer: (() -> T)? ) : LifecycleAutoInitializer { override var value: T? = null override fun onStart(owner: LifecycleOwner) { super.onStart(owner) value = initializer?.invoke() } override fun onStop(owner: LifecycleOwner) { super.onStop(owner) value = null } } ================================================ FILE: android/src/main/res/drawable/ic_splash.xml ================================================ ================================================ FILE: android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: android/src/main/res/values/colors.xml ================================================ #FC5C7D #CE1CFF #9AB3FF #FFFFFF #99FFFFFF #E6333333 #000000 #121212 #59C351 #FF9494 ================================================ FILE: android/src/main/res/values/ic_launcher_background.xml ================================================ #000000 ================================================ FILE: android/src/main/res/values/themes.xml ================================================ ================================================ FILE: build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { `kotlin-dsl` id("org.jlleitschuh.gradle.ktlint") id("org.jlleitschuh.gradle.ktlint-idea") } allprojects { repositories { google() mavenCentral() // mavenLocal() maven(url = "https://jitpack.io") maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") maven(url = "https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven") maven(url = "https://storage.googleapis.com/r8-releases/raw") } /*Fixes: Could not resolve org.nodejs:node*/ plugins.withType { configure { download = false } } tasks.withType>().configureEach { dependsOn(":common:data-models:generateI18n4kFiles") kotlinOptions { if (this is org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions) { jvmTarget = "1.8" } freeCompilerArgs = (freeCompilerArgs + listOf("-Xopt-in=kotlin.RequiresOptIn")) } } configurations.all { resolutionStrategy { eachDependency { if (requested.group == "org.jetbrains.kotlin") { @Suppress("UnstableApiUsage") useVersion(deps.kotlin.kotlinGradlePlugin.get().versionConstraint.requiredVersion) } } } } afterEvaluate { project.extensions.findByType() ?.let { kmpExt -> kmpExt.sourceSets.run { all { languageSettings.useExperimentalAnnotation("kotlin.RequiresOptIn") languageSettings.useExperimentalAnnotation("kotlinx.serialization.ExperimentalSerializationApi") } removeAll { it.name == "androidAndroidTestRelease" } } } } } ================================================ FILE: buildSrc/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { `kotlin-dsl` } group = "com.shabinder" repositories { google() // mavenLocal() mavenCentral() maven(url = "https://jitpack.io") maven(url = "https://plugins.gradle.org/m2/") maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") } dependencies { with(deps) { implementation(androidx.r8) implementation(androidx.gradle.plugin) implementation(kotlin.compose.gradle) implementation(ktlint.gradle) implementation(mosaic.gradle) implementation(kotlin.kotlinGradlePlugin) implementation(sqldelight.gradle.plugin) implementation(i18n4k.gradle.plugin) implementation(kotlin.serialization) } } kotlin { // Add Deps to compilation, so it will become available in main project sourceSets.getByName("main").kotlin.srcDir("buildSrc/src/main/kotlin") } ================================================ FILE: buildSrc/buildSrc/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { mavenCentral() } ================================================ FILE: buildSrc/buildSrc/src/main/kotlin/Versions.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:Suppress("MayBeConstant", "SpellCheckingInspection", "UnstableApiUsage") import org.gradle.api.Project import org.gradle.api.artifacts.ExternalModuleDependency import org.gradle.api.artifacts.VersionCatalog import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.kotlin.dsl.accessors.runtime.addDependencyTo import org.gradle.kotlin.dsl.getByType object Versions { // App's Version (To be bumped at each update) const val versionName = "3.6.4" const val versionCode = 32 // Android const val minSdkVersion = 21 const val compileSdkVersion = 31 const val targetSdkVersion = 29 } object HostOS { // Host OS Properties private val hostOs = System.getProperty("os.name") val isMingwX64 = hostOs.startsWith("Windows", true) val isMac = hostOs.startsWith("Mac", true) val isLinux = hostOs.startsWith("Linux", true) } val Project.Deps: VersionCatalog get() = project.extensions.getByType().named("deps") val VersionCatalog.ktorBundle get() = findBundle("ktor").get() val VersionCatalog.statelyBundle get() = findBundle("stately").get() val VersionCatalog.androidXLifecycleBundle get() = findBundle("androidx-lifecycle").get() val VersionCatalog.androidXCommonBundle get() = findBundle("androidx-common").get() val VersionCatalog.kotlinTestBundle get() = findBundle("kotlin-test").get() val VersionCatalog.sqldelightBundle get() = findBundle("sqldelight").get() val VersionCatalog.mviKotlinBundle get() = findBundle("mviKotlin").get() val VersionCatalog.essentyBundle get() = findBundle("essenty").get() val VersionCatalog.koinAndroidBundle get() = findBundle("koin-android").get() val VersionCatalog.kotlinJSWrappers get() = findBundle("kotlin-js-wrappers").get() val VersionCatalog.kotlinJunitTest get() = findDependency("kotlin-kotlinTestJunit").get() val VersionCatalog.kotlinJSTest get() = findDependency("kotlin-kotlinTestJs").get() val VersionCatalog.kermit get() = findDependency("kermit").get() val VersionCatalog.decompose get() = findDependency("decompose-dep").get() val VersionCatalog.decomposeComposeExt get() = findDependency("decompose-extensions-compose").get() val VersionCatalog.jaffree get() = findDependency("jaffree").get() val VersionCatalog.ktlintGradle get() = findDependency("ktlint-gradle").get() val VersionCatalog.androidGradle get() = findDependency("androidx-gradle-plugin").get() val VersionCatalog.mosaicGradle get() = findDependency("mosaic-gradle").get() val VersionCatalog.kotlinComposeGradle get() = findDependency("kotlin-compose-gradle").get() val VersionCatalog.kotlinGradle get() = findDependency("kotlin-kotlinGradlePlugin").get() val VersionCatalog.i18n4kGradle get() = findDependency("i18n4k-gradle-plugin").get() val VersionCatalog.sqlDelightGradle get() = findDependency("sqldelight-gradle-plugin").get() val VersionCatalog.kotlinSerializationPlugin get() = findDependency("kotlin-serialization").get() val VersionCatalog.koinCore get() = findDependency("koin-core").get() val VersionCatalog.kotlinCoroutines get() = findDependency("kotlin-coroutines").get() val VersionCatalog.kotlinxSerialization get() = findDependency("kotlinx-serialization-json").get() val VersionCatalog.ktorClientIOS get() = findDependency("ktor-client-ios").get() val VersionCatalog.ktorClientAndroid get() = findDependency("ktor-client-android").get() val VersionCatalog.ktorClientAndroidOkHttp get() = findDependency("ktor-client-okhttp").get() val VersionCatalog.ktorClientApache get() = findDependency("ktor-client-apache").get() val VersionCatalog.ktorClientJS get() = findDependency("ktor-client-js").get() val VersionCatalog.ktorClientCIO get() = findDependency("ktor-client-cio").get() val VersionCatalog.slf4j get() = findDependency("slf4j-simple").get() val VersionCatalog.sqlDelightJDBC get() = findDependency("sqlite-jdbc-driver").get() val VersionCatalog.sqlDelightNative get() = findDependency("sqldelight-native-driver").get() val VersionCatalog.sqlDelightAndroid get() = findDependency("sqldelight-android-driver").get() val VersionCatalog.sqlDelightDriver get() = findDependency("sqldelight-driver").get() ================================================ FILE: buildSrc/deps.versions.toml ================================================ [versions] kotlin = "1.6.10" androidCoroutines = "1.5.1" ktLint = "10.1.0" mosaic = "0.1.0" koin = "3.1.2" kermit = "0.1.9" mokoParcelize = "0.7.1" ktor = "1.6.7" kotlinxSerialization = "1.3.1" sqlDelight = "1.5.3" sqliteJdbcDriver = "3.34.0" slf4j = "1.7.31" i18n4k = "0.1.3" essenty = "0.2.2" multiplatformSettings = "0.7.7" decompose = "0.5.0" mviKotlin = "3.0.0-alpha03" accompanist = "0.22.0-rc" statelyVersion = "1.2.1" statelyIsoVersion = "1.2.1" androidxLifecycle = "2.4.0-alpha03" [libraries] kotlin-kotlinGradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" } kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } kotlin-kotlinTestCommon = { group = "org.jetbrains.kotlin", name = "kotlin-test-common", version.ref = "kotlin" } kotlin-kotlinTestJs = { group = "org.jetbrains.kotlin", name = "kotlin-test-js", version.ref = "kotlin" } kotlin-kotlinTestJunit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } kotlin-kotlinTestAnnotationsCommon = { group = "org.jetbrains.kotlin", name = "kotlin-test-annotations-common", version.ref = "kotlin" } kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version = "1.6.0" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-atomicfu = { group = "org.jetbrains.kotlinx", name = "atomicfu", version = "0.17.0" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.3.2" } kotlin-compose-gradle = { group = "org.jetbrains.compose", name = "compose-gradle-plugin", version = "1.0.1" } mosaic-gradle = { group = "com.jakewharton.mosaic", name = "mosaic-gradle-plugin", version.ref = "mosaic" } essenty-lifecycle = { group = "com.arkivanov.essenty", name = "lifecycle", version.ref = "essenty" } essenty-instanceKeeper = { group = "com.arkivanov.essenty", name = "instance-keeper", version.ref = "essenty" } decompose-dep = { group = "com.arkivanov.decompose", name = "decompose", version.ref = "decompose" } decompose-extensions-compose = { group = "com.arkivanov.decompose", name = "extensions-compose-jetbrains", version.ref = "decompose" } mviKotlin-dep = { group = "com.arkivanov.mvikotlin", name = "mvikotlin", version.ref = "mviKotlin" } mviKotlin-rx = { group = "com.arkivanov.mvikotlin", name = "rx", version.ref = "mviKotlin" } mviKotlin-main = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-main", version.ref = "mviKotlin" } mviKotlin-coroutines = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-extensions-coroutines", version.ref = "mviKotlin" } mviKotlin-keepers = { group = "com.arkivanov.mvikotlin", name = "keepers", version.ref = "mviKotlin" } mviKotlin-logging = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-logging", version.ref = "mviKotlin" } mviKotlin-timetravel = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-timetravel", version.ref = "mviKotlin" } mviKotlin-extensions-reaktive = { group = "com.arkivanov.mvikotlin", name = "mvikotlin-extensions-reaktive", version.ref = "mviKotlin" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-json = { group = "io.ktor", name = "ktor-client-json", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" } ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-curl = { group = "io.ktor", name = "ktor-client-curl", version.ref = "ktor" } ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache", version.ref = "ktor" } ktor-client-ios = { group = "io.ktor", name = "ktor-client-ios", version.ref = "ktor" } ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } ktor-client-js = { group = "io.ktor", name = "ktor-client-js", version.ref = "ktor" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } i18n4k-core = { group = "de.comahe.i18n4k", name = "i18n4k-core", version.ref = "i18n4k" } i18n4k-gradle-plugin = { group = "de.comahe.i18n4k", name = "i18n4k-gradle-plugin", version.ref = "i18n4k" } youtube-downloader = { group = "io.github.shabinder", name = "youtube-api-dl", version = "1.4" } fuzzy-wuzzy = { group = "io.github.shabinder", name = "fuzzywuzzy", version = "1.1" } mp3agic = { group = "com.mpatric", name = "mp3agic", version = "0.9.0" } kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } storage-chooser = { group = "com.github.shabinder", name = "storage-chooser", version = "2.0.4.45" } accompanist-inset = { group = "com.google.accompanist", name = "accompanist-insets", version.ref = "accompanist" } android-app-notifier = { group = "com.github.amitbd1508", name = "AppUpdater", version = "4.1.0" } moko-parcelize = { group = "dev.icerock.moko", name = "parcelize", version.ref = "mokoParcelize" } jaffree = { group = "com.github.kokorin.jaffree", name = "jaffree", version = "2021.08.16" } multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } countly-android = { group = "ly.count.android", name = "sdk", version = "20.11.8" } countly-desktop = { group = "ly.count.sdk", name = "java", version = "20.11.0" } stately-common = { group = "co.touchlab", name = "stately-common", version.ref = "statelyVersion" } stately-concurrency = { group = "co.touchlab", name = "stately-concurrency", version.ref = "statelyVersion" } stately-isolate = { group = "co.touchlab", name = "stately-isolate", version.ref = "statelyIsoVersion" } stately-iso-collections = { group = "co.touchlab", name = "stately-iso-collections", version.ref = "statelyIsoVersion" } sqldelight-runtime = { group = "com.squareup.sqldelight", name = "runtime", version.ref = "sqlDelight" } sqldelight-coroutines-extension = { group = "com.squareup.sqldelight", name = "coroutines-extensions", version.ref = "sqlDelight" } sqldelight-gradle-plugin = { group = "com.squareup.sqldelight", name = "gradle-plugin", version.ref = "sqlDelight" } sqldelight-driver = { group = "com.squareup.sqldelight", name = "sqlite-driver", version.ref = "sqlDelight" } sqldelight-android-driver = { group = "com.squareup.sqldelight", name = "android-driver", version.ref = "sqlDelight" } sqldelight-native-driver = { group = "com.squareup.sqldelight", name = "native-driver", version.ref = "sqlDelight" } sqlite-jdbc-driver = { group = "org.xerial", name = "sqlite-jdbc", version.ref = "sqliteJdbcDriver" } koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } koin-test = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } kotlin-js-wrappers-react = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-react", version = "17.0.2-pre.251-kotlin-1.5.31" } kotlin-js-wrappers-reactDom = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-react-dom", version = "17.0.2-pre.251-kotlin-1.5.31" } kotlin-js-wrappers-styled = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-styled", version = "5.3.1-pre.250-kotlin-1.5.31" } kotlin-js-wrappers-ext = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-extensions", version = "1.0.1-pre.251-kotlin-1.5.31" } androidx-activity = { group = "androidx.activity", name = "activity-compose", version = "1.3.1" } androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.6.0" } androidx-palette = { group = "androidx.palette", name = "palette-ktx", version = "1.0.0" } androidx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "androidCoroutines" } androidx-junit = { group = "androidx.test.ext", name = "junit", version = "1.1.2" } androidx-expresso = { group = "androidx.test.espresso", name = "espresso-core", version = "3.3.0" } androidx-r8 = { group = "com.android.tools", name = "r8", version = "3.3.28" } androidx-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version = "4.2.2" } androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "androidxLifecycle" } androidx-lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common-java8", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } androidx-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version = "1.1.5" } leak-canary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version = "2.7" } junit = { group = "junit", name = "junit", version = "4.13.2" } ktlint-gradle = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktLint" } [bundles] ktor = ["ktor-client-core","ktor-client-json","ktor-client-auth","ktor-client-logging","ktor-client-serialization"] stately = ["stately-common","stately-concurrency","stately-isolate","stately-iso-collections"] androidx-lifecycle = ["androidx-lifecycle-service","androidx-lifecycle-common","androidx-lifecycle-runtime"] androidx-common = ["androidx-activity","androidx-core"] kotlin-test = ["kotlin-kotlinTestCommon","kotlin-kotlinTestAnnotationsCommon"] sqldelight = ["sqldelight-runtime","sqldelight-coroutines-extension","sqldelight-driver"] mviKotlin = ["mviKotlin-dep","mviKotlin-main","mviKotlin-coroutines","mviKotlin-logging","mviKotlin-timetravel"] kotlinCommon = ["kotlin-coroutines", "kotlin-serialization", "kotlinx-serialization-json", "kotlinx-atomicfu"] essenty = ["essenty-lifecycle","essenty-instanceKeeper"] koin-android = ["koin-androidx-compose","koin-android"] kotlin-js-wrappers = ["kotlin-js-wrappers-react","kotlin-js-wrappers-reactDom","kotlin-js-wrappers-styled","kotlin-js-wrappers-ext"] ================================================ FILE: buildSrc/settings.gradle.kts ================================================ enableFeaturePreview("VERSION_CATALOGS") dependencyResolutionManagement { @Suppress("UnstableApiUsage") versionCatalogs { create("deps") { from(files("deps.versions.toml")) } } } rootProject.name = "spotiflyer-build" ================================================ FILE: buildSrc/src/main/kotlin/android-setup.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") /* * * Copyright (c) 2021 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 . */ plugins { id("com.android.library") id("ktlint-setup") id("compiler-args") } android { compileSdk = Versions.compileSdkVersion defaultConfig { minSdk = Versions.minSdkVersion targetSdk = Versions.targetSdkVersion } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } sourceSets { named("main") { manifest.srcFile("src/androidMain/AndroidManifest.xml") res.srcDirs("src/androidMain/res") } } } ================================================ FILE: buildSrc/src/main/kotlin/compiler-args.gradle.kts ================================================ plugins { kotlin("multiplatform") } kotlin { sourceSets { all { languageSettings.apply { optIn("kotlin.RequiresOptIn") optIn("kotlin.Experimental") optIn("kotlin.time.ExperimentalTime") optIn("kotlinx.serialization.ExperimentalSerializationApi") } } } } ================================================ FILE: buildSrc/src/main/kotlin/ktlint-setup.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("org.jlleitschuh.gradle.ktlint") id("org.jlleitschuh.gradle.ktlint-idea") } ktlint { outputToConsole.set(true) ignoreFailures.set(true) coloredOutput.set(true) verbose.set(true) disabledRules.set(setOf("filename,no-wildcard-imports")) filter { exclude("**/generated/**") exclude("**/build/**") } } ================================================ FILE: buildSrc/src/main/kotlin/multiplatform-compose-setup.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("com.android.library") id("kotlin-multiplatform") id("org.jetbrains.compose") id("kotlin-parcelize") id("ktlint-setup") id("compiler-args") } kotlin { jvm("desktop") android() sourceSets { all { languageSettings.apply { optIn("androidx.compose.animation") } } named("commonMain") { dependencies { implementation(compose.ui) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) implementation(compose.animation) implementation(Deps.kotlinCoroutines) implementation(Deps.decompose) } } named("androidMain") { dependencies { implementation(Deps.androidXCommonBundle) } } named("desktopMain") { dependencies { implementation(compose.desktop.common) } } } } ================================================ FILE: buildSrc/src/main/kotlin/multiplatform-setup-test.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("com.android.library") id("kotlin-multiplatform") id("compiler-args") } kotlin { /*IOS Target Can be only built on Mac*/ if(HostOS.isMac){ val sdkName: String? = System.getenv("SDK_NAME") val isiOSDevice = sdkName.orEmpty().startsWith("iphoneos") if (isiOSDevice) { iosArm64("ios") } else { iosX64("ios") } } jvm("desktop") android() js(BOTH) { browser() // nodejs() } sourceSets { named("commonTest") { dependencies { implementation(Deps.kotlinTestBundle) } } named("androidTest") { dependencies { implementation(Deps.kotlinJunitTest) } } named("desktopTest") { dependencies { implementation(Deps.kotlinJunitTest) } } named("jsTest") { dependencies { implementation(Deps.kotlinJSTest) } } } } ================================================ FILE: buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("kotlin-multiplatform") id("org.jetbrains.compose") id("ktlint-setup") id("kotlin-parcelize") id("compiler-args") } kotlin { /*IOS Target Can be only built on Mac*/ if (HostOS.isMac) { val sdkName: String? = System.getenv("SDK_NAME") val isiOSDevice = sdkName.orEmpty().startsWith("iphoneos") if (isiOSDevice) { iosArm64("ios") } else { iosX64("ios") {} } } jvm("desktop") android() js(BOTH) { browser { commonWebpackConfig { cssSupport.enabled = true } } // nodejs() } sourceSets { named("commonMain") { dependencies { implementation(Deps.ktorBundle) implementation(Deps.kotlinxSerialization) implementation(Deps.kotlinCoroutines) implementation(Deps.mviKotlinBundle) implementation(Deps.decompose) implementation(Deps.koinCore) } } named("androidMain") { dependencies { implementation(compose.runtime) implementation(compose.material) implementation(compose.foundation) implementation(compose.materialIconsExtended) implementation(Deps.androidXCommonBundle) implementation(Deps.decomposeComposeExt) implementation(Deps.ktorClientAndroidOkHttp) implementation(Deps.koinAndroidBundle) } } named("desktopMain") { dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) implementation(compose.desktop.common) implementation(compose.materialIconsExtended) implementation(Deps.decomposeComposeExt) implementation(Deps.ktorClientApache) implementation(Deps.slf4j) } } named("jsMain") { dependencies { implementation(Deps.ktorClientJS) } } if (HostOS.isMac) { named("iosMain") { dependencies { implementation(Deps.ktorClientIOS) } } } } } ================================================ FILE: common/compose/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ import org.jetbrains.compose.compose plugins { id("android-setup") id("multiplatform-compose-setup") } kotlin { sourceSets { all { languageSettings.apply { useExperimentalAnnotation("androidx.compose.animation") } } commonMain { dependencies { implementation(compose.material) implementation(compose.materialIconsExtended) implementation(project(":common:root")) implementation(project(":common:main")) implementation(project(":common:list")) implementation(project(":common:preference")) implementation(project(":common:core-components")) implementation(project(":common:database")) implementation(project(":common:data-models")) implementation(project(":common:dependency-injection")) implementation(deps.decompose.extensions.compose) } } } } ================================================ FILE: common/compose/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidDialog.kt ================================================ package com.shabinder.common.uikit import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable @OptIn(ExperimentalAnimationApi::class) @Composable actual fun Dialog( isVisible: Boolean, onDismiss: () -> Unit, content: @Composable () -> Unit ) { AnimatedVisibility(isVisible) { androidx.compose.ui.window.Dialog(onDismiss) { content() } } } ================================================ FILE: common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImageLoad.kt ================================================ package com.shabinder.common.uikit import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.models.dispatcherIO import kotlinx.coroutines.withContext @Composable actual fun ImageLoad( link: String, loader: suspend () -> Picture, desc: String, modifier: Modifier // placeholder: ImageVector ) { var pic by remember(link) { mutableStateOf(null) } LaunchedEffect(link) { withContext(dispatcherIO) { pic = loader().image } } Crossfade(pic) { if (it == null) { Image(PlaceHolderImage(), desc, modifier, contentScale = ContentScale.Crop) } else Image(it, desc, modifier, contentScale = ContentScale.Crop) } } ================================================ FILE: common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:Suppress("FunctionName") package com.shabinder.common.uikit import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import com.shabinder.common.database.R import com.shabinder.common.translations.Strings import kotlinx.coroutines.flow.MutableStateFlow @Composable internal actual fun imageVectorResource(id: T): ImageVector = ImageVector.Companion.vectorResource(id as Int) @Composable actual fun DownloadImageTick() { Image( getCachedPainter(R.drawable.ic_tick), Strings.downloadDone() ) } @Composable actual fun DownloadImageError(modifier: Modifier) { Image( getCachedPainter(R.drawable.ic_error), Strings.downloadError(), modifier = modifier ) } @Composable actual fun DownloadImageArrow(modifier: Modifier) { Image( getCachedPainter(R.drawable.ic_arrow), Strings.downloadStart(), modifier ) } @Composable actual fun DownloadAllImage() = getCachedPainter(R.drawable.ic_download_arrow) @Composable actual fun ShareImage() = getCachedPainter(R.drawable.ic_share_open) @Composable actual fun PlaceHolderImage() = getCachedPainter(R.drawable.ic_song_placeholder) @Composable actual fun SpotiFlyerLogo() = getCachedPainter(R.drawable.ic_spotiflyer_logo) @Composable actual fun HeartIcon() = painterResource(R.drawable.ic_heart) @Composable actual fun SpotifyLogo() = getCachedPainter(R.drawable.ic_spotify_logo) @Composable actual fun SoundboundLogo() = getCachedPainter(R.drawable.soundbound_app_logo) @Composable actual fun SaavnLogo() = getCachedPainter(R.drawable.ic_jio_saavn_logo) @Composable actual fun SoundCloudLogo() = getCachedPainter(R.drawable.ic_soundcloud) @Composable actual fun GaanaLogo() = getCachedPainter(R.drawable.ic_gaana) @Composable actual fun YoutubeLogo() = getCachedPainter(R.drawable.ic_youtube) @Composable actual fun YoutubeMusicLogo() = getCachedPainter(R.drawable.ic_youtube_music_logo) @Composable actual fun GithubLogo() = getCachedPainter(R.drawable.ic_github) @Composable actual fun PaypalLogo() = painterResource(R.drawable.ic_paypal_logo) @Composable actual fun OpenCollectiveLogo() = painterResource(R.drawable.ic_opencollective_icon) @Composable actual fun RazorPay() = painterResource(R.drawable.ic_indian_rupee) @Composable actual fun Toast( flow: MutableStateFlow, duration: ToastDuration ) { // We Have Android's Implementation of Toast so its just Empty } ================================================ FILE: common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidScrollBars.kt ================================================ package com.shabinder.common.uikit import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp actual val MARGIN_SCROLLBAR: Dp = 0.dp actual interface ScrollbarAdapter @Composable actual fun rememberScrollbarAdapter( scrollState: ScrollState ): ScrollbarAdapter = object : ScrollbarAdapter {} @Composable actual fun rememberScrollbarAdapter( scrollState: LazyListState, itemCount: Int, averageItemSize: Dp ): ScrollbarAdapter = object : ScrollbarAdapter {} @Composable actual fun VerticalScrollbar( modifier: Modifier, adapter: ScrollbarAdapter ) { } ================================================ FILE: common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/configurations/AndroidTypography.kt ================================================ package com.shabinder.common.uikit.configurations import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import com.shabinder.common.models.R actual fun montserratFont() = FontFamily( Font(R.font.montserrat_light, FontWeight.Light), Font(R.font.montserrat_regular, FontWeight.Normal), Font(R.font.montserrat_medium, FontWeight.Medium), Font(R.font.montserrat_semibold, FontWeight.SemiBold), ) actual fun pristineFont(): FontFamily = FontFamily( Font(R.font.pristine_script, FontWeight.Bold) ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectDialog.kt ================================================ package com.shabinder.common.uikit import androidx.compose.runtime.Composable @Composable expect fun Dialog( isVisible: Boolean, onDismiss: () -> Unit, content: @Composable () -> Unit ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImageLoad.kt ================================================ package com.shabinder.common.uikit import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.shabinder.common.core_components.picture.Picture @Composable expect fun ImageLoad( link: String, loader: suspend () -> Picture, desc: String, modifier: Modifier // placeholder:Painter = PlaceHolderImage() ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:Suppress("FunctionName") package com.shabinder.common.uikit import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.shabinder.common.caching.Cache private val ImageCache = Cache.Builder.newBuilder() .maximumCacheSize(15).build() @Composable internal expect fun imageVectorResource(id: T): ImageVector @Composable fun getCachedPainter(key: K): Painter { return rememberVectorPainter( ImageCache.get(key) ?: imageVectorResource(key).also { ImageCache.put(key, it) }) } @Composable expect fun DownloadImageTick() @Composable expect fun DownloadAllImage(): Painter @Composable expect fun ShareImage(): Painter @Composable expect fun PlaceHolderImage(): Painter @Composable expect fun SpotiFlyerLogo(): Painter @Composable expect fun SpotifyLogo(): Painter @Composable expect fun SoundboundLogo(): Painter @Composable expect fun SaavnLogo(): Painter @Composable expect fun SoundCloudLogo(): Painter @Composable expect fun YoutubeLogo(): Painter @Composable expect fun GaanaLogo(): Painter @Composable expect fun YoutubeMusicLogo(): Painter @Composable expect fun GithubLogo(): Painter @Composable expect fun PaypalLogo(): Painter @Composable expect fun OpenCollectiveLogo(): Painter @Composable expect fun RazorPay(): Painter @Composable expect fun HeartIcon(): Painter @Composable expect fun DownloadImageError(modifier: Modifier) @Composable expect fun DownloadImageArrow(modifier: Modifier) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ScrollBars.kt ================================================ package com.shabinder.common.uikit import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp expect val MARGIN_SCROLLBAR: Dp expect interface ScrollbarAdapter @Composable expect fun rememberScrollbarAdapter( scrollState: LazyListState, itemCount: Int, averageItemSize: Dp ): ScrollbarAdapter @Composable expect fun rememberScrollbarAdapter( scrollState: ScrollState ): ScrollbarAdapter @Composable expect fun VerticalScrollbar( modifier: Modifier, adapter: ScrollbarAdapter ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Toast.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.uikit import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.MutableStateFlow enum class ToastDuration(val value: Int) { Short(1000), Long(2500) } @Composable expect fun Toast( flow: MutableStateFlow, duration: ToastDuration ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/configurations/Color.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.configurations import androidx.compose.material.darkColors import androidx.compose.ui.graphics.Color val colorPrimary = Color(0xFFFC5C7D) val colorPrimaryDark = Color(0xFFCE1CFF) val colorAccent = Color(0xFF9AB3FF) val colorSoundbound = Color(0xFF4b9fff) val colorAccentVariant = Color(0xFF3457D5) val colorRedError = Color(0xFFFF9494) val colorSuccessGreen = Color(0xFF59C351) val darkBackgroundColor = Color(0xFF000000) val colorOffWhite = Color(0xFFE7E7E7) val transparent = Color(0x00000000) val black = Color(0xFF000000) val lightGray = Color(0xFFCCCCCC) val SpotiFlyerColors = darkColors( primary = colorPrimary, onPrimary = black, primaryVariant = colorPrimaryDark, secondary = colorAccent, onSecondary = black, error = colorRedError, onError = black, surface = darkBackgroundColor, background = darkBackgroundColor, onSurface = lightGray, onBackground = lightGray ) /** * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the * given [alpha]. Useful for situations where semi-transparent colors are undesirable. */ /* @Composable fun Colors.compositedOnSurface(alpha: Float): Color { return onSurface.copy(alpha = alpha).compositeOver(surface) }*/ ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/configurations/Shape.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.configurations import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes import androidx.compose.ui.unit.dp val SpotiFlyerShapes = Shapes( small = RoundedCornerShape(percent = 50), medium = RoundedCornerShape(size = 8.dp), large = RoundedCornerShape(size = 0.dp) ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/configurations/Theme.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.configurations import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable @Composable fun SpotiFlyerTheme(content: @Composable () -> Unit) { MaterialTheme( colors = SpotiFlyerColors, typography = SpotiFlyerTypography, shapes = SpotiFlyerShapes, content = content ) } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/configurations/Type.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.configurations import androidx.compose.material.Typography import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp expect fun montserratFont(): FontFamily expect fun pristineFont(): FontFamily val SpotiFlyerTypography = Typography( h1 = TextStyle( fontFamily = montserratFont(), fontSize = 96.sp, fontWeight = FontWeight.Light, lineHeight = 117.sp, letterSpacing = (-1.5).sp ), h2 = TextStyle( fontFamily = montserratFont(), fontSize = 60.sp, fontWeight = FontWeight.Light, lineHeight = 73.sp, letterSpacing = (-0.5).sp ), h3 = TextStyle( fontFamily = montserratFont(), fontSize = 48.sp, fontWeight = FontWeight.Normal, lineHeight = 59.sp ), h4 = TextStyle( fontFamily = montserratFont(), fontSize = 30.sp, fontWeight = FontWeight.SemiBold, lineHeight = 37.sp ), h5 = TextStyle( fontFamily = montserratFont(), fontSize = 24.sp, fontWeight = FontWeight.SemiBold, lineHeight = 29.sp ), h6 = TextStyle( fontFamily = montserratFont(), fontSize = 18.sp, fontWeight = FontWeight.Medium, lineHeight = 26.sp, letterSpacing = 0.5.sp ), subtitle1 = TextStyle( fontFamily = montserratFont(), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, lineHeight = 20.sp, letterSpacing = 0.5.sp ), subtitle2 = TextStyle( fontFamily = montserratFont(), fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 17.sp, letterSpacing = 0.1.sp ), body1 = TextStyle( fontFamily = montserratFont(), fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, letterSpacing = 0.15.sp, ), body2 = TextStyle( fontFamily = montserratFont(), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, lineHeight = 20.sp, letterSpacing = 0.25.sp ), button = TextStyle( fontFamily = montserratFont(), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, letterSpacing = 1.25.sp ), caption = TextStyle( fontFamily = montserratFont(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, letterSpacing = 0.sp ), overline = TextStyle( fontFamily = montserratFont(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, letterSpacing = 1.sp ) ) val appNameStyle = TextStyle( fontFamily = pristineFont(), fontSize = 40.sp, fontWeight = FontWeight.SemiBold, lineHeight = 42.sp, letterSpacing = (1.5).sp, color = Color(0xFFECECEC) ) ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt ================================================ package com.shabinder.common.uikit.dialogs import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.shabinder.common.models.Actions import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.Dialog import com.shabinder.common.uikit.OpenCollectiveLogo import com.shabinder.common.uikit.PaypalLogo import com.shabinder.common.uikit.RazorPay import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorAccent typealias DonationDialogCallBacks = Triple internal typealias openAction = () -> Unit internal typealias dismissAction = () -> Unit private typealias snoozeAction = () -> Unit @OptIn(ExperimentalAnimationApi::class) @Composable fun DonationDialogComponent(onDismissExtra: () -> Unit): DonationDialogCallBacks { var isDonationDialogVisible by remember { mutableStateOf(false) } DonationDialog( isDonationDialogVisible, onSnooze = { isDonationDialogVisible = false }, onDismiss = { isDonationDialogVisible = false } ) val openDonationDialog = { isDonationDialogVisible = true } val snoozeDonationDialog = { isDonationDialogVisible = false } val dismissDonationDialog = { onDismissExtra() isDonationDialogVisible = false } return DonationDialogCallBacks(openDonationDialog, dismissDonationDialog, snoozeDonationDialog) } @ExperimentalAnimationApi @Composable fun DonationDialog( isVisible: Boolean, onDismiss: () -> Unit, onSnooze: () -> Unit ) { Dialog(isVisible, onDismiss) { Card( modifier = Modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color.Gray) // Gray ) { Column(Modifier.padding(16.dp)) { Text( Strings.supportUs(), style = SpotiFlyerTypography.h5, textAlign = TextAlign.Center, color = colorAccent, modifier = Modifier ) Spacer(modifier = Modifier.padding(vertical = 4.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable( onClick = { onDismiss() Actions.instance.openPlatform("", "https://opencollective.com/spotiflyer/donate") } ) .padding(vertical = 6.dp) ) { Icon(OpenCollectiveLogo(), "Open Collective Logo", Modifier.size(24.dp), tint = Color(0xFFCCCCCC)) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = "Open Collective", style = SpotiFlyerTypography.h6 ) Text( text = Strings.worldWideDonations(), style = SpotiFlyerTypography.subtitle2 ) } } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable( onClick = { onDismiss() Actions.instance.openPlatform("", "https://www.paypal.com/paypalme/shabinder") } ) .padding(vertical = 6.dp) ) { Icon(PaypalLogo(), "Paypal Logo", Modifier.size(24.dp), tint = Color(0xFFCCCCCC)) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = "Paypal", style = SpotiFlyerTypography.h6 ) Text( text = Strings.worldWideDonations(), style = SpotiFlyerTypography.subtitle2 ) } } Row( modifier = Modifier.fillMaxWidth().padding(top = 6.dp) .clickable( onClick = { onDismiss() Actions.instance.giveDonation() } ), verticalAlignment = Alignment.CenterVertically ) { Icon(RazorPay(), "Indian Rupee Logo", Modifier.size(24.dp), tint = Color(0xFFCCCCCC)) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = "RazorPay", style = SpotiFlyerTypography.h6 ) Text( text = "${Strings.indianDonations()} (UPI / PayTM / PhonePe / Cards).", style = SpotiFlyerTypography.subtitle2 ) } } Spacer(modifier = Modifier.padding(vertical = 16.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth() ) { OutlinedButton(onClick = onDismiss) { Text(Strings.dismiss()) } TextButton(onClick = onSnooze, colors = ButtonDefaults.buttonColors()) { Text(Strings.remindLater()) } } } } } } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/ErrorInfoDialog.kt ================================================ package com.shabinder.common.uikit.dialogs import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.shabinder.common.models.Actions import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.Dialog import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorAccent typealias ErrorInfoDialogCallBacks = Pair @Composable fun ErrorInfoDialog(error: Throwable): ErrorInfoDialogCallBacks { var isErrorDialogVisible by remember { mutableStateOf(false) } val onDismissDialog = { isErrorDialogVisible = false } val openErrorDialog = { isErrorDialogVisible = true } Dialog(isErrorDialogVisible, onDismissDialog) { Card( modifier = Modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color.Gray) // Gray ) { Column(Modifier.padding(16.dp)) { Text( Strings.whatWentWrong(), style = SpotiFlyerTypography.h5, textAlign = TextAlign.Center, color = colorAccent, modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth() ) Spacer(Modifier.padding(top = 4.dp)) Text(Strings.copyCodeInGithubIssue(), fontWeight = FontWeight.SemiBold) SelectionContainer(Modifier.padding(vertical = 8.dp).verticalScroll(rememberScrollState()).weight(1f)) { Text(error.stackTraceToString(), fontWeight = FontWeight.Light) } Row( Modifier.padding(top = 8.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { TextButton(onClick = onDismissDialog, colors = ButtonDefaults.buttonColors()) { Text(Strings.dismiss()) } TextButton(onClick = { Actions.instance.copyToClipboard(error.stackTraceToString()) }, colors = ButtonDefaults.buttonColors()) { Text(Strings.copyToClipboard()) } } } } } return ErrorInfoDialogCallBacks(openErrorDialog, onDismissDialog) } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/screens/SpotiFlyerListUi.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:Suppress("UNUSED_VARIABLE") package com.shabinder.common.uikit.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExtendedFloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Info import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.Actions import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.DownloadAllImage import com.shabinder.common.uikit.DownloadImageArrow import com.shabinder.common.uikit.DownloadImageError import com.shabinder.common.uikit.DownloadImageTick import com.shabinder.common.uikit.ImageLoad import com.shabinder.common.uikit.VerticalScrollbar import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.appNameStyle import com.shabinder.common.uikit.configurations.colorAccent import com.shabinder.common.uikit.configurations.colorPrimary import com.shabinder.common.uikit.configurations.lightGray import com.shabinder.common.uikit.dialogs.DonationDialogComponent import com.shabinder.common.uikit.dialogs.ErrorInfoDialog import com.shabinder.common.uikit.rememberScrollbarAdapter @Composable fun SpotiFlyerListContent( component: SpotiFlyerList, modifier: Modifier = Modifier ) { val model by component.model.subscribeAsState() LaunchedEffect(model.errorOccurred) { /*Handle if Any Exception Occurred*/ model.errorOccurred?.let { Actions.instance.showPopUpMessage(it.message ?: Strings.errorOccurred()) component.onBackPressed() } } Box(modifier = modifier.fillMaxSize()) { val result = model.queryResult if (result == null) { /* Loading Bar */ Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator() Spacer(modifier.padding(8.dp)) Text("${Strings.loading()}...", style = appNameStyle, color = colorPrimary) } } else { val listState = rememberLazyListState() LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp), content = { item { CoverImage(result.title, result.coverUrl, component::loadImage) } itemsIndexed(model.trackList) { _, item -> TrackCard( track = item, downloadTrack = { component.onDownloadClicked(item) }, loadImage = { component.loadImage(item.albumArtURL) } ) } }, state = listState, modifier = Modifier.fillMaxSize(), ) // Donation Dialog Visibility val (openDonationDialog, dismissDonationDialog, snoozeDonationDialog) = DonationDialogComponent { component.dismissDonationDialogSetOffset() } DownloadAllButton( onClick = { component.onDownloadAllClicked(model.trackList) // Check If we are allowed to show donation Dialog if (model.askForDonation) { // Show Donation Dialog openDonationDialog() } }, modifier = Modifier.padding(bottom = 24.dp).align(Alignment.BottomCenter) ) VerticalScrollbar( modifier = Modifier.padding(end = 2.dp).align(Alignment.CenterEnd).fillMaxHeight(), adapter = rememberScrollbarAdapter( scrollState = listState, itemCount = model.trackList.size, averageItemSize = 72.dp ) ) } } } @Composable fun TrackCard( track: TrackDetails, downloadTrack: () -> Unit, loadImage: suspend () -> Picture ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { ImageLoad( track.albumArtURL, { loadImage() }, Strings.albumArt(), modifier = Modifier .width(70.dp) .height(70.dp) .clip(MaterialTheme.shapes.medium) ) Column(modifier = Modifier.padding(horizontal = 8.dp).height(60.dp).weight(1f), verticalArrangement = Arrangement.SpaceEvenly) { Text(track.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = SpotiFlyerTypography.h6, color = colorAccent) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize() ) { Text("${track.artists.firstOrNull()}...", fontSize = 12.sp, maxLines = 1) Text("${track.durationSec / 60} ${Strings.minute()}, ${track.durationSec % 60} ${Strings.second()}", fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } } when (track.downloaded) { is DownloadStatus.Downloaded -> { DownloadImageTick() } is DownloadStatus.Queued -> { CircularProgressIndicator() } is DownloadStatus.Failed -> { val (openErrorDialog, dismissErrorDialog) = ErrorInfoDialog((track.downloaded as DownloadStatus.Failed).error) Icon( Icons.Rounded.Info, Strings.downloadError(), tint = lightGray, modifier = Modifier.size(42.dp).clickable { openErrorDialog() }.padding(start = 4.dp, end = 12.dp) ) DownloadImageError( Modifier.clickable( onClick = { downloadTrack() } ) ) } is DownloadStatus.Downloading -> { CircularProgressIndicator(progress = (track.downloaded as DownloadStatus.Downloading).progress.toFloat() / 100f) } is DownloadStatus.Converting -> { CircularProgressIndicator(progress = 100f, color = colorAccent) } is DownloadStatus.NotDownloaded -> { DownloadImageArrow( Modifier.clickable( onClick = { downloadTrack() } ) ) } } } } @Composable fun CoverImage( title: String, coverURL: String, loadImage: suspend (URL: String, isCover: Boolean) -> Picture, modifier: Modifier = Modifier, ) { Column( modifier.padding(vertical = 8.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { ImageLoad( coverURL, { loadImage(coverURL, true) }, Strings.coverImage(), modifier = Modifier .padding(12.dp) .width(190.dp) .height(210.dp) .clip(MaterialTheme.shapes.medium) ) Text( text = title, style = SpotiFlyerTypography.h5, maxLines = 2, textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis, ) } /*scope.launch { updateGradient(coverURL, ctx) }*/ } @Composable fun DownloadAllButton(onClick: () -> Unit, modifier: Modifier = Modifier) { ExtendedFloatingActionButton( text = { Text(Strings.downloadAll()) }, onClick = onClick, icon = { Icon(DownloadAllImage(), Strings.downloadAll() + Strings.button(), tint = Color(0xFF000000)) }, backgroundColor = colorAccent, modifier = modifier ) } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/screens/SpotiFlyerMainUi.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.uikit.screens import androidx.compose.animation.Crossfade import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Tab import androidx.compose.material.TabPosition import androidx.compose.material.TabRow import androidx.compose.material.TabRowDefaults.tabIndicatorOffset import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults.textFieldColors import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.rounded.CardGiftcard import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Insights import androidx.compose.material.icons.rounded.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType 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.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain.HomeCategory import com.shabinder.common.models.Actions import com.shabinder.common.models.DownloadRecord import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.GaanaLogo import com.shabinder.common.uikit.GithubLogo import com.shabinder.common.uikit.ImageLoad import com.shabinder.common.uikit.SaavnLogo import com.shabinder.common.uikit.ShareImage import com.shabinder.common.uikit.SoundCloudLogo import com.shabinder.common.uikit.SoundboundLogo import com.shabinder.common.uikit.SpotifyLogo import com.shabinder.common.uikit.VerticalScrollbar import com.shabinder.common.uikit.YoutubeLogo import com.shabinder.common.uikit.YoutubeMusicLogo import com.shabinder.common.uikit.configurations.SpotiFlyerShapes import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.black import com.shabinder.common.uikit.configurations.colorAccent import com.shabinder.common.uikit.configurations.colorOffWhite import com.shabinder.common.uikit.configurations.colorPrimary import com.shabinder.common.uikit.configurations.transparent import com.shabinder.common.uikit.dialogs.DonationDialogComponent import com.shabinder.common.uikit.rememberScrollbarAdapter @Composable fun SpotiFlyerMainContent(component: SpotiFlyerMain) { val model by component.model.subscribeAsState() val (openDonationDialog, _, _) = DonationDialogComponent { component.dismissDonationDialogOffset() } Column { SearchPanel( model.link, component::onInputLinkChanged, component::onLinkSearch ) HomeTabBar( model.selectedCategory, HomeCategory.values(), component::selectCategory, ) when (model.selectedCategory) { HomeCategory.About -> AboutColumn( analyticsEnabled = model.isAnalyticsEnabled, toggleAnalytics = component::toggleAnalytics, openDonationDialog = { component.analytics.donationDialogVisit() openDonationDialog() } ) HomeCategory.History -> HistoryColumn( model.records.sortedByDescending { it.id }, component::loadImage, component::onLinkSearch ) } } } @Composable fun HomeTabBar( selectedCategory: HomeCategory, categories: Array, selectCategory: (HomeCategory) -> Unit, modifier: Modifier = Modifier ) { val selectedIndex = categories.indexOfFirst { it == selectedCategory } val indicator = @Composable { tabPositions: List -> HomeCategoryTabIndicator( Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) ) } TabRow( backgroundColor = transparent, selectedTabIndex = selectedIndex, indicator = indicator, modifier = modifier, ) { categories.forEachIndexed { index, category -> Tab( selected = index == selectedIndex, onClick = { selectCategory(category) }, text = { Text( text = when (category) { HomeCategory.About -> Strings.about() HomeCategory.History -> Strings.history() }, style = MaterialTheme.typography.body2 ) }, icon = { when (category) { HomeCategory.About -> Icon(Icons.Outlined.Info, Strings.infoTab()) HomeCategory.History -> Icon(Icons.Outlined.History, Strings.historyTab()) } } ) } } } @Composable fun SearchPanel( link: String, updateLink: (String) -> Unit, onSearch: (String) -> Unit, modifier: Modifier = Modifier ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.padding(top = 16.dp) ) { TextField( value = link, onValueChange = updateLink, leadingIcon = { Icon(Icons.Rounded.Edit, Strings.linkTextBox(), tint = Color.LightGray) }, label = { Text(text = Strings.pasteLinkHere(), color = Color.LightGray) }, singleLine = true, textStyle = TextStyle.Default.merge(TextStyle(fontSize = 18.sp, color = Color.White)), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = modifier.padding(12.dp).fillMaxWidth() .border( BorderStroke( 2.dp, Brush.horizontalGradient( listOf( colorPrimary, colorAccent ) ) ), RoundedCornerShape(30.dp) ), shape = RoundedCornerShape(size = 30.dp), colors = textFieldColors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, backgroundColor = Color.Black ) ) OutlinedButton( modifier = Modifier.padding(12.dp).wrapContentWidth(), onClick = { if (link.isBlank()) Actions.instance.showPopUpMessage(Strings.enterALink()) else { // TODO if(!isOnline(ctx)) showPopUpMessage("Check Your Internet Connection") else onSearch(link) } }, border = BorderStroke( 1.dp, Brush.horizontalGradient( listOf( colorPrimary, colorAccent ) ) ) ) { Text( text = Strings.search(), style = SpotiFlyerTypography.h6, modifier = Modifier.padding(4.dp) ) } } } @Composable fun AboutColumn( modifier: Modifier = Modifier, analyticsEnabled: Boolean, openDonationDialog: () -> Unit, toggleAnalytics: (enabled: Boolean) -> Unit ) { Box { val stateVertical = rememberScrollState(0) Column(modifier.fillMaxSize().padding(8.dp).verticalScroll(stateVertical)) { Card( modifier = modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color.Gray) ) { Column(modifier.padding(12.dp).clickable( onClick = { Actions.instance.openPlatform( "in.shabinder.soundbound", "https://soundbound.app" ) } )) { Row { Image( painter = SoundboundLogo(), "${Strings.open()} Soundbound", ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { Text( text = "Get Soundbound", style = SpotiFlyerTypography.h4, color = colorAccent ) Spacer(modifier = Modifier.padding(top = 8.dp)) Text( text = "No BOUNDs to,\nyour Music & SOUNDs.", style = SpotiFlyerTypography.h6, textAlign = TextAlign.Center, fontStyle = FontStyle.Italic, color = colorOffWhite, modifier = Modifier.align(Alignment.CenterHorizontally) .padding(top = 4.dp) ) } } TextButton( onClick = { Actions.instance.openPlatform( "in.shabinder.soundbound", "https://soundbound.app" ) }, modifier = Modifier.fillMaxWidth() .padding(vertical = 8.dp, horizontal = 16.dp), colors = ButtonDefaults.textButtonColors( contentColor = black, backgroundColor = colorAccent ) ) { Text( text = Strings.open(), style = SpotiFlyerTypography.body1, modifier = Modifier.padding(vertical = 4.dp), fontWeight = FontWeight.SemiBold ) Icon( Icons.Rounded.ChevronRight, Strings.open(), tint = black, modifier = Modifier ) } } } Spacer(modifier = Modifier.padding(top = 8.dp)) Card( modifier = modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color.Gray) ) { Column(modifier.padding(12.dp)) { Text( text = Strings.supportedPlatforms(), style = SpotiFlyerTypography.body1, color = colorAccent ) Spacer(modifier = Modifier.padding(top = 12.dp)) Row( horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth() ) { Icon( SpotifyLogo(), "${Strings.open()} Spotify", tint = Color.Unspecified, modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( onClick = { Actions.instance.openPlatform( "com.spotify.music", "https://open.spotify.com" ) } ) ) Spacer(modifier = modifier.padding(start = 16.dp)) Icon( GaanaLogo(), "${Strings.open()} Gaana", tint = Color.Unspecified, modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( onClick = { Actions.instance.openPlatform( "com.gaana", "https://www.gaana.com" ) } ) ) Spacer(modifier = modifier.padding(start = 16.dp)) Icon( SaavnLogo(), "${Strings.open()} Jio Saavn", tint = Color.Unspecified, modifier = Modifier.clickable( onClick = { Actions.instance.openPlatform( "com.jio.media.jiobeats", "https://www.jiosaavn.com/" ) } ) ) Spacer(modifier = modifier.padding(start = 16.dp)) Icon( YoutubeLogo(), "${Strings.open()} Youtube", tint = Color.Unspecified, modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( onClick = { Actions.instance.openPlatform( "com.google.android.youtube", "https://m.youtube.com" ) } ) ) Spacer(modifier = modifier.padding(start = 12.dp)) Icon( YoutubeMusicLogo(), "${Strings.open()} Youtube Music", tint = Color.Unspecified, modifier = Modifier.clip(SpotiFlyerShapes.small).clickable( onClick = { Actions.instance.openPlatform( "com.google.android.apps.youtube.music", "https://music.youtube.com/" ) } ) ) } Spacer(modifier = Modifier.padding(top = 8.dp)) Row( horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth() ) { Icon( SoundCloudLogo(), "${Strings.open()} Sound Cloud", tint = Color.Unspecified, modifier = Modifier.clip(SpotiFlyerShapes.medium).clickable( onClick = { Actions.instance.openPlatform( "com.soundcloud.android", "https://soundcloud.com/" ) } ) ) } } } Spacer(modifier = Modifier.padding(top = 8.dp)) Card( modifier = modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color.Gray) // Gray ) { Column(modifier.padding(12.dp)) { Text( text = Strings.supportDevelopment(), style = SpotiFlyerTypography.body1, color = colorAccent ) Spacer(modifier = Modifier.padding(top = 6.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable( onClick = { Actions.instance.openPlatform( "", "https://github.com/Shabinder/SpotiFlyer" ) } ) .padding(vertical = 6.dp) ) { Icon( GithubLogo(), Strings.openProjectRepo(), Modifier.size(32.dp), tint = Color(0xFFCCCCCC) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = "GitHub", style = SpotiFlyerTypography.h6 ) Text( text = Strings.starOrForkProject(), style = SpotiFlyerTypography.subtitle2 ) } } Row( modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) .clickable(onClick = { Actions.instance.openPlatform( "", "https://github.com/Shabinder/SpotiFlyer/blob/main/CONTRIBUTING.md" ) }), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Rounded.Flag, Strings.help() + Strings.translate(), Modifier.size(32.dp) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = Strings.translate(), style = SpotiFlyerTypography.h6 ) Text( text = Strings.helpTranslateDescription(), style = SpotiFlyerTypography.subtitle2 ) } } Row( modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) .clickable(onClick = openDonationDialog), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Rounded.CardGiftcard, Strings.supportDeveloper(), Modifier.size(32.dp) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = Strings.donate(), style = SpotiFlyerTypography.h6 ) Text( text = Strings.donateDescription(), // text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.", style = SpotiFlyerTypography.subtitle2 ) } } Row( modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) .clickable( onClick = { Actions.instance.shareApp() } ), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Rounded.Share, Strings.share() + Strings.title() + "App", Modifier.size(32.dp) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = Strings.share(), style = SpotiFlyerTypography.h6 ) Text( text = Strings.shareDescription(), style = SpotiFlyerTypography.subtitle2 ) } } Row( modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) .clickable( onClick = { toggleAnalytics(!analyticsEnabled) } ), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Rounded.Insights, Strings.analytics() + Strings.status(), Modifier.size(32.dp) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column( Modifier.weight(1f) ) { Text( text = Strings.analytics(), style = SpotiFlyerTypography.h6 ) Text( text = Strings.analyticsDescription(), style = SpotiFlyerTypography.subtitle2 ) } Switch( checked = analyticsEnabled, onCheckedChange = null, colors = SwitchDefaults.colors(uncheckedThumbColor = colorOffWhite) ) } } } } VerticalScrollbar( modifier = Modifier.padding(end = 2.dp).align(Alignment.CenterEnd).fillMaxHeight(), adapter = rememberScrollbarAdapter(stateVertical) ) } } @Composable fun HistoryColumn( list: List, loadImage: suspend (String) -> Picture, onItemClicked: (String) -> Unit ) { Crossfade(list) { if (it.isEmpty()) { Column( Modifier.padding(8.dp).fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( Icons.Outlined.Info, Strings.noHistoryAvailable(), modifier = Modifier.size(80.dp), colorOffWhite ) Text( Strings.noHistoryAvailable(), style = SpotiFlyerTypography.h4.copy(fontWeight = FontWeight.Light), textAlign = TextAlign.Center ) } } else { Box { val listState = rememberLazyListState() val itemList = it.distinctBy { record -> record.coverUrl } LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp), content = { items(itemList) { record -> DownloadRecordItem( item = record, loadImage, onItemClicked ) } }, state = listState, modifier = Modifier.padding(top = 8.dp).fillMaxSize() ) /*VerticalScrollbar( modifier = Modifier.padding(end = 2.dp).align(Alignment.CenterEnd).fillMaxHeight(), adapter = rememberScrollbarAdapter( scrollState = listState, itemCount = itemList.size, averageItemSize = 70.dp ) )*/ } } } } @Composable fun DownloadRecordItem( item: DownloadRecord, loadImage: suspend (String) -> Picture, onItemClicked: (String) -> Unit ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(end = 8.dp) ) { ImageLoad( item.coverUrl, { loadImage(item.coverUrl) }, Strings.albumArt(), modifier = Modifier.height(70.dp).width(70.dp).clip(SpotiFlyerShapes.medium) ) Column( modifier = Modifier.padding(horizontal = 8.dp).height(60.dp).weight(1f), verticalArrangement = Arrangement.SpaceEvenly ) { Text( item.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = SpotiFlyerTypography.h6, color = colorAccent ) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(horizontal = 8.dp).fillMaxSize() ) { Text(item.type, fontSize = 13.sp, color = colorOffWhite) Text( "${Strings.tracks()}: ${item.totalFiles}", fontSize = 13.sp, color = colorOffWhite ) } } Image( ShareImage(), Strings.reSearch(), modifier = Modifier.clickable( onClick = { // if(!isOnline(ctx)) showDialog("Check Your Internet Connection") else onItemClicked(item.link) } ) ) } } @Composable fun HomeCategoryTabIndicator( modifier: Modifier = Modifier, color: Color = MaterialTheme.colors.onSurface ) { Spacer( modifier.padding(horizontal = 24.dp) .height(3.dp) .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) ) } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/screens/SpotiFlyerPreferenceUi.kt ================================================ package com.shabinder.common.uikit.screens import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.RadioButton import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Insights import androidx.compose.material.icons.rounded.ManageAccounts import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.SnippetFolder import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.shabinder.common.models.Actions import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.spotify.SpotifyCredentials import com.shabinder.common.preference.SpotiFlyerPreference import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.configurations.SpotiFlyerShapes import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorAccent import com.shabinder.common.uikit.configurations.colorOffWhite import com.shabinder.common.uikit.configurations.colorPrimary @Composable fun SpotiFlyerPreferenceContent(component: SpotiFlyerPreference) { val model by component.model.subscribeAsState() val stateVertical = rememberScrollState(0) Column(Modifier.fillMaxSize().padding(8.dp).verticalScroll(stateVertical)) { Spacer(Modifier.padding(top = 16.dp)) Card( modifier = Modifier.fillMaxWidth(), border = BorderStroke(1.dp, Color.Gray) ) { Column(Modifier.padding(12.dp)) { Text( text = Strings.preferences(), style = SpotiFlyerTypography.body1, color = colorAccent ) Spacer(modifier = Modifier.padding(top = 12.dp)) SettingsRow( icon = rememberVectorPainter(Icons.Rounded.MusicNote), title = "Preferred Audio Quality", value = model.preferredQuality.kbps + "KBPS" ) { save -> val audioQualities = AudioQuality.values().toMutableList().apply { remove(AudioQuality.UNKNOWN) } audioQualities.forEach { quality -> Row( Modifier .fillMaxWidth() .selectable( selected = (quality == model.preferredQuality), onClick = { component.setPreferredQuality(quality) save() } ) .padding(horizontal = 16.dp, vertical = 2.dp) ) { RadioButton( selected = (quality == model.preferredQuality), onClick = { component.setPreferredQuality(quality) save() } ) Text( text = quality.kbps + " KBPS", style = SpotiFlyerTypography.h6, modifier = Modifier.padding(start = 16.dp) ) } } } Spacer(Modifier.padding(top = 12.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable( onClick = { component.selectNewDownloadDirectory() } ) ) { Icon( Icons.Rounded.SnippetFolder, Strings.setDownloadDirectory(), Modifier.size(32.dp), tint = Color(0xFFCCCCCC) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = Strings.setDownloadDirectory(), style = SpotiFlyerTypography.h6 ) Text( text = model.downloadPath, style = SpotiFlyerTypography.subtitle2 ) } } Spacer(Modifier.padding(top = 12.dp)) SettingsRow( icon = rememberVectorPainter(Icons.Rounded.ManageAccounts), title = Strings.spotifyCreds(), value = if (model.spotifyCredentials == SpotifyCredentials()) Strings.defaultString() else Strings.userSet(), contentEnd = { Spacer(Modifier.weight(1f)) Icon( Icons.Rounded.Edit, "Edit", Modifier.padding(end = 8.dp).size(24.dp), tint = Color(0xFFCCCCCC) ) } ) { save -> Spacer(Modifier.padding(top = 8.dp)) var clientID by remember { mutableStateOf(model.spotifyCredentials.clientID) } var clientSecret by remember { mutableStateOf(model.spotifyCredentials.clientSecret) } TextField( value = clientID, onValueChange = { clientID = it.trim() }, label = { Text(Strings.clientID()) }, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.padding(vertical = 4.dp)) TextField( value = clientSecret, onValueChange = { clientSecret = it.trim() }, label = { Text(Strings.clientSecret()) }, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.padding(vertical = 4.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { TextButton( onClick = { component.updateSpotifyCredentials( SpotifyCredentials( clientID, clientSecret ) ) Actions.instance.showPopUpMessage(Strings.requestAppRestart()) save() }, Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp).wrapContentWidth() .background(colorPrimary, shape = SpotiFlyerShapes.medium) .padding(horizontal = 4.dp), shape = SpotiFlyerShapes.small ) { Text( Strings.save(), color = Color.Black, fontSize = 16.sp, textAlign = TextAlign.Center ) } TextButton( onClick = { component.updateSpotifyCredentials( SpotifyCredentials() ) Actions.instance.showPopUpMessage(Strings.requestAppRestart()) save() }, Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp).wrapContentWidth() .background(colorPrimary, shape = SpotiFlyerShapes.medium) .padding(horizontal = 4.dp), shape = SpotiFlyerShapes.small ) { Text( Strings.reset(), color = Color.Black, fontSize = 16.sp, textAlign = TextAlign.Center ) } } } Spacer(Modifier.padding(top = 4.dp)) Row( modifier = Modifier.fillMaxWidth() .clickable( onClick = { component.toggleAnalytics(!model.isAnalyticsEnabled) } ), verticalAlignment = Alignment.CenterVertically ) { @Suppress("DuplicatedCode") Icon( Icons.Rounded.Insights, Strings.analytics() + Strings.status(), Modifier.size(32.dp) ) Spacer(modifier = Modifier.padding(start = 16.dp)) Column( Modifier.weight(1f) ) { Text( text = Strings.analytics(), style = SpotiFlyerTypography.h6 ) Text( text = Strings.analyticsDescription(), style = SpotiFlyerTypography.subtitle2 ) } Switch( checked = model.isAnalyticsEnabled, onCheckedChange = null, colors = SwitchDefaults.colors(uncheckedThumbColor = colorOffWhite) ) } } } Spacer(modifier = Modifier.padding(top = 8.dp)) } } @OptIn(ExperimentalAnimationApi::class) @Composable fun SettingsRow( icon: Painter, title: String, value: String, contentEnd: @Composable RowScope.() -> Unit = {}, editContent: @Composable ColumnScope.(() -> Unit) -> Unit ) { var isEditMode by remember { mutableStateOf(false) } Column { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable( onClick = { isEditMode = !isEditMode } ).padding(vertical = 6.dp) ) { Icon(icon, title, Modifier.size(32.dp), tint = Color(0xFFCCCCCC)) Spacer(modifier = Modifier.padding(start = 16.dp)) Column { Text( text = title, style = SpotiFlyerTypography.h6 ) Text( text = value, style = SpotiFlyerTypography.subtitle2 ) } contentEnd() } AnimatedVisibility(isEditMode) { Column { editContent { isEditMode = false } } } } } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/screens/SpotiFlyerRootUi.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:Suppress("EXPERIMENTAL_API_USAGE") package com.shabinder.common.uikit.screens import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Spring.StiffnessLow import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.rounded.ArrowBackIosNew import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.jetbrains.Children import com.arkivanov.decompose.extensions.compose.jetbrains.animation.child.crossfadeScale import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot.Child import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.SpotiFlyerLogo import com.shabinder.common.uikit.Toast import com.shabinder.common.uikit.ToastDuration import com.shabinder.common.uikit.configurations.appNameStyle import com.shabinder.common.uikit.configurations.colorPrimaryDark import com.shabinder.common.uikit.screens.splash.Splash import com.shabinder.common.uikit.screens.splash.SplashState import com.shabinder.common.uikit.utils.verticalGradientScrim // Splash Status private var isSplashShown = SplashState.Show @Composable fun SpotiFlyerRootContent( component: SpotiFlyerRoot, modifier: Modifier = Modifier, showSplash: Boolean = true ): SpotiFlyerRoot { isSplashShown = if (showSplash) SplashState.Show else SplashState.Completed val transitionState = remember { MutableTransitionState(isSplashShown) } val transition = updateTransition(transitionState, label = "transition") val splashAlpha by transition.animateFloat( transitionSpec = { tween(durationMillis = 100) }, label = "Splash-Alpha" ) { if (it == SplashState.Show && isSplashShown == SplashState.Show) 1f else 0f } val contentAlpha by transition.animateFloat( transitionSpec = { tween(durationMillis = 300) }, label = "Content-Alpha" ) { if (it == SplashState.Show && isSplashShown == SplashState.Show) 0f else 1f } val contentTopPadding by transition.animateDp( transitionSpec = { spring(stiffness = StiffnessLow) }, label = "Content-Padding" ) { if (it == SplashState.Show && isSplashShown == SplashState.Show) 100.dp else 0.dp } Box { Splash( modifier = modifier.alpha(splashAlpha), onTimeout = { transitionState.targetState = SplashState.Completed isSplashShown = SplashState.Completed } ) MainScreen( modifier, contentAlpha, contentTopPadding, component ) Toast( flow = component.toastState, duration = ToastDuration.Long ) } return component } @Composable fun MainScreen( modifier: Modifier = Modifier, alpha: Float, topPadding: Dp = 0.dp, component: SpotiFlyerRoot ) { val appBarColor = MaterialTheme.colors.surface.copy(alpha = 0.65f) Column( modifier = Modifier.fillMaxSize() .alpha(alpha) .verticalGradientScrim( color = colorPrimaryDark.copy(alpha = 0.38f), startYPercentage = 0.29f, endYPercentage = 0f, ).then(modifier) ) { val activeComponent = component.routerState.subscribeAsState() val callBacks = component.callBacks AppBar( backgroundColor = appBarColor, onBackPressed = callBacks::popBackToHomeScreen, openPreferenceScreen = callBacks::openPreferenceScreen, isBackButtonVisible = activeComponent.value.activeChild.instance !is Child.Main, isSettingsIconVisible = activeComponent.value.activeChild.instance is Child.Main, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.padding(top = topPadding)) Children( routerState = component.routerState, animation = crossfadeScale() ) { when (val child = it.instance) { is Child.Main -> SpotiFlyerMainContent(component = child.component) is Child.List -> SpotiFlyerListContent(component = child.component) is Child.Preference -> SpotiFlyerPreferenceContent(component = child.component) } } } } @OptIn(ExperimentalAnimationApi::class) @Composable fun AppBar( backgroundColor: Color, onBackPressed: () -> Unit, openPreferenceScreen: () -> Unit, isBackButtonVisible: Boolean, isSettingsIconVisible: Boolean, modifier: Modifier = Modifier ) { TopAppBar( backgroundColor = backgroundColor, title = { Row(verticalAlignment = Alignment.CenterVertically) { AnimatedVisibility(isBackButtonVisible) { Icon( Icons.Rounded.ArrowBackIosNew, contentDescription = Strings.backButton(), modifier = Modifier.clickable { onBackPressed() }, tint = Color.LightGray ) Spacer(Modifier.padding(horizontal = 4.dp)) } Image( SpotiFlyerLogo(), Strings.spotiflyerLogo(), Modifier.requiredHeight(66.dp).requiredWidth(42.dp), contentScale = ContentScale.FillHeight ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( text = Strings.title(), style = appNameStyle ) } }, actions = { AnimatedVisibility(isSettingsIconVisible) { IconButton( onClick = { openPreferenceScreen() } ) { Icon(Icons.Filled.Settings, Strings.preferences(), tint = Color.Gray) } } }, modifier = modifier, elevation = 0.dp ) } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/screens/splash/Splash.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.screens.splash import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.HeartIcon import com.shabinder.common.uikit.SpotiFlyerLogo import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorAccent import com.shabinder.common.uikit.configurations.colorPrimary import kotlinx.coroutines.delay private const val SplashWaitTime: Long = 2000 enum class SplashState { Show, Completed } @Composable fun Splash(modifier: Modifier = Modifier, onTimeout: () -> Unit) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // Adds composition consistency. Use the value when LaunchedEffect is first called val currentOnTimeout by rememberUpdatedState(onTimeout) LaunchedEffect(Unit) { delay(SplashWaitTime) currentOnTimeout() } Image(SpotiFlyerLogo(), Strings.spotiflyerLogo(),modifier = Modifier.fillMaxSize()) MadeInIndia(Modifier.align(Alignment.BottomCenter)) } } @Composable fun MadeInIndia( modifier: Modifier = Modifier ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.padding(8.dp) ) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Text( text = "${Strings.madeWith()} ", color = colorPrimary, fontSize = 22.sp ) Spacer(modifier = Modifier.padding(start = 4.dp)) Icon(HeartIcon(), Strings.love(), tint = Color.Unspecified) Spacer(modifier = Modifier.padding(start = 4.dp)) Text( text = " ${Strings.inIndia()}", color = colorPrimary, fontSize = 22.sp ) } Text( Strings.byDeveloperName(), style = SpotiFlyerTypography.h6, color = colorAccent, fontSize = 14.sp ) } } ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/utils/Colors.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.utils /* fun Color.contrastAgainst(background: Color): Float { val fg = if (alpha < 1f) compositeOver(background) else this val fgLuminance = fg.luminance() + 0.05f val bgLuminance = background.luminance() + 0.05f return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance) } */ ================================================ FILE: common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/utils/GradientScrim.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.uikit.utils import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import kotlin.math.pow /** * Draws a vertical gradient scrim in the foreground. * * @param color The color of the gradient scrim. * @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f) * @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f) * @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is * a linear gradient. * @param numStops The number of color stops to draw in the gradient. Higher numbers result in * the higher visual quality at the cost of draw performance. Defaults to `16`. */ fun Modifier.verticalGradientScrim( color: Color, /*@FloatRange(from = 0.0, to = 1.0)*/ startYPercentage: Float = 0f, /*@FloatRange(from = 0.0, to = 1.0)*/ endYPercentage: Float = 1f, decay: Float = 1.0f, numStops: Int = 16, fixedHeight: Float? = null ): Modifier = composed { val colors = remember(color, numStops) { if (decay != 1f) { // If we have a non-linear decay, we need to create the color gradient steps // manually val baseAlpha = color.alpha List(numStops) { i -> val x = i * 1f / (numStops - 1) val opacity = x.pow(decay) color.copy(alpha = baseAlpha * opacity) } } else { // If we have a linear decay, we just create a simple list of start + end colors listOf(color.copy(alpha = 0f), color) } } var height by remember { mutableStateOf(fixedHeight ?: 1f) } val scrimHeight by animateFloatAsState( // Whenever the target value changes, new animation // will start to the new target value targetValue = height, animationSpec = tween(durationMillis = 1500) ) val brush = remember(color, numStops, startYPercentage, endYPercentage, scrimHeight) { Brush.verticalGradient( colors = colors, startY = scrimHeight * startYPercentage, endY = scrimHeight * endYPercentage ) } drawBehind { height = fixedHeight ?: size.height drawRect(brush = brush) } } ================================================ FILE: common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopDialog.kt ================================================ package com.shabinder.common.uikit import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.loadImageBitmap import androidx.compose.ui.res.useResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogState @OptIn(ExperimentalAnimationApi::class) @Composable actual fun Dialog( isVisible: Boolean, onDismiss: () -> Unit, content: @Composable () -> Unit ) { AnimatedVisibility(isVisible) { androidx.compose.ui.window.Dialog( onDismiss, state = DialogState(width = 350.dp, height = 340.dp), title = "SpotiFlyer", icon = BitmapPainter(useResource("drawable/spotiflyer.png", ::loadImageBitmap)) ) { content() } } } ================================================ FILE: common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImageLoad.kt ================================================ package com.shabinder.common.uikit import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.models.dispatcherIO import kotlinx.coroutines.withContext @Composable actual fun ImageLoad( link: String, loader: suspend () -> Picture, desc: String, modifier: Modifier // placeholder: ImageVector ) { var pic by remember(link) { mutableStateOf(null) } LaunchedEffect(link) { withContext(dispatcherIO) { pic = loader().image } } Crossfade(pic) { if (it == null) Image(PlaceHolderImage(), desc, modifier, contentScale = ContentScale.Crop) else Image( it, desc, modifier, contentScale = ContentScale.Crop ) } } ================================================ FILE: common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:Suppress("FunctionName") package com.shabinder.common.uikit import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.loadXmlImageVector import androidx.compose.ui.res.useResource import org.xml.sax.InputSource @Composable internal actual fun imageVectorResource(id: T): ImageVector { val density = LocalDensity.current return useResource(id as String) { loadXmlImageVector(InputSource(it), density) } } @Composable actual fun DownloadImageTick() { Image( getCachedPainter("drawable/ic_tick.xml"), "Downloaded" ) } @Composable actual fun DownloadImageError(modifier: Modifier) { Image( getCachedPainter("drawable/ic_error.xml"), "Can't Download", modifier = modifier ) } @Composable actual fun DownloadImageArrow(modifier: Modifier) { Image( getCachedPainter("drawable/ic_arrow.xml"), "Download", modifier ) } @Composable actual fun DownloadAllImage() = getCachedPainter("drawable/ic_download_arrow.xml") @Composable actual fun ShareImage() = getCachedPainter("drawable/ic_share_open.xml") @Composable actual fun PlaceHolderImage() = getCachedPainter("drawable/music.xml") @Composable actual fun SpotiFlyerLogo() = getCachedPainter("drawable/ic_spotiflyer_logo.xml") @Composable actual fun HeartIcon() = getCachedPainter("drawable/ic_heart.xml") @Composable actual fun SpotifyLogo() = getCachedPainter("drawable/ic_spotify_logo.xml") @Composable actual fun SoundboundLogo() = getCachedPainter("drawable/soundbound_app_logo.xml") @Composable actual fun SaavnLogo() = getCachedPainter("drawable/ic_jio_saavn_logo.xml") @Composable actual fun SoundCloudLogo() = getCachedPainter("drawable/ic_soundcloud.xml") @Composable actual fun YoutubeLogo() = getCachedPainter("drawable/ic_youtube.xml") @Composable actual fun GaanaLogo() = getCachedPainter("drawable/ic_gaana.xml") @Composable actual fun YoutubeMusicLogo() = getCachedPainter("drawable/ic_youtube_music_logo.xml") @Composable actual fun GithubLogo() = getCachedPainter("drawable/ic_github.xml") @Composable actual fun PaypalLogo() = getCachedPainter("drawable/ic_paypal_logo.xml") @Composable actual fun OpenCollectiveLogo() = getCachedPainter("drawable/ic_opencollective_icon.xml") @Composable actual fun RazorPay() = getCachedPainter("drawable/ic_indian_rupee.xml") ================================================ FILE: common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopScrollBar.kt ================================================ package com.shabinder.common.uikit import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollbarAdapter import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp actual val MARGIN_SCROLLBAR: Dp = 8.dp actual typealias ScrollbarAdapter = ScrollbarAdapter @OptIn(ExperimentalFoundationApi::class) @Composable actual fun rememberScrollbarAdapter( scrollState: LazyListState, itemCount: Int, averageItemSize: Dp ): ScrollbarAdapter = androidx.compose.foundation.rememberScrollbarAdapter( scrollState = scrollState, ) @Composable actual fun rememberScrollbarAdapter( scrollState: ScrollState ): ScrollbarAdapter = remember(scrollState) { ScrollbarAdapter(scrollState) } @Composable actual fun VerticalScrollbar( modifier: Modifier, adapter: ScrollbarAdapter ) { androidx.compose.foundation.VerticalScrollbar( modifier = modifier, adapter = adapter ) } ================================================ FILE: common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopToast.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.uikit import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorOffWhite import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @OptIn(ExperimentalAnimationApi::class) @Composable actual fun Toast( flow: MutableStateFlow, duration: ToastDuration ) { val state = flow.collectAsState("") val message = state.value AnimatedVisibility( visible = message != "", enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }), exit = slideOutHorizontally(targetOffsetX = { it / 4 }) + fadeOut() ) { Box( modifier = Modifier.fillMaxSize().padding(bottom = 16.dp).padding(end = 16.dp), contentAlignment = Alignment.BottomEnd ) { Surface( modifier = Modifier.sizeIn(maxWidth = 250.dp, maxHeight = 80.dp), color = Color(23, 23, 23), shape = RoundedCornerShape(8.dp), border = BorderStroke(1.dp, colorOffWhite) ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Text( text = message, color = Color(210, 210, 210), textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis, style = SpotiFlyerTypography.body2, modifier = Modifier.padding(8.dp) ) } DisposableEffect(Unit) { GlobalScope.launch { delay(duration.value.toLong()) flow.value = "" } onDispose { } } } } } } ================================================ FILE: common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/configurations/DesktopTypography.kt ================================================ package com.shabinder.common.uikit.configurations import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.platform.Font actual fun montserratFont() = FontFamily( Font("font/montserrat_light.ttf", FontWeight.Light), Font("font/montserrat_regular.ttf", FontWeight.Normal), Font("font/montserrat_medium.ttf", FontWeight.Medium), Font("font/montserrat_semibold.ttf", FontWeight.SemiBold), ) actual fun pristineFont() = FontFamily( Font("font/pristine_script.ttf", FontWeight.Bold) ) ================================================ FILE: common/core-components/build.gradle.kts ================================================ plugins { id("multiplatform-setup") id("multiplatform-setup-test") kotlin("plugin.serialization") } kotlin { sourceSets { commonMain { dependencies { implementation(project(":common:data-models")) implementation(project(":common:database")) with(deps) { api(multiplatform.settings) api(kotlinx.atomicfu) implementation(mviKotlin.rx) implementation(decompose.dep) } } } androidMain { dependencies { with(deps) { implementation(mp3agic) implementation(countly.android) } implementation(project(":ffmpeg:android-ffmpeg")) } } desktopMain { dependencies { with(deps) { implementation(mp3agic) implementation(countly.desktop) implementation(jaffree) } } } jsMain { dependencies { implementation(npm("browser-id3-writer", "4.4.0")) implementation(npm("file-saver", "2.0.4")) implementation(deps.kotlin.js.wrappers.ext) } } } } ================================================ FILE: common/core-components/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/AndroidNetworkObserver.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components import android.content.Context import android.content.Context.CONNECTIVITY_SERVICE import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.net.NetworkRequest import android.util.Log import androidx.lifecycle.LiveData import com.shabinder.common.core_components.utils.isInternetAccessible import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.lang.Exception import java.net.URL import javax.net.ssl.HttpsURLConnection const val TAG = "C-Manager" /** * Save all available networks with an internet connection to a set (@validNetworks). * As long as the size of the set > 0, this LiveData emits true. * MinSdk = 21. * * Inspired by: * https://github.com/AlexSheva-mason/Rick-Morty-Database/blob/master/app/src/main/java/com/shevaalex/android/rickmortydatabase/utils/networking/ConnectionLiveData.kt */ class ConnectionLiveData(context: Context) : LiveData() { private lateinit var networkCallback: ConnectivityManager.NetworkCallback private val cm = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager private val validNetworks: MutableSet = HashSet() private fun checkValidNetworks() { postValue(validNetworks.size > 0) } override fun onActive() { checkValidNetworks() networkCallback = createNetworkCallback() val networkRequest = NetworkRequest.Builder() .addCapability(NET_CAPABILITY_INTERNET) .build() cm.registerNetworkCallback(networkRequest, networkCallback) } override fun onInactive() { cm.unregisterNetworkCallback(networkCallback) } private fun createNetworkCallback() = object : ConnectivityManager.NetworkCallback() { /* Called when a network is detected. If that network has internet, save it in the Set. Source: https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network) */ override fun onAvailable(network: Network) { Log.d(TAG, "onAvailable: $network") val networkCapabilities = cm.getNetworkCapabilities(network) val hasInternetCapability = networkCapabilities?.hasCapability(NET_CAPABILITY_INTERNET) Log.d(TAG, "onAvailable: $network, $hasInternetCapability") if (hasInternetCapability == true) { // check if this network actually has internet CoroutineScope(Dispatchers.IO).launch { val hasInternet = DoesNetworkHaveInternet.execute(network) if (hasInternet) { withContext(Dispatchers.Main) { Log.d(TAG, "onAvailable: adding network. $network") validNetworks.add(network) checkValidNetworks() } } } } } /* If the callback was registered with registerNetworkCallback() it will be called for each network which no longer satisfies the criteria of the callback. Source: https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onLost(android.net.Network) */ override fun onLost(network: Network) { Log.d(TAG, "onLost: $network") validNetworks.remove(network) checkValidNetworks() } } /** * Try Establishing an Actual Internet Connection. * If successful, that means we have internet. */ object DoesNetworkHaveInternet { suspend fun execute(network: Network): Boolean = withContext(Dispatchers.IO) { try { val url = URL("https://open.spotify.com/") val connection = network.openConnection(url) as HttpsURLConnection connection.connect() connection.disconnect() true } catch (e: Exception) { e.printStackTrace() // Handle VPN Connection / Google DNS Blocked Cases isInternetAccessible() } } } } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/LiveDataExt.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.di import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer @Composable fun LiveData.observeAsState(): State = observeAsState(value) /** * Starts observing this [LiveData] and represents its values via [State]. Every time there would * be new value posted into the [LiveData] the returned [State] will be updated causing * recomposition of every [State.value] usage. * * The inner observer will automatically be removed when this composable disposes or the current * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. * * @sample androidx.compose.runtime.livedata.samples.LiveDataWithInitialSample */ @Composable fun LiveData.observeAsState(initial: R): State { val lifecycleOwner = LocalLifecycleOwner.current val state = remember { mutableStateOf(initial) } DisposableEffect(this, lifecycleOwner) { val observer = Observer { state.value = it } observe(lifecycleOwner, observer) onDispose { removeObserver(observer) } } return state } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/analytics/AndroidAnalyticsManager.kt ================================================ package com.shabinder.common.core_components.analytics import android.app.Activity import android.app.Application import ly.count.android.sdk.Countly import ly.count.android.sdk.CountlyConfig import ly.count.android.sdk.DeviceId import org.koin.dsl.bind import org.koin.dsl.module internal class AndroidAnalyticsManager(private val mainActivity: Activity) : AnalyticsManager { companion object { private var isInitialised = false } init { // Don't Init If Instantiated on Diff Activities if (!isInitialised) { isInitialised = true init() } } override fun init() { Countly.sharedInstance().init( CountlyConfig( mainActivity.applicationContext as Application, COUNTLY_CONFIG.APP_KEY, COUNTLY_CONFIG.SERVER_URL ).apply { setIdMode(DeviceId.Type.OPEN_UDID) setViewTracking(true) enableCrashReporting() setLoggingEnabled(false) setRecordAllThreadsWithCrash() setRequiresConsent(true) setShouldIgnoreAppCrawlers(true) setEventQueueSizeToSend(5) } ) } override fun onStart() { Countly.sharedInstance().onStart(mainActivity) } override fun onStop() { Countly.sharedInstance().onStop() } override fun giveConsent() { Countly.sharedInstance().consent().giveConsentAll() } override fun isTracking(): Boolean = Countly.sharedInstance().consent().getConsent(Countly.CountlyFeatureNames.events) override fun revokeConsent() { Countly.sharedInstance().consent().removeConsentAll() } override fun sendView(name: String, extras: MutableMap) { Countly.sharedInstance().views().recordView(name, extras) } override fun sendEvent(eventName: String, extras: MutableMap) { Countly.sharedInstance().events().recordEvent(eventName, extras) } override fun sendCrashReport(error: Throwable, extras: MutableMap) { Countly.sharedInstance().crashes().recordUnhandledException(error, extras) } } internal actual fun analyticsModule() = module { factory { (mainActivity: Activity) -> AndroidAnalyticsManager(mainActivity) } bind AnalyticsManager::class } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/file_manager/AndroidFileManager.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components.file_manager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Environment import androidx.compose.ui.graphics.asImageBitmap import co.touchlab.kermit.Kermit import com.mpatric.mp3agic.InvalidDataException import com.mpatric.mp3agic.Mp3File import com.shabinder.common.core_components.media_converter.MediaConverter import com.shabinder.common.core_components.media_converter.removeAllTags import com.shabinder.common.core_components.media_converter.setId3v1Tags import com.shabinder.common.core_components.media_converter.setId3v2TagsAndSaveFile import com.shabinder.common.core_components.parallel_executor.ParallelExecutor import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.di.getMemoryEfficientBitmap import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.dispatcherIO import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.map import com.shabinder.common.models.Actions import com.shabinder.common.models.AudioFormat import com.shabinder.database.Database import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.dsl.bind import org.koin.dsl.module import java.io.File import java.io.FileOutputStream import java.io.IOException import java.net.HttpURLConnection import java.net.URL internal actual fun fileManagerModule() = module { single { AndroidFileManager(get(), get(), get(), get()) } bind FileManager::class } /* * Ignore Deprecation * `Deprecation is only a Suggestion P->` * */ @Suppress("DEPRECATION") class AndroidFileManager( override val logger: Kermit, override val preferenceManager: PreferenceManager, override val mediaConverter: MediaConverter, spotiFlyerDatabase: SpotiFlyerDatabase ) : FileManager { @Suppress("DEPRECATION") private val defaultBaseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString() override fun fileSeparator(): String = File.separator override fun imageCacheDir(): String = Actions.instance.platformActions.imageCacheDir // fun call in order to always access Updated Value override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + File.separator + "SpotiFlyer" + File.separator override fun isPresent(path: String): Boolean = File(path).exists() override fun createDirectory(dirPath: String) { val yourAppDir = File(dirPath) if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else { logger.e { "Unable to create Dir: $dirPath!" } } } else { logger.i { "$dirPath already exists" } } } @Suppress("unused") override suspend fun clearCache(): Unit = withContext(dispatcherIO) { File(imageCacheDir()).deleteRecursively() } @Suppress("BlockingMethodInNonBlockingContext") override suspend fun saveFileWithMetadata( mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit ) = withContext(dispatcherIO) { val songFile = File(trackDetails.outputFilePath) try { /* * Check , if Fetch was Used, File is saved Already, else write byteArray we Received * */ if (!songFile.exists()) { /*Make intermediate Dirs if they don't exist yet*/ songFile.parentFile?.mkdirs() } // Write Bytes to Media File songFile.writeBytes(mp3ByteArray) try { // Add Mp3 Tags and Add to Library if (trackDetails.audioFormat != AudioFormat.MP3) throw InvalidDataException("Audio Format is ${trackDetails.audioFormat}, Needs Conversion!") Mp3File(File(songFile.absolutePath)) .removeAllTags() .setId3v1Tags(trackDetails) .setId3v2TagsAndSaveFile(trackDetails) addToLibrary(songFile.absolutePath) } catch (e: Exception) { // Media File Isn't MP3 lets Convert It first if (e is InvalidDataException) { val convertedFilePath = songFile.absolutePath.substringBeforeLast('.') + ".temp.mp3" val conversionResult = mediaConverter.convertAudioFile( inputFilePath = songFile.absolutePath, outputFilePath = convertedFilePath, trackDetails.audioQuality ) conversionResult.map { outputFilePath -> Mp3File(File(outputFilePath)) .removeAllTags() .setId3v1Tags(trackDetails) .setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath) addToLibrary(trackDetails.outputFilePath) }.fold( success = {}, failure = { throw it } ) File(convertedFilePath).delete() } else throw e } SuspendableEvent.success(trackDetails.outputFilePath) } catch (e: Throwable) { e.printStackTrace() if (songFile.exists()) songFile.delete() logger.e { "${songFile.absolutePath} could not be created" } SuspendableEvent.error(e) } } override fun addToLibrary(path: String) = Actions.instance.platformActions.addToLibrary(path) override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture = withContext(dispatcherIO) { val cachePath = getImageCachePath(url) Picture( image = (loadCachedImage(cachePath, reqWidth, reqHeight) ?: freshImage( url, reqWidth, reqHeight ))?.asImageBitmap() ) } private fun loadCachedImage(cachePath: String, reqWidth: Int, reqHeight: Int): Bitmap? { return try { getMemoryEfficientBitmap(cachePath, reqWidth, reqHeight) } catch (e: Exception) { e.printStackTrace() null } } @Suppress("BlockingMethodInNonBlockingContext") override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) { try { FileOutputStream(path).use { out -> (image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out) } } catch (e: IOException) { e.printStackTrace() } } @Suppress("BlockingMethodInNonBlockingContext") private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): Bitmap? = withContext(dispatcherIO) { try { val source = URL(url) val connection: HttpURLConnection = source.openConnection() as HttpURLConnection connection.connectTimeout = 5000 connection.connect() val input: ByteArray = connection.inputStream.readBytes() // Get Memory Efficient Bitmap val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight) parallelExecutor.execute { // Decode and Cache Full Sized Image in Background cacheImage( BitmapFactory.decodeByteArray(input, 0, input.size), getImageCachePath(url) ) } bitmap // return Memory Efficient Bitmap } catch (e: Exception) { e.printStackTrace() null } } /* * Parallel Executor with 2 concurrent operation at a time. * - We will use this to queue up operations and decode Full Sized Images * - Will Decode Only a small set of images at a time , to avoid going into `Out of Memory` * */ private val parallelExecutor = ParallelExecutor(Dispatchers.IO, 2) override val db: Database? = spotiFlyerDatabase.instance } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/media_converter/AndroidMediaConverter.kt ================================================ package com.shabinder.common.core_components.media_converter import android.content.Context import android.util.Log import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.SpotiFlyerException import kotlinx.coroutines.delay import nl.bravobit.ffmpeg.ExecuteBinaryResponseHandler import nl.bravobit.ffmpeg.FFmpeg import org.koin.dsl.bind import org.koin.dsl.module class AndroidMediaConverter(private val appContext: Context) : MediaConverter() { override suspend fun convertAudioFile( inputFilePath: String, outputFilePath: String, audioQuality: AudioQuality, progressCallbacks: (Long) -> Unit, ) = executeSafelyInPool { var progressing = true var error = "" var timeout = 600_000L * 2 // 20 min val progressDelayCheck = 500L // 192 is Default val audioBitrate = if (audioQuality == AudioQuality.UNKNOWN) 192 else audioQuality.kbps.toIntOrNull() ?: 192 FFmpeg.getInstance(appContext).execute( arrayOf( "-i", inputFilePath, "-y", /*"-acodec", "libmp3lame",*/ "-b:a", "${audioBitrate}k", "-vn", outputFilePath ), object : ExecuteBinaryResponseHandler() { override fun onSuccess(message: String?) { //Log.d("FFmpeg Command", "Success $message") progressing = false Log.d("FFmpeg Success", "$message") } override fun onProgress(message: String?) { super.onProgress(message) Log.d("FFmpeg Progress", "Progress $message --- $inputFilePath") } override fun onFailure(message: String?) { error = "Failed: $message $inputFilePath" error += "FFmpeg Support" + FFmpeg.getInstance(appContext).isSupported.toString() Log.d("FFmpeg Error", error) progressing = false } } ) while (progressing) { if (timeout < 0) throw SpotiFlyerException.MP3ConversionFailed("$error Conversion Timeout for $inputFilePath") delay(progressDelayCheck) timeout -= progressDelayCheck } if(error.isNotBlank()) throw SpotiFlyerException.MP3ConversionFailed(error) // Return output file path after successful conversion outputFilePath } } internal actual fun mediaConverterModule() = module { single { AndroidMediaConverter(get()) } bind MediaConverter::class } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/media_converter/AudioTagging.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components.media_converter import android.util.Log import com.mpatric.mp3agic.ID3v1Tag import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.Mp3File import com.shabinder.common.core_components.file_manager.downloadFile import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.TrackDetails import kotlinx.coroutines.flow.collect import java.io.File import java.io.FileInputStream fun Mp3File.removeAllTags(): Mp3File { removeId3v1Tag() removeId3v2Tag() removeCustomTag() return this } fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File { val id3v1Tag = ID3v1Tag().apply { artist = track.artists.joinToString(", ") title = track.title album = track.albumName year = track.year comment = "${track.comment}" if (track.trackNumber != null) this.track = track.trackNumber.toString() } this.id3v1Tag = id3v1Tag return this } @Suppress("BlockingMethodInNonBlockingContext") suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) { val id3v2Tag = ID3v24Tag().apply { albumArtist = track.albumArtists.joinToString(", ") artist = track.artists.joinToString(", ") title = track.title album = track.albumName year = track.year genreDescription = "Genre: " + track.genre.joinToString(", ") comment = track.comment lyrics = track.lyrics ?: "" url = track.trackUrl if (track.trackNumber != null) this.track = track.trackNumber.toString() } try { val art = File(track.albumArtPath) val bytesArray = ByteArray(art.length().toInt()) val fis = FileInputStream(art) fis.read(bytesArray) // read file into bytes[] fis.close() id3v2Tag.setAlbumImage(bytesArray, "image/jpeg") this.id3v2Tag = id3v2Tag saveFile(outputFilePath ?: track.outputFilePath) } catch (e: java.io.FileNotFoundException) { Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}") try { // Image Still Not Downloaded! // Lets Download Now and Write it into Album Art downloadFile(track.albumArtURL).collect { when (it) { is DownloadResult.Error -> {} // Error is DownloadResult.Success -> { id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg") this.id3v2Tag = id3v2Tag saveFile(outputFilePath ?: track.outputFilePath) } is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show } } } catch (e: Exception) { Log.e("Error", "Couldn't Write Mp3 Album Art, error:") e.printStackTrace() } } } fun Mp3File.saveFile(filePath: String) { save(filePath.substringBeforeLast('.') + ".tagged.mp3") val oldFile = File(filePath) oldFile.delete() val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3")) newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3")) } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/picture/AndroidPicture.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.di import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.compose.ui.graphics.ImageBitmap fun getMemoryEfficientBitmap( input: ByteArray, reqWidth: Int, reqHeight: Int, offset: Int = 0, size: Int = input.size ): Bitmap? { return BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeByteArray(input, offset, size, this) // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set inJustDecodeBounds = false // Return Mem. Efficient Bitmap BitmapFactory.decodeByteArray(input, offset, size, this) } } fun getMemoryEfficientBitmap( filePath: String, reqWidth: Int, reqHeight: Int, ): Bitmap? { return BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeFile(filePath, this) // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set inJustDecodeBounds = false BitmapFactory.decodeFile(filePath, this) } } fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { // Raw height and width of image val (height: Int, width: Int) = options.run { outHeight to outWidth } var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { val halfHeight: Int = height / 2 val halfWidth: Int = width / 2 // Calculate the largest inSampleSize value that is a power of 2 and keeps both // height and width larger than the requested height and width. while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { inSampleSize *= 2 } } return inSampleSize } ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/picture/Picture.kt ================================================ package com.shabinder.common.core_components.picture import androidx.compose.ui.graphics.ImageBitmap actual data class Picture( var image: ImageBitmap? ) ================================================ FILE: common/core-components/src/androidMain/kotlin/com/shabinder/common/core_components/utils/AndroidHttpClient.kt ================================================ package com.shabinder.common.core_components.utils import android.annotation.SuppressLint import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttpConfig import okhttp3.OkHttpClient import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager actual fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient { return HttpClient(OkHttp) { engine { preconfigured = getUnsafeOkHttpClient() } extraConfig() } } fun getUnsafeOkHttpClient(): OkHttpClient { return try { // Create a trust manager that does not validate certificate chains @SuppressLint("CustomX509TrustManager") val trustAllCerts: TrustManager = object : X509TrustManager { @SuppressLint("TrustAllX509TrustManager") override fun checkClientTrusted(chain: Array?, authType: String?) { } @SuppressLint("TrustAllX509TrustManager") override fun checkServerTrusted(chain: Array?, authType: String?) { } override fun getAcceptedIssuers(): Array = arrayOf() } // Install the all-trusting trust manager val sslContext: SSLContext = SSLContext.getInstance("SSL").apply { init(null, arrayOf(trustAllCerts), SecureRandom()) } OkHttpClient.Builder().run { sslSocketFactory(sslContext.socketFactory, trustAllCerts as X509TrustManager) hostnameVerifier { _, _ -> true } followRedirects(true) build() } } catch (e: Exception) { throw RuntimeException(e) } } ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/CoreComponentsModule.kt ================================================ package com.shabinder.common.core_components import co.touchlab.kermit.Kermit import com.russhwolf.settings.Settings import com.shabinder.common.core_components.analytics.analyticsModule import com.shabinder.common.core_components.file_manager.fileManagerModule import com.shabinder.common.core_components.media_converter.mediaConverterModule import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.core_components.utils.createHttpClient import com.shabinder.common.database.getLogger import org.koin.dsl.module fun coreComponentModules(enableLogging: Boolean) = listOf( commonModule(enableLogging), analyticsModule(), fileManagerModule(), mediaConverterModule() ) private fun commonModule(enableLogging: Boolean) = module { single { createHttpClient(enableLogging) } single { Settings() } single { Kermit(getLogger()) } single { PreferenceManager(get()) } } ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/analytics/AnalyticsManager.kt ================================================ package com.shabinder.common.core_components.analytics import org.koin.core.module.Module interface AnalyticsManager { fun init() fun onStart() fun onStop() fun giveConsent() fun isTracking(): Boolean fun revokeConsent() fun sendView(name: String, extras: MutableMap = mutableMapOf()) fun sendEvent(eventName: String, extras: MutableMap = mutableMapOf()) fun track(event: AnalyticsAction) = event.track(this) fun sendCrashReport(error: Throwable, extras: MutableMap = mutableMapOf()) companion object { abstract class AnalyticsAction { abstract fun track(analyticsManager: AnalyticsManager) } } } @Suppress("ClassName", "SpellCheckingInspection") object COUNTLY_CONFIG { const val APP_KEY = "27820f304468cc651ef47d787f0cb5fe11c577df" const val SERVER_URL = "https://counlty.shabinder.in" } internal expect fun analyticsModule(): Module ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/analytics/Events.kt ================================================ package com.shabinder.common.core_components.analytics sealed class AnalyticsEvent(private val eventName: String, private val extras: MutableMap = mutableMapOf()): AnalyticsManager.Companion.AnalyticsAction() { override fun track(analyticsManager: AnalyticsManager) = analyticsManager.sendEvent(eventName,extras) object AppLaunch: AnalyticsEvent("app_launch") object DonationDialogOpen: AnalyticsEvent("donation_dialog_open") } ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/analytics/Views.kt ================================================ package com.shabinder.common.core_components.analytics import com.shabinder.common.core_components.analytics.AnalyticsManager.Companion.AnalyticsAction sealed class AnalyticsView(private val viewName: String, private val extras: MutableMap = mutableMapOf()) : AnalyticsAction() { override fun track(analyticsManager: AnalyticsManager) = analyticsManager.sendView(viewName,extras) object HomeScreen: AnalyticsView("home_screen") object ListScreen: AnalyticsView("list_screen") } ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/file_manager/FileManager.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components.file_manager import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.media_converter.MediaConverter import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.core_components.utils.createHttpClient import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.utils.removeIllegalChars import com.shabinder.common.utils.requireNotNull import com.shabinder.database.Database import io.ktor.client.HttpClient import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.statement.HttpStatement import io.ktor.http.contentLength import io.ktor.http.isSuccess import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import org.koin.core.module.Module import kotlin.math.roundToInt internal expect fun fileManagerModule(): Module interface FileManager { val logger: Kermit val preferenceManager: PreferenceManager val mediaConverter: MediaConverter val db: Database? fun isPresent(path: String): Boolean fun fileSeparator(): String fun defaultDir(): String fun imageCacheDir(): String fun createDirectory(dirPath: String) suspend fun cacheImage( image: Any, path: String ) // in Android = ImageBitmap, Desktop = BufferedImage suspend fun loadImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): Picture suspend fun clearCache() suspend fun saveFileWithMetadata( mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit = {} ): SuspendableEvent fun addToLibrary(path: String) } /* * Call this function at startup! * */ fun FileManager.createDirectories() { try { if (!defaultDir().contains("null${fileSeparator()}SpotiFlyer")) { createDirectory(defaultDir()) createDirectory(imageCacheDir()) createDirectory(defaultDir() + "Tracks" + fileSeparator()) createDirectory(defaultDir() + "Albums" + fileSeparator()) createDirectory(defaultDir() + "Playlists" + fileSeparator()) createDirectory(defaultDir() + "YT_Downloads" + fileSeparator()) } } catch (ignored: Exception) { } } fun FileManager.finalOutputDir( itemName: String, type: String, subFolder: String, defaultDir: String, extension: String = ".mp3" ): String = defaultDir + removeIllegalChars(type) + this.fileSeparator() + if (subFolder.isEmpty()) "" else { removeIllegalChars(subFolder) + this.fileSeparator() } + removeIllegalChars(itemName) + extension fun FileManager.getImageCachePath( url: String ): String = imageCacheDir() + getNameFromURL(url, isImage = true) /*DIR Specific Operation End*/ private fun getNameFromURL(url: String, isImage: Boolean = false): String { val startIndex = url.lastIndexOf('/', url.lastIndexOf('/') - 1) + 1 var fileName = if (startIndex != -1) url.substring(startIndex).replace('/', '_') else url.substringAfterLast("/") // Generify File Extensions if (isImage) { if (fileName.length - fileName.lastIndexOf(".") > 5) { fileName += ".jpeg" } else { if (fileName.endsWith(".jpg")) fileName = fileName.substringBeforeLast(".") + ".jpeg" } } return fileName } suspend fun HttpClient.downloadFile(url: String) = downloadFile(url, this) suspend fun downloadFile(url: String, client: HttpClient? = null): Flow { return flow { val httpClient = client ?: createHttpClient() httpClient.get(url).execute { response -> // Not all requests return Content Length val data = kotlin.runCatching { ByteArray(response.contentLength().requireNotNull().toInt()) }.getOrNull() ?: byteArrayOf() var offset = 0 val downloadableContent = response.content do { // Set Length optimally, after how many kb you want a progress update, now its 0.25mb val currentRead = downloadableContent.readAvailable(data, offset, 2_50_000).also { offset += it } // Calculate Download Progress val progress = data.size.takeIf { it != 0 }?.let { fileSize -> (offset * 100f / fileSize).roundToInt() } // Emit Progress Update emit(DownloadResult.Progress(progress ?: 0)) } while (currentRead > 0) // Download Complete if (response.status.isSuccess()) { emit(DownloadResult.Success(data)) } else { emit(DownloadResult.Error("File not downloaded")) } } // Close Client if We Created One during invocation if (client == null) httpClient.close() }.catch { e -> e.printStackTrace() emit(DownloadResult.Error(e.message ?: "File not downloaded")) } } suspend fun downloadByteArray( url: String, httpBuilder: HttpRequestBuilder.() -> Unit = {} ): ByteArray? { val client = createHttpClient() val response = try { client.get(url, httpBuilder) } catch (e: Exception) { return null } client.close() return response } ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/media_converter/MediaConverter.kt ================================================ package com.shabinder.common.core_components.media_converter import com.shabinder.common.core_components.parallel_executor.ParallelExecutor import com.shabinder.common.core_components.parallel_executor.ParallelProcessor import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.dispatcherDefault import com.shabinder.common.models.event.coroutines.SuspendableEvent import org.koin.core.module.Module abstract class MediaConverter : ParallelProcessor { /* * Operations Pool * */ override val parallelExecutor = ParallelExecutor(dispatcherDefault) /* * By Default AudioQuality Output will be equal to Input's Quality,i.e, Denoted by AudioQuality.UNKNOWN * */ abstract suspend fun convertAudioFile( inputFilePath: String, outputFilePath: String, audioQuality: AudioQuality = AudioQuality.UNKNOWN, progressCallbacks: (Long) -> Unit = {}, ): SuspendableEvent } internal expect fun mediaConverterModule(): Module ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/parallel_executor/ParallelExecutor.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components.parallel_executor // Dependencies: // implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt") // implementation("org.jetbrains.kotlinx:atomicfu:0.14.4") // Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e import com.shabinder.common.models.dispatcherIO import com.shabinder.common.models.event.coroutines.SuspendableEvent import io.ktor.utils.io.core.* import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.selects.select import kotlin.coroutines.CoroutineContext interface ParallelProcessor { val parallelExecutor: ParallelExecutor suspend fun executeSafelyInPool(block: suspend () -> T): SuspendableEvent { return SuspendableEvent { parallelExecutor.executeSuspending(block) } } suspend fun stopAllTasks() { parallelExecutor.closeAndReInit() } } class ParallelExecutor( private val context: CoroutineContext = dispatcherIO, concurrentOperationLimit: Int = 4 ) : Closeable, CoroutineScope { private var service: Job = SupervisorJob() override val coroutineContext get() = context + service var isClosed = atomic(false) private set private var killQueue = Channel(Channel.UNLIMITED) private var operationQueue = Channel>(Channel.RENDEZVOUS) private var concurrentOperationLimit = atomic(concurrentOperationLimit) init { startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0) } override fun close() { if (!isClosed.compareAndSet(expect = false, update = true)) return val cause = CancellationException("Executor was closed.") killQueue.close(cause) operationQueue.close(cause) service.cancel(cause) coroutineContext.cancel(cause) } fun reviveIfClosed() { if (!service.isActive) { closeAndReInit() } } fun closeAndReInit(newConcurrentOperationLimit: Int = 4) { // Close Everything close() // ReInit everything service = SupervisorJob() isClosed = atomic(false) killQueue = Channel(Channel.UNLIMITED) operationQueue = Channel(Channel.RENDEZVOUS) concurrentOperationLimit = atomic(newConcurrentOperationLimit) startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0) } private fun CoroutineScope.launchProcessor() = launch { while (true) { val operation = select?> { killQueue.onReceive { null } operationQueue.onReceive { it } } ?: break operation.execute() } } suspend fun executeSuspending(block: suspend () -> Result): Result = withContext(coroutineContext) { val operation = Operation(block) operationQueue.send(operation) operation.result.await() } fun execute(onComplete: (Result) -> Unit = {}, block: suspend () -> Result) { launch(coroutineContext) { val operation = Operation(block) operationQueue.send(operation) onComplete(operation.result.await()) } } // TODO This launches all coroutines in advance even if they're never needed. Find a lazy way to do this. @Suppress("unused") fun setConcurrentOperationLimit(limit: Int) { require(limit >= 1) { "'limit' must be greater than zero: $limit" } require(limit < 1_000_000) { "Don't use a very high limit because it will cause a lot of coroutines to be started eagerly: $limit" } startOrStopProcessors(expectedCount = limit, actualCount = concurrentOperationLimit.getAndSet(limit)) } private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) { if (!service.isActive) service = SupervisorJob() if (expectedCount == actualCount) return if (isClosed.value) return var change = expectedCount - actualCount while (change > 0 && killQueue.tryReceive().getOrNull() != null) change -= 1 if (change > 0) repeat(change) { launchProcessor() } else repeat(-change) { killQueue.trySend(Unit).isSuccess } } private class Operation( private val block: suspend () -> Result, ) { private val _result = CompletableDeferred() val result: Deferred get() = _result suspend fun execute() { try { _result.complete(block()) } catch (e: Throwable) { _result.completeExceptionally(e) } } } } /* suspend fun main() = coroutineScope { val executor = ParallelExecutor(coroutineContext) println("Concurrency: 1") coroutineScope { (1 .. 200).forEach { i -> launch { executor.execute { println("Execution $i") delay(250) when (i) { 10 -> { println("Concurrency: 5") executor.setConcurrentOperationLimit(5) } 100 -> { println("Concurrency: 1") executor.setConcurrentOperationLimit(1) } 110 -> { println("Closing executor") executor.close() } } } } delay(1) } } println("Fin.") }*/ ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/picture/Picture.kt ================================================ package com.shabinder.common.core_components.picture expect class Picture ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/preference_manager/PreferenceManager.kt ================================================ package com.shabinder.common.core_components.preference_manager import co.touchlab.stately.annotation.Throws import com.russhwolf.settings.Settings import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.spotify.SpotifyCredentials import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlin.native.concurrent.ThreadLocal class PreferenceManager( settings: Settings, ) : Settings by settings { companion object { const val DIR_KEY = "downloadDir" const val ANALYTICS_KEY = "analytics" const val FIRST_LAUNCH = "firstLaunch" const val DONATION_INTERVAL = "donationInterval" const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality" @Suppress("VARIABLE_IN_SINGLETON_WITHOUT_THREAD_LOCAL") lateinit var instance: PreferenceManager private set } init { instance = this } lateinit var analyticsManager: AnalyticsManager /* ANALYTICS */ val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false fun toggleAnalytics(enabled: Boolean) { putBoolean(ANALYTICS_KEY, enabled) if (this::analyticsManager.isInitialized) { if (enabled) analyticsManager.giveConsent() else analyticsManager.revokeConsent() } } /* DOWNLOAD DIRECTORY */ val downloadDir get() = getStringOrNull(DIR_KEY) fun setDownloadDirectory(newBasePath: String) = putString(DIR_KEY, newBasePath) /* Preferred Audio Quality */ val audioQuality get() = AudioQuality.getQuality(getStringOrNull(PREFERRED_AUDIO_QUALITY) ?: "320") fun setPreferredAudioQuality(quality: AudioQuality) = putString(PREFERRED_AUDIO_QUALITY, quality.kbps) val spotifyCredentials: SpotifyCredentials get() = getStringOrNull("spotifyCredentials")?.let { Json.decodeFromString(it) } ?: SpotifyCredentials() fun setSpotifyCredentials(credentials: SpotifyCredentials) = putString("spotifyCredentials", Json.encodeToString(SpotifyCredentials.serializer(), credentials)) /* OFFSET FOR WHEN TO ASK FOR SUPPORT */ val getDonationOffset: Int get() = (getIntOrNull(DONATION_INTERVAL) ?: 3).also { // Min. Donation Asking Interval is `3` if (it < 3) setDonationOffset(3) else setDonationOffset(it - 1) } fun setDonationOffset(offset: Int = 5) = putInt(DONATION_INTERVAL, offset) /* TO CHECK IF THIS IS APP's FIRST LAUNCH */ val isFirstLaunch get() = getBooleanOrNull(FIRST_LAUNCH) ?: true fun firstLaunchDone() = putBoolean(FIRST_LAUNCH, false) } ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/utils/NetworkingExt.kt ================================================ package com.shabinder.common.core_components.utils import com.shabinder.common.models.dispatcherIO import com.shabinder.common.utils.globalJson import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.features.HttpTimeout import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.logging.DEFAULT import io.ktor.client.features.logging.LogLevel import io.ktor.client.features.logging.Logger import io.ktor.client.features.logging.Logging import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.request.head import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import kotlinx.coroutines.withContext import kotlin.native.concurrent.SharedImmutable suspend fun isInternetAccessible(): Boolean { return withContext(dispatcherIO) { try { ktorHttpClient.head("https://open.spotify.com/") true } catch (e: Exception) { e.printStackTrace() false } } } // If Fails returns Input Url suspend inline fun HttpClient.getFinalUrl( url: String, crossinline block: HttpRequestBuilder.() -> Unit = {} ): String { return withContext(dispatcherIO) { runCatching { get(url, block).call.request.url.toString() }.getOrNull() ?: url } } fun createHttpClient(enableNetworkLogs: Boolean = false) = buildHttpClient { // https://github.com/Kotlin/kotlinx.serialization/issues/1450 install(JsonFeature) { serializer = KotlinxSerializer(globalJson) } install(HttpTimeout) { socketTimeoutMillis = 520_000 requestTimeoutMillis = 360_000 connectTimeoutMillis = 360_000 } // WorkAround for Freezing // Use httpClient.getData / httpClient.postData Extensions /*install(JsonFeature) { serializer = KotlinxSerializer( Json { isLenient = true ignoreUnknownKeys = true } ) }*/ if (enableNetworkLogs) { install(Logging) { logger = Logger.DEFAULT level = LogLevel.INFO } } } expect fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient /*Client Active Throughout App's Lifetime*/ @SharedImmutable private val ktorHttpClient = HttpClient {} ================================================ FILE: common/core-components/src/commonMain/kotlin/com.shabinder.common.core_components/utils/StoreExt.kt ================================================ package com.shabinder.common.core_components.utils import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.ValueObserver import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.rx.Disposable fun Store<*, T, *>.asValue(): Value = object : Value() { override val value: T get() = state private var disposables = emptyMap, Disposable>() override fun subscribe(observer: ValueObserver) { val disposable = states(com.arkivanov.mvikotlin.rx.observer(onNext = observer)) this.disposables += observer to disposable } override fun unsubscribe(observer: ValueObserver) { val disposable = disposables[observer] ?: return this.disposables -= observer disposable.dispose() } } ================================================ FILE: common/core-components/src/desktopMain/kotlin/com/shabinder/common/core_components/utils/DesktopHttpClient.kt ================================================ package com.shabinder.common.core_components.utils import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.apache.Apache import org.apache.http.conn.ssl.NoopHostnameVerifier import org.apache.http.conn.ssl.TrustSelfSignedStrategy import org.apache.http.ssl.SSLContextBuilder actual fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient { return HttpClient(Apache) { engine { customizeClient { setSSLContext( SSLContextBuilder .create() .loadTrustMaterial(TrustSelfSignedStrategy()) .build() ) setSSLHostnameVerifier(NoopHostnameVerifier()) } } extraConfig() } } ================================================ FILE: common/core-components/src/desktopMain/kotlin/com.shabinder.common.core_components/analytics/DesktopAnalyticsManager.kt ================================================ package com.shabinder.common.core_components.analytics import com.shabinder.common.core_components.file_manager.FileManager import ly.count.sdk.java.Config import ly.count.sdk.java.Config.DeviceIdStrategy import ly.count.sdk.java.Config.Feature import ly.count.sdk.java.ConfigCore.LoggingLevel import ly.count.sdk.java.Countly import org.koin.dsl.bind import org.koin.dsl.module import java.io.File internal class DesktopAnalyticsManager( private val fileManager: FileManager ) : AnalyticsManager { init { init() } override fun init() { val config: Config = Config(COUNTLY_CONFIG.SERVER_URL, COUNTLY_CONFIG.APP_KEY).apply { eventsBufferSize = 2 loggingLevel = LoggingLevel.ERROR setDeviceIdStrategy(DeviceIdStrategy.UUID) enableFeatures(*featuresSet) setRequiresConsent(true) } Countly.init(File(fileManager.defaultDir()), config) Countly.session().begin(); } override fun giveConsent() { Countly.onConsent(*featuresSet) } override fun isTracking(): Boolean = Countly.isTracking(Feature.Events) override fun revokeConsent() { Countly.onConsentRemoval(*featuresSet) } override fun sendView(name: String, extras: MutableMap) { Countly.api().view(name) } override fun sendEvent(eventName: String, extras: MutableMap) { Countly.api().event(eventName) .setSegmentation(extras.filterValues { it is String } as? MutableMap ?: emptyMap()).record() } override fun sendCrashReport(error: Throwable, extras: MutableMap) { Countly.api().addCrashReport( error, extras.getOrDefault("fatal", true) as Boolean, error.javaClass.simpleName, extras.filterValues { it is String } as? MutableMap ?: emptyMap() ) } companion object { val featuresSet = arrayOf( Feature.Events, Feature.Sessions, Feature.CrashReporting, Feature.Views, Feature.UserProfiles, Feature.Location, ) } override fun onStart() {} override fun onStop() {} } actual fun analyticsModule() = module { single { DesktopAnalyticsManager(get()) } bind AnalyticsManager::class } ================================================ FILE: common/core-components/src/desktopMain/kotlin/com.shabinder.common.core_components/file_manager/DesktopFileManager.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components.file_manager import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import co.touchlab.kermit.Kermit import com.github.kokorin.jaffree.JaffreeException import com.mpatric.mp3agic.InvalidDataException import com.mpatric.mp3agic.Mp3File import com.shabinder.common.core_components.media_converter.MediaConverter import com.shabinder.common.core_components.parallel_executor.ParallelExecutor import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.core_components.removeAllTags import com.shabinder.common.core_components.setId3v1Tags import com.shabinder.common.core_components.setId3v2TagsAndSaveFile import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.models.Actions import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.dispatcherIO import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.map import com.shabinder.database.Database import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.skia.Image import org.koin.dsl.bind import org.koin.dsl.module import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import javax.imageio.ImageIO internal actual fun fileManagerModule() = module { single { DesktopFileManager(get(), get(), get(), get()) } bind FileManager::class } val DownloadProgressFlow: MutableSharedFlow> = MutableSharedFlow(1) // Scope Allowing 4 Parallel Downloads val DownloadScope = ParallelExecutor(Dispatchers.IO) class DesktopFileManager( override val logger: Kermit, override val preferenceManager: PreferenceManager, override val mediaConverter: MediaConverter, spotiFlyerDatabase: SpotiFlyerDatabase, ) : FileManager { init { createDirectories() } override fun fileSeparator(): String = File.separator override fun imageCacheDir(): String = System.getProperty("user.home") + fileSeparator() + "SpotiFlyer/.images" + fileSeparator() private val defaultBaseDir = System.getProperty("user.home") override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() + "SpotiFlyer" + fileSeparator() override fun isPresent(path: String): Boolean = File(path).exists() override fun createDirectory(dirPath: String) { val yourAppDir = File(dirPath) if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else { logger.e { "Unable to create Dir: $dirPath!" } } } else { logger.i { "$dirPath already exists" } } } override suspend fun clearCache() { File(imageCacheDir()).deleteRecursively() } override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) { try { val file = File(path) if(!file.parentFile.exists()) createDirectories() (image as? BufferedImage)?.let { ImageIO.write(it, "jpeg", file) } } catch (e: IOException) { e.printStackTrace() } } @Suppress("BlockingMethodInNonBlockingContext") override suspend fun saveFileWithMetadata( mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit ) = withContext(dispatcherIO) { val songFile = File(trackDetails.outputFilePath) try { /* * Check , if Fetch was Used, File is saved Already, else write byteArray we Received * */ if (!songFile.exists()) { /*Make intermediate Dirs if they don't exist yet*/ songFile.parentFile.mkdirs() } if (mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray) try { // Add Mp3 Tags and Add to Library Mp3File(File(songFile.absolutePath)) .removeAllTags() .setId3v1Tags(trackDetails) .setId3v2TagsAndSaveFile(trackDetails) addToLibrary(songFile.absolutePath) } catch (e: Exception) { // Media File Isn't MP3 lets Convert It first if (e is InvalidDataException) { val convertedFilePath = songFile.absolutePath.substringBeforeLast('.') + ".temp.mp3" val conversionResult = mediaConverter.convertAudioFile( inputFilePath = songFile.absolutePath, outputFilePath = convertedFilePath, trackDetails.audioQuality ) conversionResult.map { outputFilePath -> Mp3File(File(outputFilePath)) .removeAllTags() .setId3v1Tags(trackDetails) .setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath) addToLibrary(trackDetails.outputFilePath) }.fold( success = {}, failure = { throw it } ) File(convertedFilePath).delete() } else throw e } SuspendableEvent.success(trackDetails.outputFilePath) } catch (e: Throwable) { if (e is JaffreeException) Actions.instance.showPopUpMessage("No FFmpeg found at path.") if (songFile.exists()) songFile.delete() logger.e { "${songFile.absolutePath} could not be created" } SuspendableEvent.error(e) } } override fun addToLibrary(path: String) {} override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture { var picture: ImageBitmap? = loadCachedImage(getImageCachePath(url), reqWidth, reqHeight) if (picture == null) picture = freshImage(url, reqWidth, reqHeight) return Picture(image = picture) } private fun loadCachedImage(cachePath: String, reqWidth: Int, reqHeight: Int): ImageBitmap? { return try { ImageIO.read(File(cachePath))?.toImageBitmap() } catch (e: Exception) { // e.printStackTrace() null } } @OptIn(DelicateCoroutinesApi::class) @Suppress("BlockingMethodInNonBlockingContext") private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? { return withContext(Dispatchers.IO) { try { val source = URL(url) val connection: HttpURLConnection = source.openConnection() as HttpURLConnection connection.connectTimeout = 5000 connection.connect() val input: InputStream = connection.inputStream val result: BufferedImage? = ImageIO.read(input) if (result != null) { GlobalScope.launch(Dispatchers.IO) { // TODO Refactor cacheImage(result, getImageCachePath(url)) } result.toImageBitmap() } else null } catch (e: Exception) { e.printStackTrace() null } } } override val db: Database? = spotiFlyerDatabase.instance } fun BufferedImage.toImageBitmap() = Image.makeFromEncoded( toByteArray(this) ).asImageBitmap() private fun toByteArray(bitmap: BufferedImage): ByteArray { val baOs = ByteArrayOutputStream() ImageIO.write(bitmap, "png", baOs) return baOs.toByteArray() } ================================================ FILE: common/core-components/src/desktopMain/kotlin/com.shabinder.common.core_components/media_converter/DesktopMediaConverter.kt ================================================ package com.shabinder.common.core_components.media_converter import com.github.kokorin.jaffree.ffmpeg.FFmpeg import com.github.kokorin.jaffree.ffmpeg.UrlInput import com.github.kokorin.jaffree.ffmpeg.UrlOutput import com.shabinder.common.models.AudioQuality import org.koin.dsl.bind import org.koin.dsl.module import kotlin.io.path.Path class DesktopMediaConverter : MediaConverter() { override suspend fun convertAudioFile( inputFilePath: String, outputFilePath: String, audioQuality: AudioQuality, progressCallbacks: (Long) -> Unit, ) = executeSafelyInPool { val audioBitrate = if (audioQuality == AudioQuality.UNKNOWN) 192 else audioQuality.kbps.toIntOrNull() ?: 192 FFmpeg.atPath().run { addInput(UrlInput.fromUrl(inputFilePath)) setOverwriteOutput(true) if (audioQuality != AudioQuality.UNKNOWN) { addArguments("-b:a", "${audioBitrate}k") } addArguments("-acodec", "libmp3lame") addArgument("-vn") addOutput(UrlOutput.toUrl(outputFilePath)) setProgressListener { progressCallbacks(it.timeMillis) } execute() return@run outputFilePath } } } internal actual fun mediaConverterModule() = module { single { DesktopMediaConverter() } bind MediaConverter::class } ================================================ FILE: common/core-components/src/desktopMain/kotlin/com.shabinder.common.core_components/media_converter/ID3Tagging.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components import com.mpatric.mp3agic.ID3v1Tag import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.Mp3File import com.shabinder.common.core_components.file_manager.downloadFile import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.TrackDetails import kotlinx.coroutines.flow.collect import java.io.File import java.io.FileInputStream fun Mp3File.removeAllTags(): Mp3File { if (hasId3v1Tag()) removeId3v1Tag() if (hasId3v2Tag()) removeId3v2Tag() if (hasCustomTag()) removeCustomTag() return this } /** * Modifying Mp3 with MetaData! **/ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File { val id3v1Tag = ID3v1Tag().apply { artist = track.artists.joinToString(",") title = track.title album = track.albumName year = track.year comment = "Genres:${track.comment}" } this.id3v1Tag = id3v1Tag return this } @Suppress("BlockingMethodInNonBlockingContext") suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) { val id3v2Tag = ID3v24Tag().apply { albumArtist = track.albumArtists.joinToString(", ") artist = track.artists.joinToString(", ") title = track.title album = track.albumName year = track.year genreDescription = "Genre: " + track.genre.joinToString(", ") comment = track.comment lyrics = track.lyrics ?: "" url = track.trackUrl if (track.trackNumber != null) this.track = track.trackNumber.toString() } try { val art = File(track.albumArtPath) val bytesArray = ByteArray(art.length().toInt()) val fis = FileInputStream(art) fis.read(bytesArray) // read file into bytes[] fis.close() id3v2Tag.setAlbumImage(bytesArray, "image/jpeg") this.id3v2Tag = id3v2Tag saveFile(outputFilePath ?: track.outputFilePath) } catch (e: java.io.FileNotFoundException) { try { // Image Still Not Downloaded! // Lets Download Now and Write it into Album Art downloadFile(track.albumArtURL).collect { when (it) { is DownloadResult.Error -> {} // Error is DownloadResult.Success -> { id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg") this.id3v2Tag = id3v2Tag saveFile(outputFilePath ?: track.outputFilePath) } is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show } } } catch (e: Exception) { e.printStackTrace() } } } fun Mp3File.saveFile(filePath: String) { save(filePath.substringBeforeLast('.') + ".tagged.mp3") val oldFile = File(filePath) oldFile.delete() val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3")) newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3")) } ================================================ FILE: common/core-components/src/desktopMain/kotlin/com.shabinder.common.core_components/picture/Picture.kt ================================================ package com.shabinder.common.core_components.picture import androidx.compose.ui.graphics.ImageBitmap actual data class Picture( var image: ImageBitmap? ) ================================================ FILE: common/core-components/src/iosMain/kotlin/com.shabinder.common.core_components/IOSDeps.kt ================================================ package com.shabinder.common.di import org.koin.core.component.KoinComponent import org.koin.core.component.inject /* * Dependency Provider for IOS * */ object IOSDeps : KoinComponent { val dir: Dir by inject() // = get() val fetchPlatformQueryResult: FetchPlatformQueryResult by inject() // get() val database get() = dir.db val sharedFlow = DownloadProgressFlow val defaultDispatcher = dispatcherDefault } ================================================ FILE: common/core-components/src/iosMain/kotlin/com.shabinder.common.core_components/IOSDir.kt ================================================ package com.shabinder.common.di import co.touchlab.kermit.Kermit import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.di.preference.PreferenceManager import com.shabinder.common.models.TrackDetails import com.shabinder.database.Database import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import platform.Foundation.NSCachesDirectory import platform.Foundation.NSDirectoryEnumerationSkipsHiddenFiles import platform.Foundation.NSFileManager import platform.Foundation.NSMusicDirectory import platform.Foundation.NSURL import platform.Foundation.NSURLConnection import platform.Foundation.NSURLRequest import platform.Foundation.NSUserDomainMask import platform.Foundation.URLByAppendingPathComponent import platform.Foundation.sendSynchronousRequest import platform.Foundation.writeToFile import platform.UIKit.UIImage import platform.UIKit.UIImageJPEGRepresentation actual class Dir actual constructor( val logger: Kermit, private val preferenceManager: PreferenceManager, spotiFlyerDatabase: SpotiFlyerDatabase, ) { actual fun isPresent(path: String): Boolean = NSFileManager.defaultManager.fileExistsAtPath(path) actual fun fileSeparator(): String = "/" private val defaultBaseDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!!.path!! // TODO Error Handling actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() + "SpotiFlyer" + fileSeparator() private val defaultDirURL: NSURL by lazy { val musicDir = NSFileManager.defaultManager.URLForDirectory(NSMusicDirectory, NSUserDomainMask, null, true, null)!! musicDir.URLByAppendingPathComponent("SpotiFlyer", true)!! } actual fun imageCacheDir(): String = imageCacheURL.path!! + fileSeparator() private val imageCacheURL: NSURL by lazy { val cacheDir = NSFileManager.defaultManager.URLForDirectory(NSCachesDirectory, NSUserDomainMask, null, true, null) cacheDir?.URLByAppendingPathComponent("SpotiFlyer", true)!! } actual fun createDirectory(dirPath: String) { try { NSFileManager.defaultManager.createDirectoryAtPath(dirPath, true, null, null) } catch (e: Exception) { e.printStackTrace() } } fun createDirectory(dirURL: NSURL) { try { NSFileManager.defaultManager.createDirectoryAtURL(dirURL, true, null, null) } catch (e: Exception) { e.printStackTrace() } } actual suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) { try { (image as? UIImage)?.let { // We Will Be Using JPEG as default format everywhere UIImageJPEGRepresentation(it, 1.0) ?.writeToFile(path, true) } } catch (e: Exception) { e.printStackTrace() } } actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture = withContext(dispatcherIO) { try { val cachePath = imageCacheURL.URLByAppendingPathComponent(getNameURL(url)) Picture(image = cachePath?.path?.let { loadCachedImage(it) } ?: loadFreshImage(url)) } catch (e: Exception) { e.printStackTrace() Picture(null) } } private fun loadCachedImage(filePath: String, reqWidth: Int = 150, reqHeight: Int = 150): UIImage? { return try { UIImage.imageWithContentsOfFile(filePath) } catch (e: Exception) { e.printStackTrace() null } } private suspend fun loadFreshImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): UIImage? = withContext(dispatcherIO) { try { val nsURL = NSURL(string = url) val data = NSURLConnection.sendSynchronousRequest(NSURLRequest.requestWithURL(nsURL), null, null) if (data != null) { UIImage.imageWithData(data)?.also { GlobalScope.launch { cacheImage(it, imageCacheDir() + getNameURL(url)) } } } else null } catch (e: Exception) { e.printStackTrace() null } } actual suspend fun clearCache(): Unit = withContext(dispatcherIO) { try { val fileManager = NSFileManager.defaultManager val paths = fileManager.contentsOfDirectoryAtURL( imageCacheURL, null, NSDirectoryEnumerationSkipsHiddenFiles, null ) paths?.forEach { (it as? NSURL)?.let { nsURL -> // Lets Remove Cached File fileManager.removeItemAtURL(nsURL, null) } } } catch (e: Exception) { e.printStackTrace() } } actual suspend fun saveFileWithMetadata( mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit ): Unit = withContext(dispatcherIO) { try { if (mp3ByteArray.isNotEmpty()) { mp3ByteArray.toNSData().writeToFile( trackDetails.outputFilePath, true ) } when (trackDetails.outputFilePath.substringAfterLast('.')) { ".mp3" -> { if (!isPresent(trackDetails.albumArtPath)) { val imageData = downloadByteArray( trackDetails.albumArtURL )?.toNSData() if (imageData != null) { UIImage.imageWithData(imageData)?.also { cacheImage(it, trackDetails.albumArtPath) } } } postProcess(trackDetails) /*val file = TLAudio(trackDetails.outputFilePath) file.addTagsAndSave( trackDetails, this::loadCachedImage, this::addToLibrary )*/ } } } catch (e: Exception) { e.printStackTrace() } } actual fun addToLibrary(path: String) { // TODO } actual val db: Database? = spotiFlyerDatabase.instance } ================================================ FILE: common/core-components/src/iosMain/kotlin/com.shabinder.common.core_components/IOSTagging.kt ================================================ package com.shabinder.common.di /* import cocoapods.TagLibIOS.TLAudio import com.shabinder.common.models.TrackDetails import platform.Foundation.NSNumber import platform.UIKit.UIImage import platform.UIKit.UIImageJPEGRepresentation suspend fun TLAudio.addTagsAndSave( trackDetails: TrackDetails, loadCachedImage:(path:String)->UIImage?, addToLibrary:(path:String)->Unit ) { title = trackDetails.title artist = trackDetails.artists.joinToString(", ") album = trackDetails.albumName comment = trackDetails.comment try { trackDetails.year?.substring(0, 4)?.toInt()?.let { year = NSNumber(it) } } catch (e: Exception) {} try { val image = loadCachedImage(trackDetails.albumArtPath) if (image != null) { setFrontCoverPicture(UIImageJPEGRepresentation(image,1.0)) save() addToLibrary(trackDetails.outputFilePath) } throw Exception("Cached Image not Present,Trying to Download...") } catch (e: Exception){ e.printStackTrace() try { downloadByteArray(trackDetails.albumArtURL)?.toNSData()?.also { setFrontCoverPicture(it) save() addToLibrary(trackDetails.outputFilePath) } } catch (e: Exception){ e.printStackTrace() } } }*/ ================================================ FILE: common/core-components/src/iosMain/kotlin/com.shabinder.common.core_components/IOSUtils.kt ================================================ package com.shabinder.common.di import kotlinx.cinterop.memScoped import kotlinx.cinterop.refTo import kotlinx.cinterop.toCValues import platform.Foundation.NSData import platform.Foundation.create import platform.posix.memcpy @OptIn(ExperimentalUnsignedTypes::class) fun ByteArray.toNSData(): NSData = memScoped { return NSData.create( bytes = toCValues().getPointer(this), length = size.toULong() ) } @OptIn(ExperimentalUnsignedTypes::class) fun NSData.toByteArray(): ByteArray = memScoped { val size = length.toInt() val nsData = ByteArray(size) memcpy(nsData.refTo(0), bytes, size.toULong()) return nsData } ================================================ FILE: common/core-components/src/iosMain/kotlin/com.shabinder.common.core_components/picture/IOSPicture.kt ================================================ package com.shabinder.common.di import platform.UIKit.UIImage actual data class Picture( val image: UIImage? ) ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/FileSave.kt ================================================ /* * * Copyright (c) 2021 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 . */ @file:JsModule("file-saver") @file:JsNonModule package com.shabinder.common.core_components 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) ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/ID3Writer.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components 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() } ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/analytics/WebAnalyticsManager.kt ================================================ package com.shabinder.common.core_components.analytics import org.koin.dsl.bind import org.koin.dsl.module // TODO("Not yet implemented") private val webAnalytics = object : AnalyticsManager { override fun init() {} override fun onStart() {} override fun onStop() {} override fun giveConsent() {} override fun isTracking(): Boolean = false override fun revokeConsent() {} override fun sendView(name: String, extras: MutableMap) {} override fun sendEvent(eventName: String, extras: MutableMap) {} override fun sendCrashReport(error: Throwable, extras: MutableMap) {} } actual fun analyticsModule() = module { single { webAnalytics } bind AnalyticsManager::class } ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/file_manager/WebFileManager.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.core_components.file_manager import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.ID3Writer import com.shabinder.common.core_components.media_converter.MediaConverter import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.core_components.saveAs import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.corsApi import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.utils.removeIllegalChars import com.shabinder.database.Database import kotlinext.js.Object import kotlinext.js.js import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collect import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.Int8Array import org.koin.dsl.bind import org.koin.dsl.module import org.w3c.dom.ImageBitmap internal actual fun fileManagerModule() = module { single { WebFileManager(get(), get(), get(), get()) } bind FileManager::class } class WebFileManager( override val logger: Kermit, override val preferenceManager: PreferenceManager, override val mediaConverter: MediaConverter, spotiFlyerDatabase: SpotiFlyerDatabase, ) : FileManager { /*init { createDirectories() }*/ /* * TODO * */ override fun fileSeparator(): String = "/" override fun imageCacheDir(): String = "TODO" + fileSeparator() + "SpotiFlyer/.images" + fileSeparator() override fun defaultDir(): String = "TODO" + fileSeparator() + "SpotiFlyer" + fileSeparator() override fun isPresent(path: String): Boolean = false override fun createDirectory(dirPath: String) {} override suspend fun clearCache() {} override suspend fun cacheImage(image: Any, path: String) {} @Suppress("BlockingMethodInNonBlockingContext") override suspend fun saveFileWithMetadata( mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit ): SuspendableEvent { return SuspendableEvent { val writer = ID3Writer(mp3ByteArray.toArrayBuffer()) val albumArt = downloadFile(corsApi + trackDetails.albumArtURL) albumArt.collect { when (it) { is DownloadResult.Success -> { logger.d { "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 -> { logger.d { "Album Art Downloading Error" } writeTagsAndSave(writer, null, trackDetails) } is DownloadResult.Progress -> logger.d { "Album Art Downloading: ${it.progress}" } } } trackDetails.outputFilePath } } private suspend fun writeTagsAndSave(writer: ID3Writer, albumArt: Object?, trackDetails: TrackDetails) { writer.apply { setFrame("TIT2", trackDetails.title) setFrame("TPE1", trackDetails.artists.toTypedArray()) 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() allTracksStatus[trackDetails.title] = DownloadStatus.Downloaded DownloadProgressFlow.emit(allTracksStatus) saveAs(writer.getBlob(), "${removeIllegalChars(trackDetails.title)}.mp3") } override fun addToLibrary(path: String) {} override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture { return Picture(url) } private fun loadCachedImage(cachePath: String): ImageBitmap? = null private suspend fun freshImage(url: String): ImageBitmap? = null override val db: Database? = spotiFlyerDatabase.instance } fun ByteArray.toArrayBuffer(): ArrayBuffer { return this.unsafeCast().buffer } val DownloadProgressFlow: MutableSharedFlow> = MutableSharedFlow(1) // Error:https://github.com/Kotlin/kotlinx.atomicfu/issues/182 // val DownloadScope = ParallelExecutor(Dispatchers.Default) //Download Pool of 4 parallel val allTracksStatus: HashMap = hashMapOf() ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/media_converter/WebMediaConverter.kt ================================================ package com.shabinder.common.core_components.media_converter import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.event.Event import com.shabinder.common.models.event.coroutines.SuspendableEvent import org.koin.dsl.bind import org.koin.dsl.module class WebMediaConverter: MediaConverter() { override suspend fun convertAudioFile( inputFilePath: String, outputFilePath: String, audioQuality: AudioQuality, progressCallbacks: (Long) -> Unit ): SuspendableEvent { // TODO("Not yet implemented") return SuspendableEvent.error(NotImplementedError()) } } internal actual fun mediaConverterModule() = module { single { WebMediaConverter() } bind MediaConverter::class } ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/picture/Picture.kt ================================================ package com.shabinder.common.core_components.picture actual data class Picture( var imageUrl: String ) ================================================ FILE: common/core-components/src/jsMain/kotlin/com.shabinder.common.core_components/utils/WebHttpClient.kt ================================================ package com.shabinder.common.core_components.utils import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.js.Js actual fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(Js) { extraConfig() } ================================================ FILE: common/data-models/build.gradle.kts ================================================ import de.comahe.i18n4k.gradle.plugin.i18n4k /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") id("kotlin-parcelize") kotlin("plugin.serialization") id("de.comahe.i18n4k") } i18n4k { inputDirectory = "../../translations" packageName = "com.shabinder.common.translations" // sourceCodeLocales = listOf("en", "de", "es", "fr", "id", "pt", "ru", "uk") } kotlin { sourceSets { /* * Depend on https://github.com/ReactiveCircus/cache4k * -As Soon as Kotlin 1.5 and Compose becomes compatible * */ all { languageSettings.apply { progressiveMode = true enableLanguageFeature("NewInference") useExperimentalAnnotation("kotlin.Experimental") useExperimentalAnnotation("kotlin.time.ExperimentalTime") } } commonMain { dependencies { with(deps) { api(bundles.stately) api(i18n4k.core) api(kermit) api(moko.parcelize) implementation(youtube.downloader) } } } } } ================================================ FILE: common/data-models/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/data-models/src/androidMain/kotlin/com.shabinder.common.models/AndroidAtomicReference.kt ================================================ package com.shabinder.common.models actual class NativeAtomicReference actual constructor(actual var value: T) ================================================ FILE: common/data-models/src/androidMain/kotlin/com.shabinder.common.models/AndroidDispatcher.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers // IO-Dispatcher actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO ================================================ FILE: common/data-models/src/androidMain/kotlin/com.shabinder.common.models/AndroidPlatformActions.kt ================================================ package com.shabinder.common.models import android.content.SharedPreferences import kotlinx.coroutines.CoroutineScope actual interface PlatformActions { companion object { const val SharedPreferencesKey = "configurations" } val imageCacheDir: String val sharedPreferences: SharedPreferences? fun addToLibrary(path: String) fun sendTracksToService(array: List) } internal actual val StubPlatformActions = object : PlatformActions { override val imageCacheDir = "" override val sharedPreferences: SharedPreferences? = null override fun addToLibrary(path: String) {} override fun sendTracksToService(array: List) {} } actual fun runBlocking(block: suspend CoroutineScope.() -> T): T = kotlinx.coroutines.runBlocking { block() } ================================================ FILE: common/data-models/src/androidMain/res/drawable/jio_saavn.xml ================================================ ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt ================================================ package com.shabinder.common.caching import kotlin.time.Duration import kotlin.time.ExperimentalTime import kotlin.time.TimeSource /** * An in-memory key-value cache with support for time-based (expiration) and size-based evictions. */ public interface Cache { /** * Returns the value associated with [key] in this cache, or null if there is no * cached value for [key]. */ public fun get(key: Key): Value? /** * Returns the value associated with [key] in this cache if exists, * otherwise gets the value by invoking [loader], associates the value with [key] in the cache, * and returns the cached value. * * Any exceptions thrown by the [loader] will be propagated to the caller of this function. */ public suspend fun get(key: Key, loader: suspend () -> Value): Value public fun getBlocking(key: Key, loader: suspend () -> Value): Value /** * Associates [value] with [key] in this cache. If the cache previously contained a * value associated with [key], the old value is replaced by [value]. */ public fun put(key: Key, value: Value) /** * Discards any cached value for key [key]. */ public fun invalidate(key: Key) /** * Discards all entries in the cache. */ public fun invalidateAll() /** * Returns a defensive copy of cache entries as [Map]. */ public fun asMap(): Map /** * Main entry point for creating a [Cache]. */ public interface Builder { /** * Specifies that each entry should be automatically removed from the cache once a fixed duration * has elapsed after the entry's creation or the most recent replacement of its value. * * When [duration] is zero, the cache's max size will be set to 0 * meaning no values will be cached. */ public fun expireAfterWrite(duration: Duration): Builder /** * Specifies that each entry should be automatically removed from the cache once a fixed duration * has elapsed after the entry's creation, the most recent replacement of its value, or its last * access. * * When [duration] is zero, the cache's max size will be set to 0 * meaning no values will be cached. */ public fun expireAfterAccess(duration: Duration): Builder /** * Specifies the maximum number of entries the cache may contain. * Cache eviction policy is based on LRU - i.e. least recently accessed entries get evicted first. * * When [size] is 0, entries will be discarded immediately and no values will be cached. * * If not set, cache size will be unlimited. */ public fun maximumCacheSize(size: Long): Builder /** * Specifies a [FakeTimeSource] for programmatically advancing the reading of the underlying * [TimeSource] used for expiry checks in tests. * * If not specified, [TimeSource.Monotonic] will be used for expiry checks. */ public fun fakeTimeSource(fakeTimeSource: FakeTimeSource): Builder /** * Builds a new instance of [Cache] with the specified configurations. */ public fun build(): Cache public companion object { /** * Returns a new [Cache.Builder] instance. */ public fun newBuilder(): Builder = CacheBuilderImpl() } } } /** * A default implementation of [Cache.Builder]. */ internal class CacheBuilderImpl : Cache.Builder { private var expireAfterWriteDuration = Duration.INFINITE private var expireAfterAccessDuration = Duration.INFINITE private var maxSize = UNSET_LONG private var fakeTimeSource: FakeTimeSource? = null override fun expireAfterWrite(duration: Duration): CacheBuilderImpl = apply { require(duration.isPositive()) { "expireAfterWrite duration must be positive" } this.expireAfterWriteDuration = duration } override fun expireAfterAccess(duration: Duration): CacheBuilderImpl = apply { require(duration.isPositive()) { "expireAfterAccess duration must be positive" } this.expireAfterAccessDuration = duration } override fun maximumCacheSize(size: Long): CacheBuilderImpl = apply { require(size >= 0) { "maximum size must not be negative" } this.maxSize = size } override fun fakeTimeSource(fakeTimeSource: FakeTimeSource): CacheBuilderImpl = apply { this.fakeTimeSource = fakeTimeSource } override fun build(): Cache { return RealCache( expireAfterWriteDuration, expireAfterAccessDuration, maxSize, fakeTimeSource ?: TimeSource.Monotonic, ) } companion object { internal const val UNSET_LONG: Long = -1 } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt ================================================ package com.shabinder.common.caching import co.touchlab.stately.concurrency.AtomicLong import kotlin.time.AbstractLongTimeSource import kotlin.time.Duration import kotlin.time.DurationUnit /** * A time source that has programmatically updatable readings with support for multi-threaded access in Kotlin/Native. * * Implementation is identical to [kotlin.time.TestTimeSource] except the internal [reading] is an [AtomicLong]. */ public class FakeTimeSource : AbstractLongTimeSource(unit = DurationUnit.NANOSECONDS) { private val reading = AtomicLong(0) override fun read(): Long = reading.get() /** * Advances the current reading value of this time source by the specified [duration]. * * [duration] value is rounded down towards zero when converting it to a [Long] number of nanoseconds. * For example, if the duration being added is `0.6.nanoseconds`, the reading doesn't advance because * the duration value is rounded to zero nanoseconds. * * @throws IllegalStateException when the reading value overflows as the result of this operation. */ public operator fun plusAssign(duration: Duration) { val delta = duration.toDouble(unit) val longDelta = delta.toLong() reading.set( reading.get().let { currentReading -> if (longDelta != Long.MIN_VALUE && longDelta != Long.MAX_VALUE) { // when delta fits in long, add it as long val newReading = currentReading + longDelta if (currentReading xor longDelta >= 0 && currentReading xor newReading < 0) overflow(duration) newReading } else { // when delta is greater than long, add it as double val newReading = currentReading + delta if (newReading > Long.MAX_VALUE || newReading < Long.MIN_VALUE) overflow(duration) newReading.toLong() } } ) } private fun overflow(duration: Duration) { throw IllegalStateException("FakeTimeSource will overflow if its reading ${reading}ns is advanced by $duration.") } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt ================================================ package com.shabinder.common.caching import co.touchlab.stately.collections.IsoMutableMap import co.touchlab.stately.collections.IsoMutableSet import co.touchlab.stately.concurrency.AtomicReference import co.touchlab.stately.concurrency.value import com.shabinder.common.models.runBlocking import kotlin.time.Duration import kotlin.time.TimeMark import kotlin.time.TimeSource /** * A Kotlin Multiplatform [Cache] implementation powered by touchlab/Stately. * * Two types of evictions are supported: * * 1. Time-based evictions (expiration) * 2. Size-based evictions * * Time-based evictions are enabled by specifying [expireAfterWriteDuration] and/or [expireAfterAccessDuration]. * When [expireAfterWriteDuration] is specified, entries will be automatically removed from the cache * once a fixed duration has elapsed after the entry's creation * or most recent replacement of its value. * When [expireAfterAccessDuration] is specified, entries will be automatically removed from the cache * once a fixed duration has elapsed after the entry's creation, * the most recent replacement of its value, or its last access. * * Note that creation and replacement of an entry is also considered an access. * * Size-based evictions are enabled by specifying [maxSize]. When the size of the cache entries grows * beyond [maxSize], least recently accessed entries will be evicted. */ internal class RealCache( val expireAfterWriteDuration: Duration, val expireAfterAccessDuration: Duration, val maxSize: Long, val timeSource: TimeSource, ) : Cache { private val cacheEntries = IsoMutableMap>() /** * Whether to perform size based evictions. */ private val evictsBySize = maxSize >= 0 /** * Whether to perform write-time based expiration. */ private val expiresAfterWrite = expireAfterWriteDuration.isFinite() /** * Whether to perform access-time (both read and write) based expiration. */ private val expiresAfterAccess = expireAfterAccessDuration.isFinite() /** * A queue of unique cache entries ordered by write time. * Used for performing write-time based cache expiration. */ private val writeQueue: IsoMutableSet>? = takeIf { expiresAfterWrite }?.let { ReorderingIsoMutableSet() } /** * A queue of unique cache entries ordered by access time. * Used for performing both write-time and read-time based cache expiration * as well as size-based eviction. * * Note that a write is also considered an access. */ private val accessQueue: IsoMutableSet>? = takeIf { expiresAfterAccess || evictsBySize }?.let { ReorderingIsoMutableSet() } override fun get(key: Key): Value? { return cacheEntries[key]?.let { if (it.isExpired()) { // clean up expired entries and return null expireEntries() null } else { // update eviction metadata recordRead(it) it.value.get() } } } override suspend fun get(key: Key, loader: suspend () -> Value): Value { return cacheEntries[key]?.let { if (it.isExpired()) { // clean up expired entries expireEntries() null } else { // update eviction metadata recordRead(it) it.value.get() } } ?: loader().let { loadedValue -> val existingValue = get(key) if (existingValue != null) { existingValue } else { put(key, loadedValue) loadedValue } } } override fun getBlocking(key: Key, loader: suspend () -> Value): Value = runBlocking { get(key, loader) } override fun put(key: Key, value: Value) { expireEntries() val existingEntry = cacheEntries[key] if (existingEntry != null) { // cache entry found recordWrite(existingEntry) existingEntry.value.set(value) } else { // create a new cache entry val nowTimeMark = timeSource.markNow() val newEntry = CacheEntry( key = key, value = AtomicReference(value), accessTimeMark = AtomicReference(nowTimeMark), writeTimeMark = AtomicReference(nowTimeMark), ) recordWrite(newEntry) cacheEntries[key] = newEntry } evictEntries() } override fun invalidate(key: Key) { expireEntries() cacheEntries.remove(key)?.also { writeQueue?.remove(it) accessQueue?.remove(it) } } override fun invalidateAll() { cacheEntries.clear() writeQueue?.clear() accessQueue?.clear() } override fun asMap(): Map { return cacheEntries.values.associate { entry -> entry.key to entry.value.get() } } /** * Remove all expired entries. */ private fun expireEntries() { val queuesToProcess = listOfNotNull( if (expiresAfterWrite) writeQueue else null, if (expiresAfterAccess) accessQueue else null ) queuesToProcess.forEach { queue -> queue.access { val iterator = queue.iterator() for (entry in iterator) { if (entry.isExpired()) { cacheEntries.remove(entry.key) // remove the entry from the current queue iterator.remove() } else { // found unexpired entry, no need to look any further break } } } } } /** * Check whether the [CacheEntry] has expired based on either access time or write time. */ private fun CacheEntry.isExpired(): Boolean { return expiresAfterAccess && (accessTimeMark.get() + expireAfterAccessDuration).hasPassedNow() || expiresAfterWrite && (writeTimeMark.get() + expireAfterWriteDuration).hasPassedNow() } /** * Evict least recently accessed entries until [cacheEntries] is no longer over capacity. */ private fun evictEntries() { if (!evictsBySize) { return } checkNotNull(accessQueue) while (cacheEntries.size > maxSize) { accessQueue.access { it.firstOrNull()?.run { cacheEntries.remove(key) writeQueue?.remove(this) accessQueue.remove(this) } } } } /** * Update the eviction metadata on the [cacheEntry] which has just been read. */ private fun recordRead(cacheEntry: CacheEntry) { if (expiresAfterAccess) { val accessTimeMark = cacheEntry.accessTimeMark.value cacheEntry.accessTimeMark.set(accessTimeMark + accessTimeMark.elapsedNow()) } accessQueue?.add(cacheEntry) } /** * Update the eviction metadata on the [CacheEntry] which is about to be written. * Note that a write is also considered an access. */ private fun recordWrite(cacheEntry: CacheEntry) { if (expiresAfterAccess) { val accessTimeMark = cacheEntry.accessTimeMark.value cacheEntry.accessTimeMark.set(accessTimeMark + accessTimeMark.elapsedNow()) } if (expiresAfterWrite) { val writeTimeMark = cacheEntry.writeTimeMark.value cacheEntry.writeTimeMark.set(writeTimeMark + writeTimeMark.elapsedNow()) } accessQueue?.add(cacheEntry) writeQueue?.add(cacheEntry) } } /** * A cache entry holds the [key] and [value] pair, * along with the metadata needed to perform cache expiration and eviction. */ private class CacheEntry( val key: Key, val value: AtomicReference, val accessTimeMark: AtomicReference, val writeTimeMark: AtomicReference, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt ================================================ package com.shabinder.common.caching import co.touchlab.stately.collections.IsoMutableSet /** * A custom [IsoMutableSet] that updates the insertion order when an element is re-inserted, * i.e. an inserted element will always be placed at the end * regardless of whether the element already exists. */ internal class ReorderingIsoMutableSet : IsoMutableSet(), MutableSet { override fun add(element: T): Boolean = access { val exists = remove(element) super.add(element) // respect the contract "true if this set did not already contain the specified element" !exists } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt ================================================ package com.shabinder.common.models import co.touchlab.stately.freeze import kotlin.jvm.JvmStatic /* * Interface Having All Platform Dependent Functions * */ interface Actions { // Platform Specific Actions val platformActions: PlatformActions // Platform Specific Implementation Preferred val isInternetAvailable: Boolean // Show Toast fun showPopUpMessage(string: String, long: Boolean = false) // Change Download Directory fun setDownloadDirectoryAction(callBack: (String) -> Unit) /* * Query Downloading Tracks * ex- Get Tracks from android service etc * */ fun queryActiveTracks() // Donate Money fun giveDonation() // Share SpotiFlyer App fun shareApp() // Copy to Clipboard fun copyToClipboard(text: String) // Open / Redirect to another Platform fun openPlatform(packageID: String, platformLink: String) fun writeMp3Tags(trackDetails: TrackDetails) companion object { /* * Holder to call platform actions from anywhere * */ @JvmStatic var instance: Actions get() = methodsAtomicRef.value set(value) { methodsAtomicRef.value = value } private val methodsAtomicRef = NativeAtomicReference(stubActions().freeze()) } } private fun stubActions(): Actions = object : Actions { override val platformActions = StubPlatformActions override fun showPopUpMessage(string: String, long: Boolean) {} override fun setDownloadDirectoryAction(callBack: (String) -> Unit) {} override fun queryActiveTracks() {} override fun giveDonation() {} override fun shareApp() {} override fun copyToClipboard(text: String) {} override fun openPlatform(packageID: String, platformLink: String) {} override fun writeMp3Tags(trackDetails: TrackDetails) {} override val isInternetAvailable: Boolean = true } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/AudioFormat.kt ================================================ package com.shabinder.common.models enum class AudioFormat { MP3, MP4, FLAC, UNKNOWN } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/AudioQuality.kt ================================================ package com.shabinder.common.models enum class AudioQuality(val kbps: String) { KBPS128("128"), KBPS160("160"), KBPS192("192"), KBPS256("256"), KBPS320("320"), UNKNOWN("-1"); companion object { fun getQuality(kbps: String): AudioQuality { return when (kbps) { "128" -> KBPS128 "160" -> KBPS160 "192" -> KBPS192 "256" -> KBPS256 "320" -> KBPS320 "-1" -> UNKNOWN else -> KBPS160 // Use 160 as baseline } } } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Consumer.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models /* * Callback Utility * */ interface Consumer { fun callback(value: T) } @Suppress("FunctionName") // Factory function inline fun Consumer(crossinline block: (T) -> Unit): Consumer = object : Consumer { override fun callback(value: T) { block(value) } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/CorsProxy.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models import io.github.shabinder.TargetPlatforms import io.github.shabinder.activePlatform sealed class CorsProxy(open val url: String) { data class SelfHostedCorsProxy(override val url: String = "https://cors.spotiflyer.ml/cors/" /*"https://spotiflyer.azurewebsites.net/"*/) : CorsProxy(url) data class PublicProxyWithExtension(override val url: String = "https://cors.bridged.cc/") : CorsProxy(url) fun toggle(mode: CorsProxy? = null): CorsProxy { mode?.let { corsProxy = mode return corsProxy } corsProxy = when (corsProxy) { is SelfHostedCorsProxy -> PublicProxyWithExtension() is PublicProxyWithExtension -> SelfHostedCorsProxy() } return corsProxy } fun extensionMode(): Boolean { return when (corsProxy) { is SelfHostedCorsProxy -> false is PublicProxyWithExtension -> true } } } /* * This Var Keeps Track for Cors Config in JS Platform * Default Self Hosted, However ask user to use extension if possible. * */ var corsProxy: CorsProxy = CorsProxy.SelfHostedCorsProxy() val corsApi get() = if (activePlatform is TargetPlatforms.Js) corsProxy.url else "" ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Dispatcher.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers // IO-Dispatcher expect val dispatcherIO: CoroutineDispatcher // Default-Dispatcher val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/DownloadObject.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models import com.shabinder.common.models.spotify.Source import dev.icerock.moko.parcelize.Parcelable import dev.icerock.moko.parcelize.Parcelize import kotlinx.serialization.Serializable @Parcelize @Serializable data class TrackDetails( var title: String, var artists: List, var durationSec: Int, var albumName: String? = null, var albumArtists: List = emptyList(), var genre: List = emptyList(), var trackNumber: Int? = null, var year: String? = null, var comment: String? = null, var lyrics: String? = null, var trackUrl: String? = null, var albumArtPath: String, // UriString in Android var albumArtURL: String, var source: Source, val progress: Int = 2, val downloadLink: String? = null, val downloaded: DownloadStatus = DownloadStatus.NotDownloaded, var audioQuality: AudioQuality = AudioQuality.KBPS192, var audioFormat: AudioFormat = AudioFormat.MP4, var outputFilePath: String, // UriString in Android var videoID: String? = null, // will be used for purposes like Downloadable Link || VideoID etc. based on Provider ) : Parcelable { val outputMp3Path get() = outputFilePath.substringBeforeLast(".") + ".mp3" } @Serializable sealed class DownloadStatus : Parcelable { @Parcelize object Downloaded : DownloadStatus() @Parcelize data class Downloading(val progress: Int = 2) : DownloadStatus() @Parcelize object Queued : DownloadStatus() @Parcelize object NotDownloaded : DownloadStatus() @Parcelize object Converting : DownloadStatus() @Parcelize data class Failed(val error: Throwable) : DownloadStatus() } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/DownloadRecord.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models data class DownloadRecord( var id: Long = 0, var type: String, var name: String, var link: String, var coverUrl: String, var totalFiles: Long = 1, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/DownloadResult.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models sealed class DownloadResult { data class Error(val message: String, val cause: Exception? = null) : DownloadResult() data class Progress(val progress: Int) : DownloadResult() data class Success(val byteArray: ByteArray) : DownloadResult() { override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false other as Success if (!byteArray.contentEquals(other.byteArray)) return false return true } override fun hashCode(): Int { return byteArray.contentHashCode() } } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/NativeAtomicReference.kt ================================================ package com.shabinder.common.models expect class NativeAtomicReference(value: T) { var value: T // fun compareAndSet(expected: T, new: T): Boolean } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/PlatformActions.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineScope expect interface PlatformActions internal expect val StubPlatformActions: PlatformActions expect fun runBlocking(block: suspend CoroutineScope.() -> T): T ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/PlatformQueryResult.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models import com.shabinder.common.models.spotify.Source import kotlinx.serialization.Serializable @Serializable data class PlatformQueryResult( var folderType: String, var subFolder: String, var title: String, var coverUrl: String, var trackList: List, var source: Source ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/SpotiFlyerException.kt ================================================ package com.shabinder.common.models import com.shabinder.common.translations.Strings sealed class SpotiFlyerException(override val message: String) : Exception(message) { data class FeatureNotImplementedYet(override val message: String = Strings.featureUnImplemented()) : SpotiFlyerException(message) data class NoInternetException(override val message: String = Strings.checkInternetConnection()) : SpotiFlyerException(message) data class MP3ConversionFailed( val extraInfo: String? = null, override val message: String = /*${Strings.mp3ConverterBusy()} */"CAUSE:$extraInfo" ) : SpotiFlyerException(message) data class GeoLocationBlocked( val extraInfo: String? = null, override val message: String = "This Content is not Accessible from your Location, try using a VPN! \nCAUSE:$extraInfo" ) : SpotiFlyerException(message) data class UnknownReason( val exception: Throwable? = null, override val message: String = Strings.unknownError() ) : SpotiFlyerException(message) data class NoMatchFound( val trackName: String? = null, override val message: String = "$trackName : ${Strings.noMatchFound()}" ) : SpotiFlyerException(message) data class YoutubeLinkNotFound( val videoID: String? = null, override val message: String = "${Strings.noLinkFound()}: $videoID" ) : SpotiFlyerException(message) data class DownloadLinkFetchFailed( val errorTrace: String ) : SpotiFlyerException(errorTrace) { constructor( trackName: String, jioSaavnError: Throwable, ytMusicError: Throwable, errorTrace: String = "${Strings.noLinkFound()}: $trackName," + " \n YtMusic Error's StackTrace: ${ytMusicError.stackTraceToString()} \n " + " \n JioSaavn Error's StackTrace: ${jioSaavnError.stackTraceToString()} \n " ): this(errorTrace) } data class LinkInvalid( val link: String? = null, override val message: String = "${Strings.linkNotValid()}\n ${link ?: ""}" ) : SpotiFlyerException(message) } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Status.kt ================================================ package com.shabinder.common.models import kotlin.jvm.JvmStatic /** * Enumeration which contains the different states a download * could go through. * * From Fetch * */ enum class Status constructor(val value: Int) { /** Indicates when a download is newly created and not yet queued.*/ NONE(0), /** Indicates when a newly created download is queued.*/ QUEUED(1), /** Indicates when a download is currently being downloaded.*/ DOWNLOADING(2), /** Indicates when a download is paused.*/ PAUSED(3), /** Indicates when a download is completed.*/ COMPLETED(4), /** Indicates when a download is cancelled.*/ CANCELLED(5), /** Indicates when a download has failed.*/ FAILED(6), /** Indicates when a download has been removed and is no longer managed by Fetch.*/ REMOVED(7), /** Indicates when a download has been deleted and is no longer managed by Fetch.*/ DELETED(8), /** Indicates when a download has been Added to Fetch for management.*/ ADDED(9); companion object { @JvmStatic fun valueOf(value: Int): Status { return when (value) { 0 -> NONE 1 -> QUEUED 2 -> DOWNLOADING 3 -> PAUSED 4 -> COMPLETED 5 -> CANCELLED 6 -> FAILED 7 -> REMOVED 8 -> DELETED 9 -> ADDED else -> NONE } } } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/YoutubeTrack.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models import kotlinx.serialization.Serializable @Serializable data class YoutubeTrack( var name: String? = null, var type: String? = null, // Song / Video var artist: String? = null, var duration: String? = null, var videoId: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Event.kt ================================================ package com.shabinder.common.models.event import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty inline fun Event<*, *>.getAs() = when (this) { is Event.Success -> value as? X is Event.Failure -> error as? X } inline fun Event.success(f: (V) -> Unit) = fold(f, {}) inline fun Event<*, E>.failure(f: (E) -> Unit) = fold({}, f) infix fun Event.or(fallback: V) = when (this) { is Event.Success -> this else -> Event.Success(fallback) } inline infix fun Event.getOrElse(fallback: (E) -> V): V { return when (this) { is Event.Success -> value is Event.Failure -> fallback(error) } } fun Event.getOrNull(): V? { return when (this) { is Event.Success -> value is Event.Failure -> null } } fun Event.getThrowableOrNull(): E? { return when (this) { is Event.Success -> null is Event.Failure -> error } } inline fun Event.mapEither( success: (V) -> U, failure: (E) -> F ): Event { return when (this) { is Event.Success -> Event.success(success(value)) is Event.Failure -> Event.error(failure(error)) } } inline fun Event.map(transform: (V) -> U): Event = try { when (this) { is Event.Success -> Event.Success(transform(value)) is Event.Failure -> Event.Failure(error) } } catch (ex: Throwable) { when (ex) { is E -> Event.error(ex) else -> throw ex } } inline fun Event.flatMap(transform: (V) -> Event): Event = try { when (this) { is Event.Success -> transform(value) is Event.Failure -> Event.Failure(error) } } catch (ex: Throwable) { when (ex) { is E -> Event.error(ex) else -> throw ex } } inline fun Event.mapError(transform: (E) -> E2) = when (this) { is Event.Success -> Event.Success(value) is Event.Failure -> Event.Failure(transform(error)) } inline fun Event.flatMapError(transform: (E) -> Event) = when (this) { is Event.Success -> Event.Success(value) is Event.Failure -> transform(error) } inline fun Event.onError(f: (E) -> Unit) = when (this) { is Event.Success -> Event.Success(value) is Event.Failure -> { f(error) this } } inline fun Event.onSuccess(f: (V) -> Unit): Event { return when (this) { is Event.Success -> { f(value) this } is Event.Failure -> this } } inline fun Event.any(predicate: (V) -> Boolean): Boolean = try { when (this) { is Event.Success -> predicate(value) is Event.Failure -> false } } catch (ex: Throwable) { false } inline fun Event.fanout(other: () -> Event): Event, *> = flatMap { outer -> other().map { outer to it } } inline fun List>.lift(): Event, E> = fold( Event.success( mutableListOf() ) as Event, E> ) { acc, Event -> acc.flatMap { combine -> Event.map { combine.apply { add(it) } } } } inline fun Event.unwrap(failure: (E) -> Nothing): V = apply { component2()?.let(failure) }.component1()!! inline fun Event.unwrapError(success: (V) -> Nothing): E = apply { component1()?.let(success) }.component2()!! sealed class Event : ReadOnlyProperty { open operator fun component1(): V? = null open operator fun component2(): E? = null inline fun fold(success: (V) -> X, failure: (E) -> X): X = when (this) { is Success -> success(this.value) is Failure -> failure(this.error) } abstract val value: V class Success(override val value: V) : Event() { override fun component1(): V? = value override fun toString() = "[Success: $value]" override fun hashCode(): Int = value.hashCode() override fun equals(other: Any?): Boolean { if (this === other) return true return other is Success<*> && value == other.value } override fun getValue(thisRef: Any?, property: KProperty<*>): V = value } class Failure(val error: E) : Event() { override fun component2(): E = error override val value: Nothing get() = throw error fun getThrowable(): E = error override fun toString() = "[Failure: $error]" override fun hashCode(): Int = error.hashCode() override fun equals(other: Any?): Boolean { if (this === other) return true return other is Failure<*> && error == other.error } override fun getValue(thisRef: Any?, property: KProperty<*>): Nothing = value } companion object { // Factory methods fun error(ex: E) = Failure(ex) fun success(v: V) = Success(v) inline fun of( value: V?, fail: (() -> Throwable) = { Throwable() } ): Event = value?.let { success(it) } ?: error(fail()) inline fun of(crossinline f: () -> V): Event = try { success(f()) } catch (ex: Throwable) { when (ex) { is E -> error(ex) else -> throw ex } } inline operator fun invoke(crossinline f: () -> V): Event = try { success(f()) } catch (ex: Throwable) { error(ex) } } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Factory.kt ================================================ package com.shabinder.common.models.event inline fun runCatching(block: () -> V): Event { return try { Event.success(block()) } catch (e: Throwable) { Event.error(e) } } inline infix fun T.runCatching(block: T.() -> V): Event { return try { Event.success(block()) } catch (e: Throwable) { Event.error(e) } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Validation.kt ================================================ package com.shabinder.common.models.event class Validation(vararg resultSequence: Event<*, E>) { val failures: List = resultSequence.filterIsInstance>().map { it.getThrowable() } val hasFailure = failures.isNotEmpty() } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendableEvent.kt ================================================ @file:Suppress("UNCHECKED_CAST") package com.shabinder.common.models.event.coroutines import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty inline fun SuspendableEvent<*, *>.getAs() = when (this) { is SuspendableEvent.Success -> value as? X is SuspendableEvent.Failure -> error as? X } suspend inline fun SuspendableEvent.success(noinline f: suspend (V) -> Unit) = fold(f, {}) suspend inline fun SuspendableEvent<*, E>.failure(noinline f: suspend (E) -> Unit) = fold({}, f) infix fun SuspendableEvent.or(fallback: V) = when (this) { is SuspendableEvent.Success -> this else -> SuspendableEvent.Success(fallback) } suspend inline infix fun SuspendableEvent.getOrElse(crossinline fallback: suspend (E) -> V): V { return when (this) { is SuspendableEvent.Success -> value is SuspendableEvent.Failure -> fallback(error) } } fun SuspendableEvent.getOrNull(): V? { return when (this) { is SuspendableEvent.Success -> value is SuspendableEvent.Failure -> null } } suspend inline fun SuspendableEvent.map( crossinline transform: suspend (V) -> U ): SuspendableEvent = try { when (this) { is SuspendableEvent.Success -> SuspendableEvent.Success(transform(value)) is SuspendableEvent.Failure -> SuspendableEvent.Failure(error) } } catch (ex: Throwable) { SuspendableEvent.error(ex as E) } suspend inline fun SuspendableEvent.flatMap( crossinline transform: suspend (V) -> SuspendableEvent ): SuspendableEvent = try { when (this) { is SuspendableEvent.Success -> transform(value) is SuspendableEvent.Failure -> SuspendableEvent.Failure(error) } } catch (ex: Throwable) { SuspendableEvent.error(ex as E) } suspend inline fun SuspendableEvent.mapError( crossinline transform: suspend (E) -> E2 ) = try { when (this) { is SuspendableEvent.Success -> SuspendableEvent.Success(value) is SuspendableEvent.Failure -> SuspendableEvent.Failure(transform(error)) } } catch (ex: Throwable) { SuspendableEvent.error(ex as E) } suspend inline fun SuspendableEvent.flatMapError( crossinline transform: suspend (E) -> SuspendableEvent ) = try { when (this) { is SuspendableEvent.Success -> SuspendableEvent.Success(value) is SuspendableEvent.Failure -> transform(error) } } catch (ex: Throwable) { SuspendableEvent.error(ex as E) } @OptIn(ExperimentalContracts::class) suspend inline fun SuspendableEvent.onSuccess(crossinline f: suspend (V) -> Unit): SuspendableEvent { contract { callsInPlace(f, InvocationKind.EXACTLY_ONCE) } return fold({ f(it); this }, { this }) } @OptIn(ExperimentalContracts::class) suspend inline fun SuspendableEvent.onFailure(crossinline f: suspend (E) -> Unit): SuspendableEvent { contract { callsInPlace(f, InvocationKind.EXACTLY_ONCE) } return fold({ this }, { f(it); this }) } suspend inline fun SuspendableEvent.any( crossinline predicate: suspend (V) -> Boolean ): Boolean = try { when (this) { is SuspendableEvent.Success -> predicate(value) is SuspendableEvent.Failure -> false } } catch (ex: Throwable) { false } suspend inline fun SuspendableEvent.fanout( crossinline other: suspend () -> SuspendableEvent ): SuspendableEvent, *> = flatMap { outer -> other().map { outer to it } } suspend fun List>.lift(): SuspendableEvent, E> = fold( SuspendableEvent.Success, E>(mutableListOf()) as SuspendableEvent, E> ) { acc, result -> acc.flatMap { combine -> result.map { combine.apply { add(it) } } } } sealed class SuspendableEvent : ReadOnlyProperty { abstract operator fun component1(): V? abstract operator fun component2(): E? suspend inline fun fold(noinline success: suspend (V) -> X, noinline failure: suspend (E) -> X): X { return when (this) { is Success -> success(this.value) is Failure -> failure(this.error) } } abstract val value: V class Success(override val value: V) : SuspendableEvent() { override fun component1(): V? = value override fun component2(): E? = null override fun toString() = "[Success: $value]" override fun hashCode(): Int = value.hashCode() override fun equals(other: Any?): Boolean { if (this === other) return true return other is Success<*, *> && value == other.value } override fun getValue(thisRef: Any?, property: KProperty<*>): V = value } class Failure(val error: E) : SuspendableEvent() { override fun component1(): V? = null override fun component2(): E? = error override val value: V get() = throw error fun getThrowable(): E = error override fun toString() = "[Failure: $error]" override fun hashCode(): Int = error.hashCode() override fun equals(other: Any?): Boolean { if (this === other) return true return other is Failure<*, *> && error == other.error } override fun getValue(thisRef: Any?, property: KProperty<*>): V = value } companion object { // Factory methods fun error(ex: E) = Failure(ex) fun success(res: V) = Success(res) inline fun of(value: V?, crossinline fail: (() -> Throwable) = { Throwable() }): SuspendableEvent { return value?.let { Success(it) } ?: error(fail()) } suspend inline fun of( crossinline block: suspend () -> V ): SuspendableEvent = try { Success(block()) } catch (ex: Throwable) { Failure(ex as E) } suspend inline operator fun invoke( crossinline block: suspend () -> V ): SuspendableEvent = of(block) } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendedValidation.kt ================================================ package com.shabinder.common.models.event.coroutines class SuspendedValidation(vararg resultSequence: SuspendableEvent<*, E>) { val failures: List = resultSequence.filterIsInstance>().map { it.getThrowable() } val hasFailure = failures.isNotEmpty() } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/Artist.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Artist( val popularity: Int, val seokey: String, val name: String, @SerialName("artwork_175x175")var artworkLink: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/CustomArtworks.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class CustomArtworks( @SerialName("40x40") val size_40p: String, @SerialName("80x80") val size_80p: String, @SerialName("110x110")val size_110p: String, @SerialName("175x175")val size_175p: String, @SerialName("480x480")val size_480p: String, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/GaanaAlbum.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class GaanaAlbum( val tracks: List?, val count: Int, val custom_artworks: CustomArtworks, val release_year: Int, val favorite_count: Int, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/GaanaArtistDetails.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class GaanaArtistDetails( val artist: List, val count: Int, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/GaanaArtistTracks.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class GaanaArtistTracks( val count: Int, val tracks: List? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/GaanaPlaylist.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class GaanaPlaylist( val modified_on: String, val count: Int, val created_on: String, val favorite_count: Int, val tracks: List, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/GaanaSong.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class GaanaSong( val tracks: List ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/GaanaTrack.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import com.shabinder.common.models.DownloadStatus import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GaanaTrack( val tags: List? = null, val seokey: String, val albumseokey: String? = null, val track_title: String, val album_title: String? = null, val language: String? = null, val duration: Int, @SerialName("artwork_large") val artworkLink: String, val artist: List = emptyList(), @SerialName("gener") val genre: List? = null, val lyrics_url: String? = null, val youtube_id: String? = null, val total_favourite_count: Int? = null, val release_date: String? = null, val play_ct: String? = null, val secondary_language: String? = null, var downloaded: DownloadStatus? = DownloadStatus.NotDownloaded ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/Genre.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class Genre( val genre_id: Int, val name: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/gaana/Tags.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.gaana import kotlinx.serialization.Serializable @Serializable data class Tags( val tag_id: Int, val tag_name: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/saavn/MoreInfo.kt ================================================ package com.shabinder.common.models.saavn import kotlinx.serialization.Serializable @Serializable data class MoreInfo( val language: String, val primary_artists: String, val singers: String, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/saavn/SaavnAlbum.kt ================================================ package com.shabinder.common.models.saavn import kotlinx.serialization.Serializable @Serializable data class SaavnAlbum( val albumid: String, val image: String, val name: String, val perma_url: String, val primary_artists: String, val primary_artists_id: String, val release_date: String, val songs: List, val title: String, val year: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/saavn/SaavnPlaylist.kt ================================================ package com.shabinder.common.models.saavn import kotlinx.serialization.Serializable @Serializable data class SaavnPlaylist( val fan_count: Int? = 0, val firstname: String? = null, val follower_count: Long? = null, val image: String, val images: List? = null, val last_updated: String, val lastname: String? = null, val list_count: String? = null, val listid: String? = null, val listname: String, // Title val perma_url: String, val songs: List, val sub_types: List? = null, val type: String = "", // chart,etc val uid: String? = null, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/saavn/SaavnSearchResult.kt ================================================ package com.shabinder.common.models.saavn import kotlinx.serialization.Serializable @Serializable data class SaavnSearchResult( val album: String? = "", val description: String, val id: String, val image: String, val title: String, val type: String, val url: String, val ctr: Int? = 0, val position: Int? = 0, val more_info: MoreInfo? = null, ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/saavn/SaavnSong.kt ================================================ package com.shabinder.common.models.saavn import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.DownloadStatus import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @Serializable data class SaavnSong @OptIn(ExperimentalSerializationApi::class) constructor( @JsonNames("320kbps") val is320Kbps: Boolean, val album: String, val album_url: String? = null, val albumid: String? = null, val artistMap: Map, val copyright_text: String? = null, val duration: String, val encrypted_media_path: String, val encrypted_media_url: String, // val explicit_content: Int = 0, val has_lyrics: Boolean = false, val id: String, val image: String = "", val label: String? = null, val label_url: String? = null, val language: String, val lyrics_snippet: String? = null, val lyrics: String? = null, val media_preview_url: String? = null, val media_url: String? = null, // Downloadable M4A Link val music: String, val music_id: String, val origin: String? = null, val perma_url: String? = null, // val play_count: Int = 0, val primary_artists: String, val primary_artists_id: String, val release_date: String, // Format - 2021-05-04 val singers: String, val song: String, // title val starring: String? = null, val type: String = "", val vcode: String? = null, val vlink: String? = null, val year: String, var downloaded: DownloadStatus = DownloadStatus.NotDownloaded ) { val audioQuality get() = if (is320Kbps) AudioQuality.KBPS320 else AudioQuality.KBPS160 } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Badges.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Badges( val pro: Boolean = false, @SerialName("pro_unlimited") val proUnlimited: Boolean = false, val verified: Boolean = false ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/CreatorSubscription.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class CreatorSubscription( val product: Product = Product() ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Format.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Format( @SerialName("mime_type") val mimeType: String = "", val protocol: String = "" ) { val isProgressive get() = protocol == "progressive" } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Media.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.Serializable @Serializable data class Media( val transcodings: List = emptyList() ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Product.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Product( val id: String = "" ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/PublisherMetadata.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class PublisherMetadata( @SerialName("album_title") val albumTitle: String = "", val artist: String = "", @SerialName("contains_music") val containsMusic: Boolean = false, val id: Int = 0, val isrc: String = "", val publisher: String = "", @SerialName("release_title") val releaseTitle: String = "", @SerialName("upc_or_ean") val upcOrEan: String = "", val urn: String = "", @SerialName("writer_composer") val writerComposer: String = "" ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Transcoding.kt ================================================ package com.shabinder.common.models.soundcloud import com.shabinder.common.models.AudioFormat import kotlinx.serialization.Serializable @Serializable data class Transcoding( val duration: Int = 0, val format: Format = Format(), val preset: String = "", val quality: String = "", //sq == 128kbps //hq == 256kbps val snipped: Boolean = false, val url: String = "" ) { val audioFormat: AudioFormat = when { preset.contains("mp3") -> AudioFormat.MP3 preset.contains("aac") || preset.contains("m4a") -> AudioFormat.MP4 preset.contains("flac") -> AudioFormat.FLAC else -> AudioFormat.UNKNOWN } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/User.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class User( @SerialName("avatar_url") val avatarUrl: String = "", val badges: Badges = Badges(), val city: String = "", @SerialName("country_code") val countryCode: String = "", @SerialName("first_name") val firstName: String = "", @SerialName("followers_count") val followersCount: Int = 0, @SerialName("full_name") val fullName: String = "", val id: Int = 0, val kind: String = "", @SerialName("last_modified") val lastModified: String = "", @SerialName("last_name") val lastName: String = "", val permalink: String = "", @SerialName("permalink_url") val permalinkUrl: String = "", @SerialName("station_permalink") val stationPermalink: String = "", @SerialName("station_urn") val stationUrn: String = "", val uri: String = "", val urn: String = "", val username: String = "", val verified: Boolean = false ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Visual.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Visual( @SerialName("entry_time") val entryTime: Int = 0, val urn: String = "", @SerialName("visual_url") val visualUrl: String = "" ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/Visuals.kt ================================================ package com.shabinder.common.models.soundcloud import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Visuals( val enabled: Boolean = false, //val tracking: Any = Any(), val urn: String = "", val visuals: List = listOf() ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/soundcloud/resolvemodel/SoundCloudResolveResponseBase.kt ================================================ package com.shabinder.common.models.soundcloud.resolvemodel import com.shabinder.common.models.AudioFormat import com.shabinder.common.models.soundcloud.Media import com.shabinder.common.models.soundcloud.PublisherMetadata import com.shabinder.common.models.soundcloud.User import com.shabinder.common.models.soundcloud.Visuals import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @Serializable @JsonClassDiscriminator("kind") sealed class SoundCloudResolveResponseBase { abstract val kind: String @SerialName("playlist") @Serializable data class SoundCloudResolveResponsePlaylist( @SerialName("artwork_url") val artworkUrl: String = "", @SerialName("calculated_artwork_url") val calculatedArtworkUrl: String = "", //t500x500, t120x120 // "https://i1.sndcdn.com/artworks-pjsabv9w0EXW3lBJ-nvjDYg-large.jpg" // https://i1.sndcdn.com/artworks-pjsabv9w0EXW3lBJ-nvjDYg-t500x500.jpg @SerialName("created_at") val createdAt: String = "", val description: String = "", @SerialName("display_date") val displayDate: String = "", val duration: Int = 0, override val kind: String = "", @SerialName("embeddable_by") val embeddableBy: String = "", val genre: String = "", val id: String = "", @SerialName("is_album") val isAlbum: Boolean = false, @SerialName("label_name") val labelName: String = "", @SerialName("last_modified") val lastModified: String = "", val license: String = "", @SerialName("likes_count") val likesCount: Int = 0, @SerialName("managed_by_feeds") val managedByFeeds: Boolean = false, val permalink: String = "", @SerialName("permalink_url") val permalinkUrl: String = "", val `public`: Boolean = false, @SerialName("published_at") val publishedAt: String = "", @SerialName("purchase_title") val purchaseTitle: String = "", @SerialName("purchase_url") val purchaseUrl: String = "", @SerialName("release_date") val releaseDate: String = "", @SerialName("reposts_count") val repostsCount: Int = 0, @SerialName("secret_token") val secretToken: String = "", @SerialName("set_type") val setType: String = "", val sharing: String = "", @SerialName("tag_list") val tagList: String = "", val title: String = "", //"Top 50: Hip-hop & Rap" @SerialName("track_count") val trackCount: Int = 0, var tracks: List = emptyList(), val uri: String = "", val user: User = User(), @SerialName("user_id") val userId: Int = 0 ) : SoundCloudResolveResponseBase() @SerialName("track") @Serializable data class SoundCloudResolveResponseTrack( @SerialName("artwork_url") val artworkUrl: String = "", val caption: String = "", @SerialName("comment_count") val commentCount: Int = 0, val commentable: Boolean = false, @SerialName("created_at") val createdAt: String = "", val description: String = "", @SerialName("display_date") val displayDate: String = "", @SerialName("download_count") val downloadCount: Int = 0, val downloadable: Boolean = false, val duration: Int = 0, @SerialName("embeddable_by") val embeddableBy: String = "", @SerialName("full_duration") val fullDuration: Int = 0, val genre: String = "", @SerialName("has_downloads_left") val hasDownloadsLeft: Boolean = false, val id: String = "", override val kind: String = "", @SerialName("label_name") val labelName: String = "", @SerialName("last_modified") val lastModified: String = "", val license: String = "", @SerialName("likes_count") val likesCount: Int = 0, val media: Media = Media(), @SerialName("monetization_model") val monetizationModel: String = "", val permalink: String = "", @SerialName("permalink_url") val permalinkUrl: String = "", @SerialName("playback_count") val playbackCount: Int = 0, val policy: String = "", val `public`: Boolean = false, @SerialName("publisher_metadata") val publisherMetadata: PublisherMetadata = PublisherMetadata(), @SerialName("purchase_title") val purchaseTitle: String = "", @SerialName("purchase_url") val purchaseUrl: String = "", @SerialName("release_date") val releaseDate: String = "", @SerialName("reposts_count") val repostsCount: Int = 0, @SerialName("secret_token") val secretToken: String = "", val sharing: String = "", val state: String = "", @SerialName("station_permalink") val stationPermalink: String = "", @SerialName("station_urn") val stationUrn: String = "", val streamable: Boolean = false, @SerialName("tag_list") val tagList: String = "", val title: String = "", @SerialName("track_authorization") val trackAuthorization: String = "", @SerialName("track_format") val trackFormat: String = "", val uri: String = "", val urn: String = "", val user: User = User(), @SerialName("user_id") val userId: Int = 0, val visuals: Visuals? = null, @SerialName("waveform_url") val waveformUrl: String = "" ) : SoundCloudResolveResponseBase() { fun getDownloadableLink(): Pair? { return (media.transcodings.firstOrNull { it.quality == "hq" && (it.format.isProgressive || it.url.contains("progressive")) } ?: media.transcodings.firstOrNull { it.quality == "sq" && (it.format.isProgressive || it.url.contains("progressive")) })?.let { it.url to it.audioFormat } } } } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Album.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class Album( var album_type: String? = null, var artists: List? = null, var available_markets: List? = null, var copyrights: List? = null, var external_ids: Map? = null, var external_urls: Map? = null, var genres: List? = null, var href: String? = null, var id: String? = null, var images: List? = null, var label: String? = null, var name: String? = null, var popularity: Int? = null, var release_date: String? = null, var release_date_precision: String? = null, var tracks: PagingObjectTrack? = null, var type: String? = null, var uri: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Artist.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class Artist( var external_urls: Map? = null, var href: String? = null, var id: String? = null, var name: String? = null, var type: String? = null, var uri: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Copyright.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class Copyright( var text: String? = null, var type: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Episodes.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class Episodes( var audio_preview_url: String?, var description: String?, var duration_ms: Int?, var explicit: Boolean?, var external_urls: Map?, var href: String?, var id: String?, var images: List?, var is_externally_hosted: Boolean?, var is_playable: Boolean?, var language: String?, var languages: List?, var name: String?, var release_date: String?, var release_date_precision: String?, var type: String?, var uri: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Followers.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class Followers( var href: String? = null, var total: Int? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Image.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class Image( var width: Int? = null, var height: Int? = null, var url: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/LinkedTrack.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class LinkedTrack( var external_urls: Map? = null, var href: String? = null, var id: String? = null, var type: String? = null, var uri: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/PagingObjectPlaylistTrack.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class PagingObjectPlaylistTrack( var href: String? = null, var items: List? = null, var limit: Int = 0, var next: String? = null, var offset: Int = 0, var previous: String? = null, var total: Int = 0 ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/PagingObjectTrack.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class PagingObjectTrack( var href: String? = null, var items: List? = null, var limit: Int = 0, var next: String? = null, var offset: Int = 0, var previous: String? = null, var total: Int = 0 ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Playlist.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Playlist( @SerialName("collaborative")var is_collaborative: Boolean? = null, var description: String? = null, var external_urls: Map? = null, var followers: Followers? = null, var href: String? = null, var id: String? = null, var images: List? = null, var name: String? = null, var owner: UserPublic? = null, @SerialName("public")var is_public: Boolean? = null, var snapshot_id: String? = null, var tracks: PagingObjectPlaylistTrack? = null, var type: String? = null, var uri: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/PlaylistTrack.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class PlaylistTrack( var added_at: String? = null, var added_by: UserPublic? = null, var track: Track? = null, var is_local: Boolean? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Source.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify enum class Source { Spotify, YouTube, Gaana, JioSaavn, SoundCloud } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/SpotifyCredentials.kt ================================================ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class SpotifyCredentials( val clientID: String = "5f573c9620494bae87890c0f08a60293", val clientSecret: String = "212476d9b0f3472eaa762d90b19b0ba8", ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/TokenData.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class TokenData( var access_token: String?, var token_type: String?, @SerialName("expires_in") var expiry: Long? ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/Track.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import com.shabinder.common.models.DownloadStatus import kotlinx.serialization.Serializable @Serializable data class Track( var artists: List? = null, var available_markets: List? = null, var is_playable: Boolean? = null, var linked_from: LinkedTrack? = null, var disc_number: Int = 0, var duration_ms: Long = 0, var explicit: Boolean? = null, var external_urls: Map? = null, var href: String? = null, var name: String? = null, var preview_url: String? = null, var track_number: Int = 0, var type: String? = null, var uri: String? = null, var album: Album? = null, var external_ids: Map? = null, var popularity: Int? = null, var downloaded: DownloadStatus = DownloadStatus.NotDownloaded ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/UserPrivate.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class UserPrivate( val country: String, var display_name: String, val email: String, var external_urls: Map? = null, var followers: Followers? = null, var href: String? = null, var id: String? = null, var images: List? = null, var product: String, var type: String? = null, var uri: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/spotify/UserPublic.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.models.spotify import kotlinx.serialization.Serializable @Serializable data class UserPublic( var display_name: String? = null, var external_urls: Map? = null, var followers: Followers? = null, var href: String? = null, var id: String? = null, var images: List? = null, var type: String? = null, var uri: String? = null ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/wynk/AlbumRefWynk.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models.wynk data class AlbumRefWynk( val id: String, val largeImage: String, val smallImage: String, val title: String, val type: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/wynk/HtDataWynk.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models.wynk data class HtDataWynk( val cutName: String, val previewUrl: String, val vcode: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/wynk/ItemWynk.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models.wynk data class ItemWynk( val album: String, val albumRef: AlbumRefWynk, val basicShortUrl: String, val branchUrl: String, val contentLang: String, val contentState: String, val count: Int, val cues: List, val downloadPrice: String, val downloadUrl: String, val duration: Int, // in Seconds val exclusive: Boolean, val formats: List, val htData: List, val id: String, val isHt: Boolean, val itemContentLang: String, val keywords: String, val largeImage: String, val lyrics_avl: String, val ostreamingUrl: String, val purchaseUrl: String, val rentUrl: String, val serverEtag: String, val shortUrl: String, val smallImage: String, // Cover Image after Replacing 120x120 with 720x720 val subtitle: String, // String : `ArtistName - TrackName` val subtitleId: String, // ARTIST NAME,artist-id , etc //USE SUBTITLE INSTEAD val subtitleType: String, // ARTIST etc val title: String, val type: String, // Song ,etc val videoPresent: Boolean ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/wynk/ShortURLWynk.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models.wynk // Use Kotlinx JSON Parsing as in YT Music data class ShortURLWynk( val actualTotal: Int, val basicShortUrl: String, val branchUrl: String, val count: Int, val downloadUrl: String, val duration: Int, val exclusive: Boolean, val followCount: String, val id: String, val isCurated: Boolean, val isFollowable: Boolean, val isHt: Boolean, val itemIds: List, val itemTypes: List, // Songs , etc val items: List, val lang: String, val largeImage: String, // Cover Image Alternate val lastUpdated: Long, val offset: Int, val owner: String, val playIcon: Boolean, val playlistImage: String, // Cover Image val redesignFeaturedImage: String, val shortUrl: String, val singers: List, val smallImage: String, val title: String, val total: Int, val type: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/models/wynk/SingerWynk.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.models.wynk data class SingerWynk( val id: String, val isCurated: Boolean, val packageId: String, val smallImage: String, val title: String, val type: String ) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/utils/Ext.kt ================================================ package com.shabinder.common.utils import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.dispatcherIO import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract fun T?.requireNotNull(): T = requireNotNull(this) @OptIn(ExperimentalContracts::class) inline fun buildString(track: TrackDetails, builderAction: StringBuilder.() -> Unit): String { contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) } return StringBuilder().run { appendLine("Find Link for ${track.title} ${if (!track.videoID.isNullOrBlank()) "-> VideoID:" + track.videoID else ""}") apply(builderAction) }.toString() } fun StringBuilder.appendPadded(data: Any?) { appendLine().append(data).appendLine() } fun StringBuilder.appendPadded(header: Any?, data: Any?) { appendLine().append(header).appendLine(data).appendLine() } suspend fun runOnMain(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.Main, block) suspend fun runOnIO(block: suspend CoroutineScope.() -> T): T = withContext(dispatcherIO, block) suspend fun runOnDefault(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.Default, block) ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/utils/JsonUtils.kt ================================================ package com.shabinder.common.utils /* * JSON UTILS * */ fun String.escape(): String { val output = StringBuilder() for (element in this) { val chx = element.code if (chx != 0) { when (element) { '\n' -> { output.append("\\n") } '\t' -> { output.append("\\t") } '\r' -> { output.append("\\r") } '\\' -> { output.append("\\\\") } '"' -> { output.append("\\\"") } '\b' -> { output.append("\\b") } /*chx >= 0x10000 -> { assert(false) { "Java stores as u16, so it should never give us a character that's bigger than 2 bytes. It literally can't." } }*/ /*chx > 127 -> { output.append(String.format("\\u%04x", chx)) }*/ else -> { output.append(element) } } } } return output.toString() } fun String.unescape(): String { val builder = StringBuilder() var i = 0 while (i < this.length) { val delimiter = this[i] i++ // consume letter or backslash if (delimiter == '\\' && i < this.length) { // consume first after backslash val ch = this[i] i++ when (ch) { '\\', '/', '"', '\'' -> { builder.append(ch) } 'n' -> builder.append('\n') 'r' -> builder.append('\r') 't' -> builder.append( '\t' ) 'b' -> builder.append('\b') 'f' -> builder.append("\\f") 'u' -> { val hex = StringBuilder() // expect 4 digits if (i + 4 > this.length) { throw RuntimeException("Not enough unicode digits! ") } for (x in this.substring(i, i + 4).toCharArray()) { // TODO in 1.5 Kotlin /*if (!x.isLetterOrDigit()) { throw RuntimeException("Bad character in unicode escape.") }*/ hex.append(x.lowercaseChar()) } i += 4 // consume those four digits. val code = hex.toString().toInt(16) builder.append(code.toChar()) } else -> { throw RuntimeException("Illegal escape sequence: \\$ch") } } } else { // it's not a backslash, or it's the last character. builder.append(delimiter) } } return builder.toString() } ================================================ FILE: common/data-models/src/commonMain/kotlin/com/shabinder/common/utils/Utils.kt ================================================ package com.shabinder.common.utils import kotlinx.serialization.json.Json import kotlin.native.concurrent.ThreadLocal @ThreadLocal val globalJson by lazy { Json { isLenient = true ignoreUnknownKeys = true coerceInputValues = true } } /** * Removing Illegal Chars from File Name * **/ fun removeIllegalChars(fileName: String): String { return fileName.replace("[^\\dA-Za-z0-9-_]".toRegex(), "_") } ================================================ FILE: common/data-models/src/desktopMain/kotlin/com/shabinder/common/models/DesktopAtomicReference.kt ================================================ package com.shabinder.common.models actual class NativeAtomicReference actual constructor(actual var value: T) ================================================ FILE: common/data-models/src/desktopMain/kotlin/com/shabinder/common/models/DesktopDispacthers.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers // IO-Dispatcher actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO ================================================ FILE: common/data-models/src/desktopMain/kotlin/com/shabinder/common/models/DesktopPlatformActions.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineScope actual interface PlatformActions internal actual val StubPlatformActions = object : PlatformActions {} actual fun runBlocking(block: suspend CoroutineScope.() -> T): T = kotlinx.coroutines.runBlocking { block() } ================================================ FILE: common/data-models/src/iosMain/kotlin/com.shabinder.common.models/IOSPlatformActions.kt ================================================ package com.shabinder.common.models import kotlin.native.concurrent.AtomicReference actual interface PlatformActions actual val StubPlatformActions = object : PlatformActions {} actual typealias NativeAtomicReference = AtomicReference ================================================ FILE: common/data-models/src/jsMain/kotlin/com.shabinder.common.models/JSPlatformActions.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.promise actual interface PlatformActions internal actual val StubPlatformActions = object : PlatformActions {} actual fun runBlocking(block: suspend CoroutineScope.() -> T): dynamic = GlobalScope.promise(block = block) ================================================ FILE: common/data-models/src/jsMain/kotlin/com.shabinder.common.models/WebActual.kt ================================================ package com.shabinder.common.models import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default ================================================ FILE: common/data-models/src/jsMain/kotlin/com.shabinder.common.models/WebAtomicReference.kt ================================================ package com.shabinder.common.models actual class NativeAtomicReference actual constructor(actual var value: T) ================================================ FILE: common/data-models/src/main/res/drawable/ic_arrow.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_download_arrow.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_error.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_gaana.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_github.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_heart.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_indian_rupee.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_instagram.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_jio_saavn_logo.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_linkedin.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_mug.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_musicplaceholder.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_opencollective_icon.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_paypal_logo.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_refreshgradient.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_round_cancel_24.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_share_open.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_song_placeholder.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_soundcloud.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_spotiflyer_logo.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_spotify_logo.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_tick.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_youtube.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/ic_youtube_music_logo.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/music.xml ================================================ ================================================ FILE: common/data-models/src/main/res/drawable/soundbound_app_logo.xml ================================================ ================================================ FILE: common/database/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") id("com.squareup.sqldelight") } sqldelight { database("Database") { packageName = "com.shabinder.database" } } kotlin { sourceSets { commonMain { dependencies { implementation(project(":common:data-models")) // SQL Delight with(deps.sqldelight) { implementation(runtime) api(coroutines.extension) } } } androidMain { dependencies { implementation(deps.sqldelight.android.driver) } } desktopMain { dependencies { with(deps) { implementation(sqldelight.driver) implementation(sqlite.jdbc.driver) } } } if (HostOS.isMac) { val iosMain by getting { dependencies { implementation(deps.sqldelight.native.driver) } } } } } ================================================ FILE: common/database/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/database/src/androidMain/kotlin/com/shabinder/common/database/ActualAndroid.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.database import co.touchlab.kermit.LogcatLogger import co.touchlab.kermit.Logger import com.shabinder.database.Database import com.squareup.sqldelight.android.AndroidSqliteDriver import org.koin.dsl.module @Suppress("RedundantNullableReturnType") actual fun databaseModule() = module { single { val driver = AndroidSqliteDriver(Database.Schema, get(), "Database.db") SpotiFlyerDatabase(Database(driver)) } } actual fun getLogger(): Logger = LogcatLogger() ================================================ FILE: common/database/src/commonMain/kotlin/com/shabinder/common/database/Expect.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.database import co.touchlab.kermit.Logger import org.koin.core.module.Module expect fun databaseModule(): Module expect fun getLogger(): Logger ================================================ FILE: common/database/src/commonMain/kotlin/com/shabinder/common/database/SpotiFlyerDatabase.kt ================================================ package com.shabinder.common.database import com.shabinder.database.Database /* Database Wrapper */ class SpotiFlyerDatabase(val instance: Database?) ================================================ FILE: common/database/src/commonMain/sqldelight/com/shabinder/common/database/DownloadRecordDatabase.sq ================================================ CREATE TABLE IF NOT EXISTS DownloadRecord ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, link TEXT NOT NULL UNIQUE ON CONFLICT REPLACE , coverUrl TEXT NOT NULL, totalFiles INTEGER NOT NULL DEFAULT 1 ); selectAll: SELECT * FROM DownloadRecord; select: SELECT * FROM DownloadRecord WHERE link = :link; add: INSERT OR REPLACE INTO DownloadRecord (type, name, link, coverUrl, totalFiles) VALUES (?,?,?,?,?); delete: DELETE FROM DownloadRecord WHERE link = :link; getLastInsertId: SELECT last_insert_rowid(); clear: DELETE FROM DownloadRecord; ================================================ FILE: common/database/src/commonMain/sqldelight/com/shabinder/common/database/TokenDB.sq ================================================ CREATE TABLE IF NOT EXISTS Token ( tokenIndex INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE, accessToken TEXT NOT NULL, expiry INTEGER NOT NULL ); add: INSERT OR REPLACE INTO Token (tokenIndex,accessToken,expiry) VALUES (0,?,?); select: SELECT * FROM Token WHERE tokenIndex = 0; clear: DELETE FROM Token; ================================================ FILE: common/database/src/desktopMain/kotlin/com/shabinder/common/database/ActualDesktop.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.database import co.touchlab.kermit.CommonLogger import co.touchlab.kermit.Logger import com.shabinder.database.Database import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver import org.koin.dsl.module import java.io.File @Suppress("RedundantNullableReturnType") actual fun databaseModule() = module { single { val databasePath = File(System.getProperty("user.home") + File.separator + "SpotiFlyer" + File.separator + "Database", "Database.db") databasePath.parentFile.mkdirs() val driver = JdbcSqliteDriver(url = "jdbc:sqlite:${databasePath.absolutePath}") .also { Database.Schema.create(it) } SpotiFlyerDatabase(Database(driver)) } } actual fun getLogger(): Logger = CommonLogger() ================================================ FILE: common/database/src/iosMain/kotlin/com.shabinder.common.database/ActualIos.kt ================================================ package com.shabinder.common.database import co.touchlab.kermit.Logger import co.touchlab.kermit.NSLogLogger import com.shabinder.database.Database import com.squareup.sqldelight.drivers.native.NativeSqliteDriver import org.koin.dsl.module @Suppress("RedundantNullableReturnType") actual fun databaseModule() = module { single { val driver = NativeSqliteDriver(Database.Schema, "Database.db") SpotiFlyerDatabase(Database(driver)) } } actual fun getLogger(): Logger = NSLogLogger() ================================================ FILE: common/database/src/jsMain/kotlin/com/shabinder/common/database/ActualJs.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.database import co.touchlab.kermit.CommonLogger import co.touchlab.kermit.Logger import org.koin.dsl.module actual fun databaseModule() = module { single { SpotiFlyerDatabase(null) } } actual fun getLogger(): Logger = CommonLogger() ================================================ FILE: common/dependency-injection/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") kotlin("plugin.serialization") } kotlin { sourceSets { commonMain { dependencies { implementation(project(":common:data-models")) implementation(project(":common:database")) implementation(project(":common:providers")) implementation(project(":common:core-components")) } } } } ================================================ FILE: common/dependency-injection/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/ApplicationInit.kt ================================================ package com.shabinder.common.di import com.shabinder.common.models.dispatcherIO import com.shabinder.common.providers.spotify.SpotifyProvider import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.koin.dsl.module class ApplicationInit( private val spotifyProvider: SpotifyProvider, ) { companion object { private var isFirstLaunch = true } /* * Init Basic Necessary Items in here, * will be called, * Android / IOS: Splash Screen * Desktop: App Startup * */ @OptIn(DelicateCoroutinesApi::class) fun init() = GlobalScope.launch(dispatcherIO) { isFirstLaunch = false spotifyProvider.authenticateSpotifyClient() } } internal fun appInitModule() = module { single { ApplicationInit(get()) } } ================================================ FILE: common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/DI.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.di import com.shabinder.common.core_components.coreComponentModules import com.shabinder.common.database.databaseModule import com.shabinder.common.providers.providersModule import org.koin.core.KoinApplication import org.koin.core.context.startKoin import org.koin.core.module.Module import org.koin.dsl.KoinAppDeclaration fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) = startKoin { appDeclaration() modules( coreComponentModules(enableNetworkLogs), listOf( providersModule(enableNetworkLogs), databaseModule(), appInitModule(), ) ) } // Called by IOS fun initKoin() = initKoin(enableNetworkLogs = false) { } private fun KoinApplication.modules(vararg moduleLists: List): KoinApplication { return modules(moduleLists.toList().flatten()) } ================================================ FILE: common/list/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") id("kotlin-parcelize") } kotlin { sourceSets { commonMain { dependencies { implementation(project(":common:dependency-injection")) implementation(project(":common:data-models")) implementation(project(":common:database")) implementation(project(":common:providers")) implementation(project(":common:core-components")) } } } } ================================================ FILE: common/list/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/list/src/commonMain/kotlin/com/shabinder/common/list/SpotiFlyerList.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.list import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.mvikotlin.core.store.StoreFactory import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.list.integration.SpotiFlyerListImpl import com.shabinder.common.models.Consumer import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.TrackDetails import com.shabinder.common.providers.FetchPlatformQueryResult import kotlinx.coroutines.flow.MutableSharedFlow interface SpotiFlyerList { val model: Value /* * Download All Tracks(after filtering already Downloaded) * */ fun onDownloadAllClicked(trackList: List) /* * Download All Tracks(after filtering already Downloaded) * */ fun onDownloadClicked(track: TrackDetails) /* * To Pop and return back to Main Screen * */ fun onBackPressed() /* * Load Image from cache/Internet and cache it * */ suspend fun loadImage(url: String, isCover: Boolean = false): Picture /* * Sync Tracks Statuses * */ fun onRefreshTracksStatuses() /* * Snooze Donation Dialog * */ fun dismissDonationDialogSetOffset() interface Dependencies { val storeFactory: StoreFactory val fetchQuery: FetchPlatformQueryResult val fileManager: FileManager val preferenceManager: PreferenceManager val link: String val listOutput: Consumer val downloadProgressFlow: MutableSharedFlow> val listAnalytics: Analytics } interface Analytics sealed class Output { object Finished : Output() } data class State( val queryResult: PlatformQueryResult? = null, val link: String = "", val trackList: List = emptyList(), val errorOccurred: Throwable? = null, val askForDonation: Boolean = false, ) } @Suppress("FunctionName") // Factory function fun SpotiFlyerList(componentContext: ComponentContext, dependencies: SpotiFlyerList.Dependencies): SpotiFlyerList = SpotiFlyerListImpl(componentContext, dependencies) ================================================ FILE: common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.list.integration import co.touchlab.stately.ensureNeverFrozen import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.lifecycle.doOnResume import com.shabinder.common.caching.Cache import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.utils.asValue import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.list.SpotiFlyerList.Dependencies import com.shabinder.common.list.SpotiFlyerList.State import com.shabinder.common.list.store.SpotiFlyerListStore.Intent import com.shabinder.common.list.store.SpotiFlyerListStoreProvider import com.shabinder.common.list.store.getStore import com.shabinder.common.models.TrackDetails internal class SpotiFlyerListImpl( componentContext: ComponentContext, dependencies: Dependencies ) : SpotiFlyerList, ComponentContext by componentContext, Dependencies by dependencies { init { instanceKeeper.ensureNeverFrozen() lifecycle.doOnResume { onRefreshTracksStatuses() } } private val store = instanceKeeper.getStore { SpotiFlyerListStoreProvider(dependencies).provide() } private val cache = Cache.Builder .newBuilder() .maximumCacheSize(30) .build() override val model: Value = store.asValue() override fun onDownloadAllClicked(trackList: List) { store.accept(Intent.StartDownloadAll(trackList)) } override fun onDownloadClicked(track: TrackDetails) { store.accept(Intent.StartDownload(track)) } override fun onBackPressed() { listOutput.callback(SpotiFlyerList.Output.Finished) } override fun onRefreshTracksStatuses() { store.accept(Intent.RefreshTracksStatuses) } override fun dismissDonationDialogSetOffset() { preferenceManager.setDonationOffset(offset = 10) } override suspend fun loadImage(url: String, isCover: Boolean): Picture { return cache.get(url) { if (isCover) fileManager.loadImage(url, 350, 350) else fileManager.loadImage(url, 150, 150) } } } ================================================ FILE: common/list/src/commonMain/kotlin/com/shabinder/common/list/store/InstanceKeeperExt.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.list.store import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.mvikotlin.core.store.Store fun > InstanceKeeper.getStore(key: Any, factory: () -> T): T = getOrCreate(key) { StoreHolder(factory()) } .store inline fun > InstanceKeeper.getStore(noinline factory: () -> T): T = getStore(T::class, factory) private class StoreHolder>( val store: T ) : InstanceKeeper.Instance { override fun onDestroy() { store.dispose() } } ================================================ FILE: common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStore.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.list.store import com.arkivanov.mvikotlin.core.store.Store import com.shabinder.common.list.SpotiFlyerList.State import com.shabinder.common.list.store.SpotiFlyerListStore.Intent import com.shabinder.common.models.TrackDetails internal interface SpotiFlyerListStore : Store { sealed class Intent { data class SearchLink(val link: String) : Intent() data class StartDownload(val track: TrackDetails) : Intent() data class StartDownloadAll(val trackList: List) : Intent() object RefreshTracksStatuses : Intent() } } ================================================ FILE: common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.list.store import com.arkivanov.mvikotlin.core.store.Reducer import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.list.SpotiFlyerList.State import com.shabinder.common.list.store.SpotiFlyerListStore.Intent import com.shabinder.common.models.Actions import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.TrackDetails import com.shabinder.common.providers.downloadTracks import com.shabinder.common.utils.runOnDefault import com.shabinder.common.utils.runOnMain import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.withContext internal class SpotiFlyerListStoreProvider(dependencies: SpotiFlyerList.Dependencies) : SpotiFlyerList.Dependencies by dependencies { fun provide(): SpotiFlyerListStore = object : SpotiFlyerListStore, Store by storeFactory.create( name = "SpotiFlyerListStore", initialState = State(), bootstrapper = SimpleBootstrapper(Unit), executorFactory = ::ExecutorImpl, reducer = ReducerImpl ) {} private sealed class Result { data class ResultFetched( val result: PlatformQueryResult, val trackList: List ) : Result() data class UpdateTrackList(val list: List) : Result() data class UpdateTrackItem(val item: TrackDetails) : Result() data class ErrorOccurred(val error: Throwable) : Result() data class AskForSupport(val isAllowed: Boolean) : Result() } private inner class ExecutorImpl : SuspendExecutor() { override suspend fun executeAction(action: Unit, getState: () -> State) { executeIntent(Intent.SearchLink(link), getState) runOnDefault { fileManager.db?.downloadRecordDatabaseQueries?.getLastInsertId() ?.executeAsOneOrNull()?.also { // See if It's Time we can request for support for maintaining this project or not fetchQuery.logger.d( message = { "Database List Last ID: $it" }, tag = "Database Last ID" ) val offset = preferenceManager.getDonationOffset dispatchOnMain( Result.AskForSupport( // Every 3rd Interval or After some offset isAllowed = offset < 4 && (it % offset == 0L) ) ) } downloadProgressFlow.collect { map -> // logger.d(map.size.toString(), "ListStore: flow Updated") getState().trackList.updateTracksStatuses(map).also { if (it.isNotEmpty()) dispatchOnMain(Result.UpdateTrackList(it)) } } } } override suspend fun executeIntent(intent: Intent, getState: () -> State) { withContext(Dispatchers.Default) { when (intent) { is Intent.SearchLink -> { val resp = fetchQuery.query(link) resp.fold( success = { result -> result.trackList = result.trackList.toMutableList() .updateTracksStatuses( downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() } ) dispatchOnMain( (Result.ResultFetched( result, result.trackList )) ) executeIntent(Intent.RefreshTracksStatuses, getState) }, failure = { dispatchOnMain(Result.ErrorOccurred(it)) } ) } is Intent.StartDownloadAll -> { val list = intent.trackList.map { if (it.downloaded is DownloadStatus.NotDownloaded || it.downloaded is DownloadStatus.Failed) return@map it.copy(downloaded = DownloadStatus.Queued) it } dispatchOnMain( Result.UpdateTrackList( list.updateTracksStatuses( downloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }) ) ) val finalList = intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded } if (finalList.isEmpty()) Actions.instance.showPopUpMessage("All Songs are Processed") else downloadTracks(finalList, fetchQuery, fileManager) } is Intent.StartDownload -> { dispatchOnMain(Result.UpdateTrackItem(intent.track.copy(downloaded = DownloadStatus.Queued))) downloadTracks(listOf(intent.track), fetchQuery, fileManager) } is Intent.RefreshTracksStatuses -> Actions.instance.queryActiveTracks() } } } private suspend fun dispatchOnMain(result: Result) = runOnMain { dispatch(result) } } private object ReducerImpl : Reducer { override fun State.reduce(result: Result): State = when (result) { is Result.ResultFetched -> copy( queryResult = result.result, trackList = result.trackList, link = link ) is Result.UpdateTrackList -> copy(trackList = result.list) is Result.UpdateTrackItem -> updateTrackItem(result.item) is Result.ErrorOccurred -> copy(errorOccurred = result.error) is Result.AskForSupport -> copy(askForDonation = result.isAllowed) } private fun State.updateTrackItem(item: TrackDetails): State { val position = this.trackList.map { it.title }.indexOf(item.title) if (position != -1) { return copy(trackList = trackList.toMutableList().apply { set(position, item) }) } return this } } private fun List.updateTracksStatuses(map: Map): List { // create a copy in order not to access real referenced ever-changing collections val trackList = ArrayList(this) val updatedMap = HashMap(map) repeat(trackList.size) { index -> trackList[index].also { oldTrack -> updatedMap[oldTrack.title]?.also { newStatus -> trackList[index] = oldTrack.copy(downloaded = newStatus) } } } return trackList } } ================================================ FILE: common/main/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") id("kotlin-parcelize") } kotlin { sourceSets { commonMain { dependencies { implementation(project(":common:dependency-injection")) implementation(project(":common:data-models")) implementation(project(":common:database")) implementation(project(":common:providers")) implementation(project(":common:core-components")) } } } } ================================================ FILE: common/main/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/main/src/commonMain/kotlin/com/shabinder/common/main/SpotiFlyerMain.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.main import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.mvikotlin.core.store.StoreFactory import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.main.integration.SpotiFlyerMainImpl import com.shabinder.common.models.Consumer import com.shabinder.common.models.DownloadRecord import com.shabinder.database.Database interface SpotiFlyerMain { val model: Value val analytics: Analytics /* * We Intend to Move to List Screen * Note: Implementation in Root * */ fun onLinkSearch(link: String) /* * Update TextBox's Text * */ fun onInputLinkChanged(link: String) /* * change TabBar Selected Category * */ fun selectCategory(category: HomeCategory) /* * change TabBar Selected Category * */ fun toggleAnalytics(enabled: Boolean) /* * Load Image from cache/Internet and cache it * */ suspend fun loadImage(url: String): Picture fun dismissDonationDialogOffset() interface Dependencies { val mainOutput: Consumer val storeFactory: StoreFactory val database: Database? val fileManager: FileManager val preferenceManager: PreferenceManager val analyticsManager: AnalyticsManager val mainAnalytics: Analytics } interface Analytics { fun donationDialogVisit() } sealed class Output { data class Search(val link: String) : Output() } data class State( val records: List = emptyList(), val link: String = "", val selectedCategory: HomeCategory = HomeCategory.About, val isAnalyticsEnabled: Boolean = false ) enum class HomeCategory { About, History } } @Suppress("FunctionName") // Factory function fun SpotiFlyerMain(componentContext: ComponentContext, dependencies: SpotiFlyerMain.Dependencies): SpotiFlyerMain = SpotiFlyerMainImpl(componentContext, dependencies) ================================================ FILE: common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.main.integration import co.touchlab.stately.ensureNeverFrozen import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.lifecycle.doOnResume import com.shabinder.common.caching.Cache import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.utils.asValue import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain.* import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider import com.shabinder.common.main.store.getStore import com.shabinder.common.models.Actions internal class SpotiFlyerMainImpl( componentContext: ComponentContext, dependencies: Dependencies ) : SpotiFlyerMain, ComponentContext by componentContext, Dependencies by dependencies { init { instanceKeeper.ensureNeverFrozen() lifecycle.doOnResume { store.accept(Intent.ToggleAnalytics(preferenceManager.isAnalyticsEnabled)) } } private val store = instanceKeeper.getStore { SpotiFlyerMainStoreProvider(dependencies).provide() } private val cache = Cache.Builder .newBuilder() .maximumCacheSize(20) .build() override val model: Value = store.asValue() override val analytics = mainAnalytics override fun onLinkSearch(link: String) { if (Actions.instance.isInternetAvailable) mainOutput.callback(Output.Search(link = link)) else Actions.instance.showPopUpMessage("Check Network Connection Please") } override fun onInputLinkChanged(link: String) { store.accept(Intent.SetLink(link)) } override fun selectCategory(category: HomeCategory) { store.accept(Intent.SelectCategory(category)) } override fun toggleAnalytics(enabled: Boolean) { store.accept(Intent.ToggleAnalytics(enabled)) } override suspend fun loadImage(url: String): Picture { return cache.get(url) { fileManager.loadImage(url, 150, 150) } } override fun dismissDonationDialogOffset() { preferenceManager.setDonationOffset() } } ================================================ FILE: common/main/src/commonMain/kotlin/com/shabinder/common/main/store/InstanceKeeperExt.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.main.store import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.mvikotlin.core.store.Store fun > InstanceKeeper.getStore(key: Any, factory: () -> T): T = getOrCreate(key) { StoreHolder(factory()) } .store inline fun > InstanceKeeper.getStore(noinline factory: () -> T): T = getStore(T::class, factory) private class StoreHolder>( val store: T ) : InstanceKeeper.Instance { override fun onDestroy() { store.dispose() } } ================================================ FILE: common/main/src/commonMain/kotlin/com/shabinder/common/main/store/SpotiFlyerMainStore.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.main.store import com.arkivanov.mvikotlin.core.store.Store import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent internal interface SpotiFlyerMainStore : Store { sealed class Intent { data class OpenPlatform(val platformID: String, val platformLink: String) : Intent() data class SetLink(val link: String) : Intent() data class SelectCategory(val category: SpotiFlyerMain.HomeCategory) : Intent() data class ToggleAnalytics(val enabled: Boolean) : Intent() object GiveDonation : Intent() object ShareApp : Intent() } } ================================================ FILE: common/main/src/commonMain/kotlin/com/shabinder/common/main/store/SpotiFlyerMainStoreProvider.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.main.store import com.arkivanov.mvikotlin.core.store.Reducer import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain.State import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent import com.shabinder.common.models.DownloadRecord import com.shabinder.common.models.Actions import com.shabinder.common.utils.runOnMain import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map internal class SpotiFlyerMainStoreProvider(dependencies: SpotiFlyerMain.Dependencies): SpotiFlyerMain.Dependencies by dependencies { fun provide(): SpotiFlyerMainStore = object : SpotiFlyerMainStore, Store by storeFactory.create( name = "SpotiFlyerHomeStore", initialState = State(), bootstrapper = SimpleBootstrapper(Unit), executorFactory = ::ExecutorImpl, reducer = ReducerImpl ) {} val updates: Flow>? = database?.downloadRecordDatabaseQueries ?.selectAll() ?.asFlow() ?.mapToList(Dispatchers.Default) ?.map { it.map { record -> record.run { DownloadRecord(id, type, name, link, coverUrl, totalFiles) } } } private sealed class Result { data class ItemsLoaded(val items: List) : Result() data class CategoryChanged(val category: SpotiFlyerMain.HomeCategory) : Result() data class LinkChanged(val link: String) : Result() data class AnalyticsToggled(val isEnabled: Boolean) : Result() } private inner class ExecutorImpl : SuspendExecutor() { override suspend fun executeAction(action: Unit, getState: () -> State) { dispatch(Result.AnalyticsToggled(preferenceManager.isAnalyticsEnabled)) updates?.collect { dispatch(Result.ItemsLoaded(it)) } } override suspend fun executeIntent(intent: Intent, getState: () -> State) { when (intent) { is Intent.OpenPlatform -> Actions.instance.openPlatform(intent.platformID, intent.platformLink) is Intent.GiveDonation -> Actions.instance.giveDonation() is Intent.ShareApp -> Actions.instance.shareApp() is Intent.SetLink -> dispatch(Result.LinkChanged(link = intent.link)) is Intent.SelectCategory -> dispatch(Result.CategoryChanged(intent.category)) is Intent.ToggleAnalytics -> { dispatch(Result.AnalyticsToggled(intent.enabled)) preferenceManager.toggleAnalytics(intent.enabled) } } } } private object ReducerImpl : Reducer { override fun State.reduce(result: Result): State = when (result) { is Result.ItemsLoaded -> copy(records = result.items) is Result.LinkChanged -> copy(link = result.link) is Result.CategoryChanged -> copy(selectedCategory = result.category) is Result.AnalyticsToggled -> copy(isAnalyticsEnabled = result.isEnabled) } } } ================================================ FILE: common/preference/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") id("kotlin-parcelize") } kotlin { sourceSets { commonMain { dependencies { implementation(project(":common:dependency-injection")) implementation(project(":common:data-models")) implementation(project(":common:database")) implementation(project(":common:core-components")) implementation(project(":common:providers")) } } } } ================================================ FILE: common/preference/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/preference/src/commonMain/kotlin/com/shabinder/common/preference/SpotiFlyerPreference.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.preference import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.mvikotlin.core.store.StoreFactory import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.models.Actions import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.Consumer import com.shabinder.common.models.spotify.SpotifyCredentials import com.shabinder.common.preference.integration.SpotiFlyerPreferenceImpl interface SpotiFlyerPreference { val model: Value val analytics: Analytics fun toggleAnalytics(enabled: Boolean) fun selectNewDownloadDirectory() fun setPreferredQuality(quality: AudioQuality) fun updateSpotifyCredentials(credentials: SpotifyCredentials) suspend fun loadImage(url: String): Picture interface Dependencies { val prefOutput: Consumer val storeFactory: StoreFactory val fileManager: FileManager val preferenceManager: PreferenceManager val analyticsManager: AnalyticsManager val actions: Actions val preferenceAnalytics: Analytics } interface Analytics sealed class Output { object Finished : Output() } data class State( val preferredQuality: AudioQuality = AudioQuality.KBPS320, val downloadPath: String = "", val isAnalyticsEnabled: Boolean = false, val spotifyCredentials: SpotifyCredentials = SpotifyCredentials() ) } @Suppress("FunctionName") // Factory function fun SpotiFlyerPreference( componentContext: ComponentContext, dependencies: SpotiFlyerPreference.Dependencies ): SpotiFlyerPreference = SpotiFlyerPreferenceImpl(componentContext, dependencies) ================================================ FILE: common/preference/src/commonMain/kotlin/com/shabinder/common/preference/integration/SpotiFlyerPreferenceImpl.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.preference.integration import co.touchlab.stately.ensureNeverFrozen import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.shabinder.common.caching.Cache import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.core_components.utils.asValue import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.spotify.SpotifyCredentials import com.shabinder.common.preference.SpotiFlyerPreference import com.shabinder.common.preference.SpotiFlyerPreference.Dependencies import com.shabinder.common.preference.SpotiFlyerPreference.State import com.shabinder.common.preference.store.SpotiFlyerPreferenceStore.Intent import com.shabinder.common.preference.store.SpotiFlyerPreferenceStoreProvider import com.shabinder.common.preference.store.getStore internal class SpotiFlyerPreferenceImpl( componentContext: ComponentContext, dependencies: Dependencies ) : SpotiFlyerPreference, ComponentContext by componentContext, Dependencies by dependencies { init { instanceKeeper.ensureNeverFrozen() } private val store = instanceKeeper.getStore { SpotiFlyerPreferenceStoreProvider(dependencies).provide() } private val cache = Cache.Builder .newBuilder() .maximumCacheSize(10) .build() override val model: Value = store.asValue() override val analytics = preferenceAnalytics override fun toggleAnalytics(enabled: Boolean) { store.accept(Intent.ToggleAnalytics(enabled)) } override fun selectNewDownloadDirectory() { actions.setDownloadDirectoryAction { store.accept(Intent.SetDownloadDirectory(it)) } } override fun setPreferredQuality(quality: AudioQuality) { store.accept(Intent.SetPreferredAudioQuality(quality)) } override fun updateSpotifyCredentials(credentials: SpotifyCredentials) { store.accept(Intent.UpdateSpotifyCredentials(credentials)) } override suspend fun loadImage(url: String): Picture { return cache.get(url) { fileManager.loadImage(url, 150, 150) } } } ================================================ FILE: common/preference/src/commonMain/kotlin/com/shabinder/common/preference/store/InstanceKeeperExt.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.preference.store import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.mvikotlin.core.store.Store fun > InstanceKeeper.getStore(key: Any, factory: () -> T): T = getOrCreate(key) { StoreHolder(factory()) } .store inline fun > InstanceKeeper.getStore(noinline factory: () -> T): T = getStore(T::class, factory) private class StoreHolder>( val store: T ) : InstanceKeeper.Instance { override fun onDestroy() { store.dispose() } } ================================================ FILE: common/preference/src/commonMain/kotlin/com/shabinder/common/preference/store/SpotiFlyerPreferenceStore.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.preference.store import com.arkivanov.mvikotlin.core.store.Store import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.spotify.SpotifyCredentials import com.shabinder.common.preference.SpotiFlyerPreference internal interface SpotiFlyerPreferenceStore : Store { sealed class Intent { data class OpenPlatform(val platformID: String, val platformLink: String) : Intent() data class ToggleAnalytics(val enabled: Boolean) : Intent() data class SetDownloadDirectory(val path: String) : Intent() data class SetPreferredAudioQuality(val quality: AudioQuality) : Intent() data class UpdateSpotifyCredentials(val credentials: SpotifyCredentials) : Intent() object GiveDonation : Intent() object ShareApp : Intent() } } ================================================ FILE: common/preference/src/commonMain/kotlin/com/shabinder/common/preference/store/SpotiFlyerPreferenceStoreProvider.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.preference.store import com.arkivanov.mvikotlin.core.store.Reducer import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.Actions import com.shabinder.common.models.spotify.SpotifyCredentials import com.shabinder.common.preference.SpotiFlyerPreference import com.shabinder.common.preference.SpotiFlyerPreference.State import com.shabinder.common.preference.store.SpotiFlyerPreferenceStore.Intent internal class SpotiFlyerPreferenceStoreProvider( dependencies: SpotiFlyerPreference.Dependencies ) : SpotiFlyerPreference.Dependencies by dependencies { fun provide(): SpotiFlyerPreferenceStore = object : SpotiFlyerPreferenceStore, Store by storeFactory.create( name = "SpotiFlyerPreferenceStore", initialState = State(), bootstrapper = SimpleBootstrapper(Unit), executorFactory = ::ExecutorImpl, reducer = ReducerImpl ) {} private sealed class Result { data class AnalyticsToggled(val isEnabled: Boolean) : Result() data class DownloadPathSet(val path: String) : Result() data class PreferredAudioQualityChanged(val quality: AudioQuality) : Result() data class SpotifyCredentialsUpdated(val spotifyCredentials: SpotifyCredentials) : Result() } private inner class ExecutorImpl : SuspendExecutor() { override suspend fun executeAction(action: Unit, getState: () -> State) { dispatch(Result.AnalyticsToggled(preferenceManager.isAnalyticsEnabled)) dispatch(Result.PreferredAudioQualityChanged(preferenceManager.audioQuality)) dispatch(Result.SpotifyCredentialsUpdated(preferenceManager.spotifyCredentials)) dispatch(Result.DownloadPathSet(fileManager.defaultDir())) } override suspend fun executeIntent(intent: Intent, getState: () -> State) { when (intent) { is Intent.OpenPlatform -> Actions.instance.openPlatform(intent.platformID, intent.platformLink) is Intent.GiveDonation -> Actions.instance.giveDonation() is Intent.ShareApp -> Actions.instance.shareApp() is Intent.ToggleAnalytics -> { dispatch(Result.AnalyticsToggled(intent.enabled)) preferenceManager.toggleAnalytics(intent.enabled) } is Intent.SetDownloadDirectory -> { dispatch(Result.DownloadPathSet(intent.path)) preferenceManager.setDownloadDirectory(intent.path) } is Intent.SetPreferredAudioQuality -> { dispatch(Result.PreferredAudioQualityChanged(intent.quality)) preferenceManager.setPreferredAudioQuality(intent.quality) } is Intent.UpdateSpotifyCredentials -> { dispatch(Result.SpotifyCredentialsUpdated(intent.credentials)) preferenceManager.setSpotifyCredentials(intent.credentials) } } } } private object ReducerImpl : Reducer { override fun State.reduce(result: Result): State = when (result) { is Result.AnalyticsToggled -> copy(isAnalyticsEnabled = result.isEnabled) is Result.DownloadPathSet -> copy(downloadPath = result.path) is Result.PreferredAudioQualityChanged -> copy(preferredQuality = result.quality) is Result.SpotifyCredentialsUpdated -> copy(spotifyCredentials = result.spotifyCredentials) } } } ================================================ FILE: common/providers/build.gradle.kts ================================================ plugins { id("multiplatform-setup") id("multiplatform-setup-test") kotlin("plugin.serialization") } kotlin { /* Targets configuration omitted. * To find out how to configure the targets, please follow the link: * https://kotlinlang.org/docs/reference/building-mpp-with-gradle.html#setting-up-targets */ sourceSets { commonMain { dependencies { with(deps) { implementation(project(":common:data-models")) implementation(project(":common:database")) implementation(project(":common:core-components")) implementation(youtube.downloader) implementation(fuzzy.wuzzy) implementation(kotlinx.datetime) } } } androidMain { dependencies { implementation(deps.mp3agic) } } desktopMain { dependencies { implementation(deps.mp3agic) implementation(deps.jaffree) } } jsMain { dependencies { implementation(npm("browser-id3-writer", "4.4.0")) implementation(npm("file-saver", "2.0.4")) } } } } ================================================ FILE: common/providers/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/providers/src/androidMain/kotlin/com/shabinder/common/providers/AndroidActual.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.Actions actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, fileManager: FileManager ) { if (list.isNotEmpty()) { Actions.instance.platformActions.sendTracksToService(ArrayList(list)) } } ================================================ FILE: common/providers/src/androidMain/kotlin/com/shabinder/common/providers/saavn/requests/decryptURL.kt ================================================ package com.shabinder.common.providers.saavn.requests import android.annotation.SuppressLint import io.ktor.util.* import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import javax.crypto.spec.DESKeySpec @SuppressLint("GetInstance") @OptIn(InternalAPI::class) actual suspend fun decryptURL(url: String): String { val dks = DESKeySpec("38346591".toByteArray()) val keyFactory = SecretKeyFactory.getInstance("DES") val key: SecretKey = keyFactory.generateSecret(dks) val cipher: Cipher = Cipher.getInstance("DES/ECB/PKCS5Padding").apply { init(Cipher.DECRYPT_MODE, key, SecureRandom()) } return cipher.doFinal(url.decodeBase64Bytes()) .decodeToString() .replace("_96.mp4", "_320.mp4") } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/Expect.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.models.TrackDetails import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlin.native.concurrent.SharedImmutable expect suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, fileManager: FileManager ) ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/FetchPlatformQueryResult.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.database.DownloadRecordDatabaseQueries import com.shabinder.common.models.AudioFormat import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.dispatcherIO import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.flatMapError import com.shabinder.common.models.event.coroutines.onFailure import com.shabinder.common.models.event.coroutines.onSuccess import com.shabinder.common.models.event.coroutines.success import com.shabinder.common.models.spotify.Source import com.shabinder.common.providers.gaana.GaanaProvider import com.shabinder.common.providers.saavn.SaavnProvider import com.shabinder.common.providers.sound_cloud.SoundCloudProvider import com.shabinder.common.providers.spotify.SpotifyProvider import com.shabinder.common.providers.youtube.YoutubeProvider import com.shabinder.common.providers.youtube_music.YoutubeMusic import com.shabinder.common.providers.youtube_to_mp3.requests.YoutubeMp3 import com.shabinder.common.utils.appendPadded import com.shabinder.common.utils.buildString import com.shabinder.common.utils.requireNotNull import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch class FetchPlatformQueryResult( private val gaanaProvider: GaanaProvider, private val spotifyProvider: SpotifyProvider, private val youtubeProvider: YoutubeProvider, private val saavnProvider: SaavnProvider, private val soundCloudProvider: SoundCloudProvider, private val youtubeMusic: YoutubeMusic, private val youtubeMp3: YoutubeMp3, val fileManager: FileManager, val preferenceManager: PreferenceManager, val logger: Kermit ) { private val db: DownloadRecordDatabaseQueries? get() = fileManager.db?.downloadRecordDatabaseQueries suspend fun query(link: String): SuspendableEvent { val result = when { // SPOTIFY link.contains("spotify", true) -> spotifyProvider.query(link) // YOUTUBE link.contains("youtube.com", true) || link.contains("youtu.be", true) -> youtubeProvider.query(link) // JioSaavn link.contains("saavn", true) -> saavnProvider.query(link) // GAANA link.contains("gaana", true) -> gaanaProvider.query(link) // SoundCloud link.contains("soundcloud", true) -> soundCloudProvider.query(link) else -> { SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link)) } } result.success { addToDatabaseAsync( link, it.copy() // Send a copy in order to not freeze Result itself ) } return result } // 1) Try Finding on JioSaavn (better quality upto 320KBPS) // 2) If Not found try finding on YouTube Music suspend fun findBestDownloadLink( track: TrackDetails, preferredQuality: AudioQuality = preferenceManager.audioQuality ): SuspendableEvent, Throwable> { var downloadLink: String? = null var audioQuality = AudioQuality.KBPS192 var audioFormat = AudioFormat.MP4 val errorTrace = buildString(track) { if (track.videoID != null) { // We Already have VideoID downloadLink = when (track.source) { Source.JioSaavn -> { AudioFormat.MP4 saavnProvider.getSongFromID(track.videoID.requireNotNull()).component1() ?.also { audioQuality = it.audioQuality } ?.media_url } Source.YouTube -> { youtubeMp3.getMp3DownloadLink( track.videoID.requireNotNull(), preferredQuality ).let { ytMp3LinkRes -> if ( ytMp3LinkRes is SuspendableEvent.Failure || ytMp3LinkRes.component1().isNullOrBlank() ) { appendPadded( "Yt1sMp3 Failed for ${track.videoID}:", ytMp3LinkRes.component2()?.stackTraceToString() ?: "couldn't fetch link for ${track.videoID} ,trying manual extraction" ) appendLine("Trying Local Extraction") SuspendableEvent { youtubeProvider.fetchVideoM4aLink(track.videoID.requireNotNull()) }.onFailure { throwable -> appendPadded("YT Manual Extraction Failed!", throwable.stackTraceToString()) }.onSuccess { audioQuality = it.second audioFormat = AudioFormat.MP4 }.component1()?.first } else { audioFormat = AudioFormat.MP3 ytMp3LinkRes.component1() } } } Source.SoundCloud -> { audioFormat = track.audioFormat soundCloudProvider.getDownloadURL(track).let { if (it is SuspendableEvent.Failure || it.component1().isNullOrEmpty()) { appendPadded( "SoundCloud Provider Failed for ${track.title}:", it.component2()?.stackTraceToString() ?: "couldn't fetch link for ${track.trackUrl}" ) null } else it.component1() } } else -> { appendPadded( "Invalid Arguments", "VideoID with ${track.source} source is not defined how to be handled" ) /*We should never reach here for now*/ null } } } // if videoID wasn't present || fetching using video ID failed if (downloadLink.isNullOrBlank()) { // Try Fetching Track from Available Sources saavnProvider.findBestSongDownloadURL( trackName = track.title, trackArtists = track.artists, preferredQuality = preferredQuality ).onSuccess { (URL, quality) -> audioFormat = AudioFormat.MP4 downloadLink = URL audioQuality = quality }.flatMapError { saavnError -> appendPadded("Fetching From Saavn Failed:", saavnError.stackTraceToString()) // Saavn Failed, Lets Try Fetching Now From Youtube Music youtubeMusic.findSongDownloadURLYT(track, preferredQuality, this).onSuccess { (URL, quality, format) -> downloadLink = URL audioQuality = quality audioFormat = format }.onFailure { // Append Error To StackTrace appendPadded( "Fetching From YT Failed:", it.stackTraceToString() ) } } } } return if (downloadLink.isNullOrBlank()) SuspendableEvent.error( SpotiFlyerException.DownloadLinkFetchFailed(errorTrace) ) else { track.audioFormat = audioFormat SuspendableEvent.success(Pair(downloadLink.requireNotNull(), audioQuality)) } } @OptIn(DelicateCoroutinesApi::class) private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) { GlobalScope.launch(dispatcherIO) { db?.add( result.folderType, result.title, link, result.coverUrl, result.trackList.size.toLong() ) } } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/ProvidersModule.kt ================================================ package com.shabinder.common.providers import com.shabinder.common.providers.gaana.GaanaProvider import com.shabinder.common.providers.saavn.SaavnProvider import com.shabinder.common.providers.sound_cloud.SoundCloudProvider import com.shabinder.common.providers.spotify.SpotifyProvider import com.shabinder.common.providers.spotify.token_store.TokenStore import com.shabinder.common.providers.youtube.YoutubeProvider import com.shabinder.common.providers.youtube_music.YoutubeMusic import com.shabinder.common.providers.youtube_to_mp3.requests.YoutubeMp3 import org.koin.dsl.module @Suppress("UNUSED_PARAMETER") fun providersModule(enableNetworkLogs: Boolean) = module { single { TokenStore(get(), get()) } single { SpotifyProvider(get(), get(), get()) } single { GaanaProvider(get(), get(), get()) } single { SaavnProvider(get(), get(), get()) } single { YoutubeProvider(get(), get(), get()) } single { SoundCloudProvider(get(), get(), get()) } single { YoutubeMp3(get(), get()) } single { YoutubeMusic(get(), get(), get(), get(), get()) } single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/gaana/GaanaProvider.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.providers.gaana import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.finalOutputDir import com.shabinder.common.core_components.file_manager.getImageCachePath import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.gaana.GaanaTrack import com.shabinder.common.models.spotify.Source import com.shabinder.common.providers.gaana.requests.GaanaRequests import io.ktor.client.* class GaanaProvider( override val httpClient: HttpClient, private val logger: Kermit, private val fileManager: FileManager, ) : GaanaRequests { private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg" suspend fun query(fullLink: String): SuspendableEvent = SuspendableEvent { // Link Schema: https://gaana.com/type/link val gaanaLink = fullLink.substringAfter("gaana.com/") val link = gaanaLink.substringAfterLast('/', "error") val type = gaanaLink.substringBeforeLast('/', "error").substringAfterLast('/') // Error if (type == "Error" || link == "Error") { throw SpotiFlyerException.LinkInvalid() } gaanaSearch( type, link ) } private suspend fun gaanaSearch( type: String, link: String, ): PlatformQueryResult { val result = PlatformQueryResult( folderType = "", subFolder = link, title = link, coverUrl = gaanaPlaceholderImageUrl, trackList = listOf(), Source.Gaana ) logger.i { "GAANA SEARCH: $type - $link" } with(result) { when (type) { "song" -> { getGaanaSong(seokey = link).tracks.firstOrNull()?.also { folderType = "Tracks" subFolder = "" trackList = listOf(it).toTrackDetailsList(folderType, subFolder) title = it.track_title coverUrl = it.artworkLink.replace("http:", "https:") } } "album" -> { getGaanaAlbum(seokey = link).also { folderType = "Albums" subFolder = link trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList() title = link coverUrl = it.custom_artworks.size_480p.replace("http:", "https:") } } "playlist" -> { getGaanaPlaylist(seokey = link).also { folderType = "Playlists" subFolder = link trackList = it.tracks.toTrackDetailsList(folderType, subFolder) title = link // coverUrl.value = "TODO" coverUrl = gaanaPlaceholderImageUrl } } "artist" -> { folderType = "Artist" subFolder = link coverUrl = gaanaPlaceholderImageUrl getGaanaArtistDetails(seokey = link).artist.firstOrNull() ?.also { title = it.name coverUrl = it.artworkLink?.replace("http:", "https:") ?: gaanaPlaceholderImageUrl } getGaanaArtistTracks(seokey = link).also { trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList() } } else -> { // TODO Handle Error } } return result } } private fun List.toTrackDetailsList(type: String, subFolder: String) = this.map { TrackDetails( title = it.track_title, artists = it.artist.map { artist -> artist?.name.toString() }, durationSec = it.duration, albumArtPath = fileManager.getImageCachePath(it.artworkLink), albumName = it.album_title, genre = it.genre?.mapNotNull { genre -> genre?.name } ?: emptyList(), year = it.release_date, comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}", trackUrl = it.lyrics_url, downloaded = it.updateStatusIfPresent(type, subFolder), source = Source.Gaana, albumArtURL = it.artworkLink.replace("http:", "https:"), outputFilePath = fileManager.finalOutputDir( it.track_title, type, subFolder, fileManager.defaultDir()/*,".m4a"*/ ) ) } private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus { return if (fileManager.isPresent( fileManager.finalOutputDir( track_title, folderType, subFolder, fileManager.defaultDir() ) ) ) { // Download Already Present!! DownloadStatus.Downloaded.also { downloaded = it } } else downloaded ?: DownloadStatus.NotDownloaded } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/gaana/requests/GaanaRequests.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.gaana.requests import com.shabinder.common.models.corsApi import com.shabinder.common.models.gaana.GaanaAlbum import com.shabinder.common.models.gaana.GaanaArtistDetails import com.shabinder.common.models.gaana.GaanaArtistTracks import com.shabinder.common.models.gaana.GaanaPlaylist import com.shabinder.common.models.gaana.GaanaSong import io.ktor.client.* import io.ktor.client.request.* interface GaanaRequests { companion object { private const val TOKEN = "b2e6d7fbc136547a940516e9b77e5990" private val BASE_URL get() = "${corsApi}https://api.gaana.com" } val httpClient: HttpClient /* * Api Request: http://api.gaana.com/?type=playlist&subtype=playlist_detail&seokey=gaana-dj-hindi-top-50-1&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON * * subtype : ["most_popular_playlist" , "playlist_home_featured" ,"playlist_detail" ,"user_playlist" ,"topCharts"] **/ suspend fun getGaanaPlaylist( type: String = "playlist", subtype: String = "playlist_detail", seokey: String, format: String = "JSON", limit: Int = 2000 ): GaanaPlaylist { return httpClient.get( "$BASE_URL/?type=$type&subtype=$subtype&seokey=$seokey&token=$TOKEN&format=$format&limit=$limit" ) } /* * Api Request: http://api.gaana.com/?type=album&subtype=album_detail&seokey=kabir-singh&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON * * subtype : ["most_popular" , "new_release" ,"featured_album" ,"similar_album" ,"all_albums", "album" ,"album_detail" ,"album_detail_info"] **/ suspend fun getGaanaAlbum( type: String = "album", subtype: String = "album_detail", seokey: String, format: String = "JSON", limit: Int = 2000 ): GaanaAlbum { return httpClient.get( "$BASE_URL/?type=$type&subtype=$subtype&seokey=$seokey&token=$TOKEN&format=$format&limit=$limit" ) } /* * Api Request: http://api.gaana.com/?type=song&subtype=song_detail&seokey=pachtaoge&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON * * subtype : ["most_popular" , "hot_songs" ,"recommendation" ,"song_detail"] **/ suspend fun getGaanaSong( type: String = "song", subtype: String = "song_detail", seokey: String, format: String = "JSON", ): GaanaSong { return httpClient.get( "$BASE_URL/?type=$type&subtype=$subtype&seokey=$seokey&token=$TOKEN&format=$format" ) } /* * Api Request: https://api.gaana.com/?type=artist&subtype=artist_details_info&seokey=neha-kakkar&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON * * subtype : ["most_popular" , "artist_list" ,"artist_track_listing" ,"artist_album" ,"similar_artist","artist_details" ,"artist_details_info"] **/ suspend fun getGaanaArtistDetails( type: String = "artist", subtype: String = "artist_details_info", seokey: String, format: String = "JSON", ): GaanaArtistDetails { return httpClient.get( "$BASE_URL/?type=$type&subtype=$subtype&seokey=$seokey&token=$TOKEN&format=$format" ) } /* * Api Request: http://api.gaana.com/?type=artist&subtype=artist_track_listing&seokey=neha-kakkar&limit=50&token=b2e6d7fbc136547a940516e9b77e5990&format=JSON * * subtype : ["most_popular" , "artist_list" ,"artist_track_listing" ,"artist_album" ,"similar_artist","artist_details" ,"artist_details_info"] **/ suspend fun getGaanaArtistTracks( type: String = "artist", subtype: String = "artist_track_listing", seokey: String, format: String = "JSON", limit: Int = 50 ): GaanaArtistTracks { return httpClient.get( "$BASE_URL/?type=$type&subtype=$subtype&seokey=$seokey&token=$TOKEN&format=$format&limit=$limit" ) } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/saavn/SaavnProvider.kt ================================================ package com.shabinder.common.providers.saavn import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.finalOutputDir import com.shabinder.common.core_components.file_manager.getImageCachePath import com.shabinder.common.models.* import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.saavn.SaavnSong import com.shabinder.common.models.spotify.Source import com.shabinder.common.providers.saavn.requests.JioSaavnRequests import com.shabinder.common.utils.removeIllegalChars import io.ktor.client.* class SaavnProvider( override val httpClient: HttpClient, override val logger: Kermit, private val fileManager: FileManager ) : JioSaavnRequests { suspend fun query(fullLink: String): SuspendableEvent = SuspendableEvent { PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.JioSaavn ).apply { val pageLink = fullLink.substringAfter("saavn.com/").substringBefore("?") when { pageLink.contains("song/", true) -> { getSong(fullLink).value.let { folderType = "Tracks" subFolder = "" trackList = listOf(it).toTrackDetails(folderType, subFolder) title = it.song coverUrl = it.image.replace("http:", "https:") } } pageLink.contains("album/", true) -> { getAlbum(fullLink).value.let { folderType = "Albums" subFolder = removeIllegalChars(it.title) trackList = it.songs.toTrackDetails(folderType, subFolder) title = it.title coverUrl = it.image.replace("http:", "https:") } } pageLink.contains("featured/", true) || pageLink.contains("playlist/", true) -> { // Playlist getPlaylist(fullLink).value.let { folderType = "Playlists" subFolder = removeIllegalChars(it.listname) trackList = it.songs.toTrackDetails(folderType, subFolder) coverUrl = it.image.replace("http:", "https:") title = it.listname } } else -> { throw SpotiFlyerException.LinkInvalid(fullLink) } } } } private fun List.toTrackDetails(type: String, subFolder: String): List = this.map { TrackDetails( title = it.song, artists = it.artistMap.keys.toMutableSet().apply { addAll(it.singers.split(",")) }.toList(), durationSec = it.duration.toInt(), albumName = it.album, albumArtPath = fileManager.getImageCachePath(it.image), year = it.year, comment = it.copyright_text, trackUrl = it.perma_url, videoID = it.id, downloadLink = it.media_url, // Downloadable Link downloaded = it.updateStatusIfPresent(type, subFolder), albumArtURL = it.image.replace("http:", "https:"), lyrics = it.lyrics ?: it.lyrics_snippet, source = Source.JioSaavn, audioQuality = if (it.is320Kbps) AudioQuality.KBPS320 else AudioQuality.KBPS160, outputFilePath = fileManager.finalOutputDir(it.song, type, subFolder, fileManager.defaultDir() /*".m4a"*/) ) } private fun SaavnSong.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus { return if (fileManager.isPresent( fileManager.finalOutputDir( song, folderType, subFolder, fileManager.defaultDir() ) ) ) { // Download Already Present!! DownloadStatus.Downloaded.also { downloaded = it } } else downloaded } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/saavn/requests/JioSaavnRequests.kt ================================================ package com.shabinder.common.providers.saavn.requests import co.touchlab.kermit.Kermit import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.corsApi import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.map import com.shabinder.common.models.event.coroutines.success import com.shabinder.common.models.saavn.SaavnAlbum import com.shabinder.common.models.saavn.SaavnPlaylist import com.shabinder.common.models.saavn.SaavnSearchResult import com.shabinder.common.models.saavn.SaavnSong import com.shabinder.common.utils.globalJson import com.shabinder.common.utils.requireNotNull import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch import io.github.shabinder.utils.getBoolean import io.github.shabinder.utils.getJsonArray import io.github.shabinder.utils.getJsonObject import io.github.shabinder.utils.getString import io.ktor.client.HttpClient import io.ktor.client.request.get import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.collections.set interface JioSaavnRequests { val httpClient: HttpClient val logger: Kermit suspend fun findBestSongDownloadURL( trackName: String, trackArtists: List, preferredQuality: AudioQuality ): SuspendableEvent, Throwable> = searchForSong(trackName).map { songs -> val bestMatch = sortByBestMatch(songs, trackName, trackArtists).keys.firstOrNull() ?: throw SpotiFlyerException.DownloadLinkFetchFailed("No SAAVN Match Found for $trackName") var audioQuality: AudioQuality = AudioQuality.KBPS160 val m4aLink: String by getSongFromID(bestMatch).map { song -> val optimalQuality = if (song.is320Kbps && ((preferredQuality.kbps.toIntOrNull() ?: 0) > 160) ) AudioQuality.KBPS320 else AudioQuality.KBPS160 audioQuality = optimalQuality song.media_url.requireNotNull().replaceAfterLast("_", "${optimalQuality.kbps}.mp4") } Pair(m4aLink, audioQuality) } suspend fun searchForSong( query: String, includeLyrics: Boolean = false ): SuspendableEvent, Throwable> = SuspendableEvent { val searchURL = search_base_url + query val results = mutableListOf() (globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject) .getJsonObject("songs") .getJsonArray("data").requireNotNull().forEach { (it as JsonObject).formatData().let { jsonObject -> results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject)) } } results } suspend fun getLyrics(ID: String): SuspendableEvent = SuspendableEvent { (Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject) .getString("lyrics").requireNotNull() } suspend fun getSong( URL: String, fetchLyrics: Boolean = false ): SuspendableEvent = SuspendableEvent { val id = getSongID(URL) val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject) .formatData(fetchLyrics) globalJson.decodeFromJsonElement(SaavnSong.serializer(), data) } suspend fun getSongFromID( ID: String, fetchLyrics: Boolean = false ): SuspendableEvent = SuspendableEvent { val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject) .formatData(fetchLyrics) globalJson.decodeFromJsonElement(SaavnSong.serializer(), data) } private suspend fun getSongID( URL: String, ): String { val res = httpClient.get(URL) return try { res.split("\"song\":{\"type\":\"")[1].split("\",\"image\":")[0].split("\"id\":\"").last() } catch (e: IndexOutOfBoundsException) { res.split("\"pid\":\"")[1].split("\",\"").first() } } suspend fun getPlaylist( URL: String, includeLyrics: Boolean = false ): SuspendableEvent = SuspendableEvent { globalJson.decodeFromJsonElement( SaavnPlaylist.serializer(), (globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL).value)) as JsonObject) .formatData(includeLyrics) ) } private suspend fun getPlaylistID( URL: String ): SuspendableEvent = SuspendableEvent { val res = httpClient.get(URL) try { res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0] } catch (e: IndexOutOfBoundsException) { res.split("\"page_id\",\"")[1].split("\",\"")[0] } } suspend fun getAlbum( URL: String, includeLyrics: Boolean = false ): SuspendableEvent = SuspendableEvent { globalJson.decodeFromJsonElement( SaavnAlbum.serializer(), (globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL).value)) as JsonObject) .formatData(includeLyrics) ) } private suspend fun getAlbumID( URL: String ): SuspendableEvent = SuspendableEvent { val res = httpClient.get(URL) try { res.split("\"album_id\":\"")[1].split('"')[0] } catch (e: IndexOutOfBoundsException) { res.split("\"page_id\",\"")[1].split("\",\"")[0] } } private suspend fun JsonObject.formatData( includeLyrics: Boolean = false ): JsonObject { return buildJsonObject { // Accommodate Incoming Json Object Data // And `Format` everything while iterating this@formatData.forEach { if (it.value is JsonPrimitive && it.value.jsonPrimitive.isString) { put(it.key, it.value.jsonPrimitive.content.format()) } else { // Format Songs Nested Collection Too if (it.key == "songs" && it.value is JsonArray) { put( it.key, buildJsonArray { getJsonArray("songs")?.forEach { song -> (song as? JsonObject)?.formatData(includeLyrics)?.let { formattedSong -> add(formattedSong) } } } ) } else { put(it.key, it.value) } } } try { var url = getString("media_preview_url")!!.replace("preview", "aac") // We Will catch NPE url = if (getBoolean("320kbps") == true) { url.replace("_96_p.mp4", "_320.mp4") } else { url.replace("_96_p.mp4", "_160.mp4") } // Add Media URL to JSON Object put("media_url", url) } catch (e: Exception) { // e.printStackTrace() // DECRYPT Encrypted Media URL getString("encrypted_media_url")?.let { put("media_url", decryptURL(it)) } // Check if 320 Kbps is available or not if (getBoolean("320kbps") != true && containsKey("media_url")) { put("media_url", getString("media_url")?.replace("_320.mp4", "_160.mp4")) } } // Increase Image Resolution put( "image", getString("image") ?.replace("150x150", "500x500") ?.replace("50x50", "500x500") ) // Fetch Lyrics if Requested // Lyrics is HTML Based if (includeLyrics) { if (getBoolean("has_lyrics") == true && containsKey("id")) { getLyrics(getString("id").requireNotNull()).success { put("lyrics", it) } } else { put("lyrics", "") } } } } fun sortByBestMatch( tracks: List, trackName: String, trackArtists: List, ): Map { /* * "linksWithMatchValue" is map with Saavn VideoID and its rating/match with 100 as Max Value **/ val linksWithMatchValue = mutableMapOf() for (result in tracks) { var hasCommonWord = false val resultName = result.title.toLowerCase().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) { // logger.i("Saavn Removing Common Word") { 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 // String Containing All Artist Names from JioSaavn Search Result val artistListString = mutableSetOf().apply { result.more_info?.singers?.split(",")?.let { addAll(it) } result.more_info?.primary_artists?.toLowerCase()?.split(",")?.let { addAll(it) } }.joinToString(" , ") for (artist in trackArtists) { if (FuzzySearch.partialRatio(artist.toLowerCase(), artistListString) > 85) artistMatchNumber++ } if (artistMatchNumber == 0) { // logger.i("Artist Match Saavn Removing") { result.toString() } continue } val artistMatch: Float = (artistMatchNumber.toFloat() / trackArtists.size) * 100 val nameMatch: Float = FuzzySearch.partialRatio(resultName, trackName).toFloat() / 100 val avgMatch = (artistMatch + nameMatch) / 2 linksWithMatchValue[result.id] = avgMatch } return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap().also { logger.i(TAG) { "Match Found for $trackName - ${!it.isNullOrEmpty()} ${it.keys.firstOrNull() ?: ""}" } } } companion object { const val TAG = "Saavn Request" // EndPoints val search_base_url = "${corsApi}https://www.jiosaavn.com/api.php?__call=autocomplete.get&_format=json&_marker=0&cc=in&includeMetaTags=1&query=" val song_details_base_url = "${corsApi}https://www.jiosaavn.com/api.php?__call=song.getDetails&cc=in&_marker=0%3F_marker%3D0&_format=json&pids=" val album_details_base_url = "${corsApi}https://www.jiosaavn.com/api.php?__call=content.getAlbumDetails&_format=json&cc=in&_marker=0%3F_marker%3D0&albumid=" val playlist_details_base_url = "${corsApi}https://www.jiosaavn.com/api.php?__call=playlist.getDetails&_format=json&cc=in&_marker=0%3F_marker%3D0&listid=" val lyrics_base_url = "${corsApi}https://www.jiosaavn.com/api.php?__call=lyrics.getLyrics&ctx=web6dot0&api_version=4&_format=json&_marker=0%3F_marker%3D0&lyrics_id=" } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/saavn/requests/JioSaavnUtils.kt ================================================ package com.shabinder.common.providers.saavn.requests import com.shabinder.common.utils.unescape expect suspend fun decryptURL(url: String): String internal fun String.format(): String { return this.unescape() .replace(""", "'") .replace("&", "&") .replace("'", "'") .replace("©", "©") } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/sound_cloud/SoundCloudProvider.kt ================================================ package com.shabinder.common.providers.sound_cloud import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.finalOutputDir import com.shabinder.common.core_components.file_manager.getImageCachePath import com.shabinder.common.models.AudioFormat import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponsePlaylist import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack import com.shabinder.common.models.spotify.Source import com.shabinder.common.providers.sound_cloud.requests.SoundCloudRequests import com.shabinder.common.providers.sound_cloud.requests.doAuthenticatedRequest import com.shabinder.common.utils.requireNotNull import io.github.shabinder.utils.getString import io.ktor.client.HttpClient import kotlinx.serialization.json.JsonObject class SoundCloudProvider( private val logger: Kermit, private val fileManager: FileManager, override val httpClient: HttpClient, ) : SoundCloudRequests { suspend fun query(fullURL: String) = SuspendableEvent { PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.SoundCloud ).apply { when (val response = fetchResult(fullURL)) { is SoundCloudResolveResponseTrack -> { folderType = "Tracks" subFolder = "" trackList = listOf(response).toTrackDetailsList(folderType, subFolder) coverUrl = response.artworkUrl title = response.title } is SoundCloudResolveResponsePlaylist -> { folderType = "Playlists" subFolder = response.title trackList = response.tracks.toTrackDetailsList(folderType, subFolder) coverUrl = response.artworkUrl.formatArtworkUrl() .ifBlank { response.calculatedArtworkUrl.formatArtworkUrl() } title = response.title } } } } suspend fun getDownloadURL(trackDetails: TrackDetails) = SuspendableEvent { doAuthenticatedRequest(trackDetails.videoID.requireNotNull()).getString("url") } private fun List.toTrackDetailsList( type: String, subFolder: String ): List = map { val downloadableInfo = it.getDownloadableLink() TrackDetails( title = it.title, //trackNumber = it.track_number, genre = listOf(it.genre), artists = /*it.artists?.map { artist -> artist?.name.toString() } ?:*/ listOf(it.user.username.ifBlank { it.genre }), albumArtists = /*it.album?.artists?.mapNotNull { artist -> artist?.name } ?:*/ emptyList(), durationSec = (it.duration / 1000), albumArtPath = fileManager.getImageCachePath(it.artworkUrl.formatArtworkUrl()), albumName = "", //it.album?.name, year = runCatching { it.displayDate.substring(0, 4) }.getOrNull(), comment = it.caption, trackUrl = it.permalinkUrl, downloaded = it.updateStatusIfPresent(type, subFolder), source = Source.SoundCloud, albumArtURL = it.artworkUrl.formatArtworkUrl(), outputFilePath = fileManager.finalOutputDir( it.title, type, subFolder, fileManager.defaultDir()/*,".m4a"*/ ), audioQuality = AudioQuality.KBPS128, videoID = downloadableInfo?.first, audioFormat = downloadableInfo?.second ?: AudioFormat.MP3 ) } private fun SoundCloudResolveResponseTrack.updateStatusIfPresent( folderType: String, subFolder: String ): DownloadStatus { return if (fileManager.isPresent( fileManager.finalOutputDir( title, folderType, subFolder, fileManager.defaultDir() ) ) ) { // Download Already Present!! DownloadStatus.Downloaded } else DownloadStatus.NotDownloaded } private fun String.formatArtworkUrl(): String { return if (isBlank()) "" else substringBeforeLast("-") + "-t500x500." + substringAfterLast(".") } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/sound_cloud/requests/SoundCloudRequests.kt ================================================ package com.shabinder.common.providers.sound_cloud.requests import com.shabinder.common.core_components.utils.getFinalUrl import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponsePlaylist import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack import com.shabinder.common.utils.globalJson import io.ktor.client.HttpClient import io.ktor.client.features.ClientRequestException import io.ktor.client.request.get import io.ktor.client.request.parameter import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.supervisorScope import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.JsonContentPolymorphicSerializer import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive interface SoundCloudRequests { val httpClient: HttpClient suspend fun fetchResult(url: String): SoundCloudResolveResponseBase { @Suppress("NAME_SHADOWING") var url = url // Fetch Full URL if Input is Shortened URL from App if (url.contains("soundcloud.app")) url = httpClient.getFinalUrl(url) return getResponseObj(url).run { when (this) { is SoundCloudResolveResponseTrack -> { getTrack() } is SoundCloudResolveResponsePlaylist -> { populatePlaylist() } else -> throw SpotiFlyerException.FeatureNotImplementedYet() } } } @Suppress("NAME_SHADOWING") suspend fun SoundCloudResolveResponseTrack.getTrack() = apply { val track = populateTrackInfo() if (track.policy == "BLOCK") throw SpotiFlyerException.GeoLocationBlocked(extraInfo = "Use VPN to access ${track.title}") if (!track.streamable) throw SpotiFlyerException.LinkInvalid("\nSound Cloud Reports that ${track.title} is not streamable !\n") return track } @Suppress("NAME_SHADOWING") suspend fun SoundCloudResolveResponsePlaylist.populatePlaylist(): SoundCloudResolveResponsePlaylist = apply { supervisorScope { try { tracks = tracks.map { async { runCatching { it.populateTrackInfo() }.getOrNull() ?: it } }.awaitAll() } catch (e: Throwable) { e.printStackTrace() } } } private suspend fun SoundCloudResolveResponseTrack.populateTrackInfo(): SoundCloudResolveResponseTrack { if (media.transcodings.isNotEmpty()) return this val infoURL = URLS.TRACK_INFO.buildURL(id) val data: String = httpClient.get(infoURL) { parameter("client_id", CLIENT_ID) } return globalJson.decodeFromString(data) } private suspend fun getResponseObj(url: String, clientID: String = CLIENT_ID): SoundCloudResolveResponseBase { val itemURL = URLS.RESOLVE.buildURL(url) val resp: SoundCloudResolveResponseBase = try { val data: String = httpClient.get(itemURL) { parameter("client_id", clientID) } globalJson.decodeFromString(SoundCloudSerializer, data) } catch (e: ClientRequestException) { if (clientID != ALT_CLIENT_ID) return getResponseObj(url, ALT_CLIENT_ID) throw e } val tracksPresent = (resp is SoundCloudResolveResponsePlaylist && resp.tracks.isNotEmpty()) if (!tracksPresent && clientID != ALT_CLIENT_ID) return getResponseObj(ALT_CLIENT_ID) return resp } @Suppress("unused") companion object { private enum class URLS(val buildURL: (arg: String) -> String) { RESOLVE({ "https://api-v2.soundcloud.com/resolve?url=$it}" }), PLAYLIST_LIKED({ "https://api-v2.soundcloud.com/users/$it/playlists/liked_and_owned?limit=200" }), FAVORITES({ "'https://api-v2.soundcloud.com/users/$it/track_likes?limit=200" }), COMMENTED({ "https://api-v2.soundcloud.com/users/$it/comments" }), TRACKS({ "https://api-v2.soundcloud.com/users/$it/tracks?limit=200" }), ALL({ "https://api-v2.soundcloud.com/profile/soundcloud:users:$it?limit=200" }), TRACK_INFO({ "https://api-v2.soundcloud.com/tracks/$it" }), ORIGINAL_DOWNLOAD({ "https://api-v2.soundcloud.com/tracks/$it/download" }), USER({ "https://api-v2.soundcloud.com/users/$it" }), ME({ "https://api-v2.soundcloud.com/me?oauth_token=$it" }), } const val CLIENT_ID = "a3e059563d7fd3372b49b37f00a00bcf" const val ALT_CLIENT_ID = "2t9loNQH90kzJcsFCODdigxfp325aq4z" object SoundCloudSerializer : JsonContentPolymorphicSerializer(SoundCloudResolveResponseBase::class) { override fun selectDeserializer(element: JsonElement): DeserializationStrategy = when { "track_count" in element.jsonObject -> SoundCloudResolveResponsePlaylist.serializer() "kind" in element.jsonObject -> { val isTrack = element.jsonObject["kind"] ?.jsonPrimitive?.content.toString() .contains("track", true) when { isTrack || "track_format" in element.jsonObject -> SoundCloudResolveResponseTrack.serializer() else -> SoundCloudResolveResponsePlaylist.serializer() } } else -> SoundCloudResolveResponsePlaylist.serializer() } } } } @OptIn(InternalSerializationApi::class) suspend inline fun SoundCloudRequests.doAuthenticatedRequest(url: String): T { var clientID: String = SoundCloudRequests.CLIENT_ID return try { httpClient.get(url) { parameter("client_id", clientID) } } catch (e: ClientRequestException) { if (clientID != SoundCloudRequests.ALT_CLIENT_ID) { clientID = SoundCloudRequests.ALT_CLIENT_ID return httpClient.get(url) { parameter("client_id", clientID) } } throw e } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/spotify/SpotifyProvider.kt ================================================ /* * Copyright (c) 2021 Shabinder Singh * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.shabinder.common.providers.spotify import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.finalOutputDir import com.shabinder.common.core_components.file_manager.getImageCachePath import com.shabinder.common.core_components.utils.createHttpClient import com.shabinder.common.models.* import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.spotify.PlaylistTrack import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Track import com.shabinder.common.providers.spotify.requests.SpotifyRequests import com.shabinder.common.providers.spotify.requests.authenticateSpotify import com.shabinder.common.providers.spotify.token_store.TokenStore import com.shabinder.common.utils.globalJson import io.ktor.client.* import io.ktor.client.features.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.* import io.ktor.client.request.* class SpotifyProvider( private val tokenStore: TokenStore, private val logger: Kermit, private val fileManager: FileManager, ) : SpotifyRequests { override suspend fun authenticateSpotifyClient(override: Boolean) { val token = if (override) authenticateSpotify().component1() else tokenStore.getToken() if (token == null) { logger.d { "Spotify Auth Failed: Please Check your Network Connection" } } else { logger.d { "Spotify Provider Created with $token" } HttpClient { defaultRequest { header("Authorization", "Bearer ${token.access_token}") } install(JsonFeature) { serializer = KotlinxSerializer(globalJson) } }.also { httpClientRef.value = it } } } override val httpClientRef = NativeAtomicReference(createHttpClient(true)) suspend fun query(fullLink: String): SuspendableEvent = SuspendableEvent { var spotifyLink = "https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim() if (!spotifyLink.contains("open.spotify")) { // Very Rare instance spotifyLink = resolveLink(spotifyLink) } val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?') val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') if (type == "Error" || link == "Error") { throw SpotiFlyerException.LinkInvalid(fullLink) } if (type == "episode" || type == "show") { throw SpotiFlyerException.FeatureNotImplementedYet( "Support for Spotify's ${type.toUpperCase()} isn't implemented yet" ) } try { spotifySearch( type, link ) } catch (e: Exception) { e.printStackTrace() // Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions authenticateSpotifyClient(true) spotifySearch( type, link ) } } private suspend fun spotifySearch( type: String, link: String ): PlatformQueryResult { return PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.Spotify ).apply { when (type) { "track" -> { getTrack(link).also { folderType = "Tracks" subFolder = "" trackList = listOf(it).toTrackDetailsList(folderType, subFolder) title = it.name.toString() coverUrl = it.album?.images?.elementAtOrNull(0)?.url.toString() } } "album" -> { val albumObject = getAlbum(link) folderType = "Albums" subFolder = albumObject.name.toString() albumObject.tracks?.items?.forEach { it.album = albumObject } albumObject.tracks?.items?.toTrackDetailsList(folderType, subFolder).let { if (it.isNullOrEmpty()) { // TODO Handle Error } else { trackList = it title = albumObject.name.toString() coverUrl = albumObject.images?.elementAtOrNull(0)?.url.toString() } } } "playlist" -> { val playlistObject = getPlaylist(link) folderType = "Playlists" subFolder = playlistObject.name.toString() val tempTrackList = mutableListOf().apply { // Add Fetched Tracks playlistObject.tracks?.items?.mapNotNull(PlaylistTrack::track)?.let { addAll(it) } } // Check For More Tracks If available var moreTracksAvailable = !playlistObject.tracks?.next.isNullOrBlank() while (moreTracksAvailable) { // Fetch Remaining Tracks val moreTracks = getPlaylistTracks(link, offset = tempTrackList.size) moreTracks.items?.mapNotNull(PlaylistTrack::track)?.let { remTracks -> tempTrackList.addAll(remTracks) } moreTracksAvailable = !moreTracks.next.isNullOrBlank() } // log("Total Tracks Fetched", tempTrackList.size.toString()) trackList = tempTrackList.toTrackDetailsList(folderType, subFolder) title = playlistObject.name.toString() coverUrl = playlistObject.images?.firstOrNull()?.url.toString() } "episode" -> { // TODO throw SpotiFlyerException.FeatureNotImplementedYet() } "show" -> { // TODO throw SpotiFlyerException.FeatureNotImplementedYet() } else -> { throw SpotiFlyerException.LinkInvalid("Provide: Spotify, Type:$type -> Link:$link") } } } } /* * New Link Schema: https://link.tospotify.com/kqTBblrjQbb, * Fetching Standard Link: https://open.spotify.com/playlist/37i9dQZF1DX9RwfGbeGQwP?si=iWz7B1tETiunDntnDo3lSQ&_branch_match_id=862039436205270630 * */ private suspend fun resolveLink( url: String ): String { val response = getResponse(url) val regex = """https://open\.spotify\.com.+\w""".toRegex() return regex.find(response)?.value.toString() } private fun List.toTrackDetailsList(type: String, subFolder: String) = this.map { TrackDetails( title = it.name.toString(), trackNumber = it.track_number, genre = it.album?.genres?.filterNotNull() ?: emptyList(), artists = it.artists?.map { artist -> artist?.name.toString() } ?: listOf(), albumArtists = it.album?.artists?.mapNotNull { artist -> artist?.name } ?: emptyList(), durationSec = (it.duration_ms / 1000).toInt(), albumArtPath = fileManager.getImageCachePath(it.album?.images?.maxByOrNull { img -> img?.width ?: 0 }?.url ?: ""), albumName = it.album?.name, year = it.album?.release_date, comment = "Genres:${it.album?.genres?.joinToString()}", trackUrl = it.href, downloaded = it.updateStatusIfPresent(type, subFolder), source = Source.Spotify, albumArtURL = it.album?.images?.maxByOrNull { img -> img?.width ?: 0 }?.url.toString(), outputFilePath = fileManager.finalOutputDir( it.name.toString(), type, subFolder, fileManager.defaultDir()/*,".m4a"*/ ) ) } private fun Track.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus { return if (fileManager.isPresent( fileManager.finalOutputDir( name.toString(), folderType, subFolder, fileManager.defaultDir() ) ) ) { // Download Already Present!! DownloadStatus.Downloaded.also { downloaded = it } } else downloaded } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/spotify/requests/SpotifyAuth.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.spotify.requests import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.Actions import com.shabinder.common.models.spotify.TokenData import com.shabinder.common.utils.globalJson import io.ktor.client.* import io.ktor.client.features.auth.* import io.ktor.client.features.auth.providers.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* import kotlin.native.concurrent.SharedImmutable suspend fun authenticateSpotify(): SuspendableEvent = SuspendableEvent { if (Actions.instance.isInternetAvailable) { spotifyAuthClient.post("https://accounts.spotify.com/api/token") { body = FormDataContent(Parameters.build { @Suppress("EXPERIMENTAL_API_USAGE_FUTURE_ERROR") append("grant_type", "client_credentials") }) } } else throw SpotiFlyerException.NoInternetException() } @SharedImmutable private val spotifyAuthClient by lazy { HttpClient { val (clientId, clientSecret) = PreferenceManager.instance.spotifyCredentials install(Auth) { basic { sendWithoutRequest { true } credentials { BasicAuthCredentials(clientId, clientSecret) } } } install(JsonFeature) { serializer = KotlinxSerializer(globalJson) } } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/spotify/requests/SpotifyRequests.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.spotify.requests import com.shabinder.common.models.NativeAtomicReference import com.shabinder.common.models.corsApi import com.shabinder.common.models.spotify.Album import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack import com.shabinder.common.models.spotify.Playlist import com.shabinder.common.models.spotify.Track import io.github.shabinder.TargetPlatforms import io.github.shabinder.activePlatform import io.ktor.client.* import io.ktor.client.request.* private val BASE_URL get() = "${corsApi}https://api.spotify.com/v1" interface SpotifyRequests { val httpClientRef: NativeAtomicReference val httpClient: HttpClient get() = httpClientRef.value suspend fun authenticateSpotifyClient(override: Boolean = activePlatform is TargetPlatforms.Js) suspend fun getPlaylist(playlistID: String): Playlist { return httpClient.get("$BASE_URL/playlists/$playlistID") } suspend fun getPlaylistTracks( playlistID: String?, offset: Int = 0, limit: Int = 100 ): PagingObjectPlaylistTrack { return httpClient.get("$BASE_URL/playlists/$playlistID/tracks?offset=$offset&limit=$limit") } suspend fun getTrack(id: String?): Track { return httpClient.get("$BASE_URL/tracks/$id") } suspend fun getEpisode(id: String?): Track { return httpClient.get("$BASE_URL/episodes/$id") } suspend fun getShow(id: String?): Track { return httpClient.get("$BASE_URL/shows/$id") } suspend fun getAlbum(id: String): Album { return httpClient.get("$BASE_URL/albums/$id") } suspend fun getResponse(url: String): String { return httpClient.get(url) } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/spotify/token_store/TokenStore.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.spotify.token_store import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.database.TokenDBQueries import com.shabinder.common.models.spotify.TokenData import com.shabinder.common.providers.spotify.requests.authenticateSpotify import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.datetime.Clock class TokenStore( private val fileManager: FileManager, private val logger: Kermit, ) { private val db: TokenDBQueries? get() = fileManager.db?.tokenDBQueries private fun save(token: TokenData) { if (!token.access_token.isNullOrBlank() && token.expiry != null) db?.add(token.access_token!!, token.expiry!! + Clock.System.now().epochSeconds) } @OptIn(DelicateCoroutinesApi::class) suspend fun getToken(): TokenData? { var token: TokenData? = db?.select()?.executeAsOneOrNull()?.let { TokenData(it.accessToken, null, it.expiry) } logger.d { "System Time:${Clock.System.now().epochSeconds} , Token Expiry:${token?.expiry}" } if ((Clock.System.now().epochSeconds > (token?.expiry ?: 0)) || token == null) { logger.d { "Requesting New Token" } token = authenticateSpotify().component1() GlobalScope.launch { token?.access_token?.let { save(token) } } } return token } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/youtube/YoutubeProvider.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.youtube import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.finalOutputDir import com.shabinder.common.core_components.file_manager.getImageCachePath import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.spotify.Source import com.shabinder.common.utils.removeIllegalChars import io.github.shabinder.YoutubeDownloader import io.github.shabinder.models.Extension import io.github.shabinder.models.YoutubeVideo import io.github.shabinder.models.formats.Format import io.github.shabinder.models.quality.AudioQuality import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.HttpStatement import io.ktor.http.HttpStatusCode import com.shabinder.common.models.AudioQuality as Quality class YoutubeProvider( private val httpClient: HttpClient, private val logger: Kermit, private val fileManager: FileManager, ) { val ytDownloader: YoutubeDownloader = YoutubeDownloader( enableCORSProxy = true, CORSProxyAddress = "https://cors.spotiflyer.ml/cors/" ) /* * YT Album Art Schema * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" * Normal Url: https://i.ytimg.com/vi/$searchId/hqdefault.jpg" * */ private val sampleDomain1 = "music.youtube.com" private val sampleDomain2 = "youtube.com" private val sampleDomain3 = "youtu.be" suspend fun query(fullLink: String): SuspendableEvent { val link = fullLink.removePrefix("https://").removePrefix("http://") if (link.contains("playlist", true) || link.contains("list", true)) { // Given Link is of a Playlist logger.i { link } val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&").substringBefore("?") return getYTPlaylist( playlistId ) } else { // Given Link is of a Video var searchId = "error" when { link.contains(sampleDomain1, true) -> { // Youtube Music searchId = link.substringAfterLast("/", "error").substringBefore("&").substringAfterLast("=") } link.contains(sampleDomain2, true) -> { // Standard Youtube Link searchId = link.substringAfterLast("=", "error").substringBefore("&") } link.contains(sampleDomain3, true) -> { // Shortened Youtube Link searchId = link.substringAfterLast("/", "error").substringBefore("&") } } return if (searchId != "error") { getYTTrack( searchId ) } else { logger.d { "Your Youtube Link is not of a Video!!" } SuspendableEvent.error(SpotiFlyerException.LinkInvalid(fullLink)) } } } private suspend fun getYTPlaylist( searchId: String ): SuspendableEvent = SuspendableEvent { PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.YouTube ).apply { val playlist = ytDownloader.getPlaylist(searchId) val playlistDetails = playlist.details val name = playlistDetails.title subFolder = removeIllegalChars(name) val videos = playlist.videos coverUrl = "https://i.ytimg.com/vi/${ videos.firstOrNull()?.videoId }/hqdefault.jpg" title = name trackList = videos.map { val imageURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg" TrackDetails( title = it.title ?: "N/A", artists = listOf(it.author ?: "N/A"), durationSec = it.lengthSeconds, albumArtPath = fileManager.getImageCachePath(imageURL), source = Source.YouTube, albumArtURL = imageURL, downloaded = if (fileManager.isPresent( fileManager.finalOutputDir( itemName = it.title ?: "N/A", type = folderType, subFolder = subFolder, fileManager.defaultDir() ) ) ) DownloadStatus.Downloaded else { DownloadStatus.NotDownloaded }, outputFilePath = fileManager.finalOutputDir( it.title ?: "N/A", folderType, subFolder, fileManager.defaultDir()/*,".m4a"*/ ), videoID = it.videoId ) } } } @Suppress("DefaultLocale") private suspend fun getYTTrack( searchId: String, ): SuspendableEvent = SuspendableEvent { PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.YouTube ).apply { val video = ytDownloader.getVideo(searchId) coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" val detail = video.videoDetails val name = detail.title?.replace(detail.author?.toUpperCase() ?: "", "", true) ?: detail.title ?: "" // logger.i{ detail.toString() } trackList = listOf( TrackDetails( title = name, artists = listOf(detail.author ?: "N/A"), durationSec = detail.lengthSeconds, albumArtPath = fileManager.getImageCachePath(coverUrl), source = Source.YouTube, albumArtURL = coverUrl, downloaded = if (fileManager.isPresent( fileManager.finalOutputDir( itemName = name, type = folderType, subFolder = subFolder, defaultDir = fileManager.defaultDir() ) ) ) DownloadStatus.Downloaded else { DownloadStatus.NotDownloaded }, outputFilePath = fileManager.finalOutputDir( name, folderType, subFolder, fileManager.defaultDir()/*,".m4a"*/ ), videoID = searchId ) ) title = name } } suspend fun fetchVideoM4aLink(videoId: String, retryCount: Int = 3): Pair { @Suppress("NAME_SHADOWING") var retryCount = retryCount var validM4aLink: String? = null var audioQuality: Quality = Quality.KBPS128 val ex = SpotiFlyerException.DownloadLinkFetchFailed("Manual Extraction Failed for VideoID: $videoId") while (validM4aLink.isNullOrEmpty() && retryCount > 0) { val m4aLink = ytDownloader.getVideo(videoId).getM4aLink()?.also { audioQuality = if (it.bitrate > 160_000) Quality.KBPS192 else Quality.KBPS128 }?.url ?: throw ex if (validateLink(m4aLink)) { validM4aLink = m4aLink } retryCount-- } if (validM4aLink.isNullOrBlank()) throw ex return validM4aLink to audioQuality } private suspend fun validateLink(link: String): Boolean { var status = HttpStatusCode.BadRequest httpClient.get(link).execute { res -> status = res.status } return status == HttpStatusCode.OK } private fun YoutubeVideo.getM4aLink(): Format? { return getAudioWithQuality(AudioQuality.high).firstOrNull { it.extension == Extension.M4A } ?: getAudioWithQuality(AudioQuality.medium).firstOrNull { it.extension == Extension.M4A } ?: getAudioWithQuality(AudioQuality.low).firstOrNull { it.extension == Extension.M4A } } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/youtube_music/YoutubeMusic.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.youtube_music import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.models.AudioFormat import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.YoutubeTrack import com.shabinder.common.models.corsApi import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.flatMap import com.shabinder.common.models.event.coroutines.flatMapError import com.shabinder.common.models.event.coroutines.map import com.shabinder.common.models.event.coroutines.onFailure import com.shabinder.common.providers.youtube.YoutubeProvider import com.shabinder.common.providers.youtube_to_mp3.requests.YoutubeMp3 import com.shabinder.common.utils.appendPadded import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch import io.ktor.client.HttpClient import io.ktor.client.request.header import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.contentType import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import kotlin.collections.set import kotlin.math.absoluteValue class YoutubeMusic constructor( private val logger: Kermit, private val httpClient: HttpClient, private val youtubeProvider: YoutubeProvider, private val youtubeMp3: YoutubeMp3, private val fileManager: FileManager, ) { companion object { const val apiKey = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" const val tag = "YT Music" } // Get Downloadable Link suspend fun findSongDownloadURLYT( trackDetails: TrackDetails, preferredQuality: AudioQuality = fileManager.preferenceManager.audioQuality, errorReportBuilder: StringBuilder? = null ): SuspendableEvent, Throwable> { return getYTIDBestMatch(trackDetails).flatMap { videoID -> // As YT compress Audio hence there is no benefit of quality for more than 192 val optimalQuality = if ((preferredQuality.kbps.toIntOrNull() ?: 0) > 192 ) AudioQuality.KBPS192 else preferredQuality // 1 Try getting Link from Yt1s youtubeMp3.getMp3DownloadLink(videoID, optimalQuality).map { Triple(it, optimalQuality, AudioFormat.MP3) }.flatMapError { errorReportBuilder?.appendPadded( "Yt1sMp3 Failed for $videoID:", it.stackTraceToString() ) // 2 if Yt1s failed , Extract Manually errorReportBuilder?.appendPadded("Extracting Manually...") SuspendableEvent { youtubeProvider.fetchVideoM4aLink(videoID) }.onFailure { throwable -> errorReportBuilder?.appendPadded("YT Manual Extraction Failed!", throwable.stackTraceToString()) }.map { (URL, quality) -> Triple(URL, quality, AudioFormat.MP4) } } } } private suspend fun getYTIDBestMatch( trackDetails: TrackDetails ): SuspendableEvent = getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}").map { matchList -> sortByBestMatch( matchList, trackName = trackDetails.title, trackArtists = trackDetails.artists, trackDurationSec = trackDetails.durationSec ).also { logger.d("YT-M Matches:") { it.entries.joinToString("\n") { "${it.key} --- ${it.value}" } } }.keys.firstOrNull() ?: throw SpotiFlyerException.NoMatchFound(trackDetails.title) } private suspend fun getYTTracks(query: String): SuspendableEvent, Throwable> = getYoutubeMusicResponse(query).map { youtubeResponseData -> val youtubeTracks = mutableListOf() val responseObj = Json.parseToJsonElement(youtubeResponseData) // logger.i { "Youtube Music Response Received" } val contentBlocks = responseObj.jsonObject["contents"] ?.jsonObject?.get("tabbedSearchResultsRenderer") ?.jsonObject?.get("tabs")?.jsonArray?.get(0) ?.jsonObject?.get("tabRenderer") ?.jsonObject?.get("content") ?.jsonObject?.get("sectionListRenderer") ?.jsonObject?.get("contents")?.jsonArray val resultBlocks = mutableListOf() if (contentBlocks != null) { for (cBlock in contentBlocks) { /** *Ignore user-suggestion *The 'itemSectionRenderer' field is for user notices (stuff like - 'showing *results for xyz, search for abc instead') we have no use for them, the for *loop below if throw a keyError if we don't ignore them */ if (cBlock.jsonObject.containsKey("itemSectionRenderer")) { continue } for ( contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray ?: listOf() ) { /** * apparently content Blocks without an 'overlay' field don't have linkBlocks * I have no clue what they are and why there even exist * if(!contents.containsKey("overlay")){ println(contents) continue TODO check and correct }*/ val result = contents.jsonObject["musicResponsiveListItemRenderer"] ?.jsonObject?.get("flexColumns")?.jsonArray // Add the linkBlock val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"] ?.jsonObject?.get("overlay") ?.jsonObject?.get("musicItemThumbnailOverlayRenderer") ?.jsonObject?.get("content") ?.jsonObject?.get("musicPlayButtonRenderer") ?.jsonObject?.get("playNavigationEndpoint") // detailsBlock is always a list, so we just append the linkBlock to it // instead of carrying along all the other junk from "musicResponsiveListItemRenderer" val finalResult = buildJsonArray { result?.let { add(it) } linkBlock?.let { add(it) } } resultBlocks.add(finalResult) } } /* We only need results that are Songs or Videos, so we filter out the rest, since ! Songs and Videos are supplied with different details, extracting all details from ! both is just carrying on redundant data, so we also have to selectively extract ! relevant details. What you need to know to understand how we do that here: ! ! Songs details are ALWAYS in the following order: ! 0 - Name ! 1 - Type (Song) ! 2 - com.shabinder.spotiflyer.models.gaana.Artist ! 3 - Album ! 4 - Duration (mm:ss) ! ! Video details are ALWAYS in the following order: ! 0 - Name ! 1 - Type (Video) ! 2 - Channel ! 3 - Viewers ! 4 - Duration (hh:mm:ss) ! ! We blindly gather all the details we get our hands on, then ! cherry-pick the details we need based on their index numbers, ! we do so only if their Type is 'Song' or 'Video */ for (result in resultBlocks) { // Blindly gather available details val availableDetails = mutableListOf() /* Filter Out dummies here itself ! 'musicResponsiveListItemFlexColumnRenderer' should have more that one ! sub-block, if not it is a dummy, why does the YTM response contain dummies? ! I have no clue. We skip these. ! Remember that we appended the linkBlock to result, treating that like the ! other constituents of a result block will lead to errors, hence the 'in ! result[:-1] ,i.e., skip last element in array ' */ for (detailArray in result.subList(0, result.size - 1)) { for (detail in detailArray.jsonArray) { if ((detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0) < 2 ) continue // if not a dummy, collect All Variables val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"] ?.jsonObject?.get("text") ?.jsonObject?.get("runs")?.jsonArray ?: listOf() for (d in details) { d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let { if (it != " • ") { availableDetails.add(it) } } } } } // logger.d("YT Music details"){availableDetails.toString()} /* ! Filter Out non-Song/Video results and incomplete results here itself ! From what we know about detail order, note that [1] - indicate result type */ if (availableDetails.size == 5 && availableDetails[1] in listOf( "Song", "Video" ) ) { // skip if result is in hours instead of minutes (no song is that long) if (availableDetails[4].split(':').size != 2) continue /* ! grab Video ID ! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...] ! so hardcoding the dict keys for data look up is an ardours process, since ! the sub-block pattern is fixed even though the key isn't, we just ! reference the dict keys by index */ val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content val ytTrack = YoutubeTrack( name = availableDetails[0], type = availableDetails[1], artist = availableDetails[2], duration = availableDetails[4], videoId = videoId ) youtubeTracks.add(ytTrack) } } } // logger.d {youtubeTracks.joinToString("\n")} youtubeTracks } fun sortByBestMatch( ytTracks: List, trackName: String, trackArtists: List, trackDurationSec: Int, ): Map { /* * "linksWithMatchValue" is map with Youtube VideoID and its rating/match with 100 as Max Value **/ val linksWithMatchValue = mutableMapOf() for (result in ytTracks) { // LoweCasing Name to match Properly // most song results on youtube go by $artist - $songName or artist1/artist2 var hasCommonWord = false val resultName = result.name?.toLowerCase()?.replace("-", " ")?.replace("/", " ") ?: "" val trackNameWords = trackName.toLowerCase().split(" ") for (nameWord in trackNameWords) { if (nameWord.isNotBlank() && FuzzySearch.partialRatio( nameWord, resultName ) > 85 ) hasCommonWord = true } // Skip this Result if No Word is Common in Name if (!hasCommonWord) { logger.d("YT Api Removing No common Word") { 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 = 0F 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 == 0F) { logger.d { "YT Api Removing Artist Match 0: $result" } continue } val artistMatch = (artistMatchNumber / trackArtists.size.toFloat()) * 100F // 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: Float = result.duration?.split(":")?.get(0)?.toFloat()?.times(60) ?.plus(result.duration?.split(":")?.get(1)?.toFloat() ?: 0F) ?.minus(trackDurationSec)?.absoluteValue ?: 0F val nonMatchValue: Float = ((difference * difference) / trackDurationSec.toFloat()) val durationMatch: Float = 100 - (nonMatchValue * 100F) val avgMatch: Float = (artistMatch + durationMatch) / 2F linksWithMatchValue[result.videoId.toString()] = avgMatch } // logger.d("YT Api Result"){"$trackName - $linksWithMatchValue"} return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap().also { logger.d(tag) { "Match Found for $trackName - ${!it.isNullOrEmpty()} ${it.keys.firstOrNull() ?: ""}" } } } private suspend fun getYoutubeMusicResponse(query: String): SuspendableEvent = SuspendableEvent { httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") { headers { append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) append("referer", "https://music.youtube.com/search") } body = buildJsonObject { putJsonObject("context") { putJsonObject("client") { put("clientName", "WEB_REMIX") put("clientVersion", "0.1") } } put("query", query) } } } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/youtube_to_mp3/requests/YoutubeMp3.kt ================================================ package com.shabinder.common.providers.youtube_to_mp3.requests import co.touchlab.kermit.Kermit import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.corsApi import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.map import io.ktor.client.* class YoutubeMp3( override val httpClient: HttpClient, override val logger: Kermit ) : Yt1sMp3 { suspend fun getMp3DownloadLink(videoID: String, quality: AudioQuality): SuspendableEvent = getLinkFromYt1sMp3(videoID, quality).map { corsApi + it } } ================================================ FILE: common/providers/src/commonMain/kotlin/com.shabinder.common.providers/youtube_to_mp3/requests/Yt1sMp3.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers.youtube_to_mp3.requests import co.touchlab.kermit.Kermit import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.corsApi import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.event.coroutines.flatMap import com.shabinder.common.models.event.coroutines.map import com.shabinder.common.utils.requireNotNull import io.github.shabinder.utils.getJsonObject import io.ktor.client.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive /* * site link: https://yt1s.com/youtube-to-mp3/en1 * Provides Direct Mp3 , No Need For FFmpeg * */ interface Yt1sMp3 { val httpClient: HttpClient val logger: Kermit /* * Downloadable Mp3 Link for YT videoID. * */ suspend fun getLinkFromYt1sMp3(videoID: String, quality: AudioQuality): SuspendableEvent = getKey(videoID, quality).flatMap { key -> getConvertedMp3Link(videoID, key).map { it["dlink"].requireNotNull() .jsonPrimitive.content.replace("\"", "") } } /* * POST:https://yt1s.com/api/ajaxSearch/index * Body Form= q:yt video link ,vt:format=mp3 * */ private suspend fun getKey(videoID: String, quality: AudioQuality): SuspendableEvent = SuspendableEvent { val response: JsonObject = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") { body = FormDataContent( Parameters.build { append("q", "https://www.youtube.com/watch?v=$videoID") append("vt", "mp3") } ) } val mp3Keys = response.getJsonObject("links") .getJsonObject("mp3") // This Site now only gives 128kbps mp3 which is reasonable val requestedKBPS = when (quality) { AudioQuality.KBPS128 -> "mp3128" else -> "mp3128"//quality.kbps } val specificQualityKey = mp3Keys.getJsonObject(requestedKBPS) ?: // Try M4a Link response.getJsonObject("links").getJsonObject("m4a").getJsonObject("140") specificQualityKey?.get("k").requireNotNull().jsonPrimitive.content } private suspend fun getConvertedMp3Link(videoID: String, key: String): SuspendableEvent = SuspendableEvent { httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") { body = FormDataContent( Parameters.build { append("vid", videoID) append("k", key) } ) } } } ================================================ FILE: common/providers/src/commonTest/kotlin/com/shabinder/common/providers/TestSpotifyTrackMatching.kt ================================================ package com.shabinder.common.providers import com.shabinder.common.core_components.utils.createHttpClient import com.shabinder.common.core_components.utils.getFinalUrl import com.shabinder.common.models.TrackDetails import com.shabinder.common.providers.utils.CommonUtils import com.shabinder.common.providers.utils.SpotifyUtils import com.shabinder.common.providers.utils.SpotifyUtils.toTrackDetailsList import io.github.shabinder.runBlocking import kotlinx.serialization.InternalSerializationApi import kotlin.test.Test class TestSpotifyTrackMatching { companion object { const val SPOTIFY_TRACK_ID = "58f4twRnbZOOVUhMUpplJ4" const val SPOTIFY_TRACK_LINK = "https://open.spotify.com/track/$SPOTIFY_TRACK_ID?si=e45de595053e4ee2" const val EXPECTED_YT_VIDEO_ID = "VNs_cCtdbPc" } private val spotifyToken: String? get() = null // get() = "BQB41HqrLcrh5eRYaL97GvaH6tRe-1EktQ8VGTWUQuFnYVWBEoTcF7T_8ogqVn1GHl9HCcMiQ0HBT-ybC74" @OptIn(InternalSerializationApi::class) @Test fun testRandomThing() = runBlocking { val res = createHttpClient().getFinalUrl("https://soundcloud.app.goo.gl/vrBzR") println(res) } @Test fun matchVideo() = runBlocking { val spotifyRequests = SpotifyUtils.getSpotifyRequests(spotifyToken) val trackDetails: TrackDetails = spotifyRequests.getTrack(SPOTIFY_TRACK_ID).toTrackDetailsList() println("TRACK_DETAILS: $trackDetails") // val matched = CommonUtils.youtubeMusic.getYTTracks(CommonUtils.getYTQueryString(trackDetails)) // println("YT-MATCHES: \n ${matched.component1()?.joinToString("\n")} \n") val ytMatch = CommonUtils.youtubeMusic.findSongDownloadURLYT(trackDetails) println("YT MATCH: $ytMatch") } } ================================================ FILE: common/providers/src/commonTest/kotlin/com/shabinder/common/providers/placeholders/FileManager.kt ================================================ package com.shabinder.common.providers.placeholders import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.picture.Picture import com.shabinder.common.database.getLogger import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.database.Database val FileManagerPlaceholder = object : FileManager { override val logger: Kermit = Kermit(getLogger()) override val preferenceManager = PreferenceManagerPlaceholder override val mediaConverter = MediaConverterPlaceholder override val db: Database? = null override fun isPresent(path: String): Boolean = false override fun fileSeparator(): String = "/" override fun defaultDir(): String = "/" override fun imageCacheDir(): String = "/" override fun createDirectory(dirPath: String) {} override suspend fun cacheImage(image: Any, path: String) {} override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture { TODO("Not yet implemented") } override suspend fun clearCache() {} override suspend fun saveFileWithMetadata( mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit ): SuspendableEvent = SuspendableEvent.success("") override fun addToLibrary(path: String) {} } ================================================ FILE: common/providers/src/commonTest/kotlin/com/shabinder/common/providers/placeholders/MediaConverter.kt ================================================ package com.shabinder.common.providers.placeholders import com.shabinder.common.core_components.media_converter.MediaConverter import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.event.coroutines.SuspendableEvent val MediaConverterPlaceholder = object : MediaConverter() { override suspend fun convertAudioFile( inputFilePath: String, outputFilePath: String, audioQuality: AudioQuality, progressCallbacks: (Long) -> Unit ): SuspendableEvent = SuspendableEvent.success("") } ================================================ FILE: common/providers/src/commonTest/kotlin/com/shabinder/common/providers/placeholders/PreferenceManager.kt ================================================ package com.shabinder.common.providers.placeholders import com.russhwolf.settings.Settings import com.shabinder.common.core_components.preference_manager.PreferenceManager private val settings = object : Settings { override val keys: Set = setOf() override val size: Int = 0 override fun clear() {} override fun getBoolean(key: String, defaultValue: Boolean): Boolean = false override fun getBooleanOrNull(key: String): Boolean? = null override fun getDouble(key: String, defaultValue: Double): Double = 0.0 override fun getDoubleOrNull(key: String): Double? = null override fun getFloat(key: String, defaultValue: Float): Float = 0f override fun getFloatOrNull(key: String): Float? = null override fun getInt(key: String, defaultValue: Int): Int = 0 override fun getIntOrNull(key: String): Int? = null override fun getLong(key: String, defaultValue: Long): Long = 0L override fun getLongOrNull(key: String): Long? = null override fun getString(key: String, defaultValue: String): String = "" override fun getStringOrNull(key: String): String? = null override fun hasKey(key: String): Boolean = false override fun putBoolean(key: String, value: Boolean) {} override fun putDouble(key: String, value: Double) {} override fun putFloat(key: String, value: Float) {} override fun putInt(key: String, value: Int) {} override fun putLong(key: String, value: Long) {} override fun putString(key: String, value: String) {} override fun remove(key: String) {} } val PreferenceManagerPlaceholder = PreferenceManager(settings) ================================================ FILE: common/providers/src/commonTest/kotlin/com/shabinder/common/providers/utils/CommonUtils.kt ================================================ package com.shabinder.common.providers.utils import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.utils.createHttpClient import com.shabinder.common.database.getLogger import com.shabinder.common.models.TrackDetails import com.shabinder.common.providers.placeholders.FileManagerPlaceholder import com.shabinder.common.providers.youtube.YoutubeProvider import com.shabinder.common.providers.youtube_music.YoutubeMusic import com.shabinder.common.providers.youtube_to_mp3.requests.YoutubeMp3 object CommonUtils { val httpClient by lazy { createHttpClient() } val logger by lazy { Kermit(getLogger()) } val youtubeProvider by lazy { YoutubeProvider(httpClient, logger, FileManagerPlaceholder) } val youtubeMp3 = YoutubeMp3(httpClient, logger) val youtubeMusic = YoutubeMusic(logger, httpClient, youtubeProvider, youtubeMp3, FileManagerPlaceholder) fun getYTQueryString(trackDetails: TrackDetails) = "${trackDetails.title} - ${trackDetails.artists.joinToString(",")}" } ================================================ FILE: common/providers/src/commonTest/kotlin/com/shabinder/common/providers/utils/SpotifyUtils.kt ================================================ package com.shabinder.common.providers.utils import com.shabinder.common.core_components.file_manager.finalOutputDir import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.NativeAtomicReference import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Track import com.shabinder.common.providers.spotify.requests.SpotifyRequests import com.shabinder.common.providers.spotify.requests.authenticateSpotify import com.shabinder.common.utils.globalJson import io.ktor.client.* import io.ktor.client.features.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.* import io.ktor.client.request.* object SpotifyUtils { suspend fun getSpotifyRequests(spotifyToken: String? = null): SpotifyRequests { val spotifyClient = getSpotifyClient(spotifyToken) return object : SpotifyRequests { override val httpClientRef: NativeAtomicReference = NativeAtomicReference(spotifyClient) override suspend fun authenticateSpotifyClient(override: Boolean) { httpClientRef.value = getSpotifyClient(spotifyToken) } } } suspend fun getSpotifyClient(spotifyToken: String? = null): HttpClient { val token = spotifyToken ?: authenticateSpotify().component1()?.access_token return if (token == null) { println("Spotify Auth Failed: Please Check your Network Connection") throw SpotiFlyerException.NoInternetException() } else { println("Spotify Token: $token") HttpClient { defaultRequest { header("Authorization", "Bearer $token") } install(JsonFeature) { serializer = KotlinxSerializer(globalJson) } } } } fun Track.toTrackDetailsList(type: String = "Track", subFolder: String = "SpotifyFolder") = let { TrackDetails( title = it.name.toString(), trackNumber = it.track_number, genre = it.album?.genres?.filterNotNull() ?: emptyList(), artists = it.artists?.map { artist -> artist?.name.toString() } ?: listOf(), albumArtists = it.album?.artists?.mapNotNull { artist -> artist?.name } ?: emptyList(), durationSec = (it.duration_ms / 1000).toInt(), albumArtPath = (it.album?.images?.firstOrNull()?.url.toString()).substringAfterLast( '/' ) + ".jpeg", albumName = it.album?.name, year = it.album?.release_date, comment = "Genres:${it.album?.genres?.joinToString()}", trackUrl = it.href, downloaded = DownloadStatus.NotDownloaded, source = Source.Spotify, albumArtURL = it.album?.images?.firstOrNull()?.url.toString(), outputFilePath = "" ) } } ================================================ FILE: common/providers/src/desktopMain/kotlin/com/shabinder/common/providers/DesktopActual.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers import com.shabinder.common.core_components.file_manager.DownloadProgressFlow import com.shabinder.common.core_components.file_manager.DownloadScope import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.downloadFile import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import kotlinx.coroutines.flow.collect actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, fileManager: FileManager ) { list.forEach { trackDetails -> DownloadScope.executeSuspending { // Send Download to Pool. fetcher.findBestDownloadLink(trackDetails).fold( success = { res -> trackDetails.audioQuality = res.second downloadFile(res.first).collect { when (it) { is DownloadResult.Error -> { DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.apply { set( trackDetails.title, DownloadStatus.Failed( it.cause ?: SpotiFlyerException.UnknownReason(it.cause) ) ) } ) } is DownloadResult.Progress -> { DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() } .apply { set(trackDetails.title, DownloadStatus.Downloading(it.progress)) } ) } is DownloadResult.Success -> { // Todo clear map DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Converting) } ) fileManager.saveFileWithMetadata(it.byteArray, trackDetails).fold( failure = { DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(it)) } ) }, success = { DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Downloaded) } ) } ) } } } }, failure = { error -> DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.apply { set(trackDetails.title, DownloadStatus.Failed(error)) } ) } ) } } } ================================================ FILE: common/providers/src/desktopMain/kotlin/com/shabinder/common/providers/saavn/requests/decryptURL.kt ================================================ package com.shabinder.common.providers.saavn.requests import io.ktor.util.* import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import javax.crypto.spec.DESKeySpec @Suppress("GetInstance") @OptIn(InternalAPI::class) actual suspend fun decryptURL(url: String): String { val dks = DESKeySpec("38346591".toByteArray()) val keyFactory = SecretKeyFactory.getInstance("DES") val key: SecretKey = keyFactory.generateSecret(dks) val cipher: Cipher = Cipher.getInstance("DES/ECB/PKCS5Padding").apply { init(Cipher.DECRYPT_MODE, key, SecureRandom()) } return cipher.doFinal(url.decodeBase64Bytes()) .decodeToString() .replace("_96.mp4", "_320.mp4") } ================================================ FILE: common/providers/src/iosMain/kotlin/com.shabinder.common.providers/IOSActual.kt ================================================ package com.shabinder.common.di import com.shabinder.common.di.utils.ParallelExecutor import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.Actions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collect @SharedImmutable actual val dispatcherIO = Dispatchers.Default @SharedImmutable actual val currentPlatform: AllPlatforms = AllPlatforms.Native @SharedImmutable val Downloader = ParallelExecutor(dispatcherIO) actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, dir: Dir ) { dir.logger.i { "Downloading ${list.size} Tracks" } for (track in list) { Downloader.execute { val url = fetcher.findMp3DownloadLink(track) if (!url.isNullOrBlank()) { // Successfully Grabbed Mp3 URL downloadFile(url).collect { fetcher.dir.logger.d { it.toString() } /*Construct a `NEW Map` from frozen Map to Modify for Native Platforms*/ val map: MutableMap = when (it) { is DownloadResult.Error -> { DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.toMutableMap().apply { set(track.title, DownloadStatus.Failed) } } is DownloadResult.Progress -> { DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.toMutableMap().apply { set(track.title, DownloadStatus.Downloading(it.progress)) } } is DownloadResult.Success -> { // Todo clear map dir.saveFileWithMetadata(it.byteArray, track, Actions.instance::writeMp3Tags) DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.toMutableMap().apply { set(track.title, DownloadStatus.Downloaded) } } else -> { mutableMapOf() } } DownloadProgressFlow.emit( map as HashMap ) } } else { DownloadProgressFlow.emit( DownloadProgressFlow.replayCache.getOrElse( 0 ) { hashMapOf() }.apply { set(track.title, DownloadStatus.Failed) } ) } } } } @SharedImmutable val DownloadProgressFlow: MutableSharedFlow> = MutableSharedFlow(1) ================================================ FILE: common/providers/src/iosMain/kotlin/com.shabinder.common.providers/saavn.requests/decryptURL.kt ================================================ ackage com.shabinder.common.di.saavn actual suspend fun decryptURL(url: String): String { TODO("Not yet implemented") } ================================================ FILE: common/providers/src/jsMain/kotlin/com.shabinder.common.providers/WebActual.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.providers import com.shabinder.common.core_components.file_manager.DownloadProgressFlow import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.file_manager.allTracksStatus import com.shabinder.common.core_components.file_manager.downloadFile import com.shabinder.common.models.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.withContext actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, fileManager: FileManager ) { list.forEach { track -> withContext(dispatcherIO) { allTracksStatus[track.title] = DownloadStatus.Queued fetcher.findBestDownloadLink(track).fold( success = { res -> track.audioQuality = res.second downloadFile(res.first).collect { when (it) { is DownloadResult.Success -> { println("Download Completed") fileManager.saveFileWithMetadata(it.byteArray, track) {} } is DownloadResult.Error -> { allTracksStatus[track.title] = DownloadStatus.Failed(it.cause ?: SpotiFlyerException.UnknownReason(it.cause)) println("Download Error: ${track.title}") } is DownloadResult.Progress -> { allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress) println("Download Progress: ${it.progress} : ${track.title}") } } DownloadProgressFlow.emit(allTracksStatus) } }, failure = { error -> allTracksStatus[track.title] = DownloadStatus.Failed(error) DownloadProgressFlow.emit(allTracksStatus) } ) } } } ================================================ FILE: common/providers/src/jsMain/kotlin/com.shabinder.common.providers/saavn/requests/decryptURL.kt ================================================ package com.shabinder.common.providers.saavn.requests actual suspend fun decryptURL(url: String): String { TODO("Not yet implemented") } ================================================ FILE: common/root/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { id("android-setup") id("multiplatform-setup") id("multiplatform-setup-test") id("kotlin-parcelize") } fun org.jetbrains.kotlin.gradle.dsl.KotlinNativeBinaryContainer.generateFramework() { framework { baseName = "SpotiFlyer" linkerOpts.add("-lsqlite3") export(project(":common:dependency-injection")) export(project(":common:data-models")) export(project(":common:database")) export(project(":common:main")) export(project(":common:core-components")) export(project(":common:providers")) export(project(":common:list")) export(project(":common:preference")) with(deps) { export(decompose.dep) export(bundles.mviKotlin) } } } kotlin { /*IOS Target Can be only built on Mac*/ if (HostOS.isMac) { val sdkName: String? = System.getenv("SDK_NAME") val isiOSDevice = sdkName.orEmpty().startsWith("iphoneos") if (isiOSDevice) { iosArm64("ios") { binaries { generateFramework() } } } else { iosX64("ios") { binaries { generateFramework() } } } } sourceSets { commonMain { dependencies { implementation(project(":common:dependency-injection")) implementation(project(":common:data-models")) implementation(project(":common:database")) implementation(project(":common:list")) implementation(project(":common:main")) implementation(project(":common:providers")) implementation(project(":common:core-components")) implementation(project(":common:preference")) } } } if (HostOS.isMac) { /*Required to Export `packForXcode`*/ sourceSets { named("iosMain") { dependencies { api(project(":common:dependency-injection")) api(project(":common:data-models")) api(project(":common:database")) api(project(":common:list")) api(project(":common:main")) api(project(":common:preference")) with(deps) { api(decompose.dep) api(bundles.mviKotlin) } } } } } } val packForXcode by tasks.creating(Sync::class) { if (HostOS.isMac) { group = "build" val mode = System.getenv("CONFIGURATION") ?: "DEBUG" val targetName = "ios" val framework = kotlin.targets.getByName( targetName ) .binaries.getFramework(mode) inputs.property("mode", mode) dependsOn(framework.linkTask) val targetDir = File(buildDir, "xcode-frameworks") from(framework.outputDirectory) into(targetDir) } } ================================================ FILE: common/root/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: common/root/src/commonMain/kotlin/com/shabinder/common/root/SpotiFlyerRoot.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.root import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.RouterState import com.arkivanov.decompose.value.Value import com.arkivanov.mvikotlin.core.store.StoreFactory import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.di.ApplicationInit import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.models.Actions import com.shabinder.common.models.DownloadStatus import com.shabinder.common.preference.SpotiFlyerPreference import com.shabinder.common.providers.FetchPlatformQueryResult import com.shabinder.common.root.SpotiFlyerRoot.Dependencies import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.integration.SpotiFlyerRootImpl import com.shabinder.database.Database import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow interface SpotiFlyerRoot { val routerState: Value> val toastState: MutableStateFlow val callBacks: SpotiFlyerRootCallBacks sealed class Child { data class Main(val component: SpotiFlyerMain) : Child() data class List(val component: SpotiFlyerList) : Child() data class Preference(val component: SpotiFlyerPreference) : Child() } interface Dependencies { val appInit: ApplicationInit val storeFactory: StoreFactory val database: Database? val fetchQuery: FetchPlatformQueryResult val fileManager: FileManager val preferenceManager: PreferenceManager val analyticsManager: AnalyticsManager val downloadProgressFlow: MutableSharedFlow> val actions: Actions } } @Suppress("FunctionName") // Factory function fun SpotiFlyerRoot(componentContext: ComponentContext, dependencies: Dependencies): SpotiFlyerRoot = SpotiFlyerRootImpl(componentContext, dependencies) ================================================ FILE: common/root/src/commonMain/kotlin/com/shabinder/common/root/callbacks/SpotiFlyerRootCallBacks.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.root.callbacks interface SpotiFlyerRootCallBacks { fun searchLink(link: String) fun showToast(text: String) fun popBackToHomeScreen() fun openPreferenceScreen() } ================================================ FILE: common/root/src/commonMain/kotlin/com/shabinder/common/root/integration/SpotiFlyerRootImpl.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package com.shabinder.common.root.integration import co.touchlab.stately.ensureNeverFrozen import co.touchlab.stately.freeze import com.arkivanov.decompose.* import com.arkivanov.decompose.router.RouterState import com.arkivanov.decompose.router.pop import com.arkivanov.decompose.router.popWhile import com.arkivanov.decompose.router.push import com.arkivanov.decompose.router.router import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.parcelable.Parcelable import com.arkivanov.essenty.parcelable.Parcelize import com.shabinder.common.core_components.analytics.AnalyticsEvent import com.shabinder.common.core_components.analytics.AnalyticsView import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.models.Actions import com.shabinder.common.models.Consumer import com.shabinder.common.preference.SpotiFlyerPreference import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot.Child import com.shabinder.common.root.SpotiFlyerRoot.Dependencies import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import kotlinx.coroutines.flow.MutableStateFlow internal class SpotiFlyerRootImpl( componentContext: ComponentContext, dependencies: Dependencies, ) : SpotiFlyerRoot, ComponentContext by componentContext, Dependencies by dependencies, Actions by dependencies.actions { init { AnalyticsEvent.AppLaunch.track(analyticsManager) instanceKeeper.ensureNeverFrozen() Actions.instance = dependencies.actions.freeze() appInit.init() } private val router = router( initialConfiguration = Configuration.Main, handleBackButton = true, childFactory = ::createChild ) override val routerState: Value> = router.state override val toastState = MutableStateFlow("") override val callBacks = object : SpotiFlyerRootCallBacks { override fun searchLink(link: String) = onMainOutput(SpotiFlyerMain.Output.Search(link)) override fun popBackToHomeScreen() { if (router.state.value.activeChild.instance !is Child.Main && router.state.value.backStack.isNotEmpty()) { router.popWhile { it !is Configuration.Main } } } override fun openPreferenceScreen() { router.push(Configuration.Preference) } override fun showToast(text: String) { toastState.value = text } } private fun createChild( configuration: Configuration, componentContext: ComponentContext, ): Child = when (configuration) { is Configuration.Main -> Child.Main( spotiFlyerMain( componentContext, Consumer(::onMainOutput), ) ) is Configuration.List -> Child.List( spotiFlyerList( componentContext, configuration.link, Consumer(::onListOutput), ) ) is Configuration.Preference -> Child.Preference( spotiFlyerPreference( componentContext, Consumer(::onPreferenceOutput), ) ) } private fun spotiFlyerMain( componentContext: ComponentContext, output: Consumer, ): SpotiFlyerMain = SpotiFlyerMain( componentContext = componentContext, dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by this { override val mainOutput: Consumer = output override val mainAnalytics = object : SpotiFlyerMain.Analytics { override fun donationDialogVisit() { AnalyticsEvent.DonationDialogOpen.track(analyticsManager) } } } ) private fun spotiFlyerList( componentContext: ComponentContext, link: String, output: Consumer ): SpotiFlyerList = SpotiFlyerList( componentContext = componentContext, dependencies = object : SpotiFlyerList.Dependencies, Dependencies by this { override val link: String = link override val listOutput: Consumer = output override val listAnalytics = object : SpotiFlyerList.Analytics {} } ) private fun spotiFlyerPreference( componentContext: ComponentContext, output: Consumer ): SpotiFlyerPreference = SpotiFlyerPreference( componentContext = componentContext, dependencies = object : SpotiFlyerPreference.Dependencies, Dependencies by this { override val prefOutput: Consumer = output override val preferenceAnalytics = object : SpotiFlyerPreference.Analytics {} } ) private fun onMainOutput(output: SpotiFlyerMain.Output) = when (output) { is SpotiFlyerMain.Output.Search -> { router.push(Configuration.List(link = output.link)) AnalyticsView.ListScreen.track(analyticsManager) } } private fun onListOutput(output: SpotiFlyerList.Output): Unit = when (output) { is SpotiFlyerList.Output.Finished -> { if (router.state.value.activeChild.instance is Child.List && router.state.value.backStack.isNotEmpty()) { router.pop() } AnalyticsView.HomeScreen.track(analyticsManager) } } private fun onPreferenceOutput(output: SpotiFlyerPreference.Output): Unit = when (output) { is SpotiFlyerPreference.Output.Finished -> { if (router.state.value.activeChild.instance is Child.Preference && router.state.value.backStack.isNotEmpty()) { router.pop() } Unit } } private sealed class Configuration : Parcelable { @Parcelize object Main : Configuration() @Parcelize object Preference : Configuration() @Parcelize data class List(val link: String) : Configuration() } } ================================================ FILE: console-app/build.gradle.kts ================================================ plugins { kotlin("jvm") kotlin("plugin.serialization") id("ktlint-setup") id("com.jakewharton.mosaic") application } group = "com.shabinder" version = Versions.versionCode repositories { mavenCentral() } application { mainClass.set("MainKt") applicationName = "spotiflyer-console-app" } dependencies { with(deps) { implementation(Koin.core) implementation(project(":common:database")) implementation(project(":common:data-models")) implementation(project(":common:dependency-injection")) implementation(project(":common:root")) implementation(project(":common:main")) implementation(project(":common:list")) implementation(project(":common:list")) // Decompose implementation(Decompose.decompose) implementation(Decompose.extensionsCompose) // MVI implementation(MVIKotlin.mvikotlin) implementation(MVIKotlin.mvikotlinMain) // Koin implementation(Koin.core) // Matomo implementation(Ktor.slf4j) implementation(Ktor.clientCore) implementation(Ktor.clientJson) implementation(Ktor.clientApache) implementation(Ktor.clientLogging) implementation(Ktor.clientSerialization) implementation(Serialization.json) // testDeps testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.5.21") } } tasks.withType().configureEach { kotlinOptions { jvmTarget = "1.8" freeCompilerArgs = freeCompilerArgs.plus( listOf( "-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true" ) ) } } tasks.test { useJUnit() } ================================================ FILE: console-app/src/main/java/common/Common.kt ================================================ @file:Suppress("FunctionName") package common import io.ktor.client.HttpClient import io.ktor.client.features.HttpTimeout import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.logging.DEFAULT import io.ktor.client.features.logging.LogLevel import io.ktor.client.features.logging.Logger import io.ktor.client.features.logging.Logging import kotlinx.serialization.json.Json internal val client = HttpClient { install(HttpTimeout) install(JsonFeature) { serializer = KotlinxSerializer( Json { ignoreUnknownKeys = true isLenient = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.INFO } } ================================================ FILE: console-app/src/main/java/common/Parameters.kt ================================================ package common import utils.byOptionalProperty import utils.byProperty internal data class Parameters( val githubToken: String, val ownerName: String, val repoName: String, val branchName: String, val filePath: String, val imageDescription: String, val commitMessage: String, val tagName: String ) { companion object { fun initParameters() = Parameters( githubToken = "GH_TOKEN".byProperty, ownerName = "OWNER_NAME".byProperty, repoName = "REPO_NAME".byProperty, branchName = "BRANCH_NAME".byOptionalProperty ?: "main", filePath = "FILE_PATH".byOptionalProperty ?: "README.md", imageDescription = "IMAGE_DESCRIPTION".byOptionalProperty ?: "IMAGE", commitMessage = "COMMIT_MESSAGE".byOptionalProperty ?: "HTML-TO-IMAGE Update", tagName = "TAG_NAME".byOptionalProperty ?: "HTI" // hctiKey = "HCTI_KEY".analytics_html_img.getByProperty ) } } ================================================ FILE: console-app/src/main/java/main.kt ================================================ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.jakewharton.mosaic.Text import com.jakewharton.mosaic.runMosaic import kotlinx.coroutines.delay fun main() = runMosaic { // TODO https://github.com/JakeWharton/mosaic/issues/3 var count by mutableStateOf(0) setContent { Text("The count is: $count") } for (i in 1..20) { delay(250) count = i } } ================================================ FILE: console-app/src/main/java/utils/Exceptions.kt ================================================ @file:Suppress("ClassName") package utils data class ENV_KEY_MISSING( val keyName: String, override val message: String? = "$keyName was not found, please check your ENV variables" ) : Exception(message) data class HCTI_URL_RESPONSE_ERROR( val response: String, override val message: String? = "Server Error, We Recieved this Resp: $response" ) : Exception(message) data class RETRY_LIMIT_EXHAUSTED( override val message: String? = "RETRY LIMIT EXHAUSTED!" ) : Exception(message) ================================================ FILE: console-app/src/main/java/utils/Ext.kt ================================================ package utils val String.byProperty: String get() = System.getenv(this) ?: throw (ENV_KEY_MISSING(this)) val String.byOptionalProperty: String? get() = System.getenv(this) fun debug(message: String) = println("\n::debug::$message") fun debug(tag: String, message: String) = println("\n::debug::$tag:\n$message") ================================================ FILE: console-app/src/main/java/utils/TestClass.kt ================================================ package utils import kotlinx.coroutines.runBlocking // Test Class- at development Time fun main(): Unit = runBlocking {} ================================================ FILE: desktop/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ import org.jetbrains.compose.compose import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { kotlin("multiplatform") id("org.jetbrains.compose") id("ktlint-setup") } group = "com.shabinder" version = Versions.versionName kotlin { jvm { compilations.all { kotlinOptions.jvmTarget = "1.8" } } tasks.named("jvmProcessResources") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } sourceSets { val jvmMain by getting { resources.srcDirs("../common/data-models/src/main/res") dependencies { implementation(compose.desktop.currentOs) implementation(project(":common:database")) implementation(project(":common:dependency-injection")) implementation(project(":common:core-components")) implementation(project(":common:data-models")) implementation(project(":common:compose")) implementation(project(":common:providers")) implementation(project(":common:root")) with(deps) { implementation(jaffree) with(decompose) { implementation(dep) implementation(extensions.compose) } with(mviKotlin) { implementation(dep) implementation(main) } implementation(koin.core) } } } val jvmTest by getting } } compose.desktop { application { mainClass = "MainKt" description = "Music Downloader for Spotify, Gaana, Jio Saavn, Youtube Music." nativeDistributions { modules("java.sql", "java.security.jgss", "jdk.crypto.ec") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "SpotiFlyer" copyright = "© 2021 Shabinder. All rights reserved." vendor = "Shabinder" val iconsRoot = project.file("src/jvmMain/resources/drawable") macOS { bundleID = "com.shabinder.spotiflyer" iconFile.set(iconsRoot.resolve("spotiflyer.icns")) } windows { iconFile.set(iconsRoot.resolve("spotiflyer.ico")) // Wondering what the heck is this? See : https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html // https://www.guidgen.com/ upgradeUuid = "50dac393-a24f-48a6-89c6-9218b24a5291" menuGroup = packageName } linux { iconFile.set(iconsRoot.resolve("spotiflyer.png")) } } } } ================================================ FILE: desktop/src/jvmMain/kotlin/Main.kt ================================================ /* * * Copyright (c) 2021 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 . */ import androidx.compose.desktop.DesktopMaterialTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.loadImageBitmap import androidx.compose.ui.res.useResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.jetbrains.lifecycle.LifecycleController import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import com.github.kokorin.jaffree.JaffreeException import com.github.kokorin.jaffree.ffmpeg.FFmpeg import com.shabinder.common.di.* import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.core_components.file_manager.DownloadProgressFlow import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.core_components.utils.isInternetAccessible import com.shabinder.common.models.PlatformActions import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.Actions import com.shabinder.common.providers.FetchPlatformQueryResult import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.translations.Strings import com.shabinder.common.uikit.SpotiFlyerLogo import com.shabinder.common.uikit.configurations.SpotiFlyerColors import com.shabinder.common.uikit.configurations.SpotiFlyerShapes import com.shabinder.common.uikit.configurations.SpotiFlyerTypography import com.shabinder.common.uikit.configurations.colorOffWhite import com.shabinder.common.uikit.screens.SpotiFlyerRootContent import com.shabinder.database.Database import kotlinx.coroutines.runBlocking import java.awt.Desktop import java.awt.Toolkit import java.awt.datatransfer.Clipboard import java.awt.datatransfer.StringSelection import java.net.URI import javax.swing.JFileChooser import javax.swing.JFileChooser.APPROVE_OPTION private val koin = initKoin(enableNetworkLogs = true).koin private lateinit var showToast: (String) -> Unit private lateinit var appWindow: ComposeWindow @OptIn(ExperimentalDecomposeApi::class) fun main() { val lifecycle = LifecycleRegistry() val rootComponent = spotiFlyerRoot(DefaultComponentContext(lifecycle)) val windowState = WindowState(width = 450.dp, height = 800.dp) singleWindowApplication( title = "SpotiFlyer", state = windowState, icon = BitmapPainter(useResource("drawable/spotiflyer.png", ::loadImageBitmap)) ) { appWindow = window LifecycleController(lifecycle, windowState) Surface( modifier = Modifier.fillMaxSize(), color = Color.Black, contentColor = colorOffWhite ) { MaterialTheme( colors = SpotiFlyerColors, typography = SpotiFlyerTypography, shapes = SpotiFlyerShapes ) { val root: SpotiFlyerRoot = SpotiFlyerRootContent(rootComponent) showToast = root.callBacks::showToast // FFmpeg WARNING try { FFmpeg.atPath().addArgument("-version").execute() } catch (e: Exception) { if (e is JaffreeException) Actions.instance.showPopUpMessage("WARNING!\nFFmpeg not found at path") } } } } // Download Tracking for Desktop Apps for Now will be measured using `GitHub Releases` // https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer } private fun spotiFlyerRoot(componentContext: ComponentContext): SpotiFlyerRoot = SpotiFlyerRoot( componentContext = componentContext, dependencies = object : SpotiFlyerRoot.Dependencies { override val appInit: ApplicationInit = koin.get() override val storeFactory = DefaultStoreFactory() override val fetchQuery: FetchPlatformQueryResult = koin.get() override val fileManager: FileManager = koin.get() override val database: Database? = fileManager.db override val analyticsManager: AnalyticsManager = koin.get() override val preferenceManager: PreferenceManager = koin.get().also { it.analyticsManager = analyticsManager // Allow Analytics for Desktop analyticsManager.giveConsent() } override val downloadProgressFlow = DownloadProgressFlow override val actions: Actions = object : Actions { override val platformActions = object : PlatformActions {} override fun showPopUpMessage(string: String, long: Boolean) { if (::showToast.isInitialized) { showToast(string) } } override fun setDownloadDirectoryAction(callBack: (String) -> Unit) { val fileChooser = JFileChooser().apply { fileSelectionMode = JFileChooser.DIRECTORIES_ONLY } when (fileChooser.showOpenDialog(appWindow)) { APPROVE_OPTION -> { val directory = fileChooser.selectedFile if (directory.canWrite()) { preferenceManager.setDownloadDirectory(directory.absolutePath) callBack(directory.absolutePath) showPopUpMessage("${Strings.setDownloadDirectory()} \n${fileManager.defaultDir()}") } else { showPopUpMessage(Strings.noWriteAccess("\n${directory.absolutePath} ")) } } else -> { showPopUpMessage("No Directory Selected") } } } override fun queryActiveTracks() { /**/ } override fun giveDonation() { openLink("https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button") } override fun shareApp() = openLink("https://github.com/Shabinder/SpotiFlyer") override fun copyToClipboard(text: String) { val data = StringSelection(text) val cb: Clipboard = Toolkit.getDefaultToolkit().systemClipboard cb.setContents(data, data) } override fun openPlatform(packageID: String, platformLink: String) = openLink(platformLink) fun openLink(link: String) { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { Desktop.getDesktop().browse(URI(link)) } } override fun writeMp3Tags(trackDetails: TrackDetails) { /*IMPLEMENTED*/ } override val isInternetAvailable: Boolean get() = runBlocking { isInternetAccessible() } } } ) ================================================ FILE: fastlane/Appfile ================================================ package_name("com.shabinder.spotiflyer") ================================================ FILE: fastlane/Fastfile ================================================ # This file contains the fastlane.tools configuration # You can find the documentation at https://docs.fastlane.tools # # For a list of all available actions, check out # # https://docs.fastlane.tools/actions # # For a list of all available plugins, check out # # https://docs.fastlane.tools/plugins/available-plugins # # Uncomment the line if you want fastlane to automatically update itself # update_fastlane default_platform(:android) platform :android do desc "Runs all the tests" lane :test do gradle(task: "test") end desc "Build Android App" lane :build do gradle(task: ":android:build") # sh "your_script.sh" # You can also use other beta testing services here end end ================================================ FILE: fastlane/metadata/android/en-US/changelogs/20.txt ================================================ - F-Droid Initial Release. - Size Reduced to 4.9 MB (14% smalled than previous apk). - Firebase Analytics/Crashlytics Removed, Self-Hosted Alternatives Used (100% Open Source). - Dependencies Updated. - Android Bitmap Compression (OOM Fixes) - Jio Saavn Support Added - 320KBPS Audio Quality is now available too. - Bug Fixes. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/21.txt ================================================ - YT Quality 128 -> 192 KBPS - Bug Fixes - Better Error Handling, and Bubbling upto the caller - Error Dialog with error info added - YT extraction fixed - Retry on Error Logo Added - Translations added for languages: Locales - de, en, es, fr, id, pt, ru, uk ================================================ FILE: fastlane/metadata/android/en-US/changelogs/22.txt ================================================ - Android 11 Crash Fix (sdk 30) ================================================ FILE: fastlane/metadata/android/en-US/changelogs/24.txt ================================================ - Major Bug Fixes: - YT Music Extraction, - Error Handling Improv, - Analytics, and Crashlytics Provider, - Some translations Added. - Many More... I cant remember now 🙃 - Now FFmpeg is custom built and added in-app : no more mp3 conversion errors ================================================ FILE: fastlane/metadata/android/en-US/changelogs/25.txt ================================================ - File size less than 100 KB, - Crash Fixes when App Went in Background - Offloaded Many CPU Intensive tasks from Main Thread - Notification Handling Improved, Now Exits Gracefully - Some translations Added. - ImageVectors Caching Implemented Expect some performance gain ================================================ FILE: fastlane/metadata/android/en-US/changelogs/26.txt ================================================ Hot Fixes: - Android App Crashes Fix when Toasting in background thread. - null Named directories creation stopped in desktop - Download Directory Chooser crashing fix in desktop. - Media Conversion Failing fix when Names have Special Characters ================================================ FILE: fastlane/metadata/android/en-US/changelogs/27.txt ================================================ - Added SoundCloud Support. - Added YT manual extraction when YT1s fails fixing many critical bugs. - Optimised List Screen Download Progress UI, it now updates smoothly & accurately. - Fixed Concurrent Access Crashes. - Added Some Language Translations. - Some Code Cleanup ================================================ FILE: fastlane/metadata/android/en-US/changelogs/28.txt ================================================ - Fixed SoundCloud Parsing Errors. - Fixed SSL Cert Validation Errors. - Compose, Kotlin and other Dependencies Updated. - Added Some Language Translations. - Code Cleanup. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/29.txt ================================================ - Fixed Response Parsing Error which leaded download failure. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/30.txt ================================================ - User Configurable Spotify Creds Support. - Crash & Visibility Fix for SettingsIcon. - Changed default Creds, thanks to spotDL. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/31.txt ================================================ - Fix for Blurry Images. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/32.txt ================================================ - Soundbound Feature Graphic. ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ "SpotiFlyer" is an App(Written in Kotlin), which aims to work as: - Download: Albums, Tracks and Playlists,etc. - Save your Data, by not Streaming your Fav. Songs Online again & again(Just Download Them!). - No ADS!, 100% Open Source. - Works straight out of the box and does not require you to generate or mess with your API keys (already included). - Even a Web-App and Desktop Apps are available, Check Out: https://github.com/Shabinder/SpotiFlyer (Encourage Us by giving us a star here) Supported Platforms: - Spotify - Gaana - Youtube - Youtube Music - Jio-Saavn - SoundCloud - (more coming soon) Note: - The availability of YouTube Music in your country is IMPORTANT, if it isn't available consider using a VPN. ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ Download All your songs from Spotify, Gaana, Jio Saavn, Youtube Music, SoundCloud. ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ SpotiFlyer ================================================ FILE: ffmpeg/android-ffmpeg/.gitignore ================================================ /build ================================================ FILE: ffmpeg/android-ffmpeg/build.gradle.bak ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' ext { publishedGroupId = 'nl.bravobit' libraryName = 'Android FFmpeg' artifact = 'android-ffmpeg' libraryDescription = 'FFmpeg/FFprobe compiled for Android. Execute FFmpeg and FFprobe commands with ease in your Android project.' siteUrl = 'https://github.com/bravobit/FFmpeg-Android' gitUrl = 'https://github.com/bravobit/FFmpeg-Android.git' libraryVersion = '1.1.7' developerId = 'Bravobit' developerName = 'Bravobit' developerEmail = 'info@bravobit.nl' licenseName = 'GNU General Public License v3.0' licenseUrl = 'https://github.com/bravobit/FFmpeg-Android/blob/master/LICENSE' allLicenses = ["GPL-3.0"] } android { compileSdkVersion 29 defaultConfig { minSdkVersion 16 targetSdkVersion 29 versionCode 18 versionName "1.2.1" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_7 targetCompatibility JavaVersion.VERSION_1_7 } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.3.1' } task sourcesJar(type: Jar) { from android.sourceSets.main.java.srcDirs classifier = 'sources' } task javadoc(type: Javadoc) { source = android.sourceSets.main.java.srcDirs classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) } task javadocJar(type: Jar, dependsOn: javadoc) { classifier = 'javadoc' from javadoc.destinationDir } artifacts { archives javadocJar archives sourcesJar } ================================================ FILE: ffmpeg/android-ffmpeg/build.gradle.kts ================================================ plugins { id("com.android.library") id("kotlin-android") } android { //ndkVersion "22.0.7026061" compileSdk = Versions.compileSdkVersion buildToolsVersion = "30.0.3" defaultConfig { consumerProguardFile("proguard-rules.pro") minSdk = Versions.minSdkVersion targetSdk = Versions.targetSdkVersion /*ndk { abiFilters.addAll(setOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a")) }*/ } buildTypes { getByName("release") { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } packagingOptions { resources { excludes.apply { add("META-INF/*") } jniLibs.pickFirsts.apply { add("**/*.so") } } } } dependencies { /**/ } ================================================ FILE: ffmpeg/android-ffmpeg/gradle.properties ================================================ ================================================ FILE: ffmpeg/android-ffmpeg/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: ffmpeg/android-ffmpeg/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/CommandResult.java ================================================ package nl.bravobit.ffmpeg; class CommandResult { final String output; final boolean success; CommandResult(boolean success, String output) { this.success = success; this.output = output; } static CommandResult getDummyFailureResponse() { return new CommandResult(false, ""); } static CommandResult getOutputFromProcess(Process process) { String output; if (success(process.exitValue())) { output = Util.convertInputStreamToString(process.getInputStream()); } else { output = Util.convertInputStreamToString(process.getErrorStream()); } return new CommandResult(success(process.exitValue()), output); } static boolean success(Integer exitValue) { return exitValue != null && exitValue == 0; } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/CpuArch.java ================================================ package nl.bravobit.ffmpeg; public enum CpuArch { ARMv7, x86, x86_64, ARM_64, NONE } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/CpuArchHelper.java ================================================ package nl.bravobit.ffmpeg; import android.os.Build; @SuppressWarnings("deprecation") public class CpuArchHelper { public static final String X86_CPU = "x86"; public static final String X86_64_CPU = "x86_64"; public static final String ARM_64_CPU = "arm64-v8a"; public static final String ARM_V7_CPU = "armeabi-v7a"; public static CpuArch getCpuArch() { Log.d("Build.CPU_ABI : " + Build.CPU_ABI); switch (Build.CPU_ABI) { case X86_CPU: return CpuArch.x86; case X86_64_CPU: return CpuArch.x86_64; case ARM_64_CPU: return CpuArch.ARM_64; case ARM_V7_CPU: return CpuArch.ARMv7; default: return CpuArch.NONE; } } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/ExecuteBinaryResponseHandler.java ================================================ package nl.bravobit.ffmpeg; public class ExecuteBinaryResponseHandler implements FFcommandExecuteResponseHandler { @Override public void onSuccess(String message) { } @Override public void onProgress(String message) { } @Override public void onFailure(String message) { } @Override public void onStart() { } @Override public void onFinish() { } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFbinaryContextProvider.java ================================================ package nl.bravobit.ffmpeg; import android.content.Context; public interface FFbinaryContextProvider { Context provide(); } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFbinaryInterface.java ================================================ package nl.bravobit.ffmpeg; import java.util.Map; interface FFbinaryInterface { /** * Executes a command * * @param environmentVars Environment variables * @param cmd command to execute * @param ffcommandExecuteResponseHandler {@link FFcommandExecuteResponseHandler} * @return the task */ FFtask execute(Map environmentVars, String[] cmd, FFcommandExecuteResponseHandler ffcommandExecuteResponseHandler); /** * Executes a command * * @param cmd command to execute * @param ffcommandExecuteResponseHandler {@link FFcommandExecuteResponseHandler} * @return the task */ FFtask execute(String[] cmd, FFcommandExecuteResponseHandler ffcommandExecuteResponseHandler); /** * Checks if FF binary is supported on this device * * @return true if FF binary is supported on this device */ boolean isSupported(); /** * Checks if a command with given task is currently running * * @param task - the task that you want to check * @return true if a command is running */ boolean isCommandRunning(FFtask task); /** * Kill given running process * * @param task - the task to kill * @return true if process is killed successfully */ boolean killRunningProcesses(FFtask task); /** * Timeout for binary process, should be minimum of 10 seconds * * @param timeout in milliseconds */ void setTimeout(long timeout); } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFbinaryObserver.java ================================================ package nl.bravobit.ffmpeg; public interface FFbinaryObserver extends Runnable { void cancel(); } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFcommandExecuteAsyncTask.java ================================================ package nl.bravobit.ffmpeg; import android.os.AsyncTask; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.util.Map; import java.util.concurrent.TimeoutException; @SuppressWarnings("deprecation") class FFcommandExecuteAsyncTask extends AsyncTask implements FFtask { private final String[] cmd; private final Map environment; private final StringBuilder outputStringBuilder = new StringBuilder(); private final FFcommandExecuteResponseHandler ffmpegExecuteResponseHandler; private final ShellCommand shellCommand; private final long timeout; private long startTime; private Process process; private String output = ""; private boolean quitPending; FFcommandExecuteAsyncTask(String[] cmd, Map environment, long timeout, FFcommandExecuteResponseHandler ffmpegExecuteResponseHandler) { this.cmd = cmd; this.timeout = timeout; this.environment = environment; this.ffmpegExecuteResponseHandler = ffmpegExecuteResponseHandler; this.shellCommand = new ShellCommand(); } @Override protected void onPreExecute() { startTime = System.currentTimeMillis(); if (ffmpegExecuteResponseHandler != null) { ffmpegExecuteResponseHandler.onStart(); } } @Override protected CommandResult doInBackground(Void... params) { CommandResult ret = CommandResult.getDummyFailureResponse(); try { process = shellCommand.run(cmd, environment); if (process == null) { return CommandResult.getDummyFailureResponse(); } Log.d("Running publishing updates method"); checkAndUpdateProcess(); ret = CommandResult.getOutputFromProcess(process); outputStringBuilder.append(ret.output); } catch (TimeoutException e) { Log.e("FFmpeg binary timed out", e); ret = new CommandResult(false, e.getMessage()); outputStringBuilder.append(ret.output); } catch (Exception e) { Log.e("Error running FFmpeg binary", e); } finally { Util.destroyProcess(process); } output = outputStringBuilder.toString(); return ret; } @Override protected void onProgressUpdate(String... values) { if (values != null && values[0] != null && ffmpegExecuteResponseHandler != null) { ffmpegExecuteResponseHandler.onProgress(values[0]); } } @Override protected void onPostExecute(CommandResult commandResult) { if (ffmpegExecuteResponseHandler != null) { if (commandResult.success) { ffmpegExecuteResponseHandler.onSuccess(output); } else { ffmpegExecuteResponseHandler.onFailure(output); } ffmpegExecuteResponseHandler.onFinish(); } } private void checkAndUpdateProcess() throws TimeoutException, InterruptedException { while (!Util.isProcessCompleted(process)) { // checking if process is completed if (Util.isProcessCompleted(process)) { return; } // Handling timeout if (timeout != Long.MAX_VALUE && System.currentTimeMillis() > startTime + timeout) { throw new TimeoutException("FFmpeg binary timed out"); } try { String line; BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream())); while ((line = reader.readLine()) != null) { if (isCancelled()) { process.destroy(); process.waitFor(); return; } if (quitPending) { sendQ(); process = null; return; } outputStringBuilder.append(line); outputStringBuilder.append("\n"); publishProgress(line); } } catch (IOException e) { e.printStackTrace(); } } } public boolean isProcessCompleted() { return Util.isProcessCompleted(process); } @Override public boolean killRunningProcess() { return Util.killAsync(this); } @Override public void sendQuitSignal() { quitPending = true; } private void sendQ() { OutputStream outputStream = process.getOutputStream(); try { outputStream.write("q\n".getBytes()); outputStream.flush(); } catch (IOException e) { e.printStackTrace(); } } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFcommandExecuteResponseHandler.java ================================================ package nl.bravobit.ffmpeg; public interface FFcommandExecuteResponseHandler extends ResponseHandler { /** * on Success * * @param message complete output of the binary command */ void onSuccess(String message); /** * on Progress * * @param message current output of binary command */ void onProgress(String message); /** * on Failure * * @param message complete output of the binary command */ void onFailure(String message); } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFmpeg.kt ================================================ package nl.bravobit.ffmpeg import android.content.Context import nl.bravobit.ffmpeg.Log.setDebug import nl.bravobit.ffmpeg.Util.isDebug import nl.bravobit.ffmpeg.FileUtils.getFFmpeg import nl.bravobit.ffmpeg.Log.e import nl.bravobit.ffmpeg.Log.d import android.os.AsyncTask import java.lang.IllegalArgumentException class FFmpeg private constructor(private val context: FFbinaryContextProvider) : FFbinaryInterface { private var timeout = Long.MAX_VALUE init { setDebug(isDebug(context.provide())) } override fun isSupported(): Boolean { // get ffmpeg file val ffmpeg = getFFmpeg(context.provide()) // check if ffmpeg can be executed if (!ffmpeg.canExecute()) { // try to make executable e("ffmpeg cannot execute") return false } d("ffmpeg is ready!") return true } override fun execute( environmentVars: Map, cmd: Array, ffmpegExecuteResponseHandler: FFcommandExecuteResponseHandler ): FFtask { return if (cmd.isNotEmpty()) { val command = arrayOfNulls(cmd.size + 1) command[0] = getFFmpeg(context.provide()).absolutePath System.arraycopy(cmd, 0, command, 1, cmd.size) val task = FFcommandExecuteAsyncTask( command, environmentVars, timeout, ffmpegExecuteResponseHandler ) task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) task } else { throw IllegalArgumentException("shell command cannot be empty") } } override fun execute( cmd: Array, ffmpegExecuteResponseHandler: FFcommandExecuteResponseHandler ): FFtask { return execute(emptyMap(), cmd, ffmpegExecuteResponseHandler) } override fun isCommandRunning(task: FFtask): Boolean { return !task.isProcessCompleted } override fun killRunningProcesses(task: FFtask): Boolean { return task.killRunningProcess() } override fun setTimeout(timeout: Long) { if (timeout >= MINIMUM_TIMEOUT) { this.timeout = timeout } } companion object { private const val MINIMUM_TIMEOUT = (10 * 1000).toLong() private var instance: FFmpeg? = null fun getInstance(context: Context): FFmpeg { return instance ?: FFmpeg { context }.also { instance = it } } } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFprobe.java ================================================ package nl.bravobit.ffmpeg; import android.content.Context; import android.os.AsyncTask; import java.io.File; import java.util.Map; @SuppressWarnings("deprecation") public class FFprobe implements FFbinaryInterface { private final FFbinaryContextProvider context; private static final long MINIMUM_TIMEOUT = 10 * 1000; private long timeout = Long.MAX_VALUE; private static FFprobe instance = null; private FFprobe(FFbinaryContextProvider context) { this.context = context; Log.setDebug(Util.isDebug(this.context.provide())); } public static FFprobe getInstance(final Context context) { if (instance == null) { instance = new FFprobe(() -> context); } return instance; } @Override public boolean isSupported() { // get ffprobe file File ffprobe = FileUtils.getFFprobe(context.provide()); // check if ffprobe can be executed if (!ffprobe.canExecute()) { // try to make executable Log.e("ffprobe cannot execute"); return false; } Log.d("ffprobe is ready!"); return true; } @Override public FFtask execute(Map environvenmentVars, String[] cmd, FFcommandExecuteResponseHandler ffcommandExecuteResponseHandler) { if (cmd.length != 0) { final String[] command = new String[cmd.length + 1]; command[0] = FileUtils.getFFprobe(context.provide()).getAbsolutePath(); System.arraycopy(cmd, 0, command, 1, cmd.length); FFcommandExecuteAsyncTask task = new FFcommandExecuteAsyncTask(command, environvenmentVars, timeout, ffcommandExecuteResponseHandler); task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); return task; } else { throw new IllegalArgumentException("shell command cannot be empty"); } } @Override public FFtask execute(String[] cmd, FFcommandExecuteResponseHandler ffcommandExecuteResponseHandler) { return execute(null, cmd, ffcommandExecuteResponseHandler); } public boolean isCommandRunning(FFtask task) { return task != null && !task.isProcessCompleted(); } @Override public boolean killRunningProcesses(FFtask task) { return task != null && task.killRunningProcess(); } @Override public void setTimeout(long timeout) { if (timeout >= MINIMUM_TIMEOUT) { this.timeout = timeout; } } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FFtask.kt ================================================ package nl.bravobit.ffmpeg interface FFtask { /** * Sends 'q' to the ff binary running process asynchronously */ fun sendQuitSignal() /** * Checks if process is completed * @return `true` if a process is running */ val isProcessCompleted: Boolean /** * Kill given running process * * @return true if process is killed successfully */ fun killRunningProcess(): Boolean } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/FileUtils.kt ================================================ package nl.bravobit.ffmpeg import android.content.Context import java.io.File internal object FileUtils { private const val FFMPEG_FILE_NAME = "lib..ffmpeg..so" private const val FFPROBE_FILE_NAME = "lib..ffprobe..so" @JvmStatic fun getFFmpeg(context: Context): File { val folder = File(context.applicationInfo.nativeLibraryDir) return File(folder, FFMPEG_FILE_NAME) } @JvmStatic fun getFFprobe(context: Context): File { val folder = File(context.applicationInfo.nativeLibraryDir) return File(folder, FFPROBE_FILE_NAME) } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/Log.kt ================================================ package nl.bravobit.ffmpeg import android.util.Log internal object Log { private var TAG = FFmpeg::class.java.simpleName private var DEBUG = false @JvmStatic fun setDebug(debug: Boolean) { DEBUG = debug } fun setTag(tag: String) { TAG = tag } @JvmStatic fun d(obj: Any?) { if (DEBUG) { Log.d(TAG, obj?.toString() ?: "") } } @JvmStatic fun e(obj: Any?) { if (DEBUG) { Log.e(TAG, obj?.toString() ?: "") } } @JvmStatic fun w(obj: Any?) { if (DEBUG) { Log.w(TAG, obj?.toString() ?: "") } } @JvmStatic fun i(obj: Any?) { if (DEBUG) { Log.i(TAG, obj?.toString() ?: "") } } @JvmStatic fun v(obj: Any?) { if (DEBUG) { Log.v(TAG, obj?.toString() ?: "") } } @JvmStatic fun e(obj: Any?, throwable: Throwable?) { if (DEBUG) { Log.e(TAG, obj?.toString() ?: "", throwable) } } @JvmStatic fun e(throwable: Throwable?) { if (DEBUG) { Log.e(TAG, "", throwable) } } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/ResponseHandler.kt ================================================ package nl.bravobit.ffmpeg interface ResponseHandler { /** * on Start */ fun onStart() /** * on Finish */ fun onFinish() } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/ShellCommand.kt ================================================ package nl.bravobit.ffmpeg internal class ShellCommand { fun run(commandString: Array, environment: Map?): Process? { var process: Process? = null try { val processBuilder = ProcessBuilder(*commandString) if (environment != null) { processBuilder.environment().putAll(environment) } process = processBuilder.start() } catch (t: Throwable) { Log.e("Exception while trying to run: " + commandString.contentToString(), t) } return process } } ================================================ FILE: ffmpeg/android-ffmpeg/src/main/java/nl/bravobit/ffmpeg/Util.kt ================================================ package nl.bravobit.ffmpeg import android.content.Context import android.content.pm.ApplicationInfo import android.os.AsyncTask import android.os.Handler import java.io.BufferedReader import java.io.IOException import java.io.InputStream import java.io.InputStreamReader internal object Util { @JvmStatic fun isDebug(context: Context): Boolean { return context.applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 } @JvmStatic fun convertInputStreamToString(inputStream: InputStream?): String? { try { val r = BufferedReader(InputStreamReader(inputStream)) var str: String? val sb = StringBuilder() while (r.readLine().also { str = it } != null) { sb.append(str) } return sb.toString() } catch (e: IOException) { Log.e("error converting input stream to string", e) } return null } @JvmStatic fun destroyProcess(process: Process?) { if (process != null) { try { process.destroy() } catch (e: Exception) { Log.e("progress destroy error", e) } } } @JvmStatic fun killAsync(asyncTask: AsyncTask<*, *, *>?): Boolean { return asyncTask != null && !asyncTask.isCancelled && asyncTask.cancel(true) } @JvmStatic fun isProcessCompleted(process: Process?): Boolean { try { if (process == null) return true process.exitValue() return true } catch (e: IllegalThreadStateException) { // do nothing } return false } fun observeOnce(predicate: ObservePredicate, run: Runnable, timeout: Int): FFbinaryObserver { val observer = Handler() val observeAction: FFbinaryObserver = object : FFbinaryObserver { private var canceled = false private var timeElapsed = 0 override fun run() { if (timeElapsed + 40 > timeout) cancel() timeElapsed += 40 if (canceled) return var readyToProceed = false readyToProceed = try { predicate.isReadyToProceed } catch (e: Exception) { Log.v("Observing " + e.message) observer.postDelayed(this, 40) return } if (readyToProceed) { Log.v("Observed") run.run() } else { Log.v("Observing") observer.postDelayed(this, 40) } } override fun cancel() { canceled = true } } observer.post(observeAction) return observeAction } interface ObservePredicate { val isReadyToProceed: Boolean } } ================================================ FILE: ffmpeg/copy-ffmpeg-executables.sh ================================================ #!/usr/bin/env bash # CD to script location cd "$(dirname "$0")" || echo "cd to $(dirname "$0") Failed" # Copy ffmpeg executables for all targets for target in arm64-v8a armeabi-v7a x86 x86_64 do mkdir -p ./android-ffmpeg/src/main/jniLibs/$target/ cp ./ffmpeg-android-maker/build/ffmpeg/$target/bin/ffmpeg ./android-ffmpeg/src/main/jniLibs/$target/lib..ffmpeg..so done ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists #distributionSha256Sum=7faa7198769f872826c8ef4f1450f839ec27f0b4d5d1e51bade63667cbccd205 distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # # * Copyright (c) 2021 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 . # # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -XX:+UseParallelGC # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official org.gradle.parallel=true org.gradle.caching=true kotlin.native.disableCompilerDaemon=true kotlin.mpp.stability.nowarn=true kotlin.mpp.enableGranularSourceSetsMetadata=true kotlin.native.enableDependencyPropagation=false xcodeproj=./spotiflyer-ios #kotlin.native.cacheKind=none #org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: infra/.terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/azurerm" { version = "2.52.0" hashes = [ "h1:bYwfAgIZFgbGVGYjnZ0OO+RumXn6UDNl2VmIm5gi8tI=", "zh:1ee2dd8215919001736ae27473844c80a04ebbd56ddd12eca7f45aab6cda2274", "zh:26791137ce5e7ea088caf75321aefb19f1ad5eb63dcc736342c99866a1b3af0e", "zh:4903fda8700381ae7b91dc0c1e2fbea6ab63f56f14a09f7ba73c914d3f9d02b3", "zh:5b6e49c9878d8586cbe38eae5188fb0a92319a5fdb33f51fe27ac50a7a8aa1f6", "zh:64d5707955e73655e6aefaa6f9abce2fedd7e068cbe71fcc84a676263f39ef3e", "zh:9646bef60395ceca137eea5bf87aa3a5b68a45e1018a6fa600a6d497a112b8ce", "zh:9e0e716e08c433974c1b48084117762f59e5323d5de62b10cde53dec6e0bd6ae", "zh:cc348e88922a82bd3ab6bab590735949f219fee9c021214861fed7c65546ec86", "zh:d09368d44ee2f759ba3427c391e21aed2dda50cc39f079dea3160e5aad2f0ab0", "zh:df88a810a6867d96d4452a0eb74e835e3c7c55522e53ee1d7a32af2e91e72abf", "zh:f8fee4ec974e31b8eeaeb95dd1d844e58fdd121dbd37e2130586f61ed9a83ac2", ] } ================================================ FILE: infra/main.tf ================================================ terraform { backend "artifactory" { // -backend-config="username=xxx@xxx.com" \ // -backend-config="password=xxxxxx" \ url = "https://spotiflyer.jfrog.io/artifactory" repo = "terraform-state" subpath = "SpotiFlyer" } } provider "azurerm" { features {} } resource "azurerm_resource_group" "main" { location = "westeurope" name = "SpotiFlyer" } resource "azurerm_application_insights" "main" { name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name application_type = "java" } resource "azurerm_app_service_plan" "main" { location = azurerm_resource_group.main.location name = azurerm_resource_group.main.name resource_group_name = azurerm_resource_group.main.name kind = "Linux" reserved = true sku { tier = "Free" size = "F1" } } resource "azurerm_app_service" "main" { resource_group_name = azurerm_app_service_plan.main.resource_group_name app_service_plan_id = azurerm_app_service_plan.main.id location = azurerm_app_service_plan.main.location name = azurerm_app_service_plan.main.name https_only = true site_config { use_32_bit_worker_process = true app_command_line = "" linux_fx_version = "DOCKER|${var.docker_image_tag}" http2_enabled = true cors { allowed_origins = ["*"] } } app_settings = { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_REGISTRY_SERVER_URL = var.docker_registry DOCKER_REGISTRY_SERVER_USERNAME = var.docker_registry_username DOCKER_REGISTRY_SERVER_PASSWORD = var.docker_registry_password AZURE_MONITOR_INSTRUMENTATION_KEY = azurerm_application_insights.main.instrumentation_key APPINSIGHTS_INSTRUMENTATIONKEY = azurerm_application_insights.main.instrumentation_key APPINSIGHTS_PROFILERFEATURE_VERSION = "1.0.0" WEBSITE_HTTPLOGGING_RETENTION_DAYS = "35" CORSANYWHERE_ALLOWLIST = var.cors_anywhere_allow_list CORSANYWHERE_RATELIMIT = var.cors_anywhere_rate_limit } } ================================================ FILE: infra/outputs.tf ================================================ output "app_service_name" { value = azurerm_app_service.main.name } output "app_service_default_hostname" { value = "https://${azurerm_app_service.main.default_site_hostname}" } ================================================ FILE: infra/variables.tf ================================================ variable "docker_registry" { type = string default = "https://docker.pkg.github.com" } variable "docker_registry_username" { type = string } variable "docker_registry_password" { type = string sensitive = true } variable "docker_image_tag" { type = string default = "docker.pkg.github.com/shabinder/cors-anywhere/server:latest" } variable "cors_anywhere_allow_list" { type = string default = "" } variable "cors_anywhere_rate_limit" { type = string default = "" } ================================================ FILE: maintenance-tasks/build.gradle.kts ================================================ plugins { kotlin("jvm") kotlin("plugin.serialization") id("ktlint-setup") application } group = "com.shabinder" version = "1.0" repositories { mavenCentral() } application { mainClass.set("MainKt") applicationName = "maintenance" } dependencies { with(deps) { implementation(slf4j.simple) implementation(bundles.ktor) implementation(ktor.client.apache) implementation(kotlinx.serialization.json) // testDep testImplementation(kotlin.kotlinTestJunit) } } tasks.test { useJUnit() } ================================================ FILE: maintenance-tasks/src/main/java/common/Common.kt ================================================ @file:Suppress("FunctionName") package common import io.ktor.client.HttpClient import io.ktor.client.features.HttpTimeout import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.logging.DEFAULT import io.ktor.client.features.logging.LogLevel import io.ktor.client.features.logging.Logger import io.ktor.client.features.logging.Logging import kotlinx.serialization.json.Json internal object Common { const val GITHUB_API = "https://api.github.com" fun START_SECTION(tagName: String = "HTI") = "" fun END_SECTION(tagName: String = "HTI") = "" const val USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:88.0) Gecko/20100101 Firefox/88.0" } internal val client = HttpClient { install(HttpTimeout) install(JsonFeature) { serializer = KotlinxSerializer( Json { ignoreUnknownKeys = true isLenient = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.INFO } } ================================================ FILE: maintenance-tasks/src/main/java/common/ContentUpdation.kt ================================================ package common /* * Helper Function to Replace Obsolete Content with new Updated Content * */ fun getUpdatedContent( oldContent: String, newInsertionText: String, tagName: String ): String { return getReplaceableRegex(tagName).replace( oldContent, getReplacementText(tagName, newInsertionText) ) } private fun getReplaceableRegex(tagName: String): Regex { return """${Common.START_SECTION(tagName)}(?s)(.*)${Common.END_SECTION(tagName)}""".toRegex() } private fun getReplacementText( tagName: String, newInsertionText: String ): String { return """ ${Common.START_SECTION(tagName)} $newInsertionText ${Common.END_SECTION(tagName)} """.trimIndent() } ================================================ FILE: maintenance-tasks/src/main/java/common/Date.kt ================================================ package common import java.util.* fun getTodayDate(): String { val c: Calendar = Calendar.getInstance() val monthName = arrayOf( "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ) val month = monthName[c.get(Calendar.MONTH)] val year: Int = c.get(Calendar.YEAR) val date: Int = c.get(Calendar.DATE) return " $date $month, $year" } ================================================ FILE: maintenance-tasks/src/main/java/common/GithubService.kt ================================================ package common import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.headers import io.ktor.client.request.put import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.util.InternalAPI import io.ktor.util.encodeBase64 import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import models.github.GithubFileContent import models.github.GithubReleasesInfo internal object GithubService { private const val baseURL = Common.GITHUB_API suspend fun getGithubRepoReleasesInfo( ownerName: String, repoName: String, ): GithubReleasesInfo { return client.get("$baseURL/repos/$ownerName/$repoName/releases") } suspend fun getGithubFileContent( secrets: Secrets, fileName: String = "README.md" ): GithubFileContent { return getGithubFileContent( token = secrets.githubToken, ownerName = secrets.ownerName, repoName = secrets.repoName, branchName = secrets.branchName, fileName = fileName ) } suspend fun getGithubFileContent( token: String, ownerName: String, repoName: String, branchName: String, fileName: String, ): GithubFileContent { val resp = client.get("$baseURL/repos/$ownerName/$repoName/contents/$fileName?ref=$branchName") { headers { header("Authorization", "token $token") } } // Get Raw Readme File val decodedString = client.get("https://raw.githubusercontent.com/$ownerName/$repoName/$branchName/$fileName") { headers { header("Authorization", "token $token") } } return GithubFileContent( decryptedContent = decodedString, sha = resp["sha"]?.jsonPrimitive.toString() .removeSurrounding("\"") ) } @OptIn(InternalAPI::class) suspend fun updateGithubFileContent( token: String, ownerName: String, repoName: String, branchName: String, fileName: String, commitMessage: String, rawContent: String, sha: String ): JsonObject { return client.put("$baseURL/repos/$ownerName/$repoName/contents/$fileName") { body = buildJsonObject { put("branch", branchName) put("message", commitMessage) put("content", rawContent.encodeBase64()) put("sha", sha) /*put("committer", buildJsonObject { put("name","Shabinder Singh") put("email","dev.shabinder@gmail.com") })*/ } headers { header("Authorization", "token $token") contentType(ContentType.Application.Json) } } } } ================================================ FILE: maintenance-tasks/src/main/java/common/HCTIService.kt ================================================ package common import io.ktor.client.request.header import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.userAgent import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import utils.HCTI_URL_RESPONSE_ERROR internal object HCTIService { private const val baseURL = "https://htmlcsstoimage.com/demo_run" /* * - When Using Either Viewport Width/Height(in Pixels) , Both are required * */ suspend fun getImageURLFromHtml( html: String, css: String = "", delayInMilliSeconds: Int = 250, viewPortHeight: String = "", viewPortWidth: String = "", deviceScale: Int = 2 ) = getImageURL( mode = "html", data = html, css = css, delayInMilliSeconds = delayInMilliSeconds, viewPortHeight = viewPortHeight, viewPortWidth = viewPortWidth, deviceScale = deviceScale ) suspend fun getImageURLFromURL( url: String, delayInMilliSeconds: Int = 250, viewPortHeight: String = "", viewPortWidth: String = "", deviceScale: Int = 2 ) = getImageURL( mode = "url", data = url, delayInMilliSeconds = delayInMilliSeconds, viewPortHeight = viewPortHeight, viewPortWidth = viewPortWidth, deviceScale = deviceScale ) private suspend fun getImageURL( mode: String, // html/url data: String, css: String = "", viewPortHeight: String = "", viewPortWidth: String = "", delayInMilliSeconds: Int = 250, deviceScale: Int = 2, ): String { val resp = client.post(baseURL) { body = buildJsonObject { put(mode, data) put("console_mode", "") put("css", css) put("selector", "") put("ms_delay", "$delayInMilliSeconds") put("render_when_ready", "") put("viewport_width", viewPortWidth) put("viewport_height", viewPortHeight) put("google_fonts", "") put("device_scale", "$deviceScale") } headers { contentType(ContentType.Application.Json) userAgent(Common.USER_AGENT) header("Referer", "https://htmlcsstoimage.com/demo") header("Origin", "https://htmlcsstoimage.com") header("Host", "htmlcsstoimage.com") } } val url = resp["url"] ?: throw HCTI_URL_RESPONSE_ERROR(response = resp.toString()) // bubble-up exceptions return url.jsonPrimitive.toString().removeSurrounding("\"") } } ================================================ FILE: maintenance-tasks/src/main/java/common/Secrets.kt ================================================ package common import utils.byOptionalProperty import utils.byProperty internal data class Secrets( val githubToken: String, val ownerName: String, val repoName: String, val branchName: String, val filePath: String, val imageDescription: String, val commitMessage: String, val tagName: String ) { companion object { fun initSecrets() = Secrets( githubToken = "GH_TOKEN".byProperty, ownerName = "OWNER_NAME".byProperty, repoName = "REPO_NAME".byProperty, branchName = "BRANCH_NAME".byOptionalProperty ?: "main", filePath = "FILE_PATH".byOptionalProperty ?: "README.md", imageDescription = "IMAGE_DESCRIPTION".byOptionalProperty ?: "IMAGE", commitMessage = "COMMIT_MESSAGE".byOptionalProperty ?: "HTML-TO-IMAGE Update", tagName = "TAG_NAME".byOptionalProperty ?: "HTI" // hctiKey = "HCTI_KEY".analytics_html_img.getByProperty ) } } ================================================ FILE: maintenance-tasks/src/main/java/main.kt ================================================ import common.GithubService import common.Secrets import kotlinx.coroutines.runBlocking import scripts.updateAnalyticsImage import scripts.updateDownloadCards import utils.debug fun main(args: Array) { debug("fun main: args -> ${args.joinToString(";")}") val secrets = Secrets.initSecrets() runBlocking { val githubFileContent = GithubService.getGithubFileContent( secrets = secrets, fileName = "README.md" ) // Content To be Processed var updatedGithubContent: String = githubFileContent.decryptedContent // TASK -> Update Analytics Image in Readme try { updatedGithubContent = updateAnalyticsImage( updatedGithubContent, secrets ) } catch (e: Exception) { debug("Analytics Image Updation Failed", e.message.toString()) } // TASK -> Update Total Downloads Card try { updatedGithubContent = updateDownloadCards( updatedGithubContent, secrets.copy(tagName = "DCI") ) } catch (e: Exception) { debug("Download Card Updation Failed", e.message.toString()) } // Write New Updated README.md GithubService.updateGithubFileContent( token = secrets.githubToken, ownerName = secrets.ownerName, repoName = secrets.repoName, branchName = secrets.branchName, fileName = secrets.filePath, commitMessage = secrets.commitMessage, rawContent = updatedGithubContent, sha = githubFileContent.sha ) } } ================================================ FILE: maintenance-tasks/src/main/java/models/github/Asset.kt ================================================ package models.github import kotlinx.serialization.Serializable @Serializable data class Asset( val browser_download_url: String, val content_type: String, val created_at: String, val download_count: Int, val id: Int, // val label: Any, val name: String, val node_id: String, val size: Int, val state: String, val updated_at: String, val uploader: Uploader, val url: String ) ================================================ FILE: maintenance-tasks/src/main/java/models/github/Author.kt ================================================ package models.github import kotlinx.serialization.Serializable @Serializable data class Author( val avatar_url: String, val events_url: String, val followers_url: String, val following_url: String, val gists_url: String, val gravatar_id: String, val html_url: String, val id: Int, val login: String, val node_id: String, val organizations_url: String, val received_events_url: String, val repos_url: String, val site_admin: Boolean, val starred_url: String, val subscriptions_url: String, val type: String, val url: String ) ================================================ FILE: maintenance-tasks/src/main/java/models/github/GithubFileContent.kt ================================================ package models.github import kotlinx.serialization.Serializable @Serializable data class GithubFileContent( val decryptedContent: String, val sha: String ) ================================================ FILE: maintenance-tasks/src/main/java/models/github/GithubReleaseInfoItem.kt ================================================ package models.github import kotlinx.serialization.Serializable @Serializable data class GithubReleaseInfoItem( val assets: List, val assets_url: String, val author: Author, val body: String, val created_at: String, val draft: Boolean, val html_url: String, val id: Int, val name: String, val node_id: String, val prerelease: Boolean, val published_at: String, val reactions: Reactions? = null, val tag_name: String, val tarball_url: String, val target_commitish: String, val upload_url: String, val url: String, val zipball_url: String ) ================================================ FILE: maintenance-tasks/src/main/java/models/github/GithubReleasesInfo.kt ================================================ package models.github typealias GithubReleasesInfo = ArrayList ================================================ FILE: maintenance-tasks/src/main/java/models/github/Reactions.kt ================================================ package models.github import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @Serializable data class Reactions( @JsonNames("+1") val upVotes: Int = 0, @JsonNames("-1") val downVotes: Int = 0, val confused: Int = 0, val eyes: Int = 0, val heart: Int = 0, val hooray: Int = 0, val laugh: Int = 0, val rocket: Int = 0, val total_count: Int = 0, val url: String? = null ) ================================================ FILE: maintenance-tasks/src/main/java/models/github/Uploader.kt ================================================ package models.github import kotlinx.serialization.Serializable @Serializable data class Uploader( val avatar_url: String, val events_url: String, val followers_url: String, val following_url: String, val gists_url: String, val gravatar_id: String, val html_url: String, val id: Int, val login: String, val node_id: String, val organizations_url: String, val received_events_url: String, val repos_url: String, val site_admin: Boolean, val starred_url: String, val subscriptions_url: String, val type: String, val url: String ) ================================================ FILE: maintenance-tasks/src/main/java/models/matomo/MatomoDownloads.kt ================================================ package models.matomo typealias MatomoDownloads = ArrayList ================================================ FILE: maintenance-tasks/src/main/java/models/matomo/MatomoDownloadsItem.kt ================================================ package models.matomo import kotlinx.serialization.Serializable @Serializable data class MatomoDownloadsItem( val idsubdatatable: Int = 0, val label: String = "com.shabinder.spotiflyer", val nb_hits: Int = 0, val nb_visits: Int = 0, val sum_time_spent: Int = 0 ) ================================================ FILE: maintenance-tasks/src/main/java/scripts/UpdateAnalyticsImage.kt ================================================ package scripts import common.* import io.ktor.client.features.* import io.ktor.client.request.* import io.ktor.client.statement.* import utils.RETRY_LIMIT_EXHAUSTED import utils.debug internal suspend fun updateAnalyticsImage( fileContent: String? = null, secrets: Secrets ): String { // debug("fun main: secrets -> $secrets") val oldContent = fileContent ?: GithubService.getGithubFileContent( secrets = secrets, fileName = "README.md" ).decryptedContent // debug("OLD FILE CONTENT",oldGithubFile) val imageURL = getAnalyticsImage().also { debug("Updated IMAGE", it) } return getUpdatedContent( oldContent, "![Today's Analytics]($imageURL)", secrets.tagName ) } internal suspend fun getAnalyticsImage(): String { var contentLength: Long var analyticsImage: String var retryCount = 5 do { /* * Get a new Image from Analytics, * - Use Any Random useless query param , * As HCTI Demo, `caches value for a specific Link` * */ val randomID = (1..100000).random() analyticsImage = HCTIService.getImageURLFromURL( url = "https://matomo.spotiflyer.ml/index.php?module=Widgetize&action=iframe&containerId=VisitOverviewWithGraph&disableLink=0&widget=1&moduleToWidgetize=CoreHome&actionToWidgetize=renderWidgetContainer&idSite=1&period=day&date=yesterday&disableLink=1&widget=$randomID", delayInMilliSeconds = 5000 ) // Sometimes we get incomplete image, hence verify `content-length` val req = client.head(analyticsImage) { timeout { socketTimeoutMillis = 100_000 } } contentLength = req.headers["Content-Length"]?.toLong() ?: 0 debug("Content Length for Analytics Image", contentLength.toString()) if (retryCount-- == 0) { // FAIL Gracefully throw(RETRY_LIMIT_EXHAUSTED()) } } while (contentLength <1_20_000) return analyticsImage } ================================================ FILE: maintenance-tasks/src/main/java/scripts/UpdateDownloadCards.kt ================================================ package scripts import common.* import io.ktor.client.features.* import io.ktor.client.request.* import io.ktor.client.statement.* import models.matomo.MatomoDownloads import utils.RETRY_LIMIT_EXHAUSTED import utils.debug internal suspend fun updateDownloadCards( fileContent: String? = null, secrets: Secrets ): String { val oldContent = fileContent ?: GithubService.getGithubFileContent( secrets = secrets, fileName = "README.md" ).decryptedContent var totalDownloads: Int = GithubService.getGithubRepoReleasesInfo( secrets.ownerName, secrets.repoName ).let { allReleases -> var totalCount = 0 for (release in allReleases) { release.assets.forEach { // debug("${it.name}: ${release.tag_name}" ,"Downloads: ${it.download_count}") totalCount += it.download_count } } debug("Total Download Count: $totalCount") return@let totalCount } // Add Matomo Downloads client.get("https://matomo.spotiflyer.ml/?module=API&method=Actions.getDownloads&idSite=1&period=year&date=today&format=JSON&token_auth=anonymous").forEach { totalDownloads += it.nb_hits } return getUpdatedContent( oldContent, """Total Downloads""", secrets.tagName ) } private suspend fun getDownloadCard( count: Int ): String { var contentLength: Long var downloadCard: String var retryCount = 5 do { downloadCard = HCTIService.getImageURLFromHtml( html = getDownloadCardHtml( count = count, date = getTodayDate() ), css = downloadCardCSS, viewPortHeight = "170", viewPortWidth = "385" ) // Sometimes we get incomplete image, hence verify `content-length` val req = client.head(downloadCard) { timeout { socketTimeoutMillis = 100_000 } } contentLength = req.headers["Content-Length"]?.toLong() ?: 0 // debug(contentLength.toString()) if (retryCount-- == 0) { // FAIL Gracefully throw(RETRY_LIMIT_EXHAUSTED()) } } while (contentLength <40_000) return downloadCard } fun getDownloadCardHtml( count: Int, date: String, // ex: 06 Jun 2021 ): String { return """

Total Downloads

Github & F-Droid

""".trimIndent() } val downloadCardCSS = """ @import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap'); * { margin: 0; padding: 0; } html, body { overflow: hidden; } .card-container { height: 150px; width: 360px; padding: 8px 12px; display: flex; transition: 0.3s; } #card { display: flex; align-self: center; width: fit-content; background: linear-gradient(120deg, #f0f0f0 20%, #f9f9f9 30%); border-radius: 22px; padding: 20px 40px; margin: 0 auto; box-shadow: 4px 8px 20px rgba(0, 0, 0, 0.06); transition: 0.3s; } #card:hover { box-shadow: none; cursor: pointer; transform: translateY(2px) } #card:hover > #profile-photo { opacity: 1 } #profile-photo { height: 90px; width: 90px; border-radius: 100px; align-self: center; box-shadow: 0 6px 30px rgba(199, 199, 199, 0.5); opacity: 0.8; transition: 0.3s; } .text-wrapper { font-family: 'Poppins', sans-serif; line-height: 0; align-self: center; margin-left: 20px; } .text-wrapper p { margin: 0; } .contact-wrapper a { display: block; white-space: nowrap; text-decoration: none; } #title { font-size: 20px; color: #5f5f5f; margin-bottom: 20px; } #source { font-size: 12px; color: #9B9B9B; margin-bottom: 22px; } #count { padding-top: 8px; font-size: 30px; color: #615F5F; margin-top: 15px; transition: 0.3s; } #date { padding-top: 12px; font-size: 14px; color: #615F5F; margin-top: 15px; transition: 0.3s; } #count:hover, #date:hover { color: #9B9B9B; } """.trimIndent() ================================================ FILE: maintenance-tasks/src/main/java/utils/Exceptions.kt ================================================ @file:Suppress("ClassName") package utils data class ENV_KEY_MISSING( val keyName: String, override val message: String? = "$keyName was not found, please check your ENV variables" ) : Exception(message) data class HCTI_URL_RESPONSE_ERROR( val response: String, override val message: String? = "Server Error, We Recieved this Resp: $response" ) : Exception(message) data class RETRY_LIMIT_EXHAUSTED( override val message: String? = "RETRY LIMIT EXHAUSTED!" ) : Exception(message) ================================================ FILE: maintenance-tasks/src/main/java/utils/Ext.kt ================================================ package utils val String.byProperty: String get() = System.getenv(this) ?: throw (ENV_KEY_MISSING(this)) val String.byOptionalProperty: String? get() = System.getenv(this) fun debug(message: String) = println("\n::debug::$message") fun debug(tag: String, message: String) = println("\n::debug::$tag:\n$message") ================================================ FILE: maintenance-tasks/src/main/java/utils/TestClass.kt ================================================ package utils import kotlinx.coroutines.runBlocking // Test Class- at development Time fun main(): Unit = runBlocking { } ================================================ FILE: scripts/build-ffmpeg.sh ================================================ #!/bin/bash ./../ffmpeg-kit/android.sh \ --lts \ --disable-everything \ --disable-network \ --disable-autodetect \ --enable-small \ --enable-decoder=aac*,ac3*,opus,vorbis \ --enable-demuxer=mov,m4v,matroska \ --enable-muxer=mp3,mp4 \ --enable-protocol=file \ --enable-encoder=mp3 \ --enable-filter=aresample \ --enable-gpl \ --enable-version3 \ --enable-cross-compile \ --enable-pic \ --enable-jni \ --enable-optimizations \ --enable-v4l2-m2m ================================================ FILE: settings.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ enableFeaturePreview("VERSION_CATALOGS") dependencyResolutionManagement { @Suppress("UnstableApiUsage") versionCatalogs { create("deps") { from(files("buildSrc/deps.versions.toml")) } } } rootProject.name = "spotiflyer" include( ":common:database", ":common:compose", ":common:root", ":common:main", ":common:list", ":common:preference", ":common:data-models", ":common:providers", ":common:core-components", ":common:dependency-injection", ":ffmpeg:android-ffmpeg", ":android", ":desktop", ":web-app", //":console-app", ":maintenance-tasks", ) ================================================ FILE: translations/Strings_cn.properties.xml ================================================ title = SpotiFlyer about = 关于 history = 历史记录 donate = 捐赠 preferences = 个性化 search = 搜索 supportedPlatforms = 支持的平台 supportDevelopment = 支持此项目 openProjectRepo = 打开项目回购 starOrForkProject = Github项目地址 help = 帮助 translate = 翻译 helpTranslateDescription = 帮助我们将此软件翻译为其他语言 supportDeveloper = 支持开发者 donateDescription = 如果你认为我的工作应该得到报酬,你可以在这里支持我。 share = 分享 shareDescription = 与您的朋友和家人分享这个应用程序。 whatWentWrong = 发生错误 copyToClipboard = 复制到剪切板 copyCodeInGithubIssue = 在创建 Github 问题,或报告此问题以获得更好的帮助 时,粘贴以下代码。 status = 状态 analytics = 分析 analyticsDescription = 您的数据是匿名的,永远不会与第 3 方服务共享。 noHistoryAvailable = 无历史记录 cleaningAndExiting = 正在清理并退出 total = 统计 completed = 已完成 failed = 失败 exit = 退出 downloading = 正在下载 processing = 处理中 queued = 已加入下载队列 setDownloadDirectory = 设置下载目录 downloadDirectorySetTo = 下载目录设置为: {0} noWriteAccess = 无访问权限: {0} , 正在恢复到上一个 shareMessage = 嘿,看看这个给力的音乐下载器 http://github.com/Shabinder/SpotiFlyer grantAnalytics = 赠款分析 noInternetConnection = 无网络连接! checkInternetConnection = 请检查网络连接。 grantPermissions = 授予权限 requiredPermissions = 需要以下权限: storagePermission = 储存权限. storagePermissionReason = 将您喜爱的歌曲下载到此设备。 backgroundRunning = 正在后台运行。 backgroundRunningReason = 在没有任何系统中断的情况下在后台下载所有歌曲。 no = 无 yes = 有 acraNotificationTitle = SpotiFlyer 崩溃了。 acraNotificationText = 请将崩溃报告发送给应用程序开发人员,因此这种不幸的事件可能不会再次发生。 albumArt = 专辑封面 tracks = 曲目 coverImage = 封面 reSearch = 重新搜索 loading = 加载中 downloadAll = 全部下载 button = 按钮 errorOccurred = 发生错误,请检查您的链接/网络连接。 downloadDone = 下载完成 downloadError = 发生错误! 无法下载此曲目。 downloadStart = 开始下载 supportUs = 我们需要你的帮助! donation = 捐赠 worldWideDonations = 全球捐赠 indianDonations = 印度捐赠 dismiss = 忽略 remindLater = 稍后提醒 mp3ConverterBusy = MP3 转换器无法访问,可能很忙! unknownError = 未知错误 noMatchFound = 未找到匹配项! noLinkFound = 未找到链接! linkNotValid = 链接不可用! featureUnImplemented = 功能尚未实现。 minute = 分 second = 秒 spotiflyerLogo = SpotiFlyer 图标 backButton = 后退键 infoTab = 详情 historyTab = 历史 linkTextBox = 链接 pasteLinkHere = 在这里粘贴链接 enterALink = 请输入链接! madeWith = 用 love = 喜欢 inIndia = 在印度 open = 打开 byDeveloperName = 开发者: Shabinder Singh ================================================ FILE: translations/Strings_cro.properties ================================================ title = SpotiFlyer about = O aplikaciji history = Povijest donate = Doniraj preferences = Izbori search = Pretraži supportedPlatforms = Podržane platforme supportDevelopment = Podržani razvoj openProjectRepo = Otvori projekt repo starOrForkProject = Označi / Uredi projekt na platformi Github. help = Pomoć translate = Prevedi helpTranslateDescription = Pomozite nam prevesti ovu aplikaciju na vaš jezik. supportDeveloper = Podržite razvojnog programera. donateDescription = Ako mislite da zaslužujem biti plaćen za svoj rad, možete me podržati ovdje. share = Podijeli shareDescription = Podijeli ovu aplikaciju sa obitelji i prijateljima. whatWentWrong = Nešto nije u redu... copyToClipboard = Kopiraj u međuspremnik copyCodeInGithubIssue = Kopiraj Zalijepi Kod Ispod kada stvarate problem na Github / Prijavite ovaj problem za bolju pomoć. status = Status analytics = Analitike analyticsDescription = Vaši podatci su anonimni i nikad se ne dijele sa trećim aplikacijama. noHistoryAvailable = Nema povijesti cleaningAndExiting = Čistim i izlazim total = Ukupno completed = Dovršeno failed = Neuspjelo exit = Izlaz downloading = Preuzimam processing = U postupku queued = Čeka se setDownloadDirectory = Postavi mjesto preuzimanja downloadDirectorySetTo = Mjesto preuzimanja postavljeno u: {0} noWriteAccess = NEMA PRISTUP za: {0} , Vraćam na prethodno shareMessage = Hej, pogledaj ovu odličnu aplikaciju za preuzimanje glazbe http://github.com/Shabinder/SpotiFlyer grantAnalytics = Dozvoli analitike noInternetConnection = Nema internetske veze! checkInternetConnection = Molimo provjerite svoju vezu s internetom. grantPermissions = Dozvoli dopuštenja requiredPermissions = Potrebna dopuštenja: storagePermission = Dopuštenje za pohranu. storagePermissionReason = Za preuzeti svoje najdraže pjesme na vaš uređaj. backgroundRunning = Radi u pozadini. backgroundRunningReason = Za preuzeti sve pjesme u pozadini bez sistemskog ometanja. no = Ne yes = Naravno acraNotificationTitle = UPS, SpotiFlyer se srušio acraNotificationText = Molim vas pošaljite iskaz o rušenju Programerima Aplikacije, Tako da se ovaj događaj ne ponovi. albumArt = Albumska umjetnsot tracks = Naslovi coverImage = Naslovna slika reSearch = Ponovno pretraži loading = Učitavam downloadAll = Preuzmi sve button = Gumb errorOccurred = Dogodila se greška, Provjerite svoj link / Vezu s internetom downloadDone = Preuzimanje završeno downloadError = Greška! Ne mogu preuzeti ovaj naslov downloadStart = Pokreni preuzimanje supportUs = Trebamo Vašu Podršku! donation = Donacija worldWideDonations = Donacije diljem svijeta indianDonations = Donacije samo iz Indije dismiss = Odbaci remindLater = Podsjeti me kasnije mp3ConverterBusy = MP3 Pretvarač se ne može dohvatiti, najvjerovatnije je ZAUZET ! unknownError = Nepoznata Greška noMatchFound = NIJE nađena podudaranost! noLinkFound = NIJE nađen link za preuzimanje linkNotValid = Uneseni link NE vrijedi! featureUnImplemented = Značajka još nije uvedena. minute = minuta second = sekundi/a spotiflyerLogo = SpotiFlyer Logotip backButton = Vrati se natrag gumb infoTab = Tabulator informacija historyTab = Tabulator povijesti linkTextBox = Prostor Za Link pasteLinkHere = Zalijepite link ovdje... enterALink = Unesite Link! madeWith = Napravljeno s love = Ljubavlju inIndia = iz Indije open = Otvorite byDeveloperName = kreirao: Shabinder Singh ================================================ FILE: translations/Strings_cs.properties ================================================ title = SpotiFlyer about = Informace history = Historie donate = Přispět preferences = Předvolby search = Hledat supportedPlatforms = Podporované platformy supportDevelopment = Podpoř rozvoj openProjectRepo = Otevřít Github repo projektu starOrForkProject = Ohvězdičkuj / Forkni si projekt na Githubu. help = Podpora translate = Přeložit helpTranslateDescription = Pomož nám přeložit tuto aplikaci do tvého jazyku. supportDeveloper = Podpořit vývojáře donateDescription = Pokud si myslíš, že si zasloužím za svou práci dostat zaplaceno, můžeš mě podpořit zde. share = Sdílet shareDescription = Sdílej tento projekt se svou rodinou a kamarády. whatWentWrong = Co se pokazilo... copyToClipboard = Zkopírovat do schránky copyCodeInGithubIssue = Zkopířuj a vlož tento kód při vytváření Github problému / nahlašování chyby pro lepší pomoc. status = Status analytics = Analytika analyticsDescription = Tvé data jsou anonymní a nejsou nikdy sdílena se službami třetích stran. noHistoryAvailable = Žádná dostupná historie cleaningAndExiting = Čištění a uzavírání total = Dohromady completed = Dokončeno failed = Nezdařeno exit = Ukončit downloading = Stahování processing = Zpracovávání queued = Ve frontě setDownloadDirectory = Nastavit adresář pro stažené skladby downloadDirectorySetTo = Adresář stažených souborů nastaven na: {0} noWriteAccess = BEZ PŘÍSTUPU K ZÁPISU v: {0} , Navrácení k předchozímu shareMessage = Hej, podívej se na tento dokonalý stahovač hudby http://github.com/Shabinder/SpotiFlyer grantAnalytics = Povolit analytika noInternetConnection = Bez internetového připojení! checkInternetConnection = Zkontroluj si prosím připojení k síti. grantPermissions = Udělit oprávnění requiredPermissions = Požadovaná oprávnění: storagePermission = Oprávnění úložiště. storagePermissionReason = Ke stažení tvých oblíbených skladeb do tohoto zařízení. backgroundRunning = Běží na pozadí. backgroundRunningReason = Jestli chcete stáhnout všechny skladby na pozadí, bez přerušení systémem. no = Ne yes = Ano acraNotificationTitle = JEJDA, SpotiFlyer spadl acraNotificationText = Prosím pošli hlášení o selhání vyvojářům aplikace, Aby se tato nešťastná událost nemusela opakovat. albumArt = Obal alba tracks = Skladby coverImage = Titulní obrázek reSearch = Hledat znova loading = Načítání downloadAll = Stáhnout vše button = Talčítko errorOccurred = Došlo k chybě, zkontroluj odkaz skladby / internetové připojení downloadDone = Stahování dokončeno downloadError = Chyba! Skladbu se nepodařilo stáhnout downloadStart = Zahájit stažení supportUs = Potřebujeme tvoji podporu! donation = Příspěvek worldWideDonations = Celosvětových příspěvků indianDonations = Pouze příspěvky z Indie dismiss = Zavřít remindLater = Připomenout později mp3ConverterBusy = MP3 Konvertor je nedostupný, pravděpodobně ZANEPRÁZDNĚNÝ! unknownError = Neznámá chyba noMatchFound = Žádná shoda nenalezena! noLinkFound = Nebyl nalezen žádný odkaz ke stažení linkNotValid = Zadaný odkaz není platný! featureUnImplemented = Funkce dosud nebyla zahrnuta. minute = min second = sek spotiflyerLogo = SpotiFlyer Logo backButton = Tlačítko zpět infoTab = Záložka info historyTab = Záložka historie linkTextBox = Textové pole odkazu pasteLinkHere = Sem vlož odkaz... enterALink = Zadej odkaz! madeWith = Vytvořeno s love = Láskou inIndia = v Indii open = Otevřeno byDeveloperName = od: Shabinder Singh ================================================ FILE: translations/Strings_de.properties ================================================ title = SpotiFlyer about = Über history = Verlauf donate = Spenden preferences = Einstellungen search = Suche supportedPlatforms = Unterstützte Plattformen supportDevelopment = Entwicklung unterstützen openProjectRepo = Projekt Repository öffnen starOrForkProject = Gib einen Stern oder "Forke" das Projekt auf Github. help = Hilfe translate = Übersetzen helpTranslateDescription = Hilf uns, SpotiFlyer in deine Sprache zu übersetzen. supportDeveloper = Entwickler unterstützen donateDescription = Wenn du denkst, dass ich für meine Arbeit eine kleine Spende verdiene, kannst du mich hier unterstützen. share = Teilen shareDescription = Teile diese App mit deinen Freunden oder deiner Familie. whatWentWrong = Etwas ist schief gelaufen. copyToClipboard = In die Zwischenablage kopieren copyCodeInGithubIssue = Kopiere die untenstehende Fehlermeldung und füge sie in einem Github Issue ein. status = Status analytics = Analyse analyticsDescription = Deine Daten sind anonym und werden nicht mit Dritten geteilt. noHistoryAvailable = Kein Verlauf verfügbar cleaningAndExiting = Leeren und Verlassen total = Insgesamt completed = Fertig failed = Fehlgeschlagen exit = Verlassen downloading = Herunterladen processing = In Bearbeitung queued = In der Warteschlange setDownloadDirectory = Downloadverzeichnis einstellen downloadDirectorySetTo = Downloadvezeichnis zu {0} geändert noWriteAccess = KEIN SCHREIBZUGRIFF auf: {0} , SpotiFlyer kehrt auf vorheriges Verzeichnis zurück shareMessage = Hey, schau dir mal diesen hervorragenden Musik-Downloader an: http://github.com/Shabinder/SpotiFlyer grantAnalytics = Analyse gewähren noInternetConnection = Keine Internetverbindung! checkInternetConnection = Bitte überprüfe deine Internetverbindung. grantPermissions = Berechtigungen erteilen requiredPermissions = Notwendige Berechtigungen: storagePermission = Speichererlaubnis storagePermissionReason = um deine Lieblingssongs auf diesem Gerät zu speichern. backgroundRunning = Ausführung im Hintergrund. backgroundRunningReason = Um alle Titel im Hintergrund ohne Systemunterbrechung herunterladen zu können. no = Nein yes = Ja acraNotificationTitle = UUPS, SpotiFlyer ist abgestürzt, :-( acraNotificationText = Bitte Senden Sie einen Absturzbericht an den App-Entwickler, sodass es in Zukunft nicht mehr passiert. albumArt = Album Art tracks = Tracks coverImage = Cover Bild reSearch = Erneut suchen loading = Laden downloadAll = Alle herunterladen button = Taste errorOccurred = Ein Fehler ist aufgetreten, bitte überprüfe den Link / deine Verbindung downloadDone = Download abgeschlossen downloadError = Fehler! Dieser Track kann nicht heruntergeladen werden downloadStart = Herunterladen starten supportUs = Wir brauchen deine Unterstützung! donation = Spenden worldWideDonations = Weltweite Spenden indianDonations = Nur indische Spenden dismiss = Ablehnen remindLater = Später erinnern mp3ConverterBusy = MP3 Konverter nicht erreichbar. Versuche es später erneut! unknownError = Unbekannter Fehler noMatchFound = KEINE Übereinstimmungen gefunden! noLinkFound = Kein herunterladbarer Link gefunden! linkNotValid = Eingegebener Link ist nicht gültig! featureUnImplemented = Funktion ist noch nicht implementiert. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Zurück Taste infoTab = Info Tab historyTab = Verlauf Tab linkTextBox = Link-Textfeld pasteLinkHere = Füge hier einen Link ein... enterALink = Gib einen Link ein! madeWith = Gemacht mit love = Liebe inIndia = in Indien open = geöffnet byDeveloperName = von: Shabinder Singh ================================================ FILE: translations/Strings_en.properties ================================================ title = SpotiFlyer about = About history = History donate = Donate preferences = Preferences search = Search supportedPlatforms = Supported platforms supportDevelopment = Support development openProjectRepo = Open Project Repo starOrForkProject = Star / Fork the project on Github. help = Help translate = Translate helpTranslateDescription = Help us translate this app in your local language. supportDeveloper = Support developer donateDescription = If you think I deserve to get paid for my work, you can support me here. share = Share shareDescription = Share this app with your friends and family. whatWentWrong = What went wrong... copyToClipboard = Copy to clipboard copyCodeInGithubIssue = Copy and paste the code below when creating a GitHub issue / Reporting this issue for better help. status = Status analytics = Analytics analyticsDescription = Your data is anonymized and never shared with 3rd party services. noHistoryAvailable = No history available cleaningAndExiting = Cleaning and exiting total = Total completed = Completed failed = Failed exit = Exit downloading = Downloading processing = Processing queued = Queued setDownloadDirectory = Set download directory downloadDirectorySetTo = Download directory set to: {0} noWriteAccess = NO WRITE ACCESS on: {0} , Reverting back to previous shareMessage = Hey, checkout this excellent music downloader http://github.com/Shabinder/SpotiFlyer grantAnalytics = Allow analytics noInternetConnection = No internet connection! checkInternetConnection = Please check your network connection. grantPermissions = Grant permissions requiredPermissions = Required permissions: storagePermission = Storage permissions. spotifyCreds = Spotify credentials. clientID = Client ID clientSecret = Client Secret defaultString = Default userSet = UserSet save = Save reset = Reset requestAppRestart = You need to restart the app for changes to take effect storagePermissionReason = To download your favourite songs to this device. backgroundRunning = Background running. backgroundRunningReason = To download all songs in background without any system interruptions. no = Nope yes = Sure acraNotificationTitle = OOPS, SpotiFlyer crashed acraNotificationText = Please send crash report to app developers, So this unfortunate event may not happen again. albumArt = Album art tracks = Tracks coverImage = Cover image reSearch = Search again loading = Loading downloadAll = Download all button = Button errorOccurred = An error Occurred, Check your Link / Connection downloadDone = Download done downloadError = Error! Cant download this track downloadStart = Start download supportUs = We need your support! donation = Donation worldWideDonations = World-wide donations indianDonations = Indian donations only dismiss = Dismiss remindLater = Remind me later mp3ConverterBusy = MP3 Converter unreachable, probably BUSY ! unknownError = Unknown error noMatchFound = No match found! noLinkFound = No downloadable link found linkNotValid = Entered link is not valid! featureUnImplemented = Feature not yet implemented. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Back button infoTab = Info tab historyTab = History tab linkTextBox = Link text box pasteLinkHere = Paste link here... enterALink = Enter a link! madeWith = Made with love = Love inIndia = in India open = Open byDeveloperName = by: Shabinder Singh ================================================ FILE: translations/Strings_es.properties ================================================ title = SpotiFlyer about = Acerca de history = Historial donate = Donar preferences = Preferencias search = Buscar supportedPlatforms = Plataformas Soportadas supportDevelopment = Apoya al Desarrollo openProjectRepo = Abrir repositorio del proyecto starOrForkProject = Da estrella / Crea una rama en Github. help = Ayuda translate = Traduce helpTranslateDescription = Ayudanos a traducir esta aplicación en tu idioma. supportDeveloper = Apoya al desarollador donateDescription = Si crees que merezco una paga por mi trabajo, aquí puedes apoyarme. share = Comparte shareDescription = Comparte esta aplicación con tus familiares y amigos. whatWentWrong = Qué salió mal... copyToClipboard = Copiar al portapapeles copyCodeInGithubIssue = Copie Pegar debajo del código mientras crea Github Issue / Reportando este problema para obtener una mejor ayuda. status = Estatus analytics = Analiticos analyticsDescription = Tus datos son anonimizados y nunca se compartiran con servicios de terceros. noHistoryAvailable = Sin historial disponible cleaningAndExiting = Limpiando y Saliendo total = Total completed = Completedo failed = Falló exit = Salir downloading = Descargando processing = Procesando queued = En Cola setDownloadDirectory = Elige Directorio de Descargas downloadDirectorySetTo = Directorio de descargas configurado a: {0} noWriteAccess = SIN ACCESO DE ESCRITURA en: {0} , Devolviendo al anterior shareMessage = Hey, checa esta excelente aplicación para descargar música http://github.com/Shabinder/SpotiFlyer grantAnalytics = Obtener Analiticos noInternetConnection = ¡No hay conexión a internet! checkInternetConnection = Por favor revisa tu conexión. grantPermissions = Obtener Permisos requiredPermissions = Permisos Requeridos: storagePermission = Permisos de Almacenamiento. storagePermissionReason = Para descargar tus canciones favoritas en este disositivo. backgroundRunning = Corriendo en segundo plano. backgroundRunningReason = Para descargar todas las canciones en segundo plano, sin Interrupciones al Sistema. no = No yes = Seguro acraNotificationTitle = UPS, SpotiFlyer Falló acraNotificationText = Por favor envia un reporte de fallas a los desarolladores, Asi este desafortunado evento no volvera a pasar. albumArt = Portada tracks = Pistas coverImage = Cubierta reSearch = Volver a buscar loading = Cargando downloadAll = Descargar Todo button = Botón errorOccurred = Ocurrió un error, Revisa tu Enlace / Conexión downloadDone = Descarga Completada downloadError = ¡Error! No puedo descargar esta Pista downloadStart = Iniciar Descarga supportUs = ¡Necesitamos tu apoyo! donation = Donaciones worldWideDonations = Donaciones Insternacionales indianDonations = Indian Donations Only dismiss = Cerrar remindLater = Recuerdame despúes mp3ConverterBusy = ¡Convertidos MP3 no disponible, probablemente OCUPADO! unknownError = Error desconocido noMatchFound = ¡Sin resultados! noLinkFound = No hay enlace de descarga linkNotValid = ¡El enlace introducido NO es valido! featureUnImplemented = Función no implementada aún. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Botón Atras infoTab = Pestaña de información historyTab = Perstaña de historial linkTextBox = Campo de enlace pasteLinkHere = Pega tu enlace aquí enterALink = ¡Coloca un enlace! madeWith = Hecho con love = Amor inIndia = en India open = Abrir byDeveloperName = por: Shabinder Singh ================================================ FILE: translations/Strings_fa.properties ================================================ title = SpotiFlyer about = درباره history = تاریخچه donate = اهدا preferences = تنظیمات search = جست‌وجو supportedPlatforms = پلتفرم‌های پشتیبانی شده supportDevelopment = حمایت از توسعه openProjectRepo = بازکردن ریپازیتوری پروژه starOrForkProject = ستاره دادن / فورک کردن پروژه در گیت‌هاب help = راهنما translate = ترجمه helpTranslateDescription = به ما در ترجمه این نرم‌افزار به زبان محلی خود کمک کنید. supportDeveloper = حمایت از توسعه‌دهنده donateDescription = اگر فکر می‌کنید که شایسته دریافت هزینه برای این کار خود هستم، می‌توانید اینجا از من حمایت کنید. share = به‌اشتراک گذاری shareDescription = این نرم‌افزار را با دوستان و خانواده خود به اشتراک بگذارید. whatWentWrong = آنچه اشتباه پیش رفت... copyToClipboard = کپی در کلیپ‌بورد copyCodeInGithubIssue = کد زیر را هنگام ساخت مشکلات / گزارش مشکلات در گیت‌هاب برای کمک بهتر کپی و پیست کنید. status = وضعیت analytics = تحلیل‌ها analyticsDescription = داده‌های شما ناشناس شده و هرگز با سرویس‌های شخص ثالث به اشتراک گذاشته نمی‌شود. noHistoryAvailable = تاریخچه‌ای موجود نیست cleaningAndExiting = پاک‌سازی و خارج شدن total = همه completed = کامل شد failed = شکست خورد exit = خروج downloading = در حال دانلود processing = در حال پردازش queued = در صف setDownloadDirectory = تنظیم پوشه دانلود downloadDirectorySetTo = تنظیم پوشه دانلود: {0} noWriteAccess = دسترسی نوشتن در: {0} وجود ندارد, بازگردانی به قبلی shareMessage = سلام. این نرم‌افزار دانلود موزیک معرکه رو امتحان کن http://github.com/Shabinder/SpotiFlyer grantAnalytics = دادن تحلیل‌ها noInternetConnection = اتصال اینرنت موجود نیست! checkInternetConnection = لطفا اتصال شبکه خود را چک کنید grantPermissions = دادن دسترسی‌ها requiredPermissions = دسترسی‌های لازم: storagePermission = دسترسی حافظه storagePermissionReason = برای دانلود آهنگ‌های مورد علاقه خود در دستگاه backgroundRunning = اجرا در پس‌زمینه backgroundRunningReason = برای دانلود تمامی آهنگ‌ها در پس‌زمینه بدون دخالت سیستم. no = نه yes = بله acraNotificationTitle = اوه , اسپاتیفلایر متوقف شد acraNotificationText = لطفا مشکل را به سازندگان برنامه گزارش کنید , تا در صورت امکان از رخ دادن این اتفاق جلوگیری شود. albumArt = تصویر آلبوم tracks = ترک‌ها coverImage = تصویر کاور reSearch = جست‌وجوی مجدد loading = درحال بارگذاری downloadAll = دانلود همه button = دکمه errorOccurred = خطایی رخ داد، لینک / اتصال اینرنت خود را چک کنید downloadDone = دانلود انجام شد downloadError = خطا! نمی‌توان این ترک را دانلود کرد downloadStart = آغاز دانلود supportUs = ما به حمایت شما نیازمندیم! donation = اهدا worldWideDonations = اهداهای سراسر جهان indianDonations = فقط اهداهای هند dismiss = رد کردن remindLater = یادآوری mp3ConverterBusy = مبدل فایل ام پی تری در دسترس نیست , احتمالا مشغول است unknownError = خطای ناشناخته noMatchFound = موردی یافت نشد! noLinkFound = لینک قابل دانلودی پیدا نشد linkNotValid = لینک وارد شده معتبر نیست! featureUnImplemented = قابلیت هنوز ایجاد نشده است. minute = دقیقه second = ثانیه spotiflyerLogo = لگو SpotiFlyer backButton = دکمه برگشت infoTab = تب اطلاعات historyTab = تب تاریخچه linkTextBox = جعبه متن لینک pasteLinkHere = لینک را اینجا درج کنید... enterALink = یک لینک وارد کنید! madeWith = ساخته شده با love = عشق inIndia = در هند open = باز کردن byDeveloperName = توسط : Shabinder Singh ================================================ FILE: translations/Strings_fr.properties ================================================ title = SpotiFlyer about = À propos history = Historique donate = Faire un don preferences = Préférences search = Rechercher supportedPlatforms = Platformes Supportées supportDevelopment = Soutenir le Développement openProjectRepo = Ouvrir le Dépot du Project starOrForkProject = Donnez une étoile / Dupliquez le projet sur Github. help = Aide translate = Traduire helpTranslateDescription = Aidez-nous à traduire cette application dans votre langue locale. supportDeveloper = Soutenir le Développeur donateDescription = Si vous pensez que je mérite d'être payé pour mon travail, vous pouvez me soutenir ici. share = Partager shareDescription = Partagez cette application avec vos amis et votre famille. whatWentWrong = Qu'est ce qui ne s'est pas bien passé... copyToClipboard = Copier dans le presse-papier copyCodeInGithubIssue = Copiez et collez le code ci-dessous lors de la création d'un problème Github / Signalement de ce problème pour une meilleure aide. status = Statut analytics = Analyses analyticsDescription = Vos données sont anonymes et ne sont jamais partagées avec des services tiers. noHistoryAvailable = Auncun historique disponible cleaningAndExiting = Nettoyer et Quitter total = Total completed = Terminé failed = Échoué exit = Quitter downloading = Téléchargement processing = En cours de traitement queued = En file d'attente setDownloadDirectory = Définir le répertoire de téléchargement downloadDirectorySetTo = Répertoire de téléchargement défini sur : {0} noWriteAccess = PAS D'ACCÈS A L'ÉCRITURE sur : {0} , Retour à la page précédente shareMessage = Hé, regarde cet excellent téléchargeur de musique http://github.com/Shabinder/SpotiFlyer grantAnalytics = Accorder les Analyses noInternetConnection = Aucune connexion à Internet ! checkInternetConnection = Veuillez vérifier votre connexion à Internet. grantPermissions = Accorder les autorisations requiredPermissions = Autorisations requises : storagePermission = Autorisation du stockage. storagePermissionReason = Pour télécharger vos chansons préférées sur cet appareil. backgroundRunning = Exécution en arrière-plan backgroundRunningReason = Pour télécharger toutes les chansons en arrière-plan sans aucune interruption du système. no = Non yes = Oui acraNotificationTitle = Oups, SpotiFlyer s'est arrêté acraNotificationText = Veuillez envoyer un rapport de crash aux développeurs de l'application, afin que cet événement malheureux ne se reproduise plus. albumArt = Pochette d'album tracks = Titres coverImage = Image de couverture reSearch = Recherche à nouveau loading = Chargement downloadAll = Tout Télécharger button = Bouton errorOccurred = Une erreur s'est produite, vérifiez votre connexion. downloadDone = Téléchargement terminé downloadError = Erreur ! Impossible de télécharger ce titre downloadStart = Démarrer le téléchargement supportUs = Nous avons besoin de votre soutien ! donation = Dons worldWideDonations = Dons dans le monde entier indianDonations = Donations indiennes dismiss = Décliner remindLater = Me rappeler plus tard mp3ConverterBusy = Convertisseur MP3 inaccessible, probablement occupé ! unknownError = Erreur inconnue noMatchFound = Aucun résultat trouvé ! noLinkFound = Aucun lien téléchargeable trouvé linkNotValid = Le lien saisi n'est PAS valide ! featureUnImplemented = Fonctionnalité pas encore implémentée. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Bouton retour infoTab = Onglet Info historyTab = Onglet Historique linkTextBox = Zone de texte du lien pasteLinkHere = Collez le lien ici... enterALink = Entrez un lien ! madeWith = Fait avec love = amour inIndia = en Inde open = Ouvrir byDeveloperName = par : Shabinder Singh ================================================ FILE: translations/Strings_hi.properties ================================================ title = स्पॉटीफ्लायर about = के बारे में history = इतिहास donate = दान करना preferences = पसंद search = खोज supportedPlatforms = समर्थित प्लेटफार्म supportDevelopment = समर्थन विकास openProjectRepo = ओपन प्रोजेक्ट रेपो starOrForkProject = जीथब पर प्रोजेक्ट को स्टार / फोर्क करें। help = मदद translate = अनुवाद करना helpTranslateDescription = इस ऐप को अपनी स्थानीय भाषा में अनुवाद करने में हमारी सहायता करें। supportDeveloper = समर्थन डेवलपर donateDescription = अगर आपको लगता है कि मैं अपने काम के लिए भुगतान पाने का हकदार हूं, तो आप यहां मेरा समर्थन कर सकते हैं। share = साझा करना shareDescription = इस ऐप को अपने दोस्तों और परिवार के साथ साझा करें। whatWentWrong = क्या गलत हुआ... copyToClipboard = क्लिपबोर्ड पर कॉपी करें copyCodeInGithubIssue = बेहतर मदद के लिए जीथब इश्यू बनाते / इस मुद्दे की रिपोर्ट करते समय कोड के नीचे कॉपी पेस्ट करें। status = स्थिति analytics = एनालिटिक्स analyticsDescription = आपका डेटा अनाम है और इसे कभी भी तृतीय पक्ष सेवा के साथ साझा नहीं किया जाता है। noHistoryAvailable = कोई इतिहास उपलब्ध नहीं cleaningAndExiting = सफाई और निकास total = कुल completed = पूरा हुआ failed = अनुत्तीर्ण होना exit = बाहर जाएं downloading = डाउनलोड processing = प्रसंस्करण queued = कतारबद्ध setDownloadDirectory = डाउनलोड निर्देशिका सेट करें downloadDirectorySetTo = डाउनलोड निर्देशिका इस पर सेट करें: {0} noWriteAccess = इस पर कोई लेखन पहुंच नहीं: {0} , पिछले पर वापस जाना shareMessage = अरे, इस उत्कृष्ट संगीत डाउनलोडर को चेकआउट करें http://github.com/Shabinder/SpotiFlyer grantAnalytics = अनुदान विश्लेषिकी noInternetConnection = कोई इंटरनेट कनेक्शन नहीं! checkInternetConnection = कृपया अपने नेटवर्क कनेक्शन की जाँच करें। grantPermissions = अनुदान अनुमतियाँ requiredPermissions = आवश्यक अनुमतियाँ: storagePermission = भंडारण अनुमति। storagePermissionReason = इस डिवाइस पर अपने पसंदीदा गाने डाउनलोड करने के लिए। backgroundRunning = पृष्ठभूमि चल रहा है। backgroundRunningReason = बिना किसी सिस्टम रुकावट के बैकग्राउंड में सभी गाने डाउनलोड करने के लिए। no = नहीं yes = ज़रूर acraNotificationTitle = ओओपीएस, स्पॉटीफ्लायर क्रैश हो गया acraNotificationText = कृपया ऐप डेवलपर्स को क्रैश रिपोर्ट भेजें, ताकि यह दुर्भाग्यपूर्ण घटना दोबारा न हो। albumArt = एलबम कला tracks = पटरियों coverImage = कवर छवि reSearch = अनुसंधान loading = लोड हो रहा है downloadAll = सभी डाउनलोड button = बटन errorOccurred = एक त्रुटि हुई, अपना लिंक / कनेक्शन जांचें downloadDone = डाउनलोड हो गया downloadError = त्रुटि! इस ट्रैक को डाउनलोड नहीं कर सकते downloadStart = डाउनलोड शुरू करें supportUs = हमें आपका समर्थन चाहिए! donation = दान worldWideDonations = वर्ल्ड वाइड डोनेशन indianDonations = केवल भारतीय दान dismiss = खारिज remindLater = बाद में याद दिलाना mp3ConverterBusy = MP3 कन्वर्टर पहुंच योग्य नहीं है, शायद व्यस्त! unknownError = अज्ञात त्रुटि noMatchFound = कोई मेल नहीं मिला! noLinkFound = कोई डाउनलोड करने योग्य लिंक नहीं मिला linkNotValid = दर्ज किया गया लिंक मान्य नहीं है! featureUnImplemented = सुविधा अभी लागू नहीं हुई है। minute = मिनट second = सेकंड spotiflyerLogo = स्पॉटीफ्लायर लोगो backButton = पिछला बटन infoTab = टैब जानकारी historyTab = इतिहास टैब linkTextBox = लिंक टेक्स्ट बॉक्स pasteLinkHere = लिंक यहां चिपकाएं... enterALink = एक लिंक दर्ज करें! madeWith = का बना हुआ love = प्रेम inIndia = भारत में open = खोलना byDeveloperName = द्वारा: शबिन्दर सिंह ================================================ FILE: translations/Strings_id.properties ================================================ title = SpotiFlyer about = Tentang history = Riwayat donate = Donasi preferences = Preferensi search = Cari supportedPlatforms = Platform yang didukung supportDevelopment = Dukung Pengembangan openProjectRepo = Buka Proyek Repository starOrForkProject = Beri bintang / fork proyek ini di GitHub. help = Bantuan translate = Terjemahan helpTranslateDescription = Bantu kami menerjemahkan aplikasi ini ke bahasa Anda. supportDeveloper = Dukung Developer donateDescription = Jika Anda ingin memberi donasi ke developer apl. ini, Anda bisa donasi disini. share = Bagikan shareDescription = Bagikan aplikasi ini kepada teman / keluarga anda. whatWentWrong = Apa yang salah... copyToClipboard = Menyalin ke clipboard copyCodeInGithubIssue = Salin Tempel Kode Di Bawah Saat membuat Masalah Github / Melaporkan masalah ini untuk bantuan yang lebih baik. status = Status analytics = Analytics analyticsDescription = Data Anda tidak dapat dilihat oleh orang lain dan TIDAK akan dibagikan ke pihak ketiga. noHistoryAvailable = Tidak ada riwayat yang tercatat. cleaningAndExiting = Cleaning And Exiting total = Total completed = Selesai failed = Gagal exit = Keluar downloading = Mengunduh processing = Mem-proses queued = Ditambahkan ke antrian setDownloadDirectory = Ubah folder unduhan downloadDirectorySetTo = Folder unduhan telah diubah ke: {0} noWriteAccess = Tidak ada akses WRITE untuk: {0}, mengembalikan ke folder sebelumnya... shareMessage = Hey, cek aplikasi pengunduh lagu Spotify ini! http://github.com/Shabinder/SpotiFlyer grantAnalytics = Izinkan Analytics noInternetConnection = Tidak ada Koneksi Internet checkInternetConnection = Cek sambungan internet Anda, lalu buka ulang aplikasi ini. grantPermissions = Aplikasi ini membutuhkan izin dibawah ini. requiredPermissions = Izin yang diperlukan: storagePermission = Mengakses penyimpanan Anda storagePermissionReason = Untuk menyimpan unduhan lagu favorit Anda di penyimpanan Anda. backgroundRunning = Berjalan di background backgroundRunningReason = Untuk mengunduh semua lagu tanpa gangguan. no = Tidak yes = Oke acraNotificationTitle = Oops, SpotiFlyer baru saja crash. acraNotificationText = Tolong kirim crash report ini ke pengembang apl. ini, sehingga hal ini tidak terjadi lagi :D albumArt = Album Art tracks = Tracks coverImage = Cover Gambar reSearch = Cari ulang loading = Memuat downloadAll = Unduh semua button = Tombol errorOccurred = Terjadi kesalahan, cek koneksi Anda / link yang ingin Anda unduh. downloadDone = Unduhan selesai! downloadError = Tidak bisa mengunduh lagu ini! downloadStart = Mulai mengunduh supportUs = Kami butuh dukungan Anda! donation = Donasi worldWideDonations = Donasi seluruh dunia indianDonations = Hanya donasi India saja dismiss = Jangan tampilkan ini lagi remindLater = Ingatkan saya nanti mp3ConverterBusy = MP3 Converter lagi sibuk! unknownError = Kesalahan tidak diketahui noMatchFound = Tidak ditemukan noLinkFound = Tidak ada yang bisa diunduh. linkNotValid = Link yang Anda masukkan tidak valid! featureUnImplemented = Fitur ini belum di-implementasikan. minute = mnt second = dtk spotiflyerLogo = Logo SpotiFlyer backButton = Kembali infoTab = Informasi historyTab = Riwayat linkTextBox = Link pasteLinkHere = Tempel link yang Anda salin disini... enterALink = Masukkan link yang Anda salin! madeWith = Made with love = Love inIndia = di India open = Buka byDeveloperName = oleh Shabinder Singh ================================================ FILE: translations/Strings_it.properties ================================================ title = SpotiFlyer about = Informazioni su SpotiFlyer history = Recenti donate = Dona preferences = Preferenze search = Cerca supportedPlatforms = Piattaforme supportate supportDevelopment = Sviluppo openProjectRepo = Apri repo del progetto starOrForkProject = Copia il progetto su Github help = Aiuto translate = Traduci helpTranslateDescription = Aiutaci a tradurre questa app nella tua lingua. supportDeveloper = Sviluppatore donateDescription = Se credi che io meriti di essere pagato per il mio lavoro, puoi contribuire qui. share = Condividi shareDescription = Condividi questa app con i tuoi amici e la tua famiglia. whatWentWrong = Qualcosa è andato storto... copyToClipboard = Copia negli appunti copyCodeInGithubIssue = Copia/Incolla il codice sottostante quando crei una richiesta di aiuto su GitHub. status = Stato analytics = Statistiche analyticsDescription = I tuoi dati sono anonmimi e non saranno mai condivisi con servizi di terze parti. noHistoryAvailable = Nessun dato disponibile cleaningAndExiting = Pulizia e chiusura in corso total = Totale completed = Completato failed = Fallito exit = Esci downloading = In download processing = In elaborazione queued = In coda setDownloadDirectory = Imposta la cartella di download downloadDirectorySetTo = Cartella: {0} noWriteAccess = NESSUN PERMESSO DI ACCESSO su: {0} , imposto la cartella precedente shareMessage = Ehi, dai uno sguardo a questo eccellente downloader http://github.com/Shabinder/SpotiFlyer grantAnalytics = Concedi dati per statistiche noInternetConnection = Nessuna connessione a internet! checkInternetConnection = Per favore controlla la tua connessione internet. grantPermissions = Concedi autorizzazione requiredPermissions = Autorizzazioni necessarie: storagePermission = Autorizzazione alla memoria storagePermissionReason = Per scaricare le tue canzoni preferite su questo dispositivo. backgroundRunning = Autorizzazione ad essere eseguita in background backgroundRunningReason = Per scaricare le tue canzoni senza interruzioni da parte del sistema operativo. no = No yes = Sì acraNotificationTitle = Ops, SpotiFlyer è crashato acraNotificationText = Per favore invia il report agli sviluppatori, in modo da non far accadere più questo evento. albumArt = Copertina tracks = Tracce coverImage = Copertina reSearch = Cerca di nuovo loading = Caricamento downloadAll = Scarica tutto button = Bottone errorOccurred = Si è verificato un errore, controlla il tuo link o la tua connessione downloadDone = Download effettuato downloadError = Errore! Non è possibile scaricare questa canzone downloadStart = Inizia il download supportUs = Abbiamo bisogno del tuo aiuto! donation = Donazione worldWideDonations = Donazioni internazionali indianDonations = Solo donazioni da India dismiss = Rifiuta remindLater = Ricordalo più tardi mp3ConverterBusy = Convertitore MP3 non disponibile unknownError = Errore sconosciuto noMatchFound = Nessuna corrispondenza trovata! noLinkFound = Nessun link scaricabile trovato! linkNotValid = Il link inserito non è valido! featureUnImplemented = Caratteristica non ancora implementata. minute = min second = sec spotiflyerLogo = Logo SpotiFlyer backButton = Pulsante indietro infoTab = Tab informazioni historyTab = Tab recenti linkTextBox = Casella di testo per link pasteLinkHere = Incolla il link qui... enterALink = Inserisci un link! madeWith = Fatto con love = Amore inIndia = in India open = Apri byDeveloperName = da: Shabinder Singh ================================================ FILE: translations/Strings_ja.properties.xml ================================================ title = SpotiFlyer about = アプリケーションについて history = 履歴 donate = 寄付する preferences = 設定 search = 検索する supportedPlatforms = サポート済みのサービス supportDevelopment = 開発を支援する openProjectRepo = プロジェクトのリポジトリを開く starOrForkProject = Githubで Star/Forkを行う help = ヘルプ translate = 翻訳 helpTranslateDescription = 翻訳を手伝う supportDeveloper = 開発者をサポートする donateDescription = 開発者が報酬を受け取るに値する仕事を成し遂げたと思うのであれば、開発者はここでサポートされます。 share = 共有する shareDescription = 友達や家族とこのアプリをシェアしよう。 whatWentWrong = What Went Wrong... copyToClipboard = クリップボードにコピー copyCodeInGithubIssue = Githubにissueを作成する際に、以下のコードを貼り付けてください。あなたの報告がアプリをより良いものにします。 status = 状態 analytics = 分析 analyticsDescription = あなたのデータは匿名であり、第三者と共有されることはありません。 noHistoryAvailable = 利用可能な履歴が存在しません cleaningAndExiting = クリーンアップと終了 total = 合計 completed = 完了 failed = 失敗 exit = 終了 downloading = ダウンロード中 processing = プロセス中 queued = キューへ追加済み setDownloadDirectory = ダウンロード場所を指定 downloadDirectorySetTo = ダウンロード場所は次の通りです: {0} noWriteAccess = 次の場所には書き込み権限が与えられていません: {0} 、 設定を戻しています。 shareMessage = さあ、この至高のダウンローダーを見てみてください! http://github.com/Shabinder/SpotiFlyer grantAnalytics = アクセス権限を与える noInternetConnection = インターネット接続がありません! checkInternetConnection = インターネットに接続できているか確認してください。 grantPermissions = 権限を与える requiredPermissions = 要求された権限: storagePermission = 保存域の権限。 storagePermissionReason = あなたの好きな曲を保存するのに必要です。 backgroundRunning = バックグラウンドで動作しています。 backgroundRunningReason = システムに中断させられることなくすべてのダウンロードを行うために必要です。 no = 許可しない yes = 許可する acraNotificationTitle = 申し訳ありません、 SpotiFlyerがクラッシュしました。 acraNotificationText = アプリ開発者に是非クラッシュレポートを送信してください。 このような残念な出来事は起こらなくなるでしょう。 albumArt = アルバムアート tracks = トラック coverImage = カバーイメージ reSearch = 再検索 loading = ロード中 downloadAll = すべてダウンロードする button = ボタン errorOccurred = エラーが発生しました。 貼り付けたリンクやインターネット接続を確認してみてください。 downloadDone = ダウンロードが終了しました downloadError = Error! Cant Download this track downloadStart = ダウンロード開始 supportUs = あなたの助けが必要です! donation = 寄付 worldWideDonations = World Wide Donations indianDonations = Indian Donations Only dismiss = 無視する remindLater = また今度知らせる mp3ConverterBusy = MP3 Converter unreachable, probably BUSY ! unknownError = Unknown Error noMatchFound = NO Match Found! noLinkFound = No Downloadable link found linkNotValid = Entered Link is NOT Valid! featureUnImplemented = Feature not yet implemented. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Back Button infoTab = Info Tab historyTab = History Tab linkTextBox = Link Text Box pasteLinkHere = Paste Link Here... enterALink = Enter A Link! madeWith = Made with love = Love inIndia = in India open = Open byDeveloperName = by: Shabinder Singh ================================================ FILE: translations/Strings_jp.properties ================================================ title = SpotiFlyer about = 約 history = 歴史 donate = 寄付 preferences = 好み search = Kensaku supportedPlatforms = サポートされているプラットフォーム supportDevelopment = 開発をサポート openProjectRepo = プロジェクトリポジトリを開く starOrForkProject = Github でプロジェクトをスター/フォークします。 help = 手 translate = 翻訳 helpTranslateDescription = このアプリをあなたの母国語に翻訳するのを手伝ってください。 supportDeveloper = サポート開発者 donateDescription = 私が私の仕事に対して報酬を得るに値すると思うなら、あなたはここで私をサポートすることができます。 シェア = シェア shareDescription = このアプリを友達や家族と共有しましょう。 whatWentWrong = 何が悪かったのか... copyToClipboard = クリップボードにコピー copyCodeInGithubIssue = Github の問題の作成中/この問題の報告中にコードの下に貼り付けをコピーしてください。 ステータス = ステータス analytics = 分析 analyticsDescription = あなたのデータは匿名化され、サードパーティのサービスと共有されることはありません。 noHistoryAvailable = 利用可能な履歴はありません cleaningAndExiting = クリーニングと終了 total = 合計 completed = 完了 failed = 失敗した exit = 出口 downloading = ダウンロード processing = 処理 queued = キューに入れられました setDownloadDirectory = ダウンロードディレクトリを設定する downloadDirectorySetTo = ディレクトリセットのダウンロード:{0} noWriteAccess = 書き込みアクセスなし:{0}、前に戻る shareMessage = ねえ、この優れた音楽ダウンローダーをチェックアウト http://github.com/Shabinder/SpotiFlyer grantAnalytics = 分析を許可する noInternetConnection = インターネットに接続できません! checkInternetConnection = ネットワーク接続を確認してください。 grantPermissions = 権限を付与する requiredPermissions = 必要な権限: storagePermission = ストレージ権限。 storagePermissionReason = お気に入りの曲をこのデバイスにダウンロードします。 backgroundRunning = バックグラウンド実行。 backgroundRunningReason = システムを中断せずにバックグラウンドですべての曲をダウンロードします。 いいえ = いいえ はい = もちろん acraNotificationTitle = おっとっと、SpotiFlyerがクラッシュしました acraNotificationText = クラッシュレポートをアプリ開発者に送信してください。このような不幸な出来事が二度と起こらない可能性があります。 albumArt = アルバムアート tracks = トラック coverImage = 表紙画像 reSearch = リサーチ loading = 読み込み中 downloadAll = すべてダウンロード button = ボタン errorOccurred = エラーが発生しました。リンク/接続を確認してください downloadDone = ダウンロード完了 downloadError = エラー! このトラックをダウンロードできません downloadStart = ダウンロード開始 supportUs = 皆様のご支援が必要です! donation = 寄付 worldWideDonations = 世界的な寄付 indianDonations = インドの寄付のみ dismiss = 解散 remindLater = 後で思い出させる mp3ConverterBusy = MP3コンバーターに到達できません、おそらく忙しいです! unknownError = 不明なエラー noMatchFound = 一致するものが見つかりません! noLinkFound = ダウンロード可能なリンクが見つかりません linkNotValid = 入力されたリンクは無効です! featureUnImplemented = 機能はまだ実装されていません。 minute = 分 second = 秒 spotiflyerLogo = SpotiFlyer ロゴ backButton = 戻るボタン infoTab = 情報タブ historyTab = [履歴]タブ linkTextBox = リンクテキストボックス pasteLinkHere = ここにリンクを貼り付け... enterALink = リンクを入力してください! madeWith = メイド を以て love = 愛情 inIndia = で インド open = 開ける byDeveloperName = 沿って: Shabinder Singh ================================================ FILE: translations/Strings_ml.properties ================================================ title = SpotiFlyer about = ഞങ്ങളെക്കുറിച്ച് history = ഹിസ്റ്ററി donate = സംഭാവനചെയ്യുക preferences = ക്രമീകരണങ്ങൾ search = തിരയുക supportedPlatforms = ലഭ്യമായ പ്ലാറ്റ്ഫോമുകൾ supportDevelopment = നിർമ്മാണത്തിൽ സഹായിക്കുക openProjectRepo = പ്രോജക്ട് റിപ്പോ തുറക്കുക starOrForkProject = ഗിറ്റ് ഹബ്. ൽ സ്റ്റാർ / ഫോർക് ചെയ്യുക help = സഹായങ്ങൾ translate = പരിഭാഷപ്പെടുത്തുക helpTranslateDescription = നിങ്ങളുടെ പ്രാദേശിക ഭാഷയിൽ ഈ ആപ്പ് വിവർത്തനം ചെയ്യാൻ ഞങ്ങളെ സഹായിക്കൂ.. supportDeveloper = ഡെവലപ്പർ നെ സപ്പോർട്ട് ചെയ്യുക donateDescription = എന്റെ ജോലിക്ക് പ്രതിഫലം ലഭിക്കാൻ ഞാൻ അർഹനാണെന്ന് നിങ്ങൾ കരുതുന്നുവെങ്കിൽ, നിങ്ങൾക്ക് എന്നെ ഇവിടെ പിന്തുണയ്ക്കാം. share = പങ്കിടുക shareDescription = നിങ്ങളുടെ സുഹൃത്തുക്കളുമായും കുടുംബവുമായും ഈ ആപ്പ് പങ്കിടുക. whatWentWrong = എന്താണ് തെറ്റിയത്... copyToClipboard = ക്ലിപ്പ്ബോർഡിലേയ്ക്ക് പകർത്തുക copyCodeInGithubIssue = മികച്ച സഹായത്തിനായി Githubൽ പുതിയ പ്രശ്നം സൃഷ്ടിക്കുമ്പോൾ / ഈ പ്രശ്നം റിപ്പോർട്ട് ചെയ്യുമ്പോൾ താഴെ ഉള്ള കോഡ് കൂടെ ഒട്ടിക്കുക. status = നിലവിലെ അവസ്ഥ analytics = അനലിറ്റിക്സ് analyticsDescription = നിങ്ങളുടെ ഡാറ്റ അജ്ഞാതമാക്കിയിരിക്കുന്നു, ഒരിക്കലും മൂന്നാം കക്ഷികളുമായി/ സേവനവുമായി പങ്കിടുകയുമില്ല. noHistoryAvailable = ഇവിടെ കാണാൻ ഒന്നും ഇല്ല cleaningAndExiting = എല്ലാം കളഞ്ഞ് പോകാം total = ആകെ completed = കഴിഞ്ഞു failed = പരാജയപ്പെട്ടു exit = പുറത്ത് പോകുക downloading = ഡൗൺലോഡ് ചെയ്യുന്നു processing = പ്രോസസ് ചെയ്യുന്നു queued = ക്യൂ വിലാണ് setDownloadDirectory = എവിടെ സൂക്ഷിക്കണം? downloadDirectorySetTo = ഇനിമുതൽ സൂക്ഷിക്കുന്ന സ്ഥലം: {0} noWriteAccess = NO WRITE ACCESS (അനുമതി ലഭിച്ചില്ല) on: {0} , പഴയ സ്ഥലത്ത് തന്നെ വെക്കാം shareMessage = ഇതൊരു മനോഹരമായ ആപ്പ് ആണ്, പാട്ടുകൾ ഡൗൺലോഡ് ചെയ്യാം http://github.com/Shabinder/SpotiFlyer grantAnalytics = വിശദാംശങ്ങൾ നൽകുക noInternetConnection = ഇൻ്റർനെറ്റ് ഇല്ല മാഷേ checkInternetConnection = ദയവായി നിങ്ങളുടെ ഇൻ്റർനെറ്റ് പരിശോധിക്കുക grantPermissions = സമ്മതം നൽകുക requiredPermissions = അനിവാര്യമായ അനുമതികൾ: storagePermission = Storage Permission. storagePermissionReason = പാട്ടുകൾ സൂക്ഷിക്കാൻ backgroundRunning = Background Running. backgroundRunningReason = തടസ്സങ്ങൾ ഇല്ലാതെ ഡൗൺലോഡ് ചെയ്യാൻ no = ഇല്ല yes = തീർച്ചയായും acraNotificationTitle = അയ്യോ Spotiflyer ക്രാഷ് ആയി acraNotificationText = ദയവായി ആപ്പ് ഡെവലപ്പർമാർക്ക് ക്രാഷ് റിപ്പോർട്ട് അയയ്ക്കുക, ഈ നിർഭാഗ്യകരമായ സംഭവം ഇനി ആവർത്തിക്കാതിരിക്കട്ടെ albumArt = ആൽബം ചിത്രം tracks = പാട്ടുകൾ coverImage = കവർ ചിത്രം reSearch = വീണ്ടും തിരയുക loading = ലോഡിംഗ് downloadAll = എല്ലാം ഡൗൺലോഡ് ചെയ്യുക button = Button errorOccurred = ഒരു പിശക് സംഭവിച്ചു, നിങ്ങളുടെ ലിങ്ക് / കണക്ഷൻ പരിശോധിക്കുക downloadDone = ഡൗൺലോഡ് പൂർത്തിയായി downloadError = അയ്യോ! ഈ ട്രാക്ക് ഡൗൺലോഡ് ചെയ്യാൻ കഴിയില്ല downloadStart = ഡൗൺലോഡ് ആരംഭിക്കുക supportUs = ഞങ്ങൾക്ക് നിങ്ങളുടെ പിന്തുണ ആവശ്യമാണ്! donation = ഡൊണേഷൻ worldWideDonations = ലോകവ്യാപകമായ സംഭാവനകൾ indianDonations = ഇന്ത്യൻ സംഭാവനകൾ മാത്രം dismiss = പിരിച്ചുവിടുക remindLater = പിന്നീട് ഓർമ്മിപ്പിക്കുക mp3ConverterBusy = MP3 കൺവെർട്ടർ ലഭ്യമല്ല, ഒരുപക്ഷേ തിരക്കിൽ ആയിരിക്കാം ! unknownError = അജ്ഞാത പിശക് noMatchFound = കാണുന്നില്ല! noLinkFound = ഡൗൺലോഡ് ചെയ്യാവുന്ന ലിങ്ക് കണ്ടെത്തിയില്ല linkNotValid = നൽകിയ ലിങ്ക് സാധുതയുള്ളതല്ല! featureUnImplemented = ഫീച്ചർ ഇതുവരെ നടപ്പിലാക്കിയിട്ടില്ല. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Back Button infoTab = Info Tab historyTab = History Tab linkTextBox = Link Text Box pasteLinkHere = ലിങ്ക് ഇവിടെ ഒട്ടിക്കുക ... enterALink = Enter A Link! madeWith = സ്നേഹപൂർവ്വം love = Love inIndia = ഭാരതത്തിൽ നിന്ന് open = Open byDeveloperName = by: ശബീന്ദർ സിംഗ് ================================================ FILE: translations/Strings_ne.properties ================================================ title = स्पोटीफ्लायर about = यस बारे history = इतिहास donate = दान गर्नुहोस् preferences = रोजाई search = खोज्नुहोस supportedPlatforms = समर्थित प्लेटफर्म supportDevelopment = बिकासमा सहयोग गर्नुहोस openProjectRepo = प्रोजेक्ट रिपो खोल्नुहोस् starOrForkProject = गिटहबमा प्रोजेक्टलाई स्टार / फोर्क गर्नुहोस। help = मद्दत translate = अनुवाद गर्नुहोस् helpTranslateDescription = हामीलाई यो एप तपाईंको स्थानीय भाषामा अनुवाद गर्न मद्दत गर्नुहोस्। supportDeveloper = विकासकर्तालाई समर्थन गर्नुहोस। donateDescription = यदि तपाईलाई लाग्छ कि म मेरो कामको लागि भुक्तानी पाउन योग्य छु, तपाईले मलाई यहाँ समर्थन गर्न सक्नुहुन्छ। share = शेयर shareDescription = आफ्नो साथी र परिवार संग यो एप शेयर गर्नुहोस्। whatWentWrong = के गल्ती भयाे... copyToClipboard = क्लिपबोर्डमा कपि गर्नुहोस् copyCodeInGithubIssue = गिटहबममा मुद्दा सिर्जना गर्दा / यो मुद्दा रिपोर्ट गर्दा राम्रो मद्दतको लागि तलको कोड कपि पेस्ट गर्नुहोस्। status = स्थिति analytics = एनालिटिक्स analyticsDescription = तपाईंको डाटा बेनामी छ र तेस्रो पक्ष सेवासँग कहिल्यै साझेदारी गरिएको छैन। noHistoryAvailable = कुनै इतिहास उपलब्ध छैन cleaningAndExiting = सफा गर्दै र बाहिर निस्कँदै total = कुल completed = पुरा भएको failed = असफल exit = बाहिर निस्कनुहोस् downloading = डाउनलोड गर्दै processing = प्रोसेस गर्दै queued = लाम लागेको setDownloadDirectory = डाउनलोड डाइरेक्टरी सेट गर्नुहोस् downloadDirectorySetTo = डाउनलोड डाइरेक्टरी {0} मा सेट भयो noWriteAccess = {0} मा लेख्ने अनुमति छैन , अघिल्लोमा फर्कदै shareMessage = हे, यो उत्कृष्ट संगीत डाउनलोडर जाँच गर्नुहोस् http://github.com/Shabinder/SpotiFlyer grantAnalytics = एनालिटिक्स प्रदान गर्नुहोस् noInternetConnection = इन्टरनेट जडान छैन! checkInternetConnection = कृपया आफ्नो नेटवर्क जडान जाँच गर्नुहोस्। grantPermissions = अनुमतिहरू प्रदान गर्नुहोस् requiredPermissions = आवश्यक अनुमतिहरू: storagePermission = भण्डारण अनुमति। storagePermissionReason = यस उपकरणमा आफ्नो मनपर्ने गीतहरू डाउनलोड गर्न। backgroundRunning = पृष्ठभूमिमा चलिरहेको छ। backgroundRunningReason = कुनै पनि प्रणाली अवरोध बिना पृष्ठभूमिमा सबै गीतहरू डाउनलोड गर्न। no = हुदैन yes = हुन्छ acraNotificationTitle = ओह, स्पोटीफ्लायर क्र्यास भयो acraNotificationText = कृपया एप विकासकर्ताहरूलाई क्र्यास रिपोर्ट पठाउनुहोस्, ताकि यो दुर्भाग्यपूर्ण घटना फेरि नदोहोरियोस्। albumArt = एल्बम कला tracks = ट्रयाकहरू coverImage = आवरण छवि reSearch = पुन: खोजी गर्नुहोस् loading = लोड गर्दै downloadAll = सबै डाउनलोड गर्नुहोस् button = बटन errorOccurred = एउटा त्रुटि देखा पर्यो, आफ्नो लिङ्क / जडान जाँच गर्नुहोस् downloadDone = डाउनलोड गरियो downloadError = त्रुटि! यो ट्रयाक डाउनलोड गर्न सकिँदैन downloadStart = डाउनलोड सुरु गर्नुहोस् supportUs = हामीलाई तपाईंको समर्थन चाहिन्छ! donation = अनुदान worldWideDonations = विश्वव्यापी अनुदान indianDonations = भारतीय अनुदान मात्र dismiss = खारेज गर्नुहोस् remindLater = पछि सम्झाउनुहोस् mp3ConverterBusy = MP3 कन्भर्टर पहुँचयोग्य छैन, सम्भवतः व्यस्त ! unknownError = अज्ञात त्रुटि noMatchFound = कुनै मिल्दो फेला परेन! noLinkFound = कुनै डाउनलोड योग्य लिङ्क फेला परेन linkNotValid = इन्टर गरिएको लिङ्क मान्य छैन! featureUnImplemented = विशेषता अझै लागू भएको छैन। minute = मिनेट second = सेकन्ड spotiflyerLogo = स्पोटिफ्लायर लोगो backButton = पछाडि बटन infoTab = जानकारी ट्याब historyTab = इतिहास ट्याब linkTextBox = लिङ्क पाठ बक्स pasteLinkHere = यहाँ लिङ्क पेस्ट गर्नुहोस्... enterALink = एउटा लिङ्क इन्टर गर्नुहोस्! madeWith = भारत मा love = प्रेम inIndia = संग निर्मित open = खोल्नुहोस् byDeveloperName = द्वारा: शबिंदर सिंह ================================================ FILE: translations/Strings_nl.properties ================================================ title = SpotiFlyer about = Over history = Geschiedenis donate = Doneren preferences = Voorkeuren search = Zoeken supportedPlatforms = Ondersteunde platformen supportDevelopment = Ontwikkeling steunen openProjectRepo = Project Repo Openen starOrForkProject = Star / Fork het project op Github. help = Hulp translate = Vertalen helpTranslateDescription = Help ons deze app te vertalen in je lokale taal. supportDeveloper = Ontwikkelaar steunen donateDescription = Als je geloofd dat ik verdien betaald te worden voor mijn werk, kan je me hier steunen. share = Delen shareDescription = Deze app delen met je familie en vrienden. whatWentWrong = Wat is er fout gegaan... copyToClipboard = Kopieer naar klembord copyCodeInGithubIssue = Kopieer en plak de onderstaande code bij het maken van (en/of het melden) van dit probleem op GitHub voor betere hulp. status = Status analytics = Analytics analyticsDescription = Je data is geanonimiseerd en wordt niet gedeeld met derde partijen. noHistoryAvailable = Geen geschiedenis beschikbaar cleaningAndExiting = Schoonmaken en sluiten total = Totaal completed = Voltooid failed = Gefaald exit = Sluiten downloading = Aan het downloaden processing = Aan het verwerken queued = In de wachtrij gezet setDownloadDirectory = Download map instellen downloadDirectorySetTo = Download map ingesteld als: {0} noWriteAccess = GEEN TOEGANG TOT SCHRIJVEN naar: {0} , Vorig pad herstellen shareMessage = Hey, check deze geweldige muziekdownloader http://github.com/Shabinder/SpotiFlyer grantAnalytics = Analytics toestaan noInternetConnection = Geen internet verbinding! checkInternetConnection = Check alstublieft uw internetverbinding. grantPermissions = Permissies toestaan requiredPermissions = Benodigde permissies: storagePermission = Opslag Permissie. storagePermissionReason = Om uw favoriete nummers op dit apperaat te downloaden. backgroundRunning = Werkend op de achtergrond. backgroundRunningReason = Om alle nummers op de achtergrond te downloaden zonder onderbroken te worden door het systeem. no = Nee yes = Ja acraNotificationTitle = OEPS, SpotiFlyer is gecrasht acraNotificationText = Stuur alstublieft dit crashrapport naar de ontwikkelaars van de app, zodat we dit ongelukkig moment in de toekomst kunnen voorkomen. albumArt = Albumhoes tracks = Nummers coverImage = Omslagfoto reSearch = Opnieuw zoeken loading = Laden downloadAll = Alles downloaden button = Knop errorOccurred = Er is een fout opgetreden, check uw link / connectie downloadDone = Downloaden klaar downloadError = Foutmelding! Kan dit nummer niet downloaden downloadStart = Download starten supportUs = We hebben uw steun nodig! donation = Donatie worldWideDonations = Wereldwijde donaties indianDonations = Exclusief donaties uit India dismiss = Afwijzen remindLater = Herinner me later mp3ConverterBusy = MP3 Omzetter onbereikbaar, waarschijnlijk BEZIG ! unknownError = Onbekende fout noMatchFound = GEEN overeenkomst gevonden! noLinkFound = Geen downloadbare link gevonden linkNotValid = De ingevoerde link is NIET geldig! featureUnImplemented = Onderdeel nog niet geïmplementeerd. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Terug Knop infoTab = Info Tabblad historyTab = Geschiedenis Tabblad linkTextBox = Link Tekstvak pasteLinkHere = Link hier plakken... enterALink = Voer een link in! madeWith = Gemaakt met love = Liefde inIndia = in India open = Open byDeveloperName = door: Shabinder Singh ================================================ FILE: translations/Strings_pl.properties ================================================ title = SpotiFlyer about = Opis history = Historia donate = Złóż donację preferences = Ustawienia search = Szukaj supportedPlatforms = Obsługiwane Platformy supportDevelopment = Wesprzyj Rozwój openProjectRepo = Otwórz repozytorium projektu starOrForkProject = Gwiazdkuj / Rozwidl pojekt na GitHubie. help = Pomoc translate = Przetłumacz helpTranslateDescription = Pomóż nam pretłumaczyć tę aplikację na swój lokalny język. supportDeveloper = Wesprzyj programistę donateDescription = Jeśli uważasz, że zasługuję na wynagrodzenie za swoją pracę, możesz tutaj mnie wesprzeć. share = Udostępnij shareDescription = Udostępnij tę aplikację znajomym i rodzinie. whatWentWrong = Co poszło nie tak... copyToClipboard = Skopiuj do schowka copyCodeInGithubIssue = Copy Paste Below Code while creating Github Issue / Reporting this issue for better help. status = Status analytics = Analityka analyticsDescription = Twoje dane są anonimizowane i nigdy nie są udostępniane usługom stron trzecich. noHistoryAvailable = Brak dostępnej historii cleaningAndExiting = Czyszczę i wychodzę total = Całość completed = Ukończono failed = Nieudane exit = Wyjście downloading = Pobieranie processing = Przetwarzanie queued = W kolejce setDownloadDirectory = Ustaw folder pobierania downloadDirectorySetTo = Folder pobierania ustawiony na: {0} noWriteAccess = Brak dostępu do zapisu w: {0} , Powracam to poprzedniego shareMessage = Hej, zobacz ten świetny program do pobierania muzyki http://github.com/Shabinder/SpotiFlyer grantAnalytics = Zezwół na zbieranie danych analitycznych noInternetConnection = Nie podłączono do internetu! checkInternetConnection = Prosze sprawdź swoje połączenie do internetu. grantPermissions = Przyznaj uprawnienia requiredPermissions = Wymagane uprawnienia: storagePermission = Zezwolenie na przechowywanie. storagePermissionReason = Aby było można pobierać utwory. backgroundRunning = Zezwolenie na działanie w tle. backgroundRunningReason = Aby było można pobierać utworów w tle bez zakłócenia. no = Nie yes = Tak acraNotificationTitle = Ups! Spotiflyer uległ awarii acraNotificationText = Wyślij raport o awarii do programistów aplikacji, aby zapobiec tej awarii w przyszłości. albumArt = Okładki albumów tracks = Utwory coverImage = Okładka reSearch = Szukaj ponownie loading = Wczytuję downloadAll = Pobierz wszystkie button = Przycisk errorOccurred = Ustąpił błąd, prosze sprawdz swoje połączenie downloadDone = Pobieranie skończone downloadError = Błąd! Nie można ściągnąć tego utworu downloadStart = Zacznij pobieranie supportUs = Potrzebujemy twojego wsparcia! donation = Donacja worldWideDonations = Podarunki międzynarodowe indianDonations = Donacje z Indii dismiss = Odrzuć remindLater = Przypomnij póżniej mp3ConverterBusy = Konwerter MP3 nieosiągalny, najprawdopodobnie zajęty! unknownError = Nieznany błąd noMatchFound = Nie znaleziono dopasowania! noLinkFound = Nie znaleziono linku do pobrania linkNotValid = Wpisany link nie jest prawidłowy! featureUnImplemented = Funkcja jeszcze nie zaimplementowana. minute = min second = sek spotiflyerLogo = Logo SpotiFlyer backButton = Przycisk wstecz infoTab = Zakładka informacji historyTab = Zakładka historii linkTextBox = Pole tekstowe linku pasteLinkHere = Wklej link tutaj... enterALink = Wpisz link madeWith = Made with love = Love inIndia = in India open = Otwórz byDeveloperName = by: Shabinder Singh ================================================ FILE: translations/Strings_pt.properties ================================================ title = SpotiFlyer about = Sobre history = Histórico donate = Doar preferences = Preferências search = Pesquisar supportedPlatforms = Plataformas Suportadas supportDevelopment = Ajude o Desenvolvimento openProjectRepo = Abrir Repositórido do Projeto starOrForkProject = Star / Fork do projeto no GitHub. help = Ajuda translate = Traduzir helpTranslateDescription = Ajude-nos a traduzir esse app para seu idioma local. supportDeveloper = Ajude o Desenvolvedor donateDescription = Se você acha que mereço ser pago pelo meu trabalho, você pode me ajudar aqui. share = Compartilhar shareDescription = Compartilhe este app com seus amigos e família. whatWentWrong = O que deu errado... copyToClipboard = Copiar para área de transferência copyCodeInGithubIssue = Copiar e colar abaixo do código ao criar o problema no Github / relatar esse problema para obter ajuda melhor. status = Status analytics = Estatísticas analyticsDescription = Seus dados enviados são anônimos e nunca serão compartilhados com serviços de terceiros. noHistoryAvailable = Histórico Indisponível. cleaningAndExiting = Limpando e Saindo total = Total completed = Completo failed = Falhou exit = Sair downloading = Baixando processing = Processando queued = Na Fila setDownloadDirectory = Definir Pasta de Download downloadDirectorySetTo = Pasta de Download Definida para: {0} noWriteAccess = NO WRITE ACCESS em: {0} , Revertendo Para o Anterior shareMessage = Ei, dá uma olhada nesse excelente Baixador de Músicas http://github.com/Shabinder/SpotiFlyer grantAnalytics = Permitir Estatísticas noInternetConnection = Sem conexão com a Internet! checkInternetConnection = Por Favor, Checar Sua Conexão com a Internet. grantPermissions = Autorizar Permissões requiredPermissions = Permissões Necessárias: storagePermission = Permissão de Armazenamento. storagePermissionReason = Para baixar suas músicas favoritas para este dispositivo. backgroundRunning = Executando Em Segundo Plano. backgroundRunningReason = Para baixar todas as músicas em segundo plano sem Interrupções do Sistema. no = Não yes = Sim acraNotificationTitle = OOPS, SpotiFlyer Parou de Funcionar! acraNotificationText = Por Favor, Enviar Relatório para os Desenvolvedores do App, Para que este evento inesperado não aconteça novamente. albumArt = Arte do Álbum tracks = Faixas coverImage = Imagem de Capa reSearch = Buscar Novamente loading = Carregando downloadAll = Baixar Todas button = Botão errorOccurred = Aconteceu Um Erro, Checar seu Link / Conexão downloadDone = Download Completo downloadError = Erro! Não foi Possível Baixar essa Faixa! downloadStart = Iniciar Download supportUs = Precisamos Do Seu Apoio! donation = Doação worldWideDonations = Doações Mundo Afora indianDonations = Apenas Doações da Índia dismiss = Descartar remindLater = Lembrar Depois mp3ConverterBusy = Conversor MP3 indisponível, provavelmente está CONGESTIONADO ! unknownError = Erro Desconhecido noMatchFound = NÃO Encontrado! noLinkFound = Link de Download Não Encontrado linkNotValid = Link Inserido NÃO é Válido! featureUnImplemented = Esta funcionalidade ainda não foi implementada. minute = min second = seg spotiflyerLogo = Logo do SpotiFlyer backButton = Botão Voltar infoTab = Aba de Informações historyTab = Aba de Histórico linkTextBox = Caixa de Texto de Link pasteLinkHere = Colar o Link Aqui... enterALink = Inserir Um Link! madeWith = Feito com love = Amor inIndia = na Índia open = Abrir byDeveloperName = por: Shabinder Singh ================================================ FILE: translations/Strings_pt_BR.properties ================================================ title = SpotiFlyer about = Sobre history = Histórico donate = Doar preferences = Preferências search = Pesquisar supportedPlatforms = Plataformas Suportadas supportDevelopment = Ajude o Desenvolvimento openProjectRepo = Abrir Repositórido do Projeto starOrForkProject = Star / Fork do projeto no GitHub. help = Ajuda translate = Traduzir helpTranslateDescription = Ajude-nos a traduzir esse app para seu idioma local. supportDeveloper = Ajude o Desenvolvedor donateDescription = Se você acha que mereço ser pago pelo meu trabalho, você pode me ajudar aqui. share = Compartilhar shareDescription = Compartilhe este app com seus amigos e família. status = Status analytics = Estatísticas analyticsDescription = Seus dados enviados são anônimos e nunca serão compartilhados com serviços de terceiros. noHistoryAvailable = Histórico Indisponível. cleaningAndExiting = Limpando e Saindo total = Total completed = Completo failed = Falhou exit = Sair downloading = Baixando processing = Processando queued = Na Fila setDownloadDirectory = Definir Pasta de Download downloadDirectorySetTo = Pasta de Download Definida para: {0} noWriteAccess = NO WRITE ACCESS em: {0} , Revertendo Para o Anterior shareMessage = Ei, dá uma olhada nesse excelente Baixador de Músicas http://github.com/Shabinder/SpotiFlyer grantAnalytics = Permitir Estatísticas noInternetConnection = Sem conexão com a Internet! checkInternetConnection = Por Favor, Checar Sua Conexão com a Internet. grantPermissions = Autorizar Permissões requiredPermissions = Permissões Necessárias: storagePermission = Permissão de Armazenamento. storagePermissionReason = Para baixar suas músicas favoritas para este dispositivo. backgroundRunning = Executando Em Segundo Plano. backgroundRunningReason = Para baixar todas as músicas em segundo plano sem Interrupções do Sistema. no = Não yes = Sim acraNotificationTitle = OOPS, SpotiFlyer Parou de Funcionar! acraNotificationText = Por Favor, Enviar Relatório para os Desenvolvedores do App, Para que este evento inesperado não aconteça novamente. albumArt = Arte do Álbum tracks = Faixas coverImage = Imagem de Capa reSearch = Buscar Novamente loading = Carregando downloadAll = Baixar Todas button = Botão errorOccurred = Aconteceu Um Erro, Checar seu Link / Conexão downloadDone = Download Completo downloadError = Erro! Não foi Possível Baixar essa Faixa! downloadStart = Iniciar Download supportUs = Precisamos Do Seu Apoio! donation = Doação worldWideDonations = Doações Mundo Afora indianDonations = Apenas Doações da Índia dismiss = Descartar remindLater = Lembrar Depois mp3ConverterBusy = Conversor MP3 indisponível, provavelmente está CONGESTIONADO ! unknownError = Erro Desconhecido noMatchFound = NÃO Encontrado! noLinkFound = Link de Download Não Encontrado linkNotValid = Link Inserido NÃO é Válido! featureUnImplemented = Esta funcionalidade ainda não foi implementada. minute = min second = seg spotiflyerLogo = Logo do SpotiFlyer backButton = Botão Voltar infoTab = Aba de Informações historyTab = Aba de Histórico linkTextBox = Caixa de Texto de Link pasteLinkHere = Colar o Link Aqui... enterALink = Inserir Um Link! madeWith = Feito com love = Amor inIndia = na Índia open = Abrir byDeveloperName = por: Shabinder Singh ================================================ FILE: translations/Strings_ro.properties ================================================ title = SpotiFlyer about = Despre history = Istoric donate = Doneaza preferences = Preferinte search = Cautare supportedPlatforms = Platforme Compatibile supportDevelopment = Sustine Dezvoltatea openProjectRepo = Repo-ul proiectului starOrForkProject = Apasa pe Star/Fork pe Github pentru a contribui la acest proiect. help = Ajutor translate = Tradu helpTranslateDescription = Ajuta-ne sa traducem în limba ta locala. supportDeveloper = Sustine Dezvoltatorul donateDescription = Daca crezi ca merit sa fiu platit pentru munca mea, poti dona aici share = Distribuie shareDescription = Distribuie aceasta aplicatie cu familia si prietenii tai. whatWentWrong = Ce s-a întamplat... copyToClipboard = Copiaza în fundal copyCodeInGithubIssue = Copiaza acest cod când creezi o cerere de ajutor În GitHub. status = Status analytics = Analize analyticsDescription = Datele tale sunt anonimizate si nu sunt împartasite cu firme 3rd Party. noHistoryAvailable = Nu exista Istoric. cleaningAndExiting = Se curata si se iese. total = Total completed = Terminat failed = Esuat exit = Iesire downloading = Se descarca processing = Se proceseaza queued = In asteptare setDownloadDirectory = Seteaza calea descarcarilor downloadDirectorySetTo = Calea descarcarilor este setata la: {0} noWriteAccess = NU SE POATE ACCESA ACEASTA CALE: {0} , Se revine la calea veche. shareMessage = Hey, uite-te la aceasta aplicatie grozava pentru descarcarea muzicii: http://github.com/Shabinder/SpotiFlyer grantAnalytics = Permite analizare. noInternetConnection = Nu exista conexiune la internet! checkInternetConnection = Te rog verifica conexiunea la internet! grantPermissions = Acorda permisiuni requiredPermissions = Permisiuni necesare: storagePermission = Permisiune de stocare. storagePermissionReason = Pentru a descarca melodiile tale preferate pe acest dispozitiv.. backgroundRunning = Rulare În fundal. backgroundRunningReason = Pentru a descarca toate melodiile in fundal fara întreruperi. no = Nu, mutlumesc yes = Desigur acraNotificationTitle = OOPS, SpotiFlyer a dat eroare acraNotificationText = Te rog trimite raportul de eroare dezvoltatorilor ca acest lucru sa nu se mai întample în viitor.. albumArt = Arta albumului tracks = Piese coverImage = Poza de coperta reSearch = Cautare din nou loading = Se Încarca downloadAll = Descarca tot button = Buton errorOccurred = A aparut o eroare te rog sa Îti verific link-ul sau conexiunea. downloadDone = Descarcare completa downloadError = Eroare! Aceasta piesa nu poate fi descarcata. downloadStart = Începe descarcarea supportUs = Avem nevoie de sustinerea ta! donation = Donari worldWideDonations = Donatii globale indianDonations = Doar donatii Indiene dismiss = Nu, multumesc remindLater = Aminteste-mi mai tarziu mp3ConverterBusy = Convertorul Mp3 nu este valabil, probabil ocupat. unknownError = Eroare necunoscuta. noMatchFound = Nu s-a gasit nicio potrivire noLinkFound = Nu s-a gasit niciun link carr poate fi descarcat linkNotValid = Link-ul introdus este invalid featureUnImplemented = Aceasta functie înca nu a fost implementata. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Buton Înapoi infoTab = Tabela Info historyTab = Tabela Istoric linkTextBox = Casuta de Link pasteLinkHere = Introdu un link aici... enterALink = Introdu un Link! madeWith = Creeat cu love = Iubire inIndia = în India open = Open byDeveloperName = De catre: Shabinder Singh ================================================ FILE: translations/Strings_ru.properties ================================================ title = SpotiFlyer about = О приложении history = История donate = Пожертвовать preferences = Предпочтения search = Поиск supportedPlatforms = Поддерживаемые платформы supportDevelopment = Поддержи разработчика openProjectRepo = Открыть проект repo starOrForkProject = Учавствуй в разработке и помогай дополнять проект на GitHub. help = Помощь translate = Локализация helpTranslateDescription = Помоги перевести приложение на родной или другой язык. supportDeveloper = Поддержи разработчика donateDescription = Если считаешь, что я заслуживаю этого, ты можешь поддержать меня здесь. share = Поделиться shareDescription = Расскажи об этом приложении знакомым - поделись полезным инструментом. whatWentWrong = Что пошло не так... copyToClipboard = Скопировать в буфер обмена copyCodeInGithubIssue = Скопируйте вставьте ниже код при создании проблемы Github / Сообщите об этой проблеме для лучшей помощи. status = Статус analytics = Аналитика analyticsDescription = Предоставить анонимные данные для улучшения приложения. Они не будут переданы третьим лицам. noHistoryAvailable = В истории пусто cleaningAndExiting = Очистка и выход total = Итог completed = Успешно выполнено failed = Возникла ошибка exit = Выход downloading = Загрузка processing = В процессе queued = В очереди setDownloadDirectory = Назначить расположение для загруженных файлов downloadDirectorySetTo = Каталог загрузки изменен на {0} noWriteAccess = Нет доступа для записи в: {0}, SpotiFlyer возвращается к предыдущему значению shareMessage = Надёжный, функциональный загрузчик музыки для YouTube, Spotify и пррчих стриминговых сервисов для Windows, Android, Mac, Linux можно установить с: http://github.com/Shabinder/SpotiFlyer grantAnalytics = Разрешить отправлять данные для улучшения приложения noInternetConnection = Нет стабильного интернет-соединения checkInternetConnection = Проверь подключение к сети grantPermissions = Предоставить разрешения requiredPermissions = Необходимые разрешения: storagePermission = Доступ к хранилищу storagePermissionReason = Для загрузки файлов во внутреннее хранилище необходимо разрешение на использоаание хранилища. backgroundRunning = Работа в фоновом режиме backgroundRunningReason = Для стабильной работы в фоновом режиме требуется отключить автоматическое закрывание приложения. no = Нет yes = Да acraNotificationTitle = Упс, кажется в SpotiFlyer возникла ошибка acraNotificationText = Отправь отчет о сбое разработчику приложения, чтобы проблема была замечена исправлена. albumArt = Обоожка альбома tracks = Треки coverImage = Обложка reSearch = Искать снова loading = Загрузка downloadAll = Скачать все button = Кнопка errorOccurred = Возникла какая-то ошибка. Проверь правильность ссылки и наличие подключение к сети. downloadDone = Загрузка выполнена downloadError = Ошибка: этот трек нельзя скачать downloadStart = Начать загрузку supportUs = Нам нужна твоя поддержка donation = Пожертвовать worldWideDonations = По всему мира indianDonations = Индия dismiss = Отклонить remindLater = Напомнить позже # Exceptions mp3ConverterBusy = В данный момент конвертер MP3 недоступен unknownError = Неизвестная ошибка noMatchFound = Совпадений не найдено noLinkFound = Ссылка для скачивания не найдена linkNotValid = Введенная ссылка недействительна featureUnImplemented = Функция еще не реализована minute = мин second = сек spotiflyerLogo = SpotiFlyer лого backButton = Кнопка назад infoTab = Вкладка со информацией historyTab = Вкладка истории linkTextBox = Поле для ссылки pasteLinkHere = Вставь ссылку сюда enterALink = Введи ссылку madeWith = Сделано с love = любовью inIndia = в Индии open = открыть byDeveloperName = от: Шабиндера Сингха ================================================ FILE: translations/Strings_tl.properties ================================================ title = SpotiFlyer about = Impormasyon history = Resulta donate = Mag-Abuloy preferences = Mga Setting search = hanapin supportedPlatforms = Suportadong Platform supportDevelopment = Sumuporta sa Developer openProjectRepo = Buksan ang proyekto ng repo starOrForkProject = Bigyan ng Star / Fork ang proyekto sa Github. help = Tumulong translate = Isalin ang Wika helpTranslateDescription = Tulungan kaming isalin ang app na ito sa iyong lokal na wika. supportDeveloper = Sumuporta sa Developer donateDescription = Kung sa tingin mo ay karapat-dapat akong mabayaran para sa aking trabaho, maaari mo akong suportahan dito. share = Ibahagi shareDescription = Ibahagi ang app na ito sa iyong mga kaibigan at pamilya. whatWentWrong = What Went Wrong... copyToClipboard = Copy to Clipboard copyCodeInGithubIssue = Copy Paste Below Code while creating Github Issue / Reporting this issue for better help. status = Kalagayan analytics = analitika analyticsDescription = Ang iyong Data ay hindi pinapakilala at hindi kailanman ibinahagi sa serbisyo ng 3rd party. noHistoryAvailable = Walang History na Magagamit cleaningAndExiting = Nililinis at Ilalabas total = Total completed = Nakumpleto failed = Nabigo exit = ilabas downloading = Nagda-download processing = Pinoproseso queued = Nakapila setDownloadDirectory = Itakda ang Direktoryo ng Pag-download downloadDirectorySetTo = I-download ng Direkta sa: {0} noWriteAccess = NO WRITE ACCESS on: {0} , Reverting Back to Previous shareMessage = Uy, tingnan ang napakahusay na Music Downloader na ito http://github.com/Shabinder/SpotiFlyer grantAnalytics = Bigyan ng Analitika noInternetConnection = Walang Koneksyon sa Internet! checkInternetConnection = Suriin ang Iyong Koneksyon sa Network. grantPermissions = Grant Permissions requiredPermissions = Required na Permissions: storagePermission = Storage Permission. storagePermissionReason = Upang i-download ang iyong mga paboritong kanta sa device na ito. backgroundRunning = Background Running. backgroundRunningReason = Upang i-download ang lahat ng mga kanta sa background nang walang anumang Mga Pagkagambala sa System. no = Nope yes = Sure acraNotificationTitle = OOPS, SpotiFlyer Crashed acraNotificationText = Magpadala ng Ulat ng Pag-crash sa Mga Developer ng App, Para hindi na maulit ang kapus-palad na kaganapang ito. albumArt = Album Art tracks = Tracks coverImage = Cover Image reSearch = hanapin ulit loading = Loading downloadAll = i Download lahat button = Button errorOccurred = May Error na Naganap, Suriin ang iyong Link / Koneksyon downloadDone = Tapos na idownload downloadError = Error! Hindi ma-download ang track na ito downloadStart = Simulan i Download supportUs = Kailangan namin ang Iyong Suporta! donation = donasyon worldWideDonations = World Wide Donations indianDonations = Indian Donations Only dismiss = Dismiss remindLater = paalalahanin mamaya mp3ConverterBusy = MP3 Converter unreachable, probably BUSY ! unknownError = Unknown Error noMatchFound = Walang nahanap na kapares! noLinkFound = Walang nahanap na link na nada-download linkNotValid = HINDI Wasto ang inilagay na Link! featureUnImplemented = Feature not yet implemented. minute = min second = sec spotiflyerLogo = SpotiFlyer Logo backButton = Back Button infoTab = Info Tab historyTab = History Tab linkTextBox = Link Text Box pasteLinkHere = Paste Link Here... enterALink = Enter A Link! madeWith = Made with love = Love inIndia = in India open = Open byDeveloperName = by: Shabinder Singh ================================================ FILE: translations/Strings_tr.properties ================================================ title = SpotiFlyer about = Hakkında history = Geçmiş donate = Bağış Yap preferences = Tercihler search = Ara supportedPlatforms = Desteklenen Platformlar supportDevelopment = Geliştirmeyi Destekleyin openProjectRepo = Proje Deposunu Aç starOrForkProject = Projeyi GitHub'ta Yıldızlayın / Fork'layın help = Yardım translate = Çeviri helpTranslateDescription = Bu uygulamayı kendi diline çevirmek için bizlere yardım et. supportDeveloper = Geliştiriciyi Destekle donateDescription = Eğer ki emeğim için para almayı hak ettiğimi düşünüyorsan, buradan beni destekle. share = Paylaş shareDescription = Bu uygulamayı arkadaşların ve ailenle paylaş. whatWentWrong = Yanlış giden şey ne... copyToClipboard = Panoya Kopyala copyCodeInGithubIssue = GitHub Sorunu / Raporu oluştururken daha iyi yardım almak için aşağıdaki kopyala ve yapıştır. status = Durum analytics = Analizler analyticsDescription = Veriniz anonim kalır ve asla 3. parti servislerle paylaşılmaz. noHistoryAvailable = Görülecek Geçmiş Yok cleaningAndExiting = Temizleniyor ve Çıkış Yapılıyor total = Toplam completed = Tamamlandı failed = Başarısız exit = Çıkış downloading = İndiriliyor processing = İşleniyor queued = Sıraya Alındı setDownloadDirectory = İndirme Konumunu Belirle downloadDirectorySetTo = İndirme konumu şuraya ayarlandı: {0} noWriteAccess = Şu Konumda YAZMA İZNİ YOK: {0} , Önceki Konuma Geri Dönülüyor shareMessage = Hey, bu mükemmel müzik indiriciye bir göz at http://github.com/Shabinder/SpotiFlyer grantAnalytics = Analizleri Yolla noInternetConnection = İnternet Bağlantısı Yok! checkInternetConnection = Lütfen internet bağlantınızı kontrol edin. grantPermissions = İzin Ver requiredPermissions = Gerekli İzinler: storagePermission = Depolama İzni. storagePermissionReason = Favori müziklerini cihazına indirebilmen için. backgroundRunning = Arka Plan İzni backgroundRunningReason = Tüm şarkıları hiçbir sistem kesintisi olmadan indirebilmek için. no = Almayayım yes = Tabii Ki acraNotificationTitle = ABOO, SpotiFlyer çöktü. acraNotificationText = Lütfen uygulama geliştiricilerine çökme raporunu yollayın ki böyle bir talihsiz olay bir daha yaşanmasın. albumArt = Albüm Resmi tracks = Parça coverImage = Kapak Resmi reSearch = Yeniden Arat loading = Yükleniyor downloadAll = Hepsini indir button = Buton errorOccurred = Bir hata meydana geldi, linkini / bağlantını kontrol et. downloadDone = İndirme Tamamlandı downloadError = Hata! Bu parça indirilemiyor downloadStart = İndirmeyi Başlat supportUs = Desteğine ihtiyacımız var! donation = Bağış worldWideDonations = Dünya Çapında Bağışlar indianDonations = Sadece Hint Bağışları dismiss = Reddet remindLater = Daha Sonra Hatırlat mp3ConverterBusy = MP3 dönüştürücüye ulaşılamıyor, büyük ihtimalle MEŞGUL! unknownError = Bilinmeyen Hata noMatchFound = HİÇBİR eşleşme bulunamadı! noLinkFound = İndirilebilir Link Bulunamadı linkNotValid = Girilen Link Geçerli DEĞİL! featureUnImplemented = Özellik henüz uygulanmadı. minute = dk second = sn spotiflyerLogo = SpotiFlyer Logosu backButton = Geri butonu infoTab = Bilgi Sekmesi historyTab = Geçmiş Sekmesi linkTextBox = Link Metni Kutusu pasteLinkHere = Linki Buraya Yapıştır... enterALink = Bir Link Gir! madeWith = Hindistan'da love = Aşk inIndia = ile yapıldı open = Aç byDeveloperName = geliştirici: Shabinder Singh ================================================ FILE: translations/Strings_tw.properties ================================================ title = SpotiFlyer about = 關於 history = 歷史紀錄 donate = 捐贈 preferences = 個人化 search = 搜索 supportedPlatforms = 受支援的平台 supportDevelopment = 給我們動力 openProjectRepo = 打開項目回購 starOrForkProject = Github 目標地址 help = 找尋使用技巧 translate = 翻譯 helpTranslateDescription = 幫助我們翻譯別國語言 supportDeveloper = 現在開始抖內! donateDescription = 你若認可此專案,歡迎抖內!! share = 分享 shareDescription = 這麼好用的工具,還不快分享!? whatWentWrong = 我出錯了…… copyToClipboard = 複製到剪貼簿 copyCodeInGithubIssue = 到 Github 上報告此問題,這樣可以更快獲得解方,貼上以下代碼。 status = 狀態 analytics = 分析 analyticsDescription = 你的資料是受保護的,我們不會與黑市做交易… noHistoryAvailable = 還沒搜過任何東西… cleaningAndExiting = 我先去睡了… total = 統計 completed = 已完成 failed = 失敗 exit = 讓我休眠 downloading = 正在下載… processing = 處理中,請稍後… queued = 已加入下載! setDownloadDirectory = 設置下載目錄 downloadDirectorySetTo = 下載目錄設置為: {0} noWriteAccess = 沒辦法設這!: {0} , 正在回到上一頁… shareMessage = 你若不參考看看,就是你吃虧 http://github.com/Shabinder/SpotiFlyer grantAnalytics = 乾爹,數鈔中… noInternetConnection = 你跑到宇宙去了!! checkInternetConnection = 快回來地球吧~ grantPermissions = 給我力量! requiredPermissions = 我需要: storagePermission =儲存權限 storagePermissionReason = 讓你在宇宙也能放歌~ backgroundRunning = 後台運行 backgroundRunningReason = 在你做別的事情時也能照樣載歌 no = 無 yes = 有 acraNotificationTitle = SpotiFlyer 爆炸了!!! acraNotificationText = 把這次爆炸的原因回報 Github,造福全人類吧! albumArt = 專輯封面 tracks = 曲目 coverImage = 封面 reSearch = 重新搜索 loading = 讀取中… downloadAll = 我全都要! button = 按鈕 errorOccurred = 出現錯誤,檢查一下貼上的鏈接或是有沒有網路 downloadDone = 下載完成~ downloadError = 出錯了!沒辦法下載這歌… downloadStart = 開始下載 supportUs = 我們需要你的幫助! donation = 抖內 worldWideDonations = 全球抖內 indianDonations = 印度抖內 dismiss = 忽略 remindLater = 等等再提醒 mp3ConverterBusy = MP3 轉譯器不知道在幹嘛?等等再試一次… unknownError = 我不知道哪裡出錯!! noMatchFound = 沒找到匹配項目… noLinkFound = 沒找到鏈接! linkNotValid = 鏈接不可用! featureUnImplemented = 這是未來科技…… minute = 分 second = 秒 spotiflyerLogo = SpotiFlyer 圖標 backButton = 返回鍵 infoTab = 詳情 historyTab = 歷史 linkTextBox = 鏈接 pasteLinkHere = 在這裡輸入鏈接~ enterALink = 請輸入鏈接! madeWith = 用 love = 喜歡 inIndia = 於印度 open = 打開 byDeveloperName = 開發者: Shabinder Singh 翻譯(TW):唐懂 ================================================ FILE: translations/Strings_uk.properties ================================================ title = SpotiFlyer about = Про програму history = Історія donate = Донат preferences = Налаштування search = Пошук supportedPlatforms = Підтримувані платформи supportDevelopment = Підтримати розробку openProjectRepo = Відкрити репозиторій starOrForkProject = Fork проекта на GitHub help = Допомога translate = Переклад helpTranslateDescription = Допоможіть перекласти цю програму на вашу мову. supportDeveloper = Підтримати розробника donateDescription = Якщо ви вважаєте, що я заслуговую на отримання грошей за свою роботу, ви можете підтримати мене тут. share = Поділитися shareDescription = Поділися цією програмою зі своїми друзями та родиною. whatWentWrong = Що пішло не так... copyToClipboard = Копіювати в буфер обміну copyCodeInGithubIssue = Скопіюйте вставку нижче коду під час створення випуску Github / звітування про цю проблему, щоб отримати кращу допомогу. status = Статус analytics = Аналіз analyticsDescription = Ваші дані анонімні та ніколи не передаються стороннім сервісам. noHistoryAvailable = Поки немає історії cleaningAndExiting = Очистка та завершення total = Всього completed = Завершенно failed = Помилка exit = Вихід downloading = Завантаження processing = Обробка queued = В черзі setDownloadDirectory = Вкажіть директорію завантаження downloadDirectorySetTo = Директорія завантаження: {0} noWriteAccess = NO WRITE ACCESS on: {0} , Повернення shareMessage = Привіт, хочу поділитися з вами, цим чудовим проектом, який дозволяє завантажувати музику http://github.com/Shabinder/SpotiFlyer grantAnalytics = Перевірка дозволів noInternetConnection = Немає підключення до інтернету! checkInternetConnection = Перевірте з'єднання з інтернетом. grantPermissions = Надати дозволи requiredPermissions = Необхідні дозволи: storagePermission = Дозвіл до пам'яті. storagePermissionReason = Щоб завантажити улюблені пісні на цей пристрій. backgroundRunning = Працює в фоні. backgroundRunningReason = Завантажувати всі пісні у фоновому режимі без будь-яких системних перебоїв. no = Ні yes = Звичайно acraNotificationTitle = Упс, SpotiFlyer крашнувся :( acraNotificationText = Надішліть звіт про аварійне завершення, розробникам додатку. albumArt = Зображення альбому tracks = Треки coverImage = Зображення обкладинки reSearch = Повторний пошук loading = Завантаження... downloadAll = Завантажити все button = Кнопка errorOccurred = Сталася помилка, перевірте своє з’єднання downloadDone = Завантаження завершенно downloadError = Помилка! Неможливо завантажити цю композицію downloadStart = Початок завантаження supportUs = Нам потрібна ваша підтримка! donation = Донат worldWideDonations = Світові пожертви indianDonations = Тільки індійські пожертви dismiss = Відхилити remindLater = Нагадати пізніше # Exceptions mp3ConverterBusy = MP3 Конвентер недоступний, напевне ЗАЙНЯТИЙ! unknownError = Невідома noMatchFound = Нічого не знайденно! noLinkFound = Посилання для завантаження не знайденно! linkNotValid = Це посилання не підходить! featureUnImplemented = Функція ще не реалізована. minute = хв second = сек spotiflyerLogo = SpotiFlyer Логотип backButton = Кнопка назад infoTab = Інформація historyTab = Історія linkTextBox = Поле для посилання pasteLinkHere = Вставте посилання сюди... enterALink = Вставте посилання! madeWith = Зроблено з love = Любовю inIndia = в Індії open = Відкрити byDeveloperName = від: Shabinder Singh ================================================ FILE: translations/Strings_ur.properties.xml ================================================ title = SpotiFlyer about = کے بارے میں history = تاریخ donate = عطیہ کریں preferences = ترجیحات search = تلاش کریں supportedPlatforms = تائید شدہ پلیٹ فارمز supportDevelopment = سپورٹ ڈویلپمنٹ openProjectRepo = پروجیکٹ ریپو کھولیں starOrForkProject = سٹار / Github پر پروجیکٹ کو فورک کریں help = مدد translate = ترجمہ کریں helpTranslateDescription = اس ایپ کا اپنی مقامی زبان میں ترجمہ کرنے میں ہماری مدد کریں supportDeveloper = سپورٹ ڈویلپر donateDescription = اگر آپ کو لگتا ہے کہ میں اپنے کام کے لیے معاوضہ لینے کا مستحق ہوں، تو آپ یہاں میری مدد کر سکتے ہیں۔ share = شیئر shareDescription = اس ایپ کو اپنے دوستوں اور خاندان کے ساتھ شیئر کریں۔ whatWentWrong = کیا غلط ہوا۔۔۔ copyToClipboard = کلپ بورڈ پر کاپی کریں۔ copyCodeInGithubIssue = بہتر مدد کے لیے گیتھب ایشو بناتے وقت / اس مسئلے کو رپورٹ کرتے وقت نیچے کوڈ کاپی پیسٹ کریں۔ status = حالت analytics = تجزیات analyticsDescription = آپ کا ڈیٹا گمنام ہے اور فریق ثالث سروس کے ساتھ کبھی شیئر نہیں کیا گیا ہے۔ noHistoryAvailable = کوئی تاریخ دستیاب نہیں ہے۔ cleaningAndExiting = صفائی اور باہر نکلنا total = کل completed = مکمل failed = ناکام exit = باہر نکلیں downloading = ڈاؤن لوڈ ہو رہا ہے processing = پروسیسنگ queued = قطار میں لگ گیا setDownloadDirectory = ڈاؤن لوڈ ڈائرکٹری سیٹ کریں downloadDirectorySetTo = ڈاؤن لوڈ ڈائرکٹری اس پر سیٹ ھوگیا ھے: {0} noWriteAccess = ٰٓاس پر رائٹ رسائی نھیں ھے: {0}، واپس پچھلے پر واپس جارھاھے shareMessage = ارے، اس بہترین میوزک ڈاؤنلوڈر کو چیک کریں http://github.com/Shabinder/SpotiFlyer grantAnalytics = تجزیات کو گرانٹ کریں noInternetConnection = کوئی انٹرنیٹ کنکشن نہیں checkInternetConnection = براہ کرم اپنا نیٹ ورک کنکشن چیک کریں۔ grantPermissions = اجازتیں دیں requiredPermissions = مطلوبہ اجازتیں: storagePermission = اسٹوریج کی اجازت۔ storagePermissionReason = اس ڈیوائس پر اپنے پسندیدہ گانے ڈاؤن لوڈ کرنے کے لیے۔ backgroundRunning = پس منظر چل رہا ہے. backgroundRunningReason = سسٹم میں رکاوٹ کے بغیر تمام گانوں کو بیک گراؤنڈ میں ڈاؤن لوڈ کرنے کے لیے۔ no = نھیں yes = ضرور acraNotificationTitle = SpotiFlyer کریش ہو گیا۔ acraNotificationText = براہ کرم کریش رپورٹ ایپ ڈیولپرز کو ارسال کریں، تاکہ یہ افسوسناک واقعہ دوبارہ پیش نہ آئے۔ albumArt = البم آرٹ tracks = ٹریکس coverImage = کور امیج reSearch = دوبارہ تلاش کریں loading = لوڈ ہو رہا ہے downloadAll = تمام ڈاؤن لوڈ کریں button = بٹن errorOccurred = ایک خرابی پیش آگئی، اپنا لنک / کنکشن چیک کریں downloadDone = ڈاؤن لوڈ ہو گیا downloadError = غلطی! اس ٹریک کو ڈاؤن لوڈ نہیں کر سکتے downloadStart = ڈاون لوڈ کرنا شروع کریں supportUs = ہمیں آپ کے تعاون کی ضرورت ہے donation = عطیہ worldWideDonations = عالمی سطح پر عطیات indianDonations = صرف ہندوستانی عطیات dismiss = برطرف کرنا remindLater = بعد میں یاد دلائیں mp3ConverterBusy = mp3 کنورٹر ناقابل رسائی، شاید مصروف unknownError = نامعلوم خامی noMatchFound = کوئی مماثلت نہیں ملی noLinkFound = کوئی ڈاؤن لوڈ کے قابل لنک نہیں ملا linkNotValid = درج کردہ لنک درست نہیں ہے featureUnImplemented = خصوصیت ابھی لاگو نہیں ہوا۔ minute = منٹ second = سیکنڈ spotiflyerLogo = SpotiFlyer لوگو backButton = پیچھے جانے کا بٹن infoTab = معلومات کا ٹیب historyTab = تاریخ ٹیب linkTextBox = لنک ٹیکسٹ باکس pasteLinkHere = لنک یہاں چسپاں کریں۔۔۔ enterALink = ایک لنک درج کریں madeWith = کے ساتھ بنایا گیا love = محبت inIndia = بھارت میں open = کھولیں byDeveloperName = از: شبندر سنگھ ================================================ FILE: web-app/build.gradle.kts ================================================ /* * * Copyright (c) 2021 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 . */ plugins { kotlin("js") } group = "com.shabinder" version = "0.1" dependencies { with(deps) { implementation(koin.core) implementation(decompose.dep) implementation(ktor.client.js) with(bundles) { implementation(mviKotlin) implementation(ktor) implementation(kotlin.js.wrappers) } } implementation(project(":common:root")) implementation(project(":common:main")) implementation(project(":common:list")) implementation(project(":common:database")) implementation(project(":common:data-models")) implementation(project(":common:providers")) implementation(project(":common:core-components")) implementation(project(":common:dependency-injection")) implementation("org.jetbrains.kotlin:kotlin-stdlib-js:${deps.kotlin.kotlinGradlePlugin.get().versionConstraint.requiredVersion}") } kotlin { js(IR) { //useCommonJs() browser { webpackTask { cssSupport.enabled = true } runTask { cssSupport.enabled = true } testTask { useKarma { useChromeHeadless() webpackConfig.cssSupport.enabled = true } } } binaries.executable() } // WorkAround: https://youtrack.jetbrains.com/issue/KT-49124 rootProject.plugins.withType { rootProject.the().apply { resolution("@webpack-cli/serve", "1.5.2") } } } ================================================ FILE: web-app/src/main/kotlin/App.kt ================================================ /* * * Copyright (c) 2021 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 . */ import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.essenty.lifecycle.destroy import com.arkivanov.essenty.lifecycle.resume import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import com.shabinder.common.core_components.file_manager.DownloadProgressFlow import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.di.ApplicationInit import com.shabinder.common.models.Actions import com.shabinder.common.models.PlatformActions import com.shabinder.common.models.TrackDetails import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.database.Database import extras.renderableChild import react.PropsWithChildren import react.RBuilder import react.RComponent import react.State import root.RootR external interface AppProps : PropsWithChildren { var dependencies: AppDependencies } @Suppress("FunctionName") fun RBuilder.App(attrs: AppProps.() -> Unit) { return child(App::class) { this.attrs(attrs) } } @Suppress("EXPERIMENTAL_IS_NOT_ENABLED", "NON_EXPORTABLE_TYPE") @OptIn(ExperimentalJsExport::class) @JsExport class App(props: AppProps) : RComponent(props) { private val lifecycle = LifecycleRegistry() private val ctx = DefaultComponentContext(lifecycle = lifecycle) private val dependencies = props.dependencies override fun RBuilder.render() { renderableChild(RootR::class, root) } private val root = SpotiFlyerRoot(ctx, object : SpotiFlyerRoot.Dependencies { override val storeFactory: StoreFactory = LoggingStoreFactory(DefaultStoreFactory) override val fetchQuery = dependencies.fetchPlatformQueryResult override val fileManager = dependencies.fileManager override val analyticsManager = dependencies.analyticsManager override val appInit: ApplicationInit = dependencies.appInit override val preferenceManager: PreferenceManager = dependencies.preferenceManager override val database: Database? = fileManager.db override val downloadProgressFlow = DownloadProgressFlow override val actions = object : Actions { override val platformActions = object : PlatformActions {} override fun showPopUpMessage(string: String, long: Boolean) { /*TODO("Not yet implemented")*/ } override fun copyToClipboard(text: String) {} override fun setDownloadDirectoryAction(callBack: (String) -> Unit) {} override fun queryActiveTracks() {} override fun giveDonation() {} override fun shareApp() {} override fun openPlatform(packageID: String, platformLink: String) {} override fun writeMp3Tags(trackDetails: TrackDetails) {/*IMPLEMENTED*/ } override val isInternetAvailable: Boolean = true } } ) override fun componentDidMount() { lifecycle.resume() } override fun componentWillUnmount() { lifecycle.destroy() } } ================================================ FILE: web-app/src/main/kotlin/Styles.kt ================================================ /* * * Copyright (c) 2021 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 . */ import kotlinx.css.Align import kotlinx.css.BorderStyle import kotlinx.css.Color import kotlinx.css.Display import kotlinx.css.JustifyContent import kotlinx.css.alignContent import kotlinx.css.alignItems import kotlinx.css.backgroundColor import kotlinx.css.borderBottomColor import kotlinx.css.borderBottomStyle import kotlinx.css.borderColor import kotlinx.css.borderRadius import kotlinx.css.borderRightColor import kotlinx.css.borderWidth import kotlinx.css.color import kotlinx.css.display import kotlinx.css.justifyContent import kotlinx.css.margin import kotlinx.css.padding import kotlinx.css.px import styled.StyleSheet val colorPrimary = Color("#FC5C7D") val colorPrimaryDark = Color("#CE1CFF") val colorAccent = Color("#9AB3FF") val colorOffWhite = Color("#E7E7E7") object Styles: StyleSheet("Searchbar", isStatic = true) { val makeRow by css { display = Display.flex alignItems = Align.center alignContent = Align.center justifyContent = JustifyContent.center } val darkMode by css { backgroundColor = Color.black color = Color.white } val circular by css { borderRadius = 30.px borderWidth = 5.px borderBottomStyle = BorderStyle.solid } val circularGradient by css { apply(circular) borderColor = Color.aqua borderBottomColor = colorPrimary borderRightColor = colorPrimary } val largePadding by css { padding(20.px) } val mediumPadding by css { padding(14.px) } val smallPadding by css { padding(4.px) } val largeMargin by css { margin(20.px) } val mediumMargin by css { margin(12.px) } val smallMargin by css { margin(4.px) } } ================================================ FILE: web-app/src/main/kotlin/client.kt ================================================ /* * * Copyright (c) 2021 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 . */ import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.analytics.AnalyticsManager import com.shabinder.common.core_components.file_manager.FileManager import com.shabinder.common.core_components.preference_manager.PreferenceManager import com.shabinder.common.di.ApplicationInit import com.shabinder.common.di.initKoin import com.shabinder.common.providers.FetchPlatformQueryResult import kotlinx.browser.document import kotlinx.browser.window import org.koin.core.component.KoinComponent import org.koin.core.component.get import react.dom.render fun main() { window.onload = { render(document.getElementById("root")) { App { dependencies = AppDependencies } } } } object AppDependencies : KoinComponent { val logger: Kermit val fileManager: FileManager val fetchPlatformQueryResult: FetchPlatformQueryResult val preferenceManager: PreferenceManager val analyticsManager: AnalyticsManager val appInit: ApplicationInit init { initKoin() fileManager = get() logger = get() fetchPlatformQueryResult = get() preferenceManager = get() analyticsManager = get() appInit = get() } } ================================================ FILE: web-app/src/main/kotlin/extras/RenderableComponent.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package extras import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.ValueObserver import react.PropsWithChildren import react.RComponent import react.State import react.setState @Suppress("EXPERIMENTAL_IS_NOT_ENABLED", "NON_EXPORTABLE_TYPE") @OptIn(ExperimentalJsExport::class) @JsExport abstract class RenderableComponent( props: Props, initialState: S ) : RComponent, S>(props) { private val subscriptions = ArrayList>() protected val component: T get() = props.component init { state = initialState } override fun componentDidMount() { subscriptions.forEach { subscribe(it) } } private fun subscribe(subscription: Subscription) { subscription.value.subscribe(subscription.observer) } override fun componentWillUnmount() { subscriptions.forEach { unsubscribe(it) } } private fun unsubscribe(subscription: Subscription) { subscription.value.unsubscribe(subscription.observer) } protected fun Value.bindToState(buildState: S.(T) -> Unit) { subscriptions += Subscription(this) { data -> setState { buildState(data) } } } protected class Subscription( val value: Value, val observer: ValueObserver ) } @Suppress("EXPERIMENTAL_IS_NOT_ENABLED", "NON_EXPORTABLE_TYPE") @OptIn(ExperimentalJsExport::class) @JsExport class RStateWrapper( var model: T ) : State external interface Props : PropsWithChildren { var component: T } ================================================ FILE: web-app/src/main/kotlin/extras/UniqueId.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package extras import kotlinext.js.Object import kotlinext.js.jsObject var uniqueId: Long = 0L internal fun Any.uniqueId(): Long { var id: dynamic = asDynamic().__unique_id if (id == undefined) { id = ++uniqueId Object.defineProperty(this, "__unique_id", jsObject { value = id }) } return id } ================================================ FILE: web-app/src/main/kotlin/extras/Utils.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package extras import react.RBuilder import kotlin.reflect.KClass fun > RBuilder.renderableChild(clazz: KClass, model: M) { child(clazz) { key = model.uniqueId().toString() attrs.component = model } } ================================================ FILE: web-app/src/main/kotlin/home/HomeScreen.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package home import com.shabinder.common.main.SpotiFlyerMain import extras.Props import extras.RStateWrapper import extras.RenderableComponent import kotlinx.browser.document import kotlinx.css.* import kotlinx.dom.appendElement import react.RBuilder import styled.css import styled.styledDiv class HomeScreen( props: Props, ) : RenderableComponent>( props, initialState = RStateWrapper(props.component.model.value) ) { init { component.model.bindToState { model = it } } override fun componentDidMount() { super.componentDidMount() // RazorPay Button val form = document.getElementById("razorpay-form")!! repeat(form.childNodes.length){ form.childNodes.item(0)?.let { it1 -> form.removeChild(it1) } form.childNodes.item(it)?.let { it1 -> form.removeChild(it1) } } form.appendElement("script"){ this.setAttribute("src","https://checkout.razorpay.com/v1/payment-button.js") this.setAttribute("async", true.toString()) this.setAttribute("data-payment_button_id", "pl_GnKuuDBdBu0ank") } } override fun RBuilder.render() { styledDiv{ css { display = Display.flex flexDirection = FlexDirection.column flexGrow = 1.0 justifyContent = JustifyContent.center alignItems = Align.center } Message { text = "Your Gateway to Nirvana, for FREE!" } SearchBar { link = state.model.link search = component::onLinkSearch onLinkChange = component::onInputLinkChanged } IconList { iconsAndPlatforms = platformIconList isBadge = false } } IconList { iconsAndPlatforms = badges isBadge = true } } } private val platformIconList = mapOf( "spotify.svg" to "https://open.spotify.com/", "gaana.svg" to "https://www.gaana.com/", //"youtube.svg" to "https://www.youtube.com/", //"youtube_music.svg" to "https://music.youtube.com/" ) private val badges = mapOf( "https://img.shields.io/github/v/release/Shabinder/SpotiFlyer?color=7885FF&label=SpotiFlyer&logo=android&style=for-the-badge" to "https://github.com/Shabinder/SpotiFlyer/releases/latest/", "https://img.shields.io/github/downloads/Shabinder/SpotiFlyer/total?style=for-the-badge&logo=android&color=17B2E7" to "https://github.com/Shabinder/SpotiFlyer/releases/latest/" ) ================================================ FILE: web-app/src/main/kotlin/home/IconList.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package home import Styles import kotlinx.css.borderRadius import kotlinx.css.height import kotlinx.css.margin import kotlinx.css.px import kotlinx.css.width import kotlinx.html.id import react.PropsWithChildren import react.RBuilder import react.dom.attrs import react.functionComponent import styled.css import styled.styledA import styled.styledDiv import styled.styledForm import styled.styledImg external interface IconListProps : PropsWithChildren { var iconsAndPlatforms: Map var isBadge: Boolean } @Suppress("FunctionName") fun RBuilder.IconList(handler: IconListProps.() -> Unit) { return child(iconList) { attrs { handler() } } } private val iconList = functionComponent("IconList") { props -> styledDiv { css { margin(18.px) if (props.isBadge) { classes.add("info-banners") } +Styles.makeRow } val firstElem = props.iconsAndPlatforms.keys.elementAt(1) for ((icon, platformLink) in props.iconsAndPlatforms) { if (icon == firstElem && props.isBadge) { //
styledForm { attrs { id = "razorpay-form" } } } styledA(href = platformLink, target = "_blank") { styledImg { attrs { src = icon } css { classes.add("glow-button") margin(8.px) if (!props.isBadge) { height = 42.px width = 42.px borderRadius = 50.px } } } } } } } ================================================ FILE: web-app/src/main/kotlin/home/Message.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package home import kotlinx.css.em import kotlinx.css.fontSize import react.PropsWithChildren import react.RBuilder import react.functionComponent import styled.css import styled.styledDiv import styled.styledH1 external interface MessageProps : PropsWithChildren { var text: String } @Suppress("FunctionName") fun RBuilder.Message(handler: MessageProps.() -> Unit) { return child(message) { attrs { handler() } } } private val message = functionComponent("Message") { props -> styledDiv { styledH1 { +props.text css { classes.add("headingTitle") fontSize = 2.6.em } } } } ================================================ FILE: web-app/src/main/kotlin/home/Searchbar.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package home import kotlinx.browser.window import kotlinx.html.InputType import kotlinx.html.js.onChangeFunction import kotlinx.html.js.onClickFunction import kotlinx.html.js.onKeyDownFunction import org.w3c.dom.HTMLInputElement import react.PropsWithChildren import react.RBuilder import react.dom.attrs import react.functionComponent import styled.css import styled.styledButton import styled.styledDiv import styled.styledImg import styled.styledInput external interface SearchbarProps : PropsWithChildren { var link: String var search: (String) -> Unit var onLinkChange: (String) -> Unit } @Suppress("FunctionName") fun RBuilder.SearchBar(handler: SearchbarProps.() -> Unit) = child(searchbar) { attrs { handler() } } val searchbar = functionComponent("SearchBar") { props -> styledDiv { css { classes.add("searchBox") } styledInput(type = InputType.url) { attrs { placeholder = "Search" onChangeFunction = { val target = it.target as HTMLInputElement props.onLinkChange(target.value) } this.onKeyDownFunction = { if (it.asDynamic().key == "Enter") { if (props.link.isEmpty()) window.alert("Enter a Link from Supported Platforms") else props.search(props.link) } } value = props.link } css { classes.add("searchInput") } } styledButton { attrs { onClickFunction = { if (props.link.isEmpty()) window.alert("Enter a Link from Supported Platforms") else props.search(props.link) } } css { classes.add("searchButton") } styledImg(src = "search.svg") { css { classes.add("search-icon") } } } } } ================================================ FILE: web-app/src/main/kotlin/list/CircularProgressBar.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import kotlinx.css.Display import kotlinx.css.JustifyContent import kotlinx.css.display import kotlinx.css.justifyContent import kotlinx.css.marginBottom import kotlinx.css.px import kotlinx.css.width import react.PropsWithChildren import react.RBuilder import react.functionComponent import styled.css import styled.styledDiv import styled.styledSpan @Suppress("FunctionName") fun RBuilder.CircularProgressBar(handler: CircularProgressBarProps.() -> Unit) { return child(circularProgressBar) { attrs { handler() } } } external interface CircularProgressBarProps : PropsWithChildren { var progress: Int } private val circularProgressBar = functionComponent("Circular-Progress-Bar") { props -> styledDiv { styledSpan { +"${props.progress}%" } styledDiv { css { classes.add("left-half-clipper") } styledDiv { css { classes.add("first50-bar") } } styledDiv { css { classes.add("value-bar") } } } css { display = Display.flex justifyContent = JustifyContent.center classes.addAll( mutableListOf( "progress-circle", "p${props.progress}" ).apply { if (props.progress > 50) add("over50") }) width = 50.px marginBottom = 65.px } } } ================================================ FILE: web-app/src/main/kotlin/list/CoverImage.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import kotlinx.css.Align import kotlinx.css.Display import kotlinx.css.FlexDirection import kotlinx.css.TextAlign import kotlinx.css.alignItems import kotlinx.css.display import kotlinx.css.flexDirection import kotlinx.css.height import kotlinx.css.marginTop import kotlinx.css.px import kotlinx.css.textAlign import kotlinx.css.width import kotlinx.html.id import react.PropsWithChildren import react.RBuilder import react.dom.attrs import react.functionComponent import styled.css import styled.styledDiv import styled.styledH1 import styled.styledImg external interface CoverImageProps : PropsWithChildren { var coverImageURL: String var coverName: String } @Suppress("FunctionName") fun RBuilder.CoverImage(handler: CoverImageProps.() -> Unit) { return child(coverImage) { attrs { handler() } } } private val coverImage = functionComponent("CoverImage") { props -> styledDiv { styledImg(src = props.coverImageURL) { css { height = 220.px width = 220.px } } styledH1 { +props.coverName css { textAlign = TextAlign.center } } attrs { id = "cover-image" } css { display = Display.flex alignItems = Align.center flexDirection = FlexDirection.column marginTop = 12.px } } } ================================================ FILE: web-app/src/main/kotlin/list/DownloadAllButton.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import kotlinx.css.Align import kotlinx.css.Display import kotlinx.css.JustifyContent import kotlinx.css.WhiteSpace import kotlinx.css.alignItems import kotlinx.css.display import kotlinx.css.fontSize import kotlinx.css.height import kotlinx.css.justifyContent import kotlinx.css.px import kotlinx.css.whiteSpace import kotlinx.html.id import kotlinx.html.js.onClickFunction import react.PropsWithChildren import react.RBuilder import react.dom.attrs import react.functionComponent import react.useEffect import react.useState import styled.css import styled.styledDiv import styled.styledH5 import styled.styledImg external interface DownloadAllButtonProps : PropsWithChildren { var isActive: Boolean var link: String var downloadAll: () -> Unit } @Suppress("FunctionName") fun RBuilder.DownloadAllButton(handler: DownloadAllButtonProps.() -> Unit) { return child(downloadAllButton) { attrs { handler() } } } private val downloadAllButton = functionComponent("DownloadAllButton") { props -> val (isClicked, setClicked) = useState(false) useEffect(mutableListOf(props.link)) { setClicked(false) } if (props.isActive) { if (isClicked) { styledDiv { css { display = Display.flex alignItems = Align.center justifyContent = JustifyContent.center height = 52.px } LoadingSpinner { } } } else { styledDiv { attrs { onClickFunction = { props.downloadAll() setClicked(true) } } styledDiv { styledImg(src = "download.svg", alt = "Download All Button") { css { classes.add("download-all-icon") height = 32.px } } styledH5 { attrs { id = "download-all-text" } +"Download All" css { whiteSpace = WhiteSpace.nowrap fontSize = 15.px } } css { classes.add("download-icon") display = Display.flex alignItems = Align.center } } css { classes.add("download-button") display = Display.flex alignItems = Align.center } } } } } ================================================ FILE: web-app/src/main/kotlin/list/DownloadButton.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import com.shabinder.common.models.DownloadStatus import kotlinx.css.borderRadius import kotlinx.css.em import kotlinx.css.margin import kotlinx.css.px import kotlinx.css.width import kotlinx.html.js.onClickFunction import react.PropsWithChildren import react.RBuilder import react.dom.attrs import react.functionComponent import styled.css import styled.styledDiv import styled.styledImg @Suppress("FunctionName") fun RBuilder.DownloadButton(handler: DownloadButtonProps.() -> Unit) { return child(downloadButton) { attrs { handler() } } } external interface DownloadButtonProps : PropsWithChildren { var onClick: () -> Unit var status: DownloadStatus } private val downloadButton = functionComponent("Circular-Progress-Bar") { props -> styledDiv { val src = when (props.status) { is DownloadStatus.NotDownloaded -> "download-gradient.svg" is DownloadStatus.Downloaded -> "check.svg" is DownloadStatus.Failed -> "error.svg" else -> "" } styledImg(src = src) { attrs { onClickFunction = { props.onClick() } } css { width = (2.5).em margin(8.px) } } css { classes.add("glow-button") borderRadius = 100.px } } } ================================================ FILE: web-app/src/main/kotlin/list/ListScreen.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.list.SpotiFlyerList.State import extras.Props import extras.RStateWrapper import extras.RenderableComponent import kotlinx.css.* import kotlinx.html.id import react.RBuilder import react.dom.attrs import styled.css import styled.styledDiv import styled.styledSection class ListScreen( props: Props, ) : RenderableComponent>( props, initialState = RStateWrapper(props.component.model.value) ) { init { component.model.bindToState { model = it } } override fun RBuilder.render() { val queryResult = state.model.queryResult styledSection { attrs { id = "list-screen" } if(queryResult == null) { LoadingAnim { } }else { CoverImage { coverImageURL = queryResult.coverUrl coverName = queryResult.title } DownloadAllButton { isActive = state.model.trackList.size > 1 downloadAll = { component.onDownloadAllClicked(state.model.trackList) } link = state.model.link } styledDiv { css { display =Display.flex flexGrow = 1.0 flexDirection = FlexDirection.column color = Color.white } state.model.trackList.forEachIndexed{ _, trackDetails -> TrackItem { details = trackDetails downloadTrack = component::onDownloadClicked } } } } css { classes.add("list-screen") display = Display.flex padding(8.px) flexDirection = FlexDirection.column flexGrow = 1.0 } } } } ================================================ FILE: web-app/src/main/kotlin/list/LoadingAnim.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import kotlinx.css.Align import kotlinx.css.Display import kotlinx.css.alignItems import kotlinx.css.display import kotlinx.css.flexGrow import kotlinx.css.height import kotlinx.css.px import kotlinx.css.width import react.PropsWithChildren import react.RBuilder import react.functionComponent import styled.css import styled.styledDiv @Suppress("FunctionName") fun RBuilder.LoadingAnim(handler: PropsWithChildren.() -> Unit) { return child(loadingAnim) { attrs { handler() } } } private val loadingAnim = functionComponent("Loading Animation") { styledDiv { css { flexGrow = 1.0 display = Display.flex alignItems = Align.center } styledDiv { styledDiv { css { classes.add("sk-cube sk-cube1") } } styledDiv { css { classes.add("sk-cube sk-cube2") } } styledDiv { css { classes.add("sk-cube sk-cube3") } } styledDiv { css { classes.add("sk-cube sk-cube4") } } styledDiv { css { classes.add("sk-cube sk-cube5") } } styledDiv { css { classes.add("sk-cube sk-cube6") } } styledDiv { css { classes.add("sk-cube sk-cube7") } } styledDiv { css { classes.add("sk-cube sk-cube8") } } styledDiv { css { classes.add("sk-cube sk-cube9") } } css { classes.add("sk-cube-grid") height = 60.px width = 60.px } } } } ================================================ FILE: web-app/src/main/kotlin/list/LoadingSpinner.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import kotlinx.css.marginRight import kotlinx.css.px import kotlinx.css.width import react.PropsWithChildren import react.RBuilder import react.functionComponent import styled.css import styled.styledDiv @Suppress("FunctionName") fun RBuilder.LoadingSpinner(handler: PropsWithChildren.() -> Unit) { return child(loadingSpinner) { attrs { handler() } } } private val loadingSpinner = functionComponent("Loading-Spinner") { styledDiv { styledDiv {} styledDiv {} styledDiv {} styledDiv {} css { classes.add("lds-ring") width = 50.px marginRight = 8.px } } } ================================================ FILE: web-app/src/main/kotlin/list/TrackItem.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package list import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails import kotlinx.css.Align import kotlinx.css.Display import kotlinx.css.FlexDirection import kotlinx.css.Overflow import kotlinx.css.TextAlign import kotlinx.css.TextOverflow import kotlinx.css.WhiteSpace import kotlinx.css.alignItems import kotlinx.css.display import kotlinx.css.em import kotlinx.css.flexDirection import kotlinx.css.flexGrow import kotlinx.css.fontSize import kotlinx.css.height import kotlinx.css.margin import kotlinx.css.minWidth import kotlinx.css.overflow import kotlinx.css.padding import kotlinx.css.paddingRight import kotlinx.css.px import kotlinx.css.textAlign import kotlinx.css.textOverflow import kotlinx.css.whiteSpace import kotlinx.css.width import kotlinx.html.id import react.PropsWithChildren import react.RBuilder import react.dom.attrs import react.functionComponent import react.useEffect import react.useState import styled.css import styled.styledDiv import styled.styledH3 import styled.styledH4 import styled.styledImg external interface TrackItemProps : PropsWithChildren { var details: TrackDetails var downloadTrack: (TrackDetails) -> Unit } @Suppress("FunctionName") fun RBuilder.TrackItem(handler: TrackItemProps.() -> Unit) { return child(trackItem) { attrs { handler() } } } private val trackItem = functionComponent("Track-Item") { props -> val (downloadStatus, setDownloadStatus) = useState(props.details.downloaded) val details = props.details useEffect(listOf(props.details)) { setDownloadStatus(props.details.downloaded) } styledDiv { styledImg(src = details.albumArtURL) { css { height = 90.px width = 90.px } } styledDiv { attrs { id = "text-details" } css { flexGrow = 1.0 minWidth = 0.px display = Display.flex flexDirection = FlexDirection.column margin(8.px) } styledDiv { css { height = 40.px alignItems = Align.center display = Display.flex } styledH3 { +details.title css { padding(8.px) fontSize = 1.3.em textOverflow = TextOverflow.ellipsis whiteSpace = WhiteSpace.nowrap overflow = Overflow.hidden } } } styledDiv { css { height = 40.px alignItems = Align.center display = Display.flex } styledH4 { +details.artists.joinToString(",") css { flexGrow = 1.0 padding(8.px) minWidth = 4.em fontSize = 1.1.em textOverflow = TextOverflow.ellipsis whiteSpace = WhiteSpace.nowrap overflow = Overflow.hidden } } styledH4 { css { textAlign = TextAlign.end flexGrow = 1.0 padding(8.px) minWidth = 4.em fontSize = 1.1.em textOverflow = TextOverflow.ellipsis whiteSpace = WhiteSpace.nowrap overflow = Overflow.hidden } +"${details.durationSec / 60} min, ${details.durationSec % 60} sec" } } } when (downloadStatus) { is DownloadStatus.NotDownloaded -> { DownloadButton { onClick = { setDownloadStatus(DownloadStatus.Queued) props.downloadTrack(details) } status = downloadStatus } } //TODO Fix Progress Indicator /*is DownloadStatus.Downloading -> { CircularProgressBar { progress = downloadStatus.progress } } is DownloadStatus.Converting -> { LoadingSpinner {} } is DownloadStatus.Queued -> { LoadingSpinner {} }*/ is DownloadStatus.Downloaded -> { DownloadButton { onClick = {} status = downloadStatus } } is DownloadStatus.Failed -> { DownloadButton { onClick = {} status = downloadStatus } } else -> LoadingSpinner { } } css { alignItems = Align.center display = Display.flex paddingRight = 16.px } } } ================================================ FILE: web-app/src/main/kotlin/navbar/NavBar.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package navbar import kotlinx.css.Align import kotlinx.css.Display import kotlinx.css.LinearDimension import kotlinx.css.alignItems import kotlinx.css.display import kotlinx.css.filter import kotlinx.css.fontSize import kotlinx.css.height import kotlinx.css.margin import kotlinx.css.marginLeft import kotlinx.css.marginRight import kotlinx.css.px import kotlinx.css.width import kotlinx.html.id import kotlinx.html.js.onBlurFunction import kotlinx.html.js.onClickFunction import react.RBuilder import react.RProps import react.dom.attrs import react.functionComponent import styled.css import styled.styledA import styled.styledDiv import styled.styledH1 import styled.styledImg import styled.styledNav @Suppress("FunctionName") fun RBuilder.NavBar(handler: NavBarProps.() -> Unit) { return child(navBar) { attrs { handler() } } } external interface NavBarProps : RProps { var isBackVisible: Boolean var popBackToHomeScreen: () -> Unit } private val navBar = functionComponent("NavBar") { props -> styledNav { css { +NavBarStyles.nav } styledDiv { attrs { onClickFunction = { props.popBackToHomeScreen() } onBlurFunction = { props.popBackToHomeScreen() } } styledImg(src = "left-arrow.svg", alt = "Back Arrow") { css { height = 42.px width = 42.px display = if (props.isBackVisible) Display.inline else Display.none filter = "invert(100)" marginRight = 12.px } } } styledA(href = "https://shabinder.github.io/SpotiFlyer/", target = "_blank") { css { display = Display.flex alignItems = Align.center } 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) } } } /*val (corsMode,setCorsMode) = useState(CorsProxy.SelfHostedCorsProxy() as CorsProxy) useEffect { setCorsMode(corsProxy) }*/ styledDiv { /*styledH4 { + "Extension" } styledDiv { styledInput(type = InputType.checkBox) { attrs{ id = "cmn-toggle-4" value = "Extension" checked = corsMode.extensionMode() onChangeFunction = { val state = it.target as HTMLInputElement if(state.checked){ setCorsMode(corsProxy.toggle(CorsProxy.PublicProxyWithExtension())) } else{ setCorsMode(corsProxy.toggle(CorsProxy.SelfHostedCorsProxy())) } println("Active Proxy: ${corsProxy.url}") } } css{ classes = mutableListOf("cmn-toggle","cmn-toggle-round-flat") } } styledLabel { attrs { htmlFor = "cmn-toggle-4" } } css{ classes = mutableListOf("switch") marginLeft = 8.px marginRight = 16.px } }*/ styledA(href = "https://github.com/Shabinder/SpotiFlyer/") { styledImg(src = "github.svg") { css { height = 42.px width = 42.px } } } css { display = Display.flex alignItems = Align.center marginLeft = LinearDimension.auto } } } } ================================================ FILE: web-app/src/main/kotlin/navbar/NavBarStyles.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package navbar import kotlinx.css.* import styled.StyleSheet object NavBarStyles : StyleSheet("WelcomeStyles", isStatic = true) { val nav by css{ padding(horizontal = 16.px) marginTop = 10.px backgroundColor = Color.transparent height = 56.px display = Display.flex flexDirection = FlexDirection.row alignItems = Align.center alignSelf = Align.stretch } } ================================================ FILE: web-app/src/main/kotlin/root/RootR.kt ================================================ /* * * Copyright (c) 2021 Shabinder Singh * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . */ package root import com.arkivanov.decompose.RouterState import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot.Child import extras.Props import extras.RenderableComponent import extras.renderableChild import home.HomeScreen import list.ListScreen import navbar.NavBar import react.RBuilder class RootR(props: Props) : RenderableComponent( props = props, initialState = State(routerState = props.component.routerState.value) ) { private val child: Child get() = component.routerState.value.activeChild.instance private val callBacks get() = component.callBacks init { component.routerState.bindToState { routerState = it } } override fun RBuilder.render() { NavBar { isBackVisible = (child is Child.List) popBackToHomeScreen = callBacks::popBackToHomeScreen } when(child){ is Child.Main -> renderableChild(HomeScreen::class, (child as Child.Main).component) is Child.List -> renderableChild(ListScreen::class, (child as Child.List).component) } } } @Suppress("NON_EXPORTABLE_TYPE", "EXPERIMENTAL_IS_NOT_ENABLED") @OptIn(ExperimentalJsExport::class) @JsExport class State( var routerState: RouterState<*, Child> ) : react.State ================================================ FILE: web-app/src/main/resources/css-circular-prog-bar.css ================================================ /* * * Copyright (c) 2021 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 . */ .progress-circle { margin: 20px; } .progress-circle:after{ border: none; position: absolute; text-align: center; display: block; border-radius: 50%; width: 4.3em; height: 4.3em; background-color: white; content: " "; margin-top: 0.35em; } /* Text inside the control */ .progress-circle span { position: absolute; line-height: 5em; width: 5em; text-align: center; display: block; color: #53777A; z-index: 2; } .left-half-clipper { /* a round circle */ border-radius: 50%; width: 5em; height: 5em; position: absolute; /* needed for clipping */ clip: rect(0, 5em, 5em, 2.5em); /* clips the whole left half*/ } /* when p>50, don't clip left half*/ .progress-circle.over50 .left-half-clipper { clip: rect(auto,auto,auto,auto); } .value-bar { /*This is an overlayed square, that is made round with the border radius, then it is cut to display only the left half, then rotated clockwise to escape the outer clipping path.*/ position: absolute; /*needed for clipping*/ clip: rect(0, 2.5em, 5em, 0); width: 5em; height: 5em; border-radius: 50%; border: 0.45em solid #53777A; /*The border is 0.35 but making it larger removes visual artifacts */ /*background-color: #4D642D;*/ /* for debug */ box-sizing: border-box; } /* Progress bar filling the whole right half for values above 50% */ .progress-circle.over50 .first50-bar { /*Progress bar for the first 50%, filling the whole right half*/ position: absolute; /*needed for clipping*/ clip: rect(0, 5em, 5em, 2.5em); background-color: #53777A; border-radius: 50%; width: 5em; height: 5em; } .progress-circle:not(.over50) .first50-bar{ display: none; } /* Progress bar rotation position */ .progress-circle.p0 .value-bar { display: none; } .progress-circle.p1 .value-bar { transform: rotate(4deg); } .progress-circle.p2 .value-bar { transform: rotate(7deg); } .progress-circle.p3 .value-bar { transform: rotate(11deg); } .progress-circle.p4 .value-bar { transform: rotate(14deg); } .progress-circle.p5 .value-bar { transform: rotate(18deg); } .progress-circle.p6 .value-bar { transform: rotate(22deg); } .progress-circle.p7 .value-bar { transform: rotate(25deg); } .progress-circle.p8 .value-bar { transform: rotate(29deg); } .progress-circle.p9 .value-bar { transform: rotate(32deg); } .progress-circle.p10 .value-bar { transform: rotate(36deg); } .progress-circle.p11 .value-bar { transform: rotate(40deg); } .progress-circle.p12 .value-bar { transform: rotate(43deg); } .progress-circle.p13 .value-bar { transform: rotate(47deg); } .progress-circle.p14 .value-bar { transform: rotate(50deg); } .progress-circle.p15 .value-bar { transform: rotate(54deg); } .progress-circle.p16 .value-bar { transform: rotate(58deg); } .progress-circle.p17 .value-bar { transform: rotate(61deg); } .progress-circle.p18 .value-bar { transform: rotate(65deg); } .progress-circle.p19 .value-bar { transform: rotate(68deg); } .progress-circle.p20 .value-bar { transform: rotate(72deg); } .progress-circle.p21 .value-bar { transform: rotate(76deg); } .progress-circle.p22 .value-bar { transform: rotate(79deg); } .progress-circle.p23 .value-bar { transform: rotate(83deg); } .progress-circle.p24 .value-bar { transform: rotate(86deg); } .progress-circle.p25 .value-bar { transform: rotate(90deg); } .progress-circle.p26 .value-bar { transform: rotate(94deg); } .progress-circle.p27 .value-bar { transform: rotate(97deg); } .progress-circle.p28 .value-bar { transform: rotate(101deg); } .progress-circle.p29 .value-bar { transform: rotate(104deg); } .progress-circle.p30 .value-bar { transform: rotate(108deg); } .progress-circle.p31 .value-bar { transform: rotate(112deg); } .progress-circle.p32 .value-bar { transform: rotate(115deg); } .progress-circle.p33 .value-bar { transform: rotate(119deg); } .progress-circle.p34 .value-bar { transform: rotate(122deg); } .progress-circle.p35 .value-bar { transform: rotate(126deg); } .progress-circle.p36 .value-bar { transform: rotate(130deg); } .progress-circle.p37 .value-bar { transform: rotate(133deg); } .progress-circle.p38 .value-bar { transform: rotate(137deg); } .progress-circle.p39 .value-bar { transform: rotate(140deg); } .progress-circle.p40 .value-bar { transform: rotate(144deg); } .progress-circle.p41 .value-bar { transform: rotate(148deg); } .progress-circle.p42 .value-bar { transform: rotate(151deg); } .progress-circle.p43 .value-bar { transform: rotate(155deg); } .progress-circle.p44 .value-bar { transform: rotate(158deg); } .progress-circle.p45 .value-bar { transform: rotate(162deg); } .progress-circle.p46 .value-bar { transform: rotate(166deg); } .progress-circle.p47 .value-bar { transform: rotate(169deg); } .progress-circle.p48 .value-bar { transform: rotate(173deg); } .progress-circle.p49 .value-bar { transform: rotate(176deg); } .progress-circle.p50 .value-bar { transform: rotate(180deg); } .progress-circle.p51 .value-bar { transform: rotate(184deg); } .progress-circle.p52 .value-bar { transform: rotate(187deg); } .progress-circle.p53 .value-bar { transform: rotate(191deg); } .progress-circle.p54 .value-bar { transform: rotate(194deg); } .progress-circle.p55 .value-bar { transform: rotate(198deg); } .progress-circle.p56 .value-bar { transform: rotate(202deg); } .progress-circle.p57 .value-bar { transform: rotate(205deg); } .progress-circle.p58 .value-bar { transform: rotate(209deg); } .progress-circle.p59 .value-bar { transform: rotate(212deg); } .progress-circle.p60 .value-bar { transform: rotate(216deg); } .progress-circle.p61 .value-bar { transform: rotate(220deg); } .progress-circle.p62 .value-bar { transform: rotate(223deg); } .progress-circle.p63 .value-bar { transform: rotate(227deg); } .progress-circle.p64 .value-bar { transform: rotate(230deg); } .progress-circle.p65 .value-bar { transform: rotate(234deg); } .progress-circle.p66 .value-bar { transform: rotate(238deg); } .progress-circle.p67 .value-bar { transform: rotate(241deg); } .progress-circle.p68 .value-bar { transform: rotate(245deg); } .progress-circle.p69 .value-bar { transform: rotate(248deg); } .progress-circle.p70 .value-bar { transform: rotate(252deg); } .progress-circle.p71 .value-bar { transform: rotate(256deg); } .progress-circle.p72 .value-bar { transform: rotate(259deg); } .progress-circle.p73 .value-bar { transform: rotate(263deg); } .progress-circle.p74 .value-bar { transform: rotate(266deg); } .progress-circle.p75 .value-bar { transform: rotate(270deg); } .progress-circle.p76 .value-bar { transform: rotate(274deg); } .progress-circle.p77 .value-bar { transform: rotate(277deg); } .progress-circle.p78 .value-bar { transform: rotate(281deg); } .progress-circle.p79 .value-bar { transform: rotate(284deg); } .progress-circle.p80 .value-bar { transform: rotate(288deg); } .progress-circle.p81 .value-bar { transform: rotate(292deg); } .progress-circle.p82 .value-bar { transform: rotate(295deg); } .progress-circle.p83 .value-bar { transform: rotate(299deg); } .progress-circle.p84 .value-bar { transform: rotate(302deg); } .progress-circle.p85 .value-bar { transform: rotate(306deg); } .progress-circle.p86 .value-bar { transform: rotate(310deg); } .progress-circle.p87 .value-bar { transform: rotate(313deg); } .progress-circle.p88 .value-bar { transform: rotate(317deg); } .progress-circle.p89 .value-bar { transform: rotate(320deg); } .progress-circle.p90 .value-bar { transform: rotate(324deg); } .progress-circle.p91 .value-bar { transform: rotate(328deg); } .progress-circle.p92 .value-bar { transform: rotate(331deg); } .progress-circle.p93 .value-bar { transform: rotate(335deg); } .progress-circle.p94 .value-bar { transform: rotate(338deg); } .progress-circle.p95 .value-bar { transform: rotate(342deg); } .progress-circle.p96 .value-bar { transform: rotate(346deg); } .progress-circle.p97 .value-bar { transform: rotate(349deg); } .progress-circle.p98 .value-bar { transform: rotate(353deg); } .progress-circle.p99 .value-bar { transform: rotate(356deg); } .progress-circle.p100 .value-bar { transform: rotate(360deg); } ================================================ FILE: web-app/src/main/resources/index.html ================================================ SpotiFlyer
================================================ FILE: web-app/src/main/resources/styles.css ================================================ /* * * Copyright (c) 2021 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 . */ @font-face { font-family: pristine; src: url("pristine_script.ttf"); } html { background-image: url("header-dark.jpg"); font-family: Lora,Helvetica Neue,Helvetica,Arial,sans-serif; } a:link, a:visited { color: white; text-decoration: none; } body, html { width: 100%; height: 100%; margin: 0; background-repeat: no-repeat; background-size: 100% 100%; background-attachment: fixed; position: relative; color: #fff; caret-color: crimson; /*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));*/ } #appName{ font-family: pristine, cursive; font-weight: 100; text-shadow: 0.3px 0.5px #ffffff; } .headingTitle{ text-align: center; margin: 10px; font-family: 'RocknRoll One', sans-serif; } .glow-button, .PaymentButton { transition: all .2s ease-in-out; } .glow-button:hover, .PaymentButton:hover { color: rgba(255, 255, 255, 1); box-shadow: 0 5px 15px rgb(105, 44, 143); transform: scale(1.1); } /*Loading Spinner*/ .lds-ring { align-items: center; justify-content: center; display: flex; } .lds-ring div { box-sizing: border-box; display: block; position: absolute; width: 3.5em; height: 3.5em; border: 8px solid #fff; border-radius: 50%; animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; border-color: #fff transparent transparent transparent; } .lds-ring div:nth-child(1) { animation-delay: -0.45s; } .lds-ring div:nth-child(2) { animation-delay: -0.3s; } .lds-ring div:nth-child(3) { animation-delay: -0.15s; } @keyframes lds-ring { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .button { text-decoration: none; color: rgba(255, 255, 255, 0.8); //background: rgb(145, 92, 182); padding: 15px 40px; border-radius: 4px; font-weight: normal; text-transform: uppercase; transition: all 0.2s ease-in-out; } .searchBox {; background: #2f3640; height: 42px; border-radius: 40px; padding: 10px; align-self: center; margin: 24px; } .searchBox:hover > .searchInput { width: 35vw; padding: 0 6px; } .searchBox:hover .search-icon { filter: none; } .searchBox:hover > .searchButton { background: white; color : #2f3640; } .search-icon { filter: invert(100%); } .searchButton { color: white; float: right; width: 40px; height: 40px; border-radius: 50%; background: #2f3640; display: flex; outline: none; justify-content: center; align-items: center; transition: 0.4s; } .searchInput { border:none; background: none; outline:none; float:left; padding: 0; color: white; font-size: 16px; transition: 0.4s; line-height: 40px; width: 0px; } /*Download All Button*/ #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); } } /*Extension Switch*/ .cmn-toggle { position: absolute; margin-left: -9999px; visibility: hidden; } .cmn-toggle + label { display: block; position: relative; cursor: pointer; outline: none; user-select: none; } input.cmn-toggle-round-flat + label { /* width = 2*height or 2*border-radius */ padding: 2px; width: 40px; height: 20px; border: 3px solid #dddddd; border-radius: 60px; transition: border-color 0.3s; } input.cmn-toggle-round-flat + label:before, input.cmn-toggle-round-flat + label:after { display: block; position: absolute; content: ""; } input.cmn-toggle-round-flat + label:after { /* width = 2*border-radius */ top: 4px; left: 4px; bottom: 4px; width: 16px; background-color: #dddddd; border-radius: 52px; transition: margin 0.3s, background 0.3s; } input.cmn-toggle-round-flat:checked + label { border-color: #8ce196; } input.cmn-toggle-round-flat:checked + label:after { /* margin-left = border-radius from 'input.cmn-toggle-round-flat + label' */ margin-left: 20px; background-color: #8ce196; } @media screen and (max-width: 600px) { /* CSS HERE ONLY ON PHONE */ .info-banners { flex-direction: column; } .searchBox:hover > .searchInput { width: 60vw; padding: 0 6px; } .searchInput { width: 55vw; padding: 0 6px; } .search-icon { filter: none; } .searchButton { background: white; color : #2f3640; } }