├── .idea ├── .name ├── .gitignore ├── codeStyles │ └── codeStyleConfig.xml ├── compiler.xml ├── kotlinc.xml ├── vcs.xml ├── deploymentTargetDropDown.xml ├── migrations.xml ├── gradle.xml ├── misc.xml └── inspectionProfiles │ └── Project_Default.xml ├── app ├── .gitignore └── src │ └── main │ ├── proto │ ├── sp │ │ ├── context_menu.proto │ │ ├── DacPlayer.proto │ │ ├── DacComponent.proto │ │ ├── home │ │ │ ├── ExperimentalComponents.proto │ │ │ ├── CollectionLinksComponents.proto │ │ │ ├── HomeViewServiceRequest.proto │ │ │ ├── HeadingComponents.proto │ │ │ ├── AlbumCardComponents.proto │ │ │ ├── ArtistCardComponents.proto │ │ │ ├── TrackCardComponents.proto │ │ │ ├── EpisodeCardComponents.proto │ │ │ ├── PlaylistCardComponents.proto │ │ │ ├── ToolbarComponents2.proto │ │ │ ├── ShowCardComponents.proto │ │ │ ├── ToolbarComponents.proto │ │ │ ├── CoreComponents.proto │ │ │ ├── PromoComponents.proto │ │ │ ├── ShortcutsComponents.proto │ │ │ └── ActionCardsComponents.proto │ │ ├── AllPlans.proto │ │ ├── DacComponents.proto │ │ ├── PremiumPlanRow.proto │ │ ├── podcastextensions.proto │ │ ├── collection │ │ │ ├── extension_descriptor_type.proto │ │ │ └── collection2v2.proto │ │ ├── podcast_ratings.proto │ │ ├── popcount2_external.proto │ │ └── identity.proto │ ├── sp_ext │ │ ├── dac_jetispot.proto │ │ ├── CollectionUpdate.proto │ │ ├── contentfeed_client.proto │ │ ├── searchview.proto │ │ ├── color_lyrics.proto │ │ └── contentfeed.proto │ └── AppConfig.proto │ ├── res │ ├── values │ │ ├── .idea │ │ │ ├── .gitignore │ │ │ ├── misc.xml │ │ │ ├── vcs.xml │ │ │ └── modules.xml │ │ ├── themes.xml │ │ └── plurals.xml │ ├── raw │ │ └── keep.xml │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ ├── xml │ │ ├── automotive_app_desc.xml │ │ └── nsc.xml │ ├── drawable-xxxhdpi │ │ ├── round_home_white_24.png │ │ ├── round_history_white_24.png │ │ ├── round_assistant_white_24.png │ │ └── round_library_music_white_24.png │ ├── values-v29 │ │ └── themes.xml │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_sound_wave_outline_28.xml │ │ └── ic_launcher_foreground.xml │ └── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ └── java │ └── bruhcollective │ └── itaysonlab │ └── jetispot │ ├── core │ ├── objs │ │ ├── gql │ │ │ ├── GqlWrap.kt │ │ │ └── ExtractedColors.kt │ │ ├── misc │ │ │ └── SpBlendInviteLink.kt │ │ ├── hub │ │ │ ├── HubResponse.kt │ │ │ ├── HubEvent.kt │ │ │ └── HubItem.kt │ │ ├── tags │ │ │ └── ContentFilterResponse.kt │ │ ├── player │ │ │ └── PlayFromContextData.kt │ │ └── external │ │ │ └── ExternalPersonalizedRecs.kt │ ├── util │ │ ├── RfitKtx.kt │ │ ├── Log.kt │ │ ├── Revision.kt │ │ ├── SpUtils.kt │ │ ├── Device.kt │ │ └── PlayCommandFactory.kt │ ├── api │ │ ├── SpBlendApi.kt │ │ ├── SpExternalIntegrationApi.kt │ │ ├── SpColorLyricsApi.kt │ │ ├── ClientTokenHandler.kt │ │ ├── SpPartnersApi.kt │ │ └── SpCollectionApi.kt │ ├── ext │ │ └── MetadataExt.kt │ ├── collection │ │ ├── db │ │ │ ├── model2 │ │ │ │ ├── CollectionContentFilter.kt │ │ │ │ ├── CollectionEntry.kt │ │ │ │ ├── CollectionArtist.kt │ │ │ │ ├── CollectionShow.kt │ │ │ │ ├── CollectionAlbum.kt │ │ │ │ ├── CollectionEpisode.kt │ │ │ │ ├── rootlist │ │ │ │ │ └── CollectionRootlistItem.kt │ │ │ │ ├── CollectionPinnedItem.kt │ │ │ │ └── CollectionTrack.kt │ │ │ ├── model │ │ │ │ └── LocalCollectionCategory.kt │ │ │ ├── LocalCollectionRepository.kt │ │ │ └── LocalCollectionDatabase.kt │ │ └── paging │ │ │ └── CollectionAlbumsPagingSource.kt │ ├── di │ │ ├── ext │ │ │ └── DiExt.kt │ │ ├── ApplicationModule.kt │ │ └── CollectionModule.kt │ ├── metadata_db │ │ └── SpMetadataDb.kt │ ├── lyrics │ │ ├── SpLyricsRequester.kt │ │ └── SpLyricsController.kt │ ├── SpSessionManager.kt │ ├── SpAuthManager.kt │ └── SpConfigurationManager.kt │ ├── ui │ ├── shared │ │ ├── WindowInsets.kt │ │ ├── dynamic_blocks │ │ │ ├── DynamicLikeButton.kt │ │ │ └── DynamicPlayButton.kt │ │ ├── PagingLoadingPage.kt │ │ ├── NavController.kt │ │ └── SharedTexts.kt │ ├── dac │ │ ├── components_home │ │ │ ├── RecentlyPlayedSectionComponentBinder.kt │ │ │ ├── SectionHeaderComponentBinder.kt │ │ │ ├── SnappyGridSectionComponentBinder.kt │ │ │ ├── FilterComponentBinder.kt │ │ │ ├── ToolbarComponentBinder.kt │ │ │ └── SmallActionCardBinder.kt │ │ ├── DacDelegate.kt │ │ └── components_plans │ │ │ ├── DisclaimerComponentBinder.kt │ │ │ └── PlanComponentBinder.kt │ ├── ext │ │ ├── ProtoExt.kt │ │ ├── TopBarExt.kt │ │ ├── ModifierExt.kt │ │ ├── ColorExt.kt │ │ ├── ContextExt.kt │ │ └── FlowExt.kt │ ├── hub │ │ ├── components │ │ │ ├── ShortcutsContainer.kt │ │ │ ├── TextRow.kt │ │ │ ├── HomeSectionHeader.kt │ │ │ ├── AlbumTrackRow.kt │ │ │ ├── SectionHeader.kt │ │ │ ├── ImageRow.kt │ │ │ ├── FindCard.kt │ │ │ ├── OutlineButton.kt │ │ │ ├── LargerRow.kt │ │ │ ├── ArtistPinnedItem.kt │ │ │ ├── ShortcutsCard.kt │ │ │ ├── HomeSectionLargeHeader.kt │ │ │ ├── SingleFocusCard.kt │ │ │ ├── Carousel.kt │ │ │ ├── MediumCard.kt │ │ │ ├── ArtistTrackRow.kt │ │ │ ├── ArtistHeader.kt │ │ │ ├── GridMediumCard.kt │ │ │ ├── PodcastTopicsStrip.kt │ │ │ ├── PlaylistTrackRow.kt │ │ │ └── ShowHeader.kt │ │ ├── HubScreenDelegate.kt │ │ ├── HubEventHandler.kt │ │ ├── virt │ │ │ ├── CollectionEntityView.kt │ │ │ └── ShowEntityView.kt │ │ └── HubBinder.kt │ ├── monet │ │ ├── google │ │ │ ├── utils │ │ │ │ └── StringUtils.java │ │ │ └── palettes │ │ │ │ └── TonalPalette.java │ │ └── ColorToScheme.kt │ ├── blocks │ │ └── TwoColumnAndImageBlock.kt │ ├── theme │ │ └── Theme.kt │ ├── navigation │ │ └── NavigationController.kt │ └── screens │ │ ├── hub │ │ ├── BrowseScreen.kt │ │ ├── BrowseRadioScreen.kt │ │ ├── PodcastShowScreen.kt │ │ ├── AbsHubViewModel.kt │ │ └── PlaylistScreen.kt │ │ ├── search │ │ └── SearchViewModel.kt │ │ ├── blend │ │ └── BlendCreateInvitationScreen.kt │ │ ├── Screen.kt │ │ ├── nowplaying │ │ └── NowPlayingScreen.kt │ │ ├── auth │ │ └── AuthScreenViewModel.kt │ │ ├── history │ │ └── ListeningHistoryScreen.kt │ │ └── dynamic │ │ └── DynamicSpIdScreen.kt │ ├── ManageStorageActivity.kt │ ├── playback │ ├── helpers │ │ ├── MediaItemWrapper.kt │ │ └── MediaItemBuilder.kt │ ├── service │ │ ├── refl │ │ │ └── SpReflect.kt │ │ └── AudioFocusManager.kt │ └── sp │ │ └── LowToHighQualityPicker.kt │ └── SpApp.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── gradle.properties └── README.md /.idea/.name: -------------------------------------------------------------------------------- 1 | Jetispot -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/context_menu.proto: -------------------------------------------------------------------------------- 1 | // com.spotify.home.dac.contextMenu.v1.proto -------------------------------------------------------------------------------- /app/src/main/res/values/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /app/src/main/res/raw/keep.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/xml/automotive_app_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/round_home_white_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/drawable-xxxhdpi/round_home_white_24.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/round_history_white_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/drawable-xxxhdpi/round_history_white_24.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/round_assistant_white_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/drawable-xxxhdpi/round_assistant_white_24.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/round_library_music_white_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iTaysonLab/jetispot/HEAD/app/src/main/res/drawable-xxxhdpi/round_library_music_white_24.png -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_home/RecentlyPlayedSectionComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_home 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | fun RecentlyPlayedSectionComponentBinder() { 7 | // TODO: lazy load recents 8 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/DacPlayer.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package com.spotify.dac.player.v1.proto; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.dac.player.v1.proto"; 9 | 10 | message PlayCommand { 11 | bytes context = 1; 12 | bytes options = 2; 13 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/DacComponent.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package dac.api.components; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.dac.api.components"; 9 | 10 | message VerticalListComponent { 11 | repeated google.protobuf.Any components = 2; 12 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpBlendApi.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.api 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.objs.misc.SpBlendInviteLink 4 | import retrofit2.http.POST 5 | 6 | interface SpBlendApi { 7 | @POST("/blend-invitation/v1/generate") 8 | suspend fun generateBlend(): SpBlendInviteLink 9 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/ext/MetadataExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.ext 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.util.SpUtils 4 | import com.spotify.metadata.Metadata 5 | 6 | val Metadata.Track.imageUrl: String? get() = SpUtils.getImageUrl(this.album.coverGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId) -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionContentFilter.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcFilters") 7 | data class CollectionContentFilter( 8 | @PrimaryKey val name: String, 9 | val query: String 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/hub/HubResponse.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.hub 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | class HubResponse ( 7 | val title: String? = null, 8 | val header: HubItem? = null, 9 | val body: List, 10 | val id: String? = null, // album-entity-view 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/ext/ProtoExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.ext 2 | 3 | import com.google.protobuf.Any 4 | import com.google.protobuf.Message 5 | 6 | @Suppress("UNCHECKED_CAST") 7 | fun Any.dynamicUnpack(): Message = unpack(Class.forName(typeUrl.split("/")[1].let { 8 | if (!it.startsWith("com.spotify")) "com.spotify.${it}" else it 9 | }) as Class) -------------------------------------------------------------------------------- /app/src/main/proto/sp_ext/dac_jetispot.proto: -------------------------------------------------------------------------------- 1 | // Сustom DAC blocks for Jetispot usage 2 | syntax = "proto3"; 3 | 4 | option java_package = "bruhcollective.itaysonlab.jetispot.proto"; 5 | option java_multiple_files = true; 6 | 7 | message ErrorComponent { 8 | enum ErrorType { 9 | UNSUPPORTED = 0; 10 | GENERIC_EXCEPTION = 1; 11 | } 12 | 13 | ErrorType type = 1; 14 | string message = 2; 15 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionEntry.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | interface CollectionEntry { 4 | fun ceId(): String 5 | fun ceUri(): String 6 | fun ceTimestamp(): Long 7 | fun ceModifyPredef(type: PredefCeType, dyn: String) {} 8 | } 9 | 10 | enum class PredefCeType { 11 | COLLECTION, EPISODES 12 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/ext/DiExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.di.ext 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.Request 5 | 6 | fun OkHttpClient.Builder.interceptRequest(scope: Request.Builder.(Request) -> Unit) = addInterceptor { chain -> 7 | val request = chain.request() 8 | chain.proceed(request.newBuilder().apply { scope(this, request) }.build()) 9 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ExperimentalComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.experimental.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.experimental.v1.proto"; 7 | 8 | message FilterComponent { 9 | repeated Facet facets = 1; 10 | } 11 | 12 | message Facet { 13 | string title = 1; 14 | string value = 2; 15 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model/LocalCollectionCategory.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcTypes") 7 | data class LocalCollectionCategory( 8 | @PrimaryKey val type: String, 9 | val syncToken: String // or revision in case of rootList 10 | ) -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/CollectionLinksComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message LikedSongsCardMediumComponent {} 9 | message LikedSongsCardSmallComponent {} 10 | 11 | message YourEpisodesCardMediumComponent {} 12 | message YourEpisodesCardSmallComponent {} -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/HomeViewServiceRequest.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package com.spotify.home.dac.viewservice.v1.proto; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.home.dac.viewservice.v1.proto"; 9 | 10 | message HomeViewServiceRequest { 11 | string facet = 1; 12 | string client_timezone = 2; 13 | map feature_flags = 3; 14 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/tags/ContentFilterResponse.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.tags 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | class ContentFilterResponse( 7 | val contentFilters: List 8 | ) 9 | 10 | @JsonClass(generateAdapter = true) 11 | class ContentFilter( 12 | val title: String, 13 | val query: String 14 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/AllPlans.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.allplans.v1; 4 | 5 | option java_package = "com.spotify.allplans.v1"; 6 | option java_multiple_files = true; 7 | 8 | message PlanComponent { 9 | string planName = 1; 10 | string planColor = 2; 11 | string availableAccounts = 3; 12 | string planPrice = 4; 13 | string uri = 5; 14 | } 15 | 16 | message DisclaimerComponent { 17 | string text = 1; 18 | } -------------------------------------------------------------------------------- /app/src/main/res/xml/nsc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | spotify.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ShortcutsContainer.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 5 | import bruhcollective.itaysonlab.jetispot.ui.hub.HubBinder 6 | 7 | @Composable 8 | fun ShortcutsContainer ( 9 | children: List 10 | ) { 11 | children.forEach { item -> 12 | HubBinder(item) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/DacDelegate.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.runtime.staticCompositionLocalOf 5 | import com.spotify.dac.player.v1.proto.PlayCommand 6 | 7 | @Stable 8 | interface DacDelegate { 9 | fun dispatchPlay(command: PlayCommand) 10 | } 11 | 12 | val LocalDacDelegate = staticCompositionLocalOf { error("DacDelegate should be initialized") } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | maven { url 'https://jitpack.io' } 7 | } 8 | } 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | maven { url 'https://jitpack.io' } 15 | } 16 | } 17 | rootProject.name = "Jetispot" 18 | include ':app' 19 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/ext/TopBarExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.ext 2 | 3 | import androidx.compose.material3.ExperimentalMaterial3Api 4 | import androidx.compose.material3.TopAppBarDefaults 5 | import androidx.compose.material3.TopAppBarScrollBehavior 6 | import androidx.compose.runtime.Composable 7 | 8 | @OptIn(ExperimentalMaterial3Api::class) 9 | @Composable 10 | fun rememberEUCScrollBehavior(): TopAppBarScrollBehavior { 11 | return TopAppBarDefaults.exitUntilCollapsedScrollBehavior() 12 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtist.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcArtists") 7 | data class CollectionArtist( 8 | @PrimaryKey val id: String, 9 | val uri: String, 10 | val name: String, 11 | val picture: String, 12 | val addedAt: Int 13 | ): CollectionEntry { 14 | override fun ceId() = id 15 | override fun ceUri() = uri 16 | override fun ceTimestamp() = addedAt.toLong() 17 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionShow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcShows") 7 | data class CollectionShow( 8 | @PrimaryKey val uri: String, 9 | val name: String, 10 | val publisher: String, 11 | val picture: String, 12 | val addedAt: Int 13 | ): CollectionEntry { 14 | override fun ceId() = uri 15 | override fun ceUri() = uri 16 | override fun ceTimestamp() = addedAt.toLong() 17 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/HeadingComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package com.spotify.home.dac.component.heading.v1.proto; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.home.dac.component.heading.v1.proto"; 9 | 10 | message RecsplanationHeadingSingleTextComponent { 11 | HighlightedText highlighted_text = 1; 12 | string navigate_uri = 2; 13 | string image_uri = 3; 14 | } 15 | 16 | message HighlightedText { 17 | string text = 1; 18 | int32 start_inclusive = 2; 19 | int32 end_exclusive = 3; 20 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/util/Log.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.util 2 | 3 | import bruhcollective.itaysonlab.jetispot.BuildConfig 4 | 5 | object Log { 6 | fun d(tag: String, message: String) = dbg { android.util.Log.d(tag(tag), message) } 7 | fun w(tag: String, message: String) = dbg { android.util.Log.w(tag(tag), message) } 8 | fun e(tag: String, message: String) = dbg { android.util.Log.e(tag(tag), message) } 9 | 10 | private fun tag (tag: String) = "Sp:$tag" 11 | private fun dbg (ifdbg: () -> Unit) = if (BuildConfig.DEBUG) ifdbg() else Unit 12 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/DacComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package dac.api.v1.proto; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.dac.api.v1.proto"; 9 | 10 | message DacResponse { 11 | google.protobuf.Any component = 2; 12 | } 13 | 14 | message DacRequest { 15 | string uri = 2; 16 | google.protobuf.Any featureRequest = 3; 17 | ClientInfo clientInfo = 4; 18 | 19 | message ClientInfo { 20 | string appName = 1; // ANDROID_MUSIC_APP 21 | string version = 2; 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionAlbum.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcAlbums") 7 | data class CollectionAlbum( 8 | @PrimaryKey val id: String, 9 | val uri: String, 10 | val name: String, 11 | val rawArtistsData: String, 12 | val picture: String, 13 | val addedAt: Int 14 | ): CollectionEntry { 15 | override fun ceId() = id 16 | override fun ceUri() = uri 17 | override fun ceTimestamp() = addedAt.toLong() 18 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/gql/ExtractedColors.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.gql 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | class ExtractedColors( 7 | val extractedColors: List 8 | ) 9 | 10 | @JsonClass(generateAdapter = true) 11 | class ExtractedColorsResponse( 12 | val colorRaw: ExtractedColor, 13 | val colorLight: ExtractedColor, 14 | val colorDark: ExtractedColor, 15 | ) 16 | 17 | @JsonClass(generateAdapter = true) 18 | class ExtractedColor( 19 | val hex: String, 20 | val isFallback: Boolean 21 | ) -------------------------------------------------------------------------------- /app/src/main/proto/sp/PremiumPlanRow.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package spotify.pamviewservice.v1.proto; 4 | 5 | option java_package = "com.spotify.pamviewservice.v1.proto"; 6 | 7 | enum SubscriptionType { 8 | UNKNOWN = 0; 9 | RECURRING_MONTHLY = 1; 10 | TRIAL = 2; 11 | PREPAID = 3; 12 | } 13 | 14 | message AllPremiumPlansRow { 15 | bool is_trial = 1; 16 | bool is_prepaid = 2; 17 | string premium_plan = 3; 18 | string premium_plan_color = 4; 19 | uint32 prepaid_days_remaining = 5; 20 | SubscriptionType subscription_type = 6; 21 | repeated string available_plan_names = 7; 22 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpExternalIntegrationApi.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.api 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.objs.external.PersonalizedRecommendationsRequest 4 | import bruhcollective.itaysonlab.jetispot.core.objs.external.PersonalizedRecommendationsResponse 5 | import retrofit2.http.Body 6 | import retrofit2.http.POST 7 | 8 | interface SpExternalIntegrationApi { 9 | @POST("v2/personalized-recommendations") 10 | suspend fun personalizedRecommendations( 11 | @Body body: PersonalizedRecommendationsRequest 12 | ): PersonalizedRecommendationsResponse 13 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionEpisode.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcEpisodes") 7 | data class CollectionEpisode( 8 | @PrimaryKey val uri: String, 9 | val name: String, 10 | val description: String, 11 | val showName: String, 12 | val showUri: String, 13 | val picture: String, 14 | val addedAt: Int 15 | ): CollectionEntry { 16 | override fun ceId() = uri 17 | override fun ceUri() = uri 18 | override fun ceTimestamp() = addedAt.toLong() 19 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp_ext/CollectionUpdate.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package bruhcollective.itaysonlab.swedentricks.protos; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "bruhcollective.itaysonlab.swedentricks.protos"; 9 | 10 | message CollectionUpdate { 11 | repeated CollectionUpdateEntry items = 1; 12 | } 13 | 14 | message CollectionUpdateEntry { 15 | enum Type { 16 | TRACK = 0; 17 | ALBUM = 1; 18 | ARTIST = 4; 19 | SHOW = 5; 20 | EPISODE = 6; 21 | } 22 | 23 | Type type = 1; 24 | bytes identifier = 2; 25 | int32 addedAt = 5; 26 | bool removed = 6; 27 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpColorLyricsApi.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.api 2 | 3 | import com.spotify.lyrics.v2.lyrics.proto.ColorLyricsResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Path 6 | import retrofit2.http.Query 7 | 8 | interface SpColorLyricsApi { 9 | @GET("/color-lyrics/v2/track/{spotifyId}/") 10 | suspend fun getLyrics( 11 | @Path("spotifyId") spotifyId: String, 12 | @Query("vocalRemoval") vocalRemoval: Boolean = false, 13 | @Query("syllableSync") syllableSync: Boolean = false, 14 | @Query("clientLanguage") clientLanguage: String = "en_US", 15 | ): ColorLyricsResponse 16 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/podcastextensions.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package spotify.podcast.extensions; 4 | 5 | option objc_class_prefix = "SPT"; 6 | option java_multiple_files = true; 7 | option optimize_for = CODE_SIZE; 8 | option java_outer_classname = "PodcastExtensionsProto"; 9 | option java_package = "com.spotify.podcastextensions.proto"; 10 | 11 | message PodcastTopics { 12 | repeated PodcastTopic topics = 1; 13 | } 14 | 15 | message PodcastTopic { 16 | string uri = 1; 17 | string title = 2; 18 | } 19 | 20 | message PodcastHtmlDescription { 21 | Header header = 1; 22 | message Header { 23 | 24 | } 25 | 26 | string html_description = 2; 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/AlbumCardComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message AlbumCardSmallComponent { 9 | string title = 1; 10 | string navigate_uri = 2; 11 | string image_uri = 3; 12 | } 13 | 14 | message AlbumCardMediumComponent { 15 | string title = 1; 16 | string subtitle = 2; 17 | string navigate_uri = 3; 18 | string image_uri = 4; 19 | } 20 | 21 | message AlbumCardLargeComponent { 22 | string title = 1; 23 | string subtitle = 2; 24 | string navigate_uri = 3; 25 | string image_uri = 4; 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ArtistCardComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message ArtistCardSmallComponent { 9 | string title = 1; 10 | string navigate_uri = 2; 11 | string image_uri = 3; 12 | } 13 | 14 | message ArtistCardMediumComponent { 15 | string title = 1; 16 | string subtitle = 2; 17 | string navigate_uri = 3; 18 | string image_uri = 4; 19 | } 20 | 21 | message ArtistCardLargeComponent { 22 | string title = 1; 23 | string subtitle = 2; 24 | string navigate_uri = 3; 25 | string image_uri = 4; 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/TrackCardComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message TrackCardSmallComponent { 9 | string title = 1; 10 | string navigate_uri = 2; 11 | string image_uri = 3; 12 | } 13 | 14 | message TrackCardMediumComponent { 15 | string title = 1; 16 | string subtitle = 2; 17 | string navigate_uri = 3; 18 | string image_uri = 4; 19 | } 20 | 21 | message TrackCardLargeComponent { 22 | string title = 1; 23 | string subtitle = 2; 24 | string navigate_uri = 3; 25 | string image_uri = 4; 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/EpisodeCardComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message EpisodeCardSmallComponent { 9 | string title = 1; 10 | string navigate_uri = 2; 11 | string image_uri = 3; 12 | } 13 | 14 | message EpisodeCardMediumComponent { 15 | string title = 1; 16 | string subtitle = 2; 17 | string navigate_uri = 3; 18 | string image_uri = 4; 19 | } 20 | 21 | message EpisodeCardLargeComponent { 22 | string title = 1; 23 | string subtitle = 2; 24 | string navigate_uri = 3; 25 | string image_uri = 4; 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/PlaylistCardComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message PlaylistCardSmallComponent { 9 | string title = 1; 10 | string navigate_uri = 2; 11 | string image_uri = 3; 12 | } 13 | 14 | message PlaylistCardMediumComponent { 15 | string title = 1; 16 | string subtitle = 2; 17 | string navigate_uri = 3; 18 | string image_uri = 4; 19 | } 20 | 21 | message PlaylistCardLargeComponent { 22 | string title = 1; 23 | string subtitle = 2; 24 | string navigate_uri = 3; 25 | string image_uri = 4; 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/rootlist/CollectionRootlistItem.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2.rootlist 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionEntry 6 | 7 | @Entity(tableName = "rootlist") 8 | data class CollectionRootlistItem( 9 | @PrimaryKey val uri: String, 10 | val timestamp: Long, 11 | val name: String, 12 | val ownerUsername: String, 13 | val picture: String 14 | ): CollectionEntry { 15 | override fun ceId() = uri 16 | override fun ceUri() = uri 17 | override fun ceTimestamp() = timestamp / 1000L 18 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_plans/DisclaimerComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_plans 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.unit.dp 8 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 9 | import com.spotify.allplans.v1.DisclaimerComponent 10 | 11 | @Composable 12 | fun DisclaimerComponentBinder( 13 | item: DisclaimerComponent 14 | ) { 15 | Column { 16 | Subtext(text = item.text, modifier = Modifier.padding(start = 16.dp)) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/shared/dynamic_blocks/DynamicLikeButton.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.shared.dynamic_blocks 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.rounded.Favorite 5 | import androidx.compose.material3.Icon 6 | import androidx.compose.material3.IconButton 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | 10 | @Composable 11 | fun DynamicLikeButton( 12 | objectUrl: String, 13 | modifier: Modifier = Modifier 14 | ) { 15 | IconButton(onClick = { 16 | // todo 17 | }, modifier = modifier) { 18 | Icon(Icons.Rounded.Favorite, contentDescription = null) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/collection/extension_descriptor_type.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package spotify.descriptorextension; 4 | 5 | option java_multiple_files = true; 6 | option optimize_for = CODE_SIZE; 7 | option java_package = "com.spotify.descriptorextension.proto"; 8 | 9 | message ExtensionDescriptor { 10 | string text = 1; 11 | float weight = 2; 12 | repeated ExtensionDescriptorType types = 3; 13 | } 14 | 15 | message ExtensionDescriptorData { 16 | repeated ExtensionDescriptor descriptors = 1; 17 | } 18 | 19 | enum ExtensionDescriptorType { 20 | UNKNOWN = 0; 21 | GENRE = 1; 22 | MOOD = 2; 23 | ACTIVITY = 3; 24 | INSTRUMENT = 4; 25 | TIME = 5; 26 | ERA = 6; 27 | AESTHETIC = 7; 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/ext/ModifierExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.ext 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.input.pointer.PointerEventPass 5 | import androidx.compose.ui.input.pointer.PointerInputChange 6 | import androidx.compose.ui.input.pointer.pointerInput 7 | 8 | fun Modifier.disableTouch(disabled: Boolean = true) = 9 | if (disabled) { 10 | pointerInput(Unit) { 11 | awaitPointerEventScope { 12 | while (true) { 13 | awaitPointerEvent(pass = PointerEventPass.Initial).changes.forEach(PointerInputChange::consume) 14 | } 15 | } 16 | } 17 | } else { 18 | Modifier 19 | } -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ToolbarComponents2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | import "sp/DacPlayer.proto"; 5 | 6 | package com.spotify.home.dac.component.v2.proto; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.spotify.home.dac.component.v2.proto"; 10 | 11 | message ToolbarComponentV2 { 12 | string day_part_message = 1; 13 | string subtitle = 2; 14 | repeated google.protobuf.Any items = 3; 15 | ToolbarItemProfileComponent profile_button = 4; 16 | } 17 | 18 | message ToolbarItemProfileComponent { 19 | string accessibility_title = 1; 20 | string username = 2; 21 | string user_first_initial = 3; 22 | string navigate_uri = 4; 23 | string image_uri = 5; 24 | string user_display_name = 6; 25 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/paging/CollectionAlbumsPagingSource.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager 6 | import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum 7 | 8 | class CollectionAlbumsPagingSource(private val collection: SpCollectionManager): PagingSource() { 9 | override suspend fun load(params: LoadParams): LoadResult { 10 | TODO() 11 | } 12 | 13 | override fun getRefreshKey(state: PagingState): Int? { 14 | TODO() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/shared/PagingLoadingPage.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.shared 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun PagingLoadingPage ( 14 | modifier: Modifier 15 | ) { 16 | Box(modifier) { 17 | CircularProgressIndicator( 18 | modifier = Modifier 19 | .align(Alignment.Center) 20 | .size(56.dp) 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/proto/AppConfig.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "bruhcollective.itaysonlab.jetispot.proto"; 4 | option java_multiple_files = true; 5 | 6 | message AppConfig { 7 | PlayerConfig playerConfig = 1; 8 | } 9 | 10 | message PlayerConfig { 11 | bool autoplay = 1; 12 | bool normalization = 2; 13 | AudioQuality preferredQuality = 3; 14 | AudioNormalization normalizationLevel = 4; 15 | int32 crossfade = 5; // 12s - max 16 | bool preload = 6; 17 | bool useTremolo = 7; 18 | } 19 | 20 | enum AudioQuality { 21 | NORMAL = 0; 22 | HIGH = 1; 23 | VERY_HIGH = 2; 24 | LOW = 3; 25 | FLAC = 4; 26 | } 27 | 28 | // (loud at +6, normal at +3, quiet at -5) 29 | enum AudioNormalization { 30 | QUIET = 0; 31 | BALANCED = 1; 32 | LOUD = 2; 33 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ShowCardComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.spotify.home.dac.component.v1.proto; 4 | 5 | option java_multiple_files = true; 6 | option java_package = "com.spotify.home.dac.component.v1.proto"; 7 | 8 | message ShowCardSmallComponent { 9 | string title = 1; 10 | string show_categories = 2; 11 | string navigate_uri = 3; 12 | string image_uri = 4; 13 | } 14 | 15 | message ShowCardMediumComponent { 16 | string title = 1; 17 | string subtitle = 2; 18 | string show_categories = 3; 19 | string navigate_uri = 4; 20 | string image_uri = 5; 21 | } 22 | 23 | message ShowCardLargeComponent { 24 | string title = 1; 25 | string subtitle = 2; 26 | string show_categories = 3; 27 | string navigate_uri = 4; 28 | string image_uri = 5; 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ToolbarComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | import "sp/DacPlayer.proto"; 5 | 6 | package com.spotify.home.dac.component.v1.proto; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.spotify.home.dac.component.v1.proto"; 10 | 11 | message ToolbarComponent { 12 | string day_part_message = 1; 13 | string subtitle = 2; 14 | repeated google.protobuf.Any items = 4; 15 | } 16 | 17 | message ToolbarItemFeedComponent { 18 | string title = 1; 19 | string navigate_uri = 2; 20 | } 21 | 22 | message ToolbarItemListeningHistoryComponent { 23 | string title = 1; 24 | string navigate_uri = 2; 25 | } 26 | 27 | message ToolbarItemSettingsComponent { 28 | string title = 1; 29 | string navigate_uri = 2; 30 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/TextRow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | import androidx.compose.ui.unit.sp 10 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubText 11 | 12 | @Composable 13 | fun TextRow( 14 | text: HubText 15 | ) { 16 | Text(text.title ?: text.description ?: "", color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (text.description == null) 1f else 0.7f), fontSize = 16.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) 17 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/metadata_db/SpMetadataDb.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.metadata_db 2 | 3 | import android.content.Context 4 | import com.google.protobuf.ByteString 5 | import com.tencent.mmkv.MMKV 6 | import dagger.hilt.android.qualifiers.ApplicationContext 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | class SpMetadataDb @Inject constructor( 12 | @ApplicationContext private val context: Context 13 | ) { 14 | private val instance = MMKV.mmkvWithID("metadata") 15 | 16 | fun contains(uri: String) = instance.containsKey(uri) 17 | 18 | fun get(uri: String): ByteArray = instance.getBytes(uri, null)!! 19 | fun put(uri: String, msg: ByteArray) = instance.encode(uri, msg) 20 | 21 | fun clear() = instance.clearAll() 22 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/podcast_ratings.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package spotify.ratings; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | option objc_class_prefix = "SPT"; 8 | option java_multiple_files = true; 9 | option optimize_for = CODE_SIZE; 10 | option java_outer_classname = "RatingsMetadataProto"; 11 | option java_package = "com.spotify.podcastcreatorinteractivity.v1"; 12 | 13 | message Rating { 14 | string user_id = 1; 15 | string show_uri = 2; 16 | int32 rating = 3; 17 | google.protobuf.Timestamp rated_at = 4; 18 | } 19 | 20 | message AverageRating { 21 | double average = 1; 22 | int64 total_ratings = 2; 23 | bool show_average = 3; 24 | } 25 | 26 | message PodcastRating { 27 | AverageRating average_rating = 1; 28 | Rating rating = 2; 29 | bool can_rate = 3; 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/proto/sp_ext/contentfeed_client.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "sp_ext/contentfeed.proto"; 4 | 5 | package com.spotify.contentfeed.proto.v1.client; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.contentfeed.proto.v1.client"; 9 | 10 | message FeedItemsResponse { 11 | repeated com.spotify.contentfeed.proto.v1.common.FeedItem items = 1; 12 | string requestId = 2; 13 | } 14 | 15 | message FeedItemsStateRequest { 16 | repeated com.spotify.contentfeed.proto.v1.common.FeedItemState items = 1; 17 | } 18 | 19 | message FeedItemsRequest { 20 | enum Filter { 21 | FILTER_INVALID = 0; 22 | FILTER_PODCAST_EPISODE_RELEASE_UNPLAYED = 1; 23 | } 24 | 25 | repeated com.spotify.contentfeed.proto.v1.common.ContentType contentTypes = 1; 26 | repeated Filter filters = 2; 27 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionPinnedItem.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "lcPins") 7 | data class CollectionPinnedItem( 8 | @PrimaryKey val uri: String, 9 | val name: String, 10 | val subtitle: String, 11 | val picture: String, 12 | val addedAt: Int 13 | ): CollectionEntry { 14 | @Transient var predefType: PredefCeType? = null 15 | @Transient var predefDyn: String = "" 16 | 17 | override fun ceId() = uri 18 | override fun ceUri() = uri 19 | override fun ceTimestamp() = addedAt.toLong() 20 | 21 | override fun ceModifyPredef(type: PredefCeType, dyn: String) { 22 | predefType = type 23 | predefDyn = dyn 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ManageStorageActivity.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.core.view.WindowCompat 7 | import bruhcollective.itaysonlab.jetispot.ui.screens.config.StorageScreen 8 | import bruhcollective.itaysonlab.jetispot.ui.theme.ApplicationTheme 9 | import dagger.hilt.android.AndroidEntryPoint 10 | 11 | @AndroidEntryPoint 12 | class ManageStorageActivity: ComponentActivity() { 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | 16 | WindowCompat.setDecorFitsSystemWindows(window, false) 17 | 18 | setContent { 19 | ApplicationTheme { 20 | StorageScreen() 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.di 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import android.content.res.Resources 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | object ApplicationModule { 15 | @Provides 16 | fun provideResources(@ApplicationContext context: Context): Resources { 17 | return context.resources 18 | } 19 | 20 | @Provides 21 | fun providePackageManager(@ApplicationContext context: Context): PackageManager { 22 | return context.packageManager 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/hub/HubEvent.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.hub 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextData 4 | import com.squareup.moshi.JsonClass 5 | import dev.zacsweers.moshix.sealed.annotations.DefaultObject 6 | import dev.zacsweers.moshix.sealed.annotations.TypeLabel 7 | 8 | @JsonClass(generateAdapter = true, generator = "sealed:name") 9 | sealed class HubEvent { 10 | @JsonClass(generateAdapter = true) 11 | @TypeLabel("navigate") 12 | class NavigateToUri ( 13 | val data: NavigateUri 14 | ): HubEvent() 15 | 16 | @JsonClass(generateAdapter = true) 17 | @TypeLabel("playFromContext") 18 | class PlayFromContext ( 19 | val data: PlayFromContextData 20 | ): HubEvent() 21 | 22 | @DefaultObject 23 | object Unknown: HubEvent() 24 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_home/SectionHeaderComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_home 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | 14 | @Composable 15 | fun SectionHeaderComponentBinder ( 16 | text: String 17 | ) { 18 | Text(text = text, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 21.sp, modifier = Modifier.padding(16.dp)) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/ClientTokenHandler.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.api 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 4 | import com.spotify.clienttoken.http.v0.ClientToken 5 | import xyz.gianlu.librespot.dealer.ApiClient 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class ClientTokenHandler @Inject constructor( 11 | private val spSessionManager: SpSessionManager 12 | ) { 13 | private val apiMethod = ApiClient::class.java.getDeclaredMethod("clientToken").also { it.isAccessible = true } 14 | private var sessionToken: String = "" 15 | 16 | fun requestToken() = sessionToken.ifEmpty { 17 | (apiMethod.invoke(spSessionManager.session.api()) as ClientToken.ClientTokenResponse).grantedToken.token.also { 18 | sessionToken = it 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/popcount2_external.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | package spotify.popcount2.proto; 4 | 5 | option optimize_for = CODE_SIZE; 6 | 7 | message PopcountRequest { 8 | 9 | } 10 | 11 | message PopcountResult { 12 | optional sint64 count = 1; 13 | optional bool truncated = 2; 14 | repeated string user = 3; 15 | repeated string userid = 6; 16 | optional int64 raw_count = 7; 17 | optional bool count_hidden_from_users = 8; 18 | } 19 | 20 | message PopcountUserUpdate { 21 | optional string user = 1; 22 | optional sint64 timestamp = 2; 23 | optional bool added = 3; 24 | optional string userid = 4; 25 | } 26 | 27 | message PopcountFollowerResult { 28 | optional bool is_truncated = 1; 29 | repeated bytes user_id = 2; 30 | } 31 | 32 | message PopcountSetFollowerCounterValueRequest { 33 | optional int64 count = 1; 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionRepository.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.collection.db.model.LocalCollectionCategory 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class LocalCollectionRepository @Inject constructor( 11 | private val db: LocalCollectionDatabase, 12 | private val dao: LocalCollectionDao 13 | ) { 14 | suspend fun insertOrUpdateCollection( 15 | collectionSet: String, 16 | syncToken: String 17 | ) { 18 | dao.updateCollectionCategory(LocalCollectionCategory(collectionSet, syncToken)) 19 | } 20 | 21 | suspend fun clean() { 22 | withContext(Dispatchers.Default) { db.clearAllTables() } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpPartnersApi.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.api 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.objs.gql.ExtractedColors 4 | import bruhcollective.itaysonlab.jetispot.core.objs.gql.GqlWrap 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | // TODO: research getExtendedMetadata EXTRACTED_METADATA value and get rid of GraphQL nonsense 9 | interface SpPartnersApi { 10 | @GET("/pathfinder/v1/query") 11 | suspend fun fetchExtractedColors( 12 | @Query("operationName") opName: String = "fetchExtractedColors", 13 | @Query("extensions") extensions: String = "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"d7696dd106f3c84a1f3ca37225a1de292e66a2d5aced37a66632585eeb3bbbfa\"}}", 14 | @Query("variables") variables: String // variables={"uris":["{picUrl}"]} 15 | ): GqlWrap 16 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sound_wave_outline_28.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionTrack.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "lcTracks") 8 | data class CollectionTrack( 9 | @PrimaryKey val id: String, 10 | val uri: String, 11 | val name: String, 12 | val albumId: String, 13 | val albumName: String, 14 | @ColumnInfo(defaultValue = "") val mainArtistName: String, 15 | val mainArtistId: String, // for metadata&joins 16 | val rawArtistsData: String, // for UI, format: ID=Name (example: 1=Artist|2=Artist2) 17 | val hasLyrics: Boolean, 18 | val isExplicit: Boolean, 19 | val duration: Int, 20 | val picture: String, 21 | @ColumnInfo(defaultValue = "") val descriptors: String, // indie|modern rock, for example 22 | val addedAt: Int 23 | ) -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/CoreComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | 5 | package com.spotify.home.dac.component.v1.proto; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.home.dac.component.v1.proto"; 9 | 10 | message HomePageComponent { 11 | repeated google.protobuf.Any components = 2; 12 | } 13 | 14 | message SectionHeaderComponent { 15 | string title = 1; 16 | } 17 | 18 | message SectionComponent { 19 | repeated google.protobuf.Any components = 2; 20 | } 21 | 22 | message RecsplanationHeadingComponent { 23 | string title = 1; 24 | string subtitle = 2; 25 | string navigate_uri = 3; 26 | string image_uri = 4; 27 | } 28 | 29 | // only ubi(soft?!) data here, recent played data are fetched separately 30 | message RecentlyPlayedSectionComponent {} 31 | 32 | message SnappyGridSectionComponent { 33 | repeated google.protobuf.Any components = 1; 34 | int32 rows = 2; 35 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/playback/helpers/MediaItemWrapper.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.playback.helpers 2 | 3 | import androidx.compose.ui.graphics.asImageBitmap 4 | import androidx.media2.common.MediaItem 5 | import androidx.media2.common.MediaMetadata 6 | 7 | class MediaItemWrapper( 8 | private val item: MediaItem? = null 9 | ) { 10 | val hasMetadata get() = item != null 11 | 12 | val title get() = item?.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "Unknown Title" 13 | val artist get() = item?.metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "Unknown Artist" 14 | val album get() = item?.metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "Unknown Album" 15 | val duration get() = item?.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L 16 | 17 | val artwork get() = item?.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART) 18 | val artworkCompose by lazy { artwork?.asImageBitmap() } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/ext/ColorExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.ext 2 | 3 | import androidx.annotation.FloatRange 4 | import androidx.compose.material3.ColorScheme 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.graphics.compositeOver 7 | import androidx.compose.ui.unit.Dp 8 | import androidx.compose.ui.unit.dp 9 | import kotlin.math.ln 10 | 11 | fun ColorScheme.compositeSurfaceElevation( 12 | elevation: Dp, 13 | ): Color { 14 | if (elevation == 0.dp) return surface 15 | val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f 16 | return surfaceTint.copy(alpha = alpha).compositeOver(surface) 17 | } 18 | 19 | fun Color.blendWith(color: Color, @FloatRange(from = 0.0, to = 1.0) ratio: Float): Color { 20 | val inv = 1f - ratio 21 | return copy( 22 | red = red * inv + color.red * ratio, 23 | blue = blue * inv + color.blue * ratio, 24 | green = green * inv + color.green * ratio, 25 | ) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/util/Revision.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.util 2 | 3 | import android.util.Base64 4 | import com.google.protobuf.ByteString 5 | import java.nio.ByteBuffer 6 | 7 | object Revision { 8 | fun base64ToRevision(b64: String) = Base64.decode(b64, Base64.DEFAULT).let { b64dec -> 9 | val buffer = ByteBuffer.wrap(b64dec) 10 | 11 | val revId = buffer.int 12 | val revHash = ByteArray(buffer.remaining()).also { buffer.get(it) }.joinToString("") { 13 | (0xFF and it.toInt()).toString(16).padStart(2, '0') 14 | }.padEnd(40, '0') 15 | 16 | return@let "$revId,$revHash" 17 | } 18 | 19 | fun byteStringToRevision(bs: ByteString): String { 20 | val buffer = bs.asReadOnlyByteBuffer() 21 | 22 | val revId = buffer.int 23 | val revHash = ByteArray(buffer.remaining()).also { buffer.get(it) }.joinToString("") { 24 | (0xFF and it.toInt()).toString(16).padStart(2, '0') 25 | }.padEnd(40, '0') 26 | 27 | return "$revId,$revHash" 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpCollectionApi.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.api 2 | 3 | import com.spotify.collection2.v2.proto.Collection2V2 4 | import retrofit2.http.Body 5 | import retrofit2.http.Header 6 | import retrofit2.http.Headers 7 | import retrofit2.http.POST 8 | 9 | interface SpCollectionApi { 10 | @POST("write") 11 | @Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto") 12 | suspend fun write(@Body data: Collection2V2.WriteRequest) 13 | 14 | @POST("delta") 15 | @Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto") 16 | suspend fun delta(@Body data: Collection2V2.DeltaRequest): Collection2V2.DeltaResponse 17 | 18 | @POST("paging") 19 | @Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto") 20 | suspend fun paging(@Body data: Collection2V2.PageRequest): Collection2V2.PageResponse 21 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_home/SnappyGridSectionComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_home 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.foundation.lazy.grid.GridCells 6 | import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid 7 | import androidx.compose.foundation.lazy.grid.items 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import bruhcollective.itaysonlab.jetispot.ui.dac.DacRender 12 | import com.spotify.home.dac.component.v1.proto.SnappyGridSectionComponent 13 | 14 | @Composable 15 | fun SnappyGridSectionComponentBinder( 16 | item: SnappyGridSectionComponent 17 | ) { 18 | LazyHorizontalGrid(rows = GridCells.Fixed(item.componentsCount), Modifier.fillMaxWidth().height(56.dp * item.componentsCount)) { 19 | items(item.componentsList) { cItem -> 20 | DacRender(item = item) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/shared/dynamic_blocks/DynamicPlayButton.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.shared.dynamic_blocks 2 | 3 | import androidx.compose.foundation.shape.CircleShape 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.rounded.PlayArrow 6 | import androidx.compose.material3.FilledIconButton 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.draw.clip 11 | import bruhcollective.itaysonlab.jetispot.ui.dac.LocalDacDelegate 12 | import com.spotify.dac.player.v1.proto.PlayCommand 13 | 14 | @Composable 15 | fun DynamicPlayButton( 16 | command: PlayCommand, 17 | modifier: Modifier = Modifier 18 | ) { 19 | val dacDelegate = LocalDacDelegate.current 20 | FilledIconButton( 21 | onClick = { dacDelegate.dispatchPlay(command) }, modifier = modifier.clip(CircleShape) 22 | ) { 23 | Icon(Icons.Rounded.PlayArrow, contentDescription = null) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 19 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/HubScreenDelegate.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.staticCompositionLocalOf 7 | import androidx.compose.ui.graphics.Color 8 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextData 9 | import kotlinx.coroutines.CoroutineScope 10 | 11 | @Stable 12 | interface HubScreenDelegate { 13 | fun play(data: PlayFromContextData) 14 | fun isSurroundedWithPadding(): Boolean = false 15 | // headers 16 | suspend fun calculateDominantColor(url: String, dark: Boolean): Color = Color.Transparent 17 | suspend fun getLikedSongsCount(artistId: String): Int = 0 18 | // states 19 | fun getMainObjectAddedState(): State = mutableStateOf(false) 20 | fun toggleMainObjectAddedState() {} 21 | fun sendCustomCommand(scope: CoroutineScope, cmd: Any): Any = Unit 22 | } 23 | 24 | val LocalHubScreenDelegate = staticCompositionLocalOf { error("HubScreenDelegate should be initialized") } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/SpApp.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot 2 | 3 | import android.app.Application 4 | import android.os.Build 5 | import bruhcollective.itaysonlab.jetispot.playback.sp.AndroidNativeDecoder 6 | import com.tencent.mmkv.MMKV 7 | import dagger.hilt.android.HiltAndroidApp 8 | import org.slf4j.LoggerFactory 9 | import org.slf4j.impl.HandroidLoggerAdapter 10 | import xyz.gianlu.librespot.audio.decoders.Decoders 11 | import xyz.gianlu.librespot.audio.format.SuperAudioFormat 12 | import xyz.gianlu.librespot.player.state.DeviceStateHandler 13 | 14 | @HiltAndroidApp 15 | class SpApp: Application() { 16 | init { 17 | Decoders.registerDecoder(SuperAudioFormat.VORBIS, AndroidNativeDecoder::class.java) 18 | Decoders.registerDecoder(SuperAudioFormat.MP3, AndroidNativeDecoder::class.java) 19 | 20 | HandroidLoggerAdapter.DEBUG = BuildConfig.DEBUG 21 | HandroidLoggerAdapter.ANDROID_API_LEVEL = Build.VERSION.SDK_INT 22 | HandroidLoggerAdapter.APP_NAME = "SpApp" 23 | } 24 | 25 | override fun onCreate() { 26 | super.onCreate() 27 | MMKV.initialize(this, "${filesDir.absolutePath}/spa_meta") 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/identity.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package spotify.identity.v3; 4 | 5 | import "google/protobuf/field_mask.proto"; 6 | import "google/protobuf/wrappers.proto"; 7 | 8 | option optimize_for = CODE_SIZE; 9 | option java_outer_classname = "IdentityV3"; 10 | option java_package = "com.spotify.identity.proto.v3"; 11 | 12 | message Image { 13 | int32 max_width = 1; 14 | int32 max_height = 2; 15 | string url = 3; 16 | } 17 | 18 | message UserProfile { 19 | google.protobuf.StringValue username = 1; 20 | google.protobuf.StringValue name = 2; 21 | repeated Image images = 3; 22 | google.protobuf.BoolValue verified = 4; 23 | google.protobuf.BoolValue edit_profile_disabled = 5; 24 | google.protobuf.BoolValue report_abuse_disabled = 6; 25 | google.protobuf.BoolValue abuse_reported_name = 7; 26 | google.protobuf.BoolValue abuse_reported_image = 8; 27 | google.protobuf.BoolValue has_spotify_name = 9; 28 | google.protobuf.BoolValue has_spotify_image = 10; 29 | google.protobuf.Int32Value color = 11; 30 | } 31 | 32 | message UserProfileUpdateRequest { 33 | google.protobuf.FieldMask mask = 1; 34 | UserProfile user_profile = 2; 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/ext/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.ext 2 | 3 | import android.app.Activity 4 | import android.content.* 5 | import androidx.core.content.getSystemService 6 | import androidx.core.net.toUri 7 | import androidx.navigation.NavController 8 | 9 | fun NavController.openUrl(url: String) = context.openUrl(url) 10 | fun Context.openUrl(url: String) = startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) 11 | 12 | fun Context.shareUrl(url: String, title: String? = null) = startActivity(Intent(Intent.ACTION_SEND).apply { 13 | type = "text/plain" 14 | putExtra(Intent.EXTRA_TEXT, url) 15 | putExtra(Intent.EXTRA_TITLE, title) 16 | }.let { Intent.createChooser(it, null) }) 17 | 18 | fun Context.copy(txt: String) { 19 | getSystemService()?.setPrimaryClip(ClipData.newPlainText("jetispot", txt)) 20 | } 21 | 22 | // Shamelessly taken from Google 23 | internal fun Context.findActivity(): Activity { 24 | var context = this 25 | while (context is ContextWrapper) { 26 | if (context is Activity) return context 27 | context = context.baseContext 28 | } 29 | throw IllegalStateException("Not an Activity context!") 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/HomeSectionHeader.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubText 14 | import bruhcollective.itaysonlab.jetispot.ui.hub.HubScreenDelegate 15 | import bruhcollective.itaysonlab.jetispot.ui.hub.LocalHubScreenDelegate 16 | 17 | @Composable 18 | fun HomeSectionHeader ( 19 | text: HubText, 20 | ) { 21 | Box(Modifier.padding(vertical = 8.dp).padding(horizontal = if (LocalHubScreenDelegate.current.isSurroundedWithPadding()) 0.dp else 16.dp)) { 22 | Text(text = text.title!!, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 21.sp, modifier = Modifier.align(Alignment.CenterStart)) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/lyrics/SpLyricsRequester.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.lyrics 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.api.SpColorLyricsApi 4 | import bruhcollective.itaysonlab.jetispot.core.metadata_db.SpMetadataDb 5 | import com.spotify.lyrics.v2.lyrics.proto.ColorLyricsResponse 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class SpLyricsRequester @Inject constructor( 13 | private val spMetadataDb: SpMetadataDb, 14 | private val spColorLyricsApi: SpColorLyricsApi 15 | ) { 16 | suspend fun request(track: String) = withContext(Dispatchers.IO) { 17 | val uri = "lyrics:$track" 18 | 19 | if (spMetadataDb.contains(uri)) { 20 | return@withContext ColorLyricsResponse.parseFrom(spMetadataDb.get(uri)) 21 | } else { 22 | val response = spColorLyricsApi.getLyrics(spotifyId = track) 23 | 24 | if (response.hasLyrics()) { 25 | spMetadataDb.put(uri, response.toByteArray()) 26 | return@withContext response 27 | } else { 28 | return@withContext null 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/ext/FlowExt.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.ext 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.platform.LocalLifecycleOwner 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleOwner 10 | import androidx.lifecycle.flowWithLifecycle 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlin.coroutines.CoroutineContext 13 | import kotlin.coroutines.EmptyCoroutineContext 14 | 15 | @Composable 16 | fun rememberFlow( 17 | flow: Flow, 18 | lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current 19 | ): Flow { 20 | return remember(key1 = flow, key2 = lifecycleOwner) { flow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) } 21 | } 22 | 23 | @Composable 24 | fun Flow.collectAsStateLifecycleAware( 25 | initial: R, 26 | context: CoroutineContext = EmptyCoroutineContext 27 | ): State { 28 | val lifecycleAwareFlow = rememberFlow(flow = this) 29 | return lifecycleAwareFlow.collectAsState(initial = initial, context = context) 30 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/playback/service/refl/SpReflect.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.playback.service.refl 2 | 3 | import androidx.media2.common.SessionPlayer 4 | import com.google.gson.JsonParser 5 | import xyz.gianlu.librespot.player.PagesLoader 6 | import xyz.gianlu.librespot.player.Player 7 | 8 | class SpReflect( 9 | private val player: () -> Player 10 | ) { 11 | fun playUsingData (data: String) { 12 | try { 13 | player().callPlayFromObj(JsonParser.parseString(data).asJsonObject) 14 | } catch (e: Exception) { 15 | e.printStackTrace() 16 | } 17 | } 18 | 19 | @SessionPlayer.RepeatMode 20 | fun getRepeatMode(): Int { 21 | val options = stateOf(player()).options 22 | return when { 23 | options.repeatingContext -> SessionPlayer.REPEAT_MODE_ALL 24 | options.repeatingTrack -> SessionPlayer.REPEAT_MODE_ONE 25 | else -> SessionPlayer.REPEAT_MODE_NONE 26 | } 27 | } 28 | 29 | @SessionPlayer.ShuffleMode 30 | fun getShuffleMode(): Int { 31 | return if (stateOf(player()).options.shufflingContext) SessionPlayer.SHUFFLE_MODE_ALL else SessionPlayer.SHUFFLE_MODE_NONE 32 | } 33 | 34 | private fun stateOf(player: Player) = player.stateWrapper.state 35 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/PromoComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | import "sp/DacPlayer.proto"; 5 | 6 | package com.spotify.home.dac.component.v1.proto; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.spotify.home.dac.component.v1.proto"; 10 | 11 | message PromoSectionHeadingComponent { 12 | string title = 1; 13 | google.protobuf.Any context_menu = 2; 14 | } 15 | 16 | message AlbumCardPromoComponent { 17 | string title = 1; 18 | string subtitle = 2; 19 | string navigate_uri = 3; 20 | string like_uri = 4; 21 | string image_uri = 5; 22 | string tag = 6; 23 | spotify.dac.player.v1.proto.PlayCommand play_command = 7; 24 | } 25 | 26 | message PromoCardOnlyYouComponent { 27 | string title = 1; 28 | string subtitle = 2; 29 | string navigate_uri = 3; 30 | string background_image_uri = 4; 31 | } 32 | 33 | message PromoCardHomeComponent { 34 | string title = 1; 35 | string subtitle = 2; 36 | string tag = 3; 37 | string navigate_uri = 4; 38 | string background_image_uri = 5; 39 | string logo_image_uri = 6; 40 | string gradient_color = 7; 41 | spotify.dac.player.v1.proto.PlayCommand play_command = 8; 42 | google.protobuf.Any context_menu = 9; 43 | string play_button_color = 10; 44 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/util/SpUtils.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.util 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.provider.Settings 6 | import com.google.protobuf.ByteString 7 | import xyz.gianlu.librespot.common.Utils 8 | import xyz.gianlu.librespot.metadata.ImageId 9 | 10 | object SpUtils { 11 | const val SPOTIFY_APP_VERSION = "8.7.68.568" 12 | 13 | fun getDeviceName(appContext: Context): String { 14 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { 15 | val deviceName = Settings.Global.getString(appContext.contentResolver, Settings.Global.DEVICE_NAME) 16 | if (deviceName == Build.MODEL) Build.MODEL else "$deviceName (${Build.MODEL})" 17 | } else { 18 | Build.MODEL 19 | } 20 | } 21 | 22 | fun getRandomString(length: Int) : String { 23 | val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') 24 | return (1..length).map { allowedChars.random() }.joinToString("") 25 | } 26 | 27 | fun getScannableUrl(uri: String) = "https://scannables.scdn.co/uri/800/$uri" 28 | fun getImageUrl(bytes: ByteString?) = if (bytes != null) "https://i.scdn.co/image/${Utils.bytesToHex(bytes).lowercase()}" else null 29 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/AlbumTrackRow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.unit.dp 10 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 11 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 12 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 13 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 14 | 15 | @Composable 16 | fun AlbumTrackRow( 17 | item: HubItem 18 | ) { 19 | Column(Modifier.clickableHub(item).fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { 20 | var drawnTitle = false 21 | 22 | if (!item.text?.title.isNullOrEmpty()) { 23 | drawnTitle = true 24 | MediumText(item.text!!.title!!, fontWeight = FontWeight.Normal) 25 | } 26 | 27 | if (!item.text?.subtitle.isNullOrEmpty()) { 28 | Subtext(item.text!!.subtitle!!, modifier = Modifier.padding(top = if (drawnTitle) 4.dp else 8.dp)) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/HubEventHandler.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.ui.Modifier 5 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubEvent 6 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 7 | import bruhcollective.itaysonlab.jetispot.ui.navigation.NavigationController 8 | import bruhcollective.itaysonlab.jetispot.ui.shared.navAndHubClickable 9 | import bruhcollective.itaysonlab.jetispot.ui.shared.navClickable 10 | 11 | object HubEventHandler { 12 | fun handle (navController: NavigationController, delegate: HubScreenDelegate, event: HubEvent) { 13 | when (event) { 14 | is HubEvent.NavigateToUri -> { 15 | if (event.data.uri.startsWith("http")) { 16 | navController.openInBrowser(event.data.uri) 17 | } else { 18 | navController.navigate(event.data.uri) 19 | } 20 | } 21 | is HubEvent.PlayFromContext -> delegate.play(event.data) 22 | HubEvent.Unknown -> {} 23 | } 24 | } 25 | } 26 | 27 | @Stable 28 | fun Modifier.clickableHub(item: HubItem) = navAndHubClickable(enabled = item.events?.click != null) { navController, delegate -> 29 | HubEventHandler.handle(navController, delegate, item.events!!.click!!) 30 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/SectionHeader.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubText 14 | import bruhcollective.itaysonlab.jetispot.ui.hub.LocalHubScreenDelegate 15 | 16 | @Composable 17 | fun SectionHeader( 18 | text: HubText, 19 | ) { 20 | Box( 21 | Modifier 22 | .padding( 23 | vertical = 8.dp, 24 | horizontal = if (LocalHubScreenDelegate.current.isSurroundedWithPadding()) 0.dp else 16.dp 25 | ) 26 | ) { 27 | Text( 28 | text = text.title!!, 29 | color = MaterialTheme.colorScheme.onSurface, 30 | fontWeight = FontWeight.Bold, 31 | fontSize = 16.sp, 32 | modifier = Modifier.align(Alignment.CenterStart) 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/monet/google/utils/StringUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package bruhcollective.itaysonlab.jetispot.ui.monet.google.utils; 18 | 19 | /** Utility methods for string representations of colors. */ 20 | final class StringUtils { 21 | private StringUtils() {} 22 | 23 | /** 24 | * Hex string representing color, ex. #ff0000 for red. 25 | * 26 | * @param argb ARGB representation of a color. 27 | */ 28 | public static String hexFromArgb(int argb) { 29 | int red = ColorUtils.redFromArgb(argb); 30 | int blue = ColorUtils.blueFromArgb(argb); 31 | int green = ColorUtils.greenFromArgb(argb); 32 | return String.format("#%02x%02x%02x", red, green, blue); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/util/Device.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.util 2 | 3 | import android.app.usage.StorageStatsManager 4 | import android.content.Context 5 | import android.os.Build 6 | import android.os.Environment 7 | import android.os.StatFs 8 | import android.os.storage.StorageManager 9 | import androidx.annotation.RequiresApi 10 | import androidx.core.content.getSystemService 11 | 12 | object Device { 13 | fun getInternalStorageSize(context: Context): StorageSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 14 | context.getSystemService()?.let { modernImpl(it) } ?: legacyImpl() 15 | } else { 16 | legacyImpl() 17 | } 18 | 19 | @RequiresApi(Build.VERSION_CODES.O) 20 | private fun modernImpl(manager: StorageStatsManager) = StorageSize(manager.getFreeBytes(StorageManager.UUID_DEFAULT) to manager.getTotalBytes(StorageManager.UUID_DEFAULT)) 21 | 22 | private fun statFs() = StatFs(Environment.getDataDirectory().path) 23 | private fun legacyImpl() = statFs().let { fs -> StorageSize(fs.availableBlocksLong * fs.blockSizeLong to fs.blockCountLong * fs.blockSizeLong) } 24 | 25 | @JvmInline 26 | value class StorageSize(private val src: Pair) { 27 | val free get() = src.first 28 | val total get() = src.second 29 | val taken get() = total - free 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ImageRow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.shape.CircleShape 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.draw.clip 9 | import androidx.compose.ui.text.font.FontWeight 10 | import androidx.compose.ui.unit.dp 11 | import androidx.compose.ui.unit.sp 12 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 13 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 16 | 17 | @Composable 18 | fun ImageRow( 19 | item: HubItem 20 | ) { 21 | Row(Modifier.clickableHub(item).fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { 22 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier 23 | .size(42.dp) 24 | .clip(CircleShape)) 25 | 26 | Column(Modifier.padding(horizontal = 12.dp).align(Alignment.CenterVertically)) { 27 | MediumText(item.text!!.title!!, fontWeight = FontWeight.Normal, fontSize = 18.sp) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/SpSessionManager.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core 2 | 3 | import android.content.Context 4 | import bruhcollective.itaysonlab.jetispot.core.util.SpUtils 5 | import com.spotify.connectstate.Connect 6 | import dagger.hilt.android.qualifiers.ApplicationContext 7 | import xyz.gianlu.librespot.core.Session 8 | import java.io.File 9 | import java.util.* 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Suppress("BlockingMethodInNonBlockingContext") 14 | @Singleton 15 | class SpSessionManager @Inject constructor( 16 | @ApplicationContext val appContext: Context, 17 | ) { 18 | private var _session: Session? = null 19 | val session get() = _session ?: throw IllegalStateException("Session is not created yet!") 20 | 21 | fun createSession(): Session.Builder = Session.Builder(createCfg()).setDeviceType(Connect.DeviceType.SMARTPHONE).setDeviceName( 22 | SpUtils.getDeviceName(appContext)).setDeviceId(null).setPreferredLocale(Locale.getDefault().language) 23 | private fun createCfg() = Session.Configuration.Builder().setCacheEnabled(true).setDoCacheCleanUp(true).setCacheDir(File(appContext.cacheDir, "spa_cache")).setStoredCredentialsFile(File(appContext.filesDir, "spa_creds")).build() 24 | 25 | fun isSignedIn() = _session?.isValid == true 26 | fun setSession(s: Session) { _session = s } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/FindCard.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.Card 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.text.style.TextOverflow 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 15 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 17 | 18 | @Composable 19 | fun FindCard( 20 | item: HubItem 21 | ) { 22 | Card(modifier = Modifier.height(100.dp).fillMaxWidth().clickableHub(item)) { 23 | Box { 24 | PreviewableAsyncImage(imageUrl = item.images?.background?.uri, placeholderType = item.images?.background?.placeholder, modifier = Modifier.fillMaxSize()) 25 | Text(item.text!!.title!!, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.TopStart).padding(12.dp)) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/OutlineButton.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.Icon 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.rounded.ChevronRight 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 16 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 18 | 19 | @Composable 20 | fun OutlineButton( 21 | item: HubItem 22 | ) { 23 | Box(Modifier.clickableHub(item).padding(16.dp)) { 24 | Row(Modifier.align(Alignment.Center)) { 25 | MediumText(text = item.text?.title!!, modifier = Modifier.align(Alignment.CenterVertically)) 26 | Icon(Icons.Rounded.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 2.dp).size(20.dp).align(Alignment.CenterVertically)) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/CollectionModule.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import bruhcollective.itaysonlab.jetispot.core.SpMetadataRequester 6 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 7 | import bruhcollective.itaysonlab.jetispot.core.api.SpCollectionApi 8 | import bruhcollective.itaysonlab.jetispot.core.api.SpInternalApi 9 | import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager 10 | import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionDao 11 | import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionDatabase 12 | import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionRepository 13 | import dagger.Module 14 | import dagger.Provides 15 | import dagger.hilt.InstallIn 16 | import dagger.hilt.android.qualifiers.ApplicationContext 17 | import dagger.hilt.components.SingletonComponent 18 | 19 | @Module 20 | @InstallIn(SingletonComponent::class) 21 | object CollectionModule { 22 | @Provides 23 | fun provideDatabase ( 24 | @ApplicationContext appCtx: Context 25 | ): LocalCollectionDatabase = Room.databaseBuilder(appCtx, LocalCollectionDatabase::class.java, "spCollection").build() 26 | 27 | @Provides 28 | fun provideDao ( 29 | db: LocalCollectionDatabase 30 | ): LocalCollectionDao = db.dao() 31 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/collection/collection2v2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package spotify.collection.proto.v2; 4 | 5 | option optimize_for = CODE_SIZE; 6 | option java_package = "com.spotify.collection2.v2.proto"; 7 | 8 | message PageRequest { 9 | string username = 1; 10 | string set = 2; 11 | string pagination_token = 3; 12 | int32 limit = 4; 13 | } 14 | 15 | message CollectionItem { 16 | string uri = 1; 17 | int32 added_at = 2; 18 | bool is_removed = 3; 19 | } 20 | 21 | message PageResponse { 22 | repeated CollectionItem items = 1; 23 | string next_page_token = 2; 24 | string sync_token = 3; 25 | } 26 | 27 | message DeltaRequest { 28 | string username = 1; 29 | string set = 2; 30 | string last_sync_token = 3; 31 | } 32 | 33 | message DeltaResponse { 34 | bool delta_update_possible = 1; 35 | repeated CollectionItem items = 2; 36 | string sync_token = 3; 37 | } 38 | 39 | message WriteRequest { 40 | string username = 1; 41 | string set = 2; 42 | repeated CollectionItem items = 3; 43 | string client_update_id = 4; 44 | } 45 | 46 | message PubSubUpdate { 47 | string username = 1; 48 | string set = 2; 49 | repeated CollectionItem items = 3; 50 | string client_update_id = 4; 51 | } 52 | 53 | message InitializedRequest { 54 | string username = 1; 55 | string set = 2; 56 | } 57 | 58 | message InitializedResponse { 59 | bool initialized = 1; 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/player/PlayFromContextData.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.player 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | class PlayFromContextData ( 7 | val uri: String, 8 | val player: PlayFromContextPlayerData, 9 | ) 10 | 11 | @JsonClass(generateAdapter = true) 12 | class PlayFromContextPlayerData ( 13 | val context: PfcContextData? = null, 14 | val options: PfcOptions? = null, 15 | val state: PfcState? = null, 16 | val play_origin: Map = mapOf(), 17 | ) 18 | 19 | @JsonClass(generateAdapter = true) 20 | class PfcContextData ( 21 | val url: String? = null, 22 | val uri: String, 23 | val metadata: PfcContextMetadata? = null 24 | ) 25 | 26 | @JsonClass(generateAdapter = true) 27 | class PfcOptions ( 28 | val skip_to: PfcOptSkipTo? = null, 29 | val player_options_override: PfcStateOptions? = null 30 | ) 31 | 32 | @JsonClass(generateAdapter = true) 33 | class PfcContextMetadata ( 34 | val context_description: String? = null, 35 | ) 36 | 37 | @JsonClass(generateAdapter = true) 38 | class PfcOptSkipTo ( 39 | val page_index: Int? = null, 40 | val track_uri: String 41 | ) 42 | 43 | @JsonClass(generateAdapter = true) 44 | class PfcState ( 45 | val options: PfcStateOptions? = null 46 | ) 47 | 48 | @JsonClass(generateAdapter = true) 49 | class PfcStateOptions ( 50 | val shuffling_context: Boolean = false 51 | ) -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/LargerRow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 13 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 17 | 18 | @Composable 19 | fun LargerRow ( 20 | item: HubItem 21 | ) { 22 | Row( 23 | Modifier 24 | .clickableHub(item) 25 | .padding(horizontal = 16.dp, vertical = 2.dp)) { 26 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier.size(72.dp).padding(end = 16.dp).padding(vertical = 8.dp)) 27 | Column(Modifier.align(Alignment.CenterVertically)) { 28 | MediumText(item.text!!.title!!, fontWeight = FontWeight.Normal, modifier = Modifier.padding(bottom = 4.dp)) 29 | Subtext(item.text.subtitle!!) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ShortcutsComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | import "sp/DacPlayer.proto"; 5 | 6 | package com.spotify.home.dac.component.v1.proto; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.spotify.home.dac.component.v1.proto"; 10 | 11 | message ShortcutsSectionComponent { 12 | string title = 1; 13 | repeated google.protobuf.Any shortcuts = 2; 14 | } 15 | 16 | message AlbumCardShortcutComponent { 17 | string title = 1; 18 | string navigate_uri = 2; 19 | string image_uri = 3; 20 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 4; 21 | } 22 | 23 | message PlaylistCardShortcutComponent { 24 | string title = 1; 25 | string navigate_uri = 2; 26 | string image_uri = 3; 27 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 4; 28 | } 29 | 30 | message ShowCardShortcutComponent { 31 | string title = 1; 32 | string navigate_uri = 2; 33 | string image_uri = 3; 34 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 4; 35 | } 36 | 37 | message ArtistCardShortcutComponent { 38 | string title = 1; 39 | string navigate_uri = 2; 40 | string image_uri = 3; 41 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 4; 42 | } 43 | 44 | message EpisodeCardShortcutComponent { 45 | string title = 1; 46 | string navigate_uri = 2; 47 | string image_uri = 3; 48 | bool fresh = 4; 49 | int64 progress_percentage = 5; 50 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 6; 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ArtistPinnedItem.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 13 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 17 | 18 | @Composable 19 | fun ArtistPinnedItem ( 20 | item: HubItem 21 | ) { 22 | Row( 23 | Modifier 24 | .clickableHub(item) 25 | .padding(horizontal = 16.dp, vertical = 2.dp)) { 26 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier.size(72.dp).padding(vertical = 8.dp).padding(end = 16.dp)) 27 | Column(Modifier.align(Alignment.CenterVertically)) { 28 | MediumText(item.text!!.title!!, fontWeight = FontWeight.Normal, modifier = Modifier.padding(bottom = 4.dp)) 29 | Subtext(item.text.subtitle!!) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp_ext/searchview.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "bruhcollective.itaysonlab.jetispot.proto"; 4 | option java_multiple_files = true; 5 | 6 | message SearchViewResponse { 7 | repeated SearchEntity hits = 1; 8 | } 9 | 10 | message SearchEntity { 11 | string uri = 1; 12 | string name = 2; 13 | string image_uri = 3; 14 | 15 | oneof entity { 16 | Artist artist = 4; 17 | Track track = 5; 18 | Album album = 6; 19 | Playlist playlist = 7; 20 | } 21 | 22 | message Artist { 23 | bool verified = 1; 24 | } 25 | 26 | message Track { 27 | message OnDemand { 28 | string trackUri = 1; 29 | string playlistUri = 2; 30 | } 31 | 32 | message RelatedEntity { 33 | string uri = 1; 34 | string name = 2; 35 | } 36 | 37 | bool explicit = 1; 38 | bool windowed = 2; 39 | RelatedEntity trackAlbum = 3; 40 | repeated RelatedEntity trackArtists = 4; 41 | optional string previewId = 5; 42 | optional bool mogef19 = 6; 43 | optional bool lyricsMatch = 7; 44 | optional OnDemand onDemand = 8; 45 | } 46 | 47 | message Album { 48 | enum Type { 49 | UNDEFINED = 0; 50 | ALBUM = 1; 51 | SINGLE = 2; 52 | COMPILATION = 3; 53 | EP = 4; 54 | AUDIOBOOK = 5; 55 | PODCAST = 6; 56 | } 57 | 58 | repeated string artistNames = 1; 59 | Type type = 2; 60 | int32 releaseDate = 3; 61 | } 62 | 63 | message Playlist { 64 | bool personalized = 1; 65 | bool ownedBySpotify = 2; 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/blocks/TwoColumnAndImageBlock.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.blocks 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.unit.dp 10 | import androidx.compose.ui.unit.sp 11 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 12 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 13 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 14 | import xyz.gianlu.librespot.metadata.ImageId 15 | 16 | @Composable 17 | fun TwoColumnAndImageBlock( 18 | artworkUri: String?, 19 | title: String, 20 | text: String, 21 | modifier: Modifier = Modifier 22 | ) { 23 | Row(modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { 24 | PreviewableAsyncImage(imageUrl = remember(artworkUri) { 25 | artworkUri?.let { "https://i.scdn.co/image/" + ImageId.fromUri(it).hexId() } 26 | }, placeholderType = "track", modifier = Modifier 27 | .size(48.dp)) 28 | 29 | Column(Modifier.padding(horizontal = 12.dp).align(Alignment.CenterVertically)) { 30 | MediumText(title, fontWeight = FontWeight.Normal, fontSize = 18.sp) 31 | Spacer(Modifier.height(4.dp)) 32 | Subtext(text, modifier = Modifier, maxLines = 1) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDatabase.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.collection.db 2 | 3 | import androidx.room.AutoMigration 4 | import androidx.room.Database 5 | import androidx.room.DeleteTable 6 | import androidx.room.RoomDatabase 7 | import androidx.room.migration.AutoMigrationSpec 8 | import bruhcollective.itaysonlab.jetispot.core.collection.db.model.LocalCollectionCategory 9 | import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.* 10 | import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.rootlist.CollectionRootlistItem 11 | 12 | @Database( 13 | entities = [ 14 | LocalCollectionCategory::class, 15 | CollectionArtist::class, 16 | CollectionAlbum::class, 17 | CollectionTrack::class, 18 | CollectionRootlistItem::class, 19 | CollectionContentFilter::class, 20 | CollectionPinnedItem::class, 21 | CollectionShow::class, 22 | CollectionEpisode::class, 23 | ], version = 7, autoMigrations = [ 24 | AutoMigration(from = 1, to = 2), 25 | AutoMigration(from = 2, to = 3), 26 | AutoMigration(from = 3, to = 4), 27 | AutoMigration(from = 4, to = 5, spec = LocalCollectionDatabase.RemoveArtistsMeta::class), 28 | AutoMigration(from = 5, to = 6), 29 | AutoMigration(from = 6, to = 7), 30 | ], exportSchema = true 31 | ) 32 | abstract class LocalCollectionDatabase : RoomDatabase() { 33 | abstract fun dao(): LocalCollectionDao 34 | 35 | @DeleteTable(tableName = "lcMetaArtists") 36 | class RemoveArtistsMeta: AutoMigrationSpec 37 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ShortcutsCard.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.Card 5 | import androidx.compose.material3.CardDefaults 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.text.style.TextOverflow 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 15 | import bruhcollective.itaysonlab.jetispot.ui.ext.compositeSurfaceElevation 16 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 18 | 19 | @Composable 20 | fun ShortcutsCard( 21 | item: HubItem 22 | ) { 23 | Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.compositeSurfaceElevation(3.dp)), modifier = Modifier.height(56.dp).fillMaxWidth()) { 24 | Row(Modifier.clickableHub(item)) { 25 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier.size(56.dp)) 26 | Text(item.text!!.title!!, fontSize = 13.sp, lineHeight = 18.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 8.dp)) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_home/FilterComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_home 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.lazy.LazyRow 6 | import androidx.compose.foundation.lazy.items 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.rounded.Check 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.FilterChip 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import com.spotify.home.dac.component.experimental.v1.proto.FilterComponent 17 | 18 | @OptIn(ExperimentalMaterial3Api::class) 19 | @Composable 20 | fun FilterComponentBinder ( 21 | component: FilterComponent, 22 | selectedFacet: String, 23 | selectFacet: (String) -> Unit, 24 | ) { 25 | LazyRow(Modifier.padding(start = 16.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { 26 | items(component.facetsList) { item -> 27 | val selected = selectedFacet == item.value 28 | FilterChip(selected = selected, onClick = { 29 | selectFacet(if (selected) "default" else item.value) 30 | }, label = { 31 | Text(item.title) 32 | }, leadingIcon = { 33 | if (selected) Icon(Icons.Rounded.Check, null) 34 | }) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.SideEffect 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.unit.dp 12 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 13 | 14 | @Composable 15 | fun ApplicationTheme( 16 | darkTheme: Boolean = isSystemInDarkTheme(), 17 | content: @Composable () -> Unit 18 | ) { 19 | val sysUiController = rememberSystemUiController() 20 | 21 | SideEffect { 22 | sysUiController.setSystemBarsColor(color = Color.Transparent, darkIcons = !darkTheme) 23 | } 24 | 25 | MaterialTheme( 26 | colorScheme = provideColorScheme(darkTheme), 27 | shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(8.dp)), 28 | content = content 29 | ) 30 | } 31 | 32 | @Composable 33 | private fun provideColorScheme(darkTheme: Boolean): ColorScheme { 34 | return when { 35 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> if (darkTheme) { 36 | dynamicDarkColorScheme(LocalContext.current) 37 | } else { 38 | dynamicLightColorScheme(LocalContext.current) 39 | } 40 | else -> if (darkTheme) { 41 | darkColorScheme() 42 | } else { 43 | lightColorScheme() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/HomeSectionLargeHeader.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 15 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 18 | import bruhcollective.itaysonlab.jetispot.ui.shared.SubtextOverline 19 | 20 | @Composable 21 | fun HomeSectionLargeHeader ( 22 | item: HubItem 23 | ) { 24 | Row(Modifier.padding(vertical = 8.dp).clickableHub(item)) { 25 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier 26 | .size(48.dp) 27 | .clip(CircleShape)) 28 | 29 | Column(Modifier.padding(horizontal = 12.dp).align(Alignment.CenterVertically)) { 30 | SubtextOverline(item.text!!.subtitle!!.uppercase(), modifier = Modifier) 31 | MediumText(item.text.title!!, modifier = Modifier.padding(top = 2.dp), fontSize = 21.sp) 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/SingleFocusCard.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.Card 5 | import androidx.compose.material3.CardDefaults 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 12 | import bruhcollective.itaysonlab.jetispot.ui.ext.compositeSurfaceElevation 13 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 17 | 18 | @Composable 19 | fun SingleFocusCard ( 20 | item: HubItem 21 | ) { 22 | Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.compositeSurfaceElevation(3.dp)), modifier = Modifier 23 | .height(120.dp) 24 | .fillMaxWidth() 25 | .clickableHub(item)) { 26 | Row { 27 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier 28 | .fillMaxHeight() 29 | .width(120.dp)) 30 | Box(Modifier.fillMaxSize().padding(16.dp)) { 31 | Column(Modifier.align(Alignment.TopStart)) { 32 | MediumText(text = item.text!!.title!!) 33 | Subtext(text = item.text.subtitle!!) 34 | } 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.enableR8.fullMode=true 24 | android.nonTransitiveRClass=true 25 | android.enableResourceOptimizations=true 26 | android.enableAppCompileTimeRClass=true 27 | android.useMinimalKeepRules=true 28 | android.includeDependencyInfoInApks=false 29 | android.defaults.buildfeatures.buildconfig=true 30 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /app/src/main/proto/sp_ext/color_lyrics.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_multiple_files = true; 4 | option java_package = "com.spotify.lyrics.v2.lyrics.proto"; 5 | 6 | // com.spotify.colorlyrics.proto 7 | message ColorLyricsResponse { 8 | message ColorData { 9 | int32 background = 1; 10 | int32 text = 2; 11 | int32 highlight_text = 3; 12 | } 13 | 14 | LyricsResponse lyrics = 1; 15 | optional ColorData colors = 2; 16 | bool has_vocal_removal = 3; 17 | optional ColorData vocal_removal_colors = 4; 18 | } 19 | 20 | message LyricsResponse { 21 | enum SyncType { 22 | UNSYNCED = 0; 23 | LINE_SYNCED = 1; 24 | SYLLABLE_SYNCED = 2; 25 | } 26 | 27 | message LyricsLine { 28 | int64 start_time_ms = 1; 29 | string words = 2; 30 | repeated Syllable syllables = 3; 31 | 32 | message Syllable { 33 | int64 start_time_ms = 1; 34 | int64 num_chars = 2; 35 | } 36 | } 37 | 38 | message AndroidIntent { 39 | string provider = 1; 40 | string provider_android_app_id = 2; 41 | string action = 3; 42 | string data = 4; 43 | string content_type = 5; 44 | } 45 | 46 | message Alternative { 47 | string language = 1; 48 | repeated LyricsLine lines = 2; 49 | bool is_rtl_language = 3; 50 | } 51 | 52 | SyncType syncType = 1; 53 | repeated LyricsLine lines = 2; 54 | string provider = 3; 55 | string provider_lyrics_id = 4; 56 | string provider_display_name = 5; 57 | optional AndroidIntent sync_lyrics_android_intent = 6; 58 | string sync_lyrics_uri = 7; 59 | bool is_dense_typeface = 8; 60 | repeated Alternative alternatives = 9; 61 | string language = 10; 62 | bool is_rtl_language = 11; 63 | int32 fullscreen_action = 12; 64 | } 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/Carousel.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.lazy.LazyRow 7 | import androidx.compose.foundation.lazy.items 8 | import androidx.compose.material.Text 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 16 | import bruhcollective.itaysonlab.jetispot.ui.hub.HubBinder 17 | import bruhcollective.itaysonlab.jetispot.ui.hub.LocalHubScreenDelegate 18 | 19 | @Composable 20 | fun Carousel( 21 | item: HubItem, 22 | ) { 23 | val isSurroundedWithPadding = LocalHubScreenDelegate.current.isSurroundedWithPadding() 24 | 25 | Column(Modifier.padding(vertical = if (isSurroundedWithPadding) 0.dp else 8.dp)) { 26 | if (item.text != null) { 27 | Text(text = item.text.title!!, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 16.sp, 28 | modifier = Modifier.padding(horizontal = if (isSurroundedWithPadding) 0.dp else 16.dp).padding(bottom = 12.dp)) 29 | } 30 | 31 | LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = if (isSurroundedWithPadding) 0.dp else 16.dp)) { 32 | items(item.children ?: listOf()) { cItem -> 33 | HubBinder(cItem) 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/navigation/NavigationController.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.navigation 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.annotation.StringRes 6 | import androidx.compose.runtime.Immutable 7 | import androidx.compose.runtime.staticCompositionLocalOf 8 | import androidx.navigation.NavHostController 9 | import bruhcollective.itaysonlab.jetispot.ui.screens.BottomSheet 10 | import bruhcollective.itaysonlab.jetispot.ui.screens.Dialog 11 | import bruhcollective.itaysonlab.jetispot.ui.screens.Screen 12 | 13 | @JvmInline 14 | @Immutable 15 | value class NavigationController( 16 | val controller: () -> NavHostController 17 | ) { 18 | // Not recommended 19 | fun navigate(route: String) = controller().navigate(route) 20 | 21 | fun navigate(screen: Screen) = controller().navigate(screen.route) 22 | fun navigate(dialog: Dialog) = controller().navigate(dialog.route) 23 | 24 | fun navigate(sheet: BottomSheet, args: Map) { 25 | var url = sheet.route 26 | 27 | args.forEach { entry -> 28 | url = url.replace("{${entry.key}}", entry.value) 29 | } 30 | 31 | controller().navigate(url) 32 | } 33 | 34 | fun navigateAndClearStack(screen: Screen) = controller().navigate(screen.route) { popUpTo(Screen.NavGraph.route) } 35 | 36 | fun popBackStack() = controller().popBackStack() 37 | 38 | fun context() = controller().context 39 | fun string(@StringRes id: Int) = context().getString(id) 40 | fun openInBrowser(uri: String) = context().startActivity(Intent(Intent.ACTION_VIEW).setData(Uri.parse(uri))) 41 | } 42 | 43 | val LocalNavigationController = staticCompositionLocalOf { error("Supply NavigationController in composable scope!") } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/hub/BrowseScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.hub 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.rounded.ArrowBack 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.input.nestedscroll.nestedScroll 10 | import androidx.compose.ui.platform.LocalDensity 11 | import androidx.compose.ui.text.style.TextOverflow 12 | import androidx.compose.ui.unit.dp 13 | import bruhcollective.itaysonlab.jetispot.ui.ext.rememberEUCScrollBehavior 14 | import bruhcollective.itaysonlab.jetispot.ui.navigation.LocalNavigationController 15 | 16 | @OptIn(ExperimentalMaterial3Api::class) 17 | @Composable 18 | fun BrowseScreen( 19 | id: String 20 | ) { 21 | val navController = LocalNavigationController.current 22 | val scrollBehavior = rememberEUCScrollBehavior() 23 | var appBarTitle by remember { mutableStateOf("") } 24 | 25 | Scaffold(topBar = { 26 | LargeTopAppBar(title = { 27 | Text(appBarTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) 28 | }, navigationIcon = { 29 | IconButton(onClick = { navController.popBackStack() }) { 30 | Icon(Icons.Rounded.ArrowBack, null) 31 | } 32 | }, colors = TopAppBarDefaults.largeTopAppBarColors(), scrollBehavior = scrollBehavior) 33 | }, modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), contentWindowInsets = WindowInsets(top = 0.dp)) { padding -> 34 | Box(Modifier.padding(padding)) { 35 | HubScreen( 36 | needContentPadding = false, 37 | loader = { getBrowseView(id) }, 38 | onAppBarTitleChange = { appBarTitle = it } 39 | ) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/shared/NavController.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.shared 2 | 3 | import androidx.compose.foundation.LocalIndication 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.interaction.MutableInteractionSource 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.composed 9 | import bruhcollective.itaysonlab.jetispot.ui.hub.HubScreenDelegate 10 | import bruhcollective.itaysonlab.jetispot.ui.hub.LocalHubScreenDelegate 11 | import bruhcollective.itaysonlab.jetispot.ui.navigation.LocalNavigationController 12 | import bruhcollective.itaysonlab.jetispot.ui.navigation.NavigationController 13 | 14 | fun Modifier.navClickable( 15 | enabled: Boolean = true, 16 | enableRipple: Boolean = true, 17 | onClick: (NavigationController) -> Unit 18 | ) = composed { 19 | val navController = LocalNavigationController.current 20 | 21 | Modifier.clickable( 22 | enabled = enabled, 23 | indication = if (enableRipple) LocalIndication.current else null, 24 | interactionSource = remember { MutableInteractionSource() }, 25 | ) { 26 | onClick(navController) 27 | } 28 | } 29 | 30 | fun Modifier.navAndHubClickable( 31 | enabled: Boolean = true, 32 | enableRipple: Boolean = true, 33 | onClick: (NavigationController, HubScreenDelegate) -> Unit 34 | ) = composed { 35 | val navController = LocalNavigationController.current 36 | val hubScreenDelegate = LocalHubScreenDelegate.current 37 | 38 | Modifier.clickable( 39 | enabled = enabled, 40 | indication = if (enableRipple) LocalIndication.current else null, 41 | interactionSource = remember { MutableInteractionSource() }, 42 | ) { 43 | onClick(navController, hubScreenDelegate) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/shared/SharedTexts.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.shared 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.text.style.TextAlign 10 | import androidx.compose.ui.text.style.TextOverflow 11 | import androidx.compose.ui.unit.TextUnit 12 | import androidx.compose.ui.unit.sp 13 | 14 | @Composable 15 | fun MediumText ( 16 | text: String, 17 | modifier: Modifier = Modifier, 18 | color: Color = Color.Unspecified, 19 | fontWeight: FontWeight = FontWeight.Bold, 20 | lineHeight: TextUnit = TextUnit.Unspecified, 21 | maxLines: Int = 1, 22 | fontSize: TextUnit = 16.sp, 23 | textAlign: TextAlign? = null, 24 | ) { 25 | Text(text, textAlign = textAlign, color = color, fontSize = fontSize, fontWeight = fontWeight, maxLines = maxLines, lineHeight = lineHeight, overflow = TextOverflow.Ellipsis, modifier = modifier) 26 | } 27 | 28 | @Composable 29 | fun Subtext ( 30 | text: String, 31 | modifier: Modifier = Modifier, 32 | fontSize: TextUnit = 12.sp, 33 | maxLines: Int = 2, 34 | textAlign: TextAlign? = null, 35 | ) { 36 | Text(text, textAlign = textAlign, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), fontSize = fontSize, lineHeight = 18.sp, maxLines = maxLines, overflow = TextOverflow.Ellipsis, modifier = modifier) 37 | } 38 | 39 | @Composable 40 | fun SubtextOverline ( 41 | text: String, 42 | modifier: Modifier = Modifier 43 | ) { 44 | Text(text, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), letterSpacing = 2.sp, fontSize = 12.sp, lineHeight = 18.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = modifier) 45 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/MediumCard.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.draw.clip 11 | import androidx.compose.ui.unit.dp 12 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 13 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 17 | 18 | @Composable 19 | fun MediumCard( 20 | item: HubItem 21 | ) { 22 | val size = 160.dp 23 | 24 | Column(Modifier.width(size).clickableHub(item)) { 25 | var drawnTitle = false 26 | 27 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier.size(size).clip( 28 | RoundedCornerShape(if (item.images?.main?.isRounded == true) 12.dp else 0.dp) 29 | )) 30 | 31 | if (!item.text?.title.isNullOrEmpty()) { 32 | drawnTitle = true 33 | MediumText(item.text!!.title!!, modifier = Modifier.padding(top = 8.dp)) 34 | } 35 | 36 | if (!item.text?.subtitle.isNullOrEmpty()) { 37 | Subtext(item.text!!.subtitle!!, modifier = Modifier.padding(top = if (drawnTitle) 4.dp else 8.dp)) 38 | } else if (!item.text?.description.isNullOrEmpty()) { 39 | Subtext(item.text!!.description!!, modifier = Modifier.padding(top = if (drawnTitle) 4.dp else 8.dp)) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/SpAuthManager.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import xyz.gianlu.librespot.core.Session 7 | import java.io.File 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class SpAuthManager @Inject constructor( 13 | private val spSessionManager: SpSessionManager, 14 | private val spPlayerManager: SpPlayerManager, 15 | private val spCollectionManager: SpCollectionManager 16 | ) { 17 | @Suppress("BlockingMethodInNonBlockingContext") 18 | suspend fun authWith(username: String, password: String) = withContext(Dispatchers.IO) { 19 | try { 20 | spSessionManager.setSession(spSessionManager.createSession().userPass(username, password).create()) 21 | spPlayerManager.createPlayer() 22 | spCollectionManager.init() 23 | AuthResult.Success 24 | } catch (se: Session.SpotifyAuthenticationException) { 25 | AuthResult.SpError(se.message ?: "Unknown error") 26 | } catch (e: Exception) { 27 | e.printStackTrace() 28 | AuthResult.Exception(e) 29 | } 30 | } 31 | 32 | @Suppress("BlockingMethodInNonBlockingContext") 33 | suspend fun authStored() = withContext(Dispatchers.IO) { 34 | runCatching { 35 | spSessionManager.setSession(spSessionManager.createSession().stored().create()) 36 | spPlayerManager.createPlayer() 37 | spCollectionManager.init() 38 | } 39 | } 40 | 41 | sealed class AuthResult { 42 | object Success: AuthResult() 43 | class SpError(val msg: String): AuthResult() 44 | class Exception(val e: kotlin.Exception): AuthResult() 45 | } 46 | 47 | fun reset() { 48 | File(spSessionManager.appContext.filesDir, "spa_creds").delete() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/hub/BrowseRadioScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.hub 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.rounded.ArrowBack 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.input.nestedscroll.nestedScroll 13 | import androidx.compose.ui.text.style.TextOverflow 14 | import androidx.compose.ui.unit.dp 15 | import bruhcollective.itaysonlab.jetispot.ui.ext.rememberEUCScrollBehavior 16 | import bruhcollective.itaysonlab.jetispot.ui.navigation.LocalNavigationController 17 | 18 | @OptIn(ExperimentalMaterial3Api::class) 19 | @Composable 20 | fun BrowseRadioScreen() { 21 | val navController = LocalNavigationController.current 22 | val scrollBehavior = rememberEUCScrollBehavior() 23 | var appBarTitle by remember { mutableStateOf("") } 24 | 25 | Scaffold(topBar = { 26 | LargeTopAppBar(title = { 27 | Text(appBarTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) 28 | }, navigationIcon = { 29 | IconButton(onClick = { navController.popBackStack() }) { 30 | Icon(Icons.Rounded.ArrowBack, null) 31 | } 32 | }, colors = TopAppBarDefaults.largeTopAppBarColors(), scrollBehavior = scrollBehavior) 33 | }, modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), contentWindowInsets = WindowInsets(top = 0.dp)) { padding -> 34 | Box(Modifier.padding(padding)) { 35 | HubScreen( 36 | needContentPadding = false, 37 | loader = { getRadioHub() }, 38 | onAppBarTitleChange = { appBarTitle = it } 39 | ) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.search 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.compose.ui.text.input.TextFieldValue 7 | import androidx.lifecycle.ViewModel 8 | import bruhcollective.itaysonlab.jetispot.core.SpPlayerServiceManager 9 | import bruhcollective.itaysonlab.jetispot.core.api.SpInternalApi 10 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PfcContextData 11 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextPlayerData 12 | import bruhcollective.itaysonlab.jetispot.core.util.playCommand 13 | import bruhcollective.itaysonlab.jetispot.proto.SearchViewResponse 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import kotlinx.coroutines.CoroutineScope 16 | import kotlinx.coroutines.MainScope 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class SearchViewModel @Inject constructor( 21 | private val spInternalApi: SpInternalApi, 22 | private val spPlayerServiceManager: SpPlayerServiceManager 23 | ): ViewModel(), CoroutineScope by MainScope() { 24 | var searchQuery by mutableStateOf(TextFieldValue()) 25 | 26 | var searchResponse by mutableStateOf(null) 27 | private set 28 | 29 | suspend fun initiateSearch() { 30 | searchResponse = null 31 | searchResponse = spInternalApi.search( 32 | query = searchQuery.text 33 | ) 34 | } 35 | 36 | fun dispatchPlay(uri: String) { 37 | spPlayerServiceManager.play( 38 | playCommand(uri) { 39 | contextUri = "spotify:search:${searchQuery.text.replace(" ", "+")}" 40 | } 41 | ) 42 | } 43 | 44 | fun clear() { 45 | searchQuery = TextFieldValue() 46 | searchResponse = null 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp_ext/contentfeed.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/timestamp.proto"; 4 | 5 | package com.spotify.contentfeed.proto.v1.common; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "com.spotify.contentfeed.proto.v1.common"; 9 | 10 | message FeedItem { 11 | string id = 1; 12 | ContentType contentType = 2; 13 | string deduplicationKey = 3; 14 | string targetUri = 4; 15 | google.protobuf.Timestamp timestamp = 5; 16 | FeedItemState state = 6; 17 | 18 | oneof typeSpecificAttributes { 19 | MusicRelease musicRelease = 7; 20 | PodcastEpisodeRelease podcastEpisodeRelease = 8; 21 | } 22 | } 23 | 24 | message FeedItemState { 25 | enum InteractionState { 26 | INTERACTION_STATE_INVALID = 0; 27 | NEW = 1; 28 | SEEN = 2; 29 | } 30 | 31 | string itemId = 1; 32 | InteractionState interactionState = 2; 33 | optional google.protobuf.Timestamp timestamp = 3; 34 | } 35 | 36 | enum ContentType { 37 | CONTENT_TYPE_INVALID = 0; 38 | MUSIC_RELEASE = 1; 39 | PODCAST_EPISODE_RELEASE = 2; 40 | RECOMMENDATION = 3; 41 | ARTIST_OFFER = 5; 42 | } 43 | 44 | message MusicRelease { 45 | AlbumType albumType = 1; 46 | repeated string artists = 2; 47 | optional bool explicit = 3; 48 | string albumName = 4; 49 | string imageUrl = 5; 50 | } 51 | 52 | message PodcastEpisodeRelease { 53 | string showName = 1; 54 | int32 episodeType = 2; // enum? 55 | int32 durationMilliseconds = 3; 56 | bool explicit = 4; 57 | string episodeName = 5; 58 | string imageUrl = 6; 59 | bool is19PlusOnly = 7; 60 | bool isMusicAndTalk = 8; 61 | bool isPaywalled = 9; 62 | bool isUserSubscribed = 10; 63 | repeated string musicAndTalkArtistNames = 11; 64 | optional string episodeDescription = 12; 65 | } 66 | 67 | enum AlbumType { 68 | UNSPECIFIED = 0; 69 | ALBUM = 1; 70 | SINGLE = 2; 71 | COMPILATION = 3; 72 | EP = 4; 73 | AUDIOBOOK = 5; 74 | PODCAST = 6; 75 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/blend/BlendCreateInvitationScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.blend 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.hilt.navigation.compose.hiltViewModel 5 | import androidx.lifecycle.ViewModel 6 | import bruhcollective.itaysonlab.jetispot.R 7 | import bruhcollective.itaysonlab.jetispot.core.SpConfigurationManager 8 | import bruhcollective.itaysonlab.jetispot.core.api.SpBlendApi 9 | import bruhcollective.itaysonlab.jetispot.proto.AppConfig 10 | import bruhcollective.itaysonlab.jetispot.ui.ext.shareUrl 11 | import bruhcollective.itaysonlab.jetispot.ui.screens.config.BaseConfigScreen 12 | import bruhcollective.itaysonlab.jetispot.ui.screens.config.ConfigItem 13 | import bruhcollective.itaysonlab.jetispot.ui.screens.config.ConfigViewModel 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import kotlinx.coroutines.* 16 | import javax.inject.Inject 17 | 18 | @Composable 19 | fun BlendCreateInvitationScreen ( 20 | viewModel: BlendCreateInvitationViewModel = hiltViewModel() 21 | ) { 22 | BaseConfigScreen(viewModel) 23 | } 24 | 25 | @HiltViewModel 26 | class BlendCreateInvitationViewModel @Inject constructor( 27 | private val spBlendApi: SpBlendApi 28 | ) : ViewModel(), ConfigViewModel, CoroutineScope by MainScope() { 29 | override suspend fun modifyDatastore(runOnBuilder: AppConfig.Builder.() -> Unit) {} 30 | override fun provideDataStore() = SpConfigurationManager.EMPTY 31 | override fun provideTitle() = R.string.blend_create 32 | 33 | override fun provideConfigList() = buildList { 34 | add(ConfigItem.Preference( 35 | R.string.blend_create_btn, { _, _ -> "" } 36 | ) { 37 | launch { 38 | val link = withContext(Dispatchers.IO) { spBlendApi.generateBlend().invite } 39 | it.context().shareUrl(link, it.string(R.string.blend_invite)) 40 | } 41 | }) 42 | 43 | add(ConfigItem.Info(R.string.blend_info)) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ArtistTrackRow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.text.font.FontWeight 12 | import androidx.compose.ui.unit.dp 13 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 14 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 18 | 19 | @Composable 20 | fun ArtistTrackRow( 21 | item: HubItem 22 | ) { 23 | Row( 24 | Modifier 25 | .clickableHub(item) 26 | .padding(horizontal = 16.dp, vertical = 12.dp)) { 27 | Text(text = (item.custom!!["rowNumber"] as Double).toInt().toString(), modifier = Modifier 28 | .align(Alignment.CenterVertically) 29 | .padding(end = 16.dp)) 30 | 31 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier.align(Alignment.CenterVertically).size(48.dp)) 32 | 33 | Column(Modifier.align(Alignment.CenterVertically).padding(start = 16.dp)) { 34 | var drawnTitle = false 35 | 36 | if (!item.text?.title.isNullOrEmpty()) { 37 | drawnTitle = true 38 | MediumText(item.text!!.title!!, fontWeight = FontWeight.Normal) 39 | } 40 | 41 | if (!item.text?.subtitle.isNullOrEmpty()) { 42 | Subtext(item.text!!.subtitle!!, modifier = Modifier.padding(top = if (drawnTitle) 4.dp else 8.dp)) 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_plans/PlanComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_plans 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.shape.RoundedCornerShape 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.rounded.Paid 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.unit.dp 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.navClickable 18 | import com.spotify.allplans.v1.PlanComponent 19 | 20 | @Composable 21 | fun PlanComponentBinder( 22 | item: PlanComponent 23 | ) { 24 | Row(Modifier.navClickable { navController -> 25 | navController.openInBrowser(item.uri) 26 | }.fillMaxWidth().padding(horizontal = 16.dp)) { 27 | Surface( 28 | tonalElevation = 32.dp, modifier = Modifier 29 | .clip(RoundedCornerShape(8.dp)) 30 | .size(56.dp) 31 | ) { 32 | Icon( 33 | Icons.Rounded.Paid, 34 | null, 35 | tint = Color(android.graphics.Color.parseColor(item.planColor)), 36 | modifier = Modifier 37 | .padding(12.dp) 38 | .fillMaxSize() 39 | ) 40 | } 41 | 42 | Column( 43 | Modifier 44 | .padding(start = 16.dp) 45 | .align(Alignment.CenterVertically) 46 | ) { 47 | MediumText(text = item.planName) 48 | Subtext(text = item.planPrice, modifier = Modifier.padding(top = 4.dp)) 49 | Subtext(text = item.availableAccounts, modifier = Modifier.padding(top = 4.dp)) 50 | } 51 | } 52 | 53 | Spacer(modifier = Modifier.height(8.dp)) 54 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/Screen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.rounded.LibraryMusic 6 | import androidx.compose.material.icons.rounded.Search 7 | import androidx.compose.material.icons.rounded.Home 8 | import androidx.compose.runtime.Immutable 9 | import bruhcollective.itaysonlab.jetispot.R 10 | 11 | @Immutable 12 | enum class Screen( 13 | val route: String, 14 | @StringRes val title: Int = 0, 15 | ) { 16 | // internal 17 | NavGraph("nav_graph"), 18 | CoreLoading("coreLoading"), 19 | Authorization("auth"), 20 | SpotifyIdRedirect("spotify:{uri}"), 21 | // bottom 22 | Feed("feed", title = R.string.tab_home), 23 | Search("search", title = R.string.tab_search), 24 | Library("library", title = R.string.tab_library), 25 | // hubs/dac 26 | DacViewCurrentPlan("dac/viewCurrentPlan", title = R.string.plan_overview), 27 | DacViewAllPlans("dac/viewAllPlans", title = R.string.all_plans), 28 | // config 29 | Config("config"), 30 | StorageConfig("config/storage"), 31 | QualityConfig("config/playbackQuality"), 32 | NormalizationConfig("config/playbackNormalization"); 33 | 34 | companion object { 35 | val hideNavigationBar = setOf(CoreLoading.route, Authorization.route, Dialog.AuthDisclaimer.route) 36 | val deeplinkCapable = mapOf(SpotifyIdRedirect to "https://open.spotify.com/{type}/{typeId}") 37 | val showInBottomNavigation = mapOf( 38 | Feed to Icons.Rounded.Home, 39 | Search to Icons.Rounded.Search, 40 | Library to Icons.Rounded.LibraryMusic 41 | ) 42 | } 43 | } 44 | 45 | @Immutable 46 | enum class Dialog( 47 | val route: String 48 | ) { 49 | AuthDisclaimer("dialogs/disclaimers"), 50 | Logout("dialogs/logout") 51 | } 52 | 53 | @Immutable 54 | enum class BottomSheet( 55 | val route: String 56 | ) { 57 | JumpToArtist("bs/jumpToArtist/{artistIdsAndRoles}") // ID=ROLE|ID=ROLE 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/hub/PodcastShowScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.hub 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.hilt.navigation.compose.hiltViewModel 7 | import bruhcollective.itaysonlab.jetispot.core.SpMetadataRequester 8 | import bruhcollective.itaysonlab.jetispot.core.SpPlayerServiceManager 9 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 10 | import bruhcollective.itaysonlab.jetispot.core.api.SpPartnersApi 11 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextData 12 | import bruhcollective.itaysonlab.jetispot.ui.hub.virt.ShowEntityView 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import javax.inject.Inject 15 | 16 | @Composable 17 | fun PodcastShowScreen( 18 | id: String, 19 | viewModel: PodcastShowViewModel = hiltViewModel() 20 | ) { 21 | LaunchedEffect(Unit) { 22 | viewModel.load { viewModel.loadInternal(id) } 23 | } 24 | 25 | HubScaffold( 26 | appBarTitle = viewModel.title.value, 27 | state = viewModel.state, 28 | viewModel = viewModel 29 | ) { 30 | viewModel.reload { viewModel.loadInternal(id) } 31 | } 32 | } 33 | 34 | @HiltViewModel 35 | class PodcastShowViewModel @Inject constructor( 36 | private val spSessionManager: SpSessionManager, 37 | private val spPartnersApi: SpPartnersApi, 38 | private val spPlayerServiceManager: SpPlayerServiceManager, 39 | private val spMetadataRequester: SpMetadataRequester 40 | ) : AbsHubViewModel() { 41 | val title = mutableStateOf("") 42 | 43 | suspend fun loadInternal(id: String) = ShowEntityView.create(spSessionManager, spMetadataRequester, id).also { title.value = it.title ?: "" } 44 | 45 | override fun play(data: PlayFromContextData) = play(spPlayerServiceManager, data) 46 | override suspend fun calculateDominantColor(url: String, dark: Boolean) = calculateDominantColor(spPartnersApi, url, dark) 47 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/nowplaying/NowPlayingScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.nowplaying 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.material.BottomSheetState 9 | import androidx.compose.material.ExperimentalMaterialApi 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.rememberCoroutineScope 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.alpha 15 | import androidx.compose.ui.unit.dp 16 | import androidx.hilt.navigation.compose.hiltViewModel 17 | import bruhcollective.itaysonlab.jetispot.ui.screens.nowplaying.fullscreen.NowPlayingFullscreenComposition 18 | import kotlinx.coroutines.launch 19 | 20 | @Composable 21 | @OptIn(ExperimentalMaterialApi::class) 22 | fun NowPlayingScreen( 23 | bottomSheetState: BottomSheetState, 24 | bsOffset: () -> Float, 25 | queueOpened: Boolean, 26 | setQueueOpened: (Boolean) -> Unit, 27 | lyricsOpened: Boolean, 28 | setLyricsOpened: (Boolean) -> Unit, 29 | viewModel: NowPlayingViewModel = hiltViewModel() 30 | ) { 31 | val scope = rememberCoroutineScope() 32 | 33 | Box(Modifier.fillMaxSize()) { 34 | NowPlayingFullscreenComposition( 35 | queueOpened = queueOpened, 36 | setQueueOpened = setQueueOpened, 37 | lyricsOpened = lyricsOpened, 38 | setLyricsOpened = setLyricsOpened, 39 | bottomSheetState = bottomSheetState, 40 | viewModel = viewModel 41 | ) 42 | 43 | NowPlayingMiniplayer( 44 | viewModel, 45 | Modifier 46 | .alpha(1f - bsOffset()) 47 | .clickable { scope.launch { bottomSheetState.expand() } } 48 | .fillMaxWidth() 49 | .height(72.dp) 50 | .align(Alignment.TopStart) 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/auth/AuthScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.auth 2 | 3 | import android.content.res.Resources 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.viewModelScope 9 | import bruhcollective.itaysonlab.jetispot.R 10 | import bruhcollective.itaysonlab.jetispot.core.SpAuthManager 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @Stable 16 | @HiltViewModel 17 | class AuthScreenViewModel @Inject constructor( 18 | private val authManager: SpAuthManager, 19 | private val resources: Resources, 20 | ) : ViewModel() { 21 | 22 | private val _isAuthInProgress = mutableStateOf(false) 23 | val isAuthInProgress: State = _isAuthInProgress 24 | 25 | fun auth( 26 | username: String, 27 | password: String, 28 | onSuccess: () -> Unit, 29 | onFailure: (String) -> Unit, 30 | ) { 31 | if (isAuthInProgress.value) return 32 | 33 | viewModelScope.launch { 34 | if (username.isEmpty() || password.isEmpty()) { 35 | onFailure(resources.getString(R.string.auth_err_empty)) 36 | return@launch 37 | } 38 | 39 | _isAuthInProgress.value = true 40 | 41 | when (val result = authManager.authWith(username, password)) { 42 | SpAuthManager.AuthResult.Success -> onSuccess() 43 | is SpAuthManager.AuthResult.Exception -> onFailure("Java Error: ${result.e.message}") 44 | is SpAuthManager.AuthResult.SpError -> onFailure( 45 | when (result.msg) { 46 | "BadCredentials" -> resources.getString(R.string.auth_err_badcreds) 47 | "PremiumAccountRequired" -> resources.getString(R.string.auth_err_premium) 48 | else -> "Spotify API error: ${result.msg}" 49 | } 50 | ) 51 | } 52 | 53 | _isAuthInProgress.value = false 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ArtistHeader.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Brush 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.TileMode 11 | import androidx.compose.ui.layout.ContentScale 12 | import androidx.compose.ui.platform.LocalConfiguration 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 17 | import coil.compose.AsyncImage 18 | 19 | @Composable 20 | fun ArtistHeader( 21 | item: HubItem 22 | ) { 23 | Column( 24 | modifier = Modifier 25 | .size((LocalConfiguration.current.screenWidthDp).dp, 310.dp), 26 | ) { 27 | Box { 28 | AsyncImage( 29 | model = item.images?.main?.uri, contentDescription = null, 30 | Modifier.fillMaxWidth(), 31 | contentScale = ContentScale.FillWidth 32 | ) 33 | 34 | Column( 35 | Modifier 36 | .background( 37 | Brush.verticalGradient( 38 | 0F to Color.Transparent, 39 | 1F to Color.Black, 40 | ), 41 | ) 42 | .fillMaxSize() 43 | ) {} 44 | 45 | MediumText( 46 | text = item.text?.title!!, 47 | fontSize = 50.sp, 48 | color = Color.White, 49 | modifier = Modifier 50 | .align(Alignment.BottomStart) 51 | .padding(horizontal = 16.dp) 52 | .padding(bottom = 8.dp) 53 | ) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/hub/HubItem.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.hub 2 | 3 | import com.squareup.moshi.JsonClass 4 | import java.util.* 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class HubItem ( 8 | val component: HubComponent, 9 | val id: String = UUID.randomUUID().toString(), 10 | // types 11 | val text: HubText? = null, 12 | val children: List? = null, 13 | val images: HubImages? = null, 14 | val events: HubEvents? = null, 15 | // 16 | val custom: Map? = null, 17 | val metadata: HubItemMetadata? = null 18 | ) 19 | 20 | // 21 | 22 | @JsonClass(generateAdapter = true) 23 | class HubText ( 24 | val title: String? = null, 25 | val subtitle: String? = null, 26 | val description: String? = null 27 | ) 28 | 29 | @JsonClass(generateAdapter = true) 30 | class HubImages ( 31 | val main: HubImage? = null, 32 | val background: HubImage? = null, 33 | ) 34 | 35 | @JsonClass(generateAdapter = true) 36 | class HubImage ( 37 | val uri: String? = null, 38 | val placeholder: String? = null, 39 | val custom: HubImageCustom? = null 40 | ) { 41 | val isRounded = custom?.style == "rounded" 42 | } 43 | 44 | @JsonClass(generateAdapter = true) 45 | class HubImageCustom ( 46 | val style: String? = null 47 | ) 48 | 49 | @JsonClass(generateAdapter = true) 50 | class HubEvents ( 51 | val click: HubEvent? 52 | ) 53 | 54 | @JsonClass(generateAdapter = true) 55 | class NavigateUri ( 56 | val uri: String 57 | ) 58 | 59 | @JsonClass(generateAdapter = true) 60 | class HubItemMetadata ( 61 | val uri: String? = null, 62 | val album: JsonAlbum? = null, 63 | val artist: JsonArtist? = null, 64 | ) 65 | 66 | @JsonClass(generateAdapter = true) 67 | class JsonAlbum ( 68 | val year: Int, 69 | val artists: List, 70 | val type: String 71 | ) 72 | 73 | @JsonClass(generateAdapter = true) 74 | class JsonArtist ( 75 | val name: String?, 76 | val uri: String?, 77 | val images: List? 78 | ) 79 | 80 | @JsonClass(generateAdapter = true) 81 | class JsonImage ( 82 | val uri: String, 83 | val width: Int, 84 | val height: Int 85 | ) -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/util/PlayCommandFactory.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.util 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.objs.player.* 4 | import com.spotify.dac.player.v1.proto.PlayCommand 5 | import com.squareup.moshi.Moshi 6 | import com.squareup.moshi.adapter 7 | 8 | @DslMarker 9 | @Target(AnnotationTarget.TYPE, AnnotationTarget.CLASS) 10 | annotation class PlayCommandDsl 11 | 12 | inline fun playCommand(uri: String, init: (@PlayCommandDsl PlayCommandBuilder).() -> Unit): PlayFromContextData { 13 | return PlayCommandBuilder(uri).apply(init).build() 14 | } 15 | 16 | class PlayCommandBuilder ( 17 | private val contentUri: String 18 | ) { 19 | var contextUri: String = "" 20 | var skipToUri: String? = null 21 | var shuffle: Boolean = false 22 | 23 | fun build(): PlayFromContextData { 24 | require(contextUri.isNotEmpty()) { "Context URI should not be empty!" } 25 | require(contextUri.startsWith("spotify:")) { "Context URI should be a Spotify URI!" } 26 | 27 | skipToUri?.let { 28 | require(it.startsWith("spotify:track")) { "Context Skip-To URI should be a Spotify Track URI!" } 29 | } 30 | 31 | return PlayFromContextData( 32 | uri = contentUri, 33 | player = PlayFromContextPlayerData( 34 | context = PfcContextData(url = "context://$contextUri", uri = contextUri), 35 | options = PfcOptions(skip_to = skipToUri?.let { PfcOptSkipTo(track_uri = it) }, player_options_override = PfcStateOptions(shuffling_context = shuffle)) 36 | ) 37 | ) 38 | } 39 | } 40 | 41 | fun PlayCommand.toApplicationPlayCommand(moshi: Moshi): PlayFromContextData { 42 | val context = moshi.adapter(PfcContextData::class.java).fromJson(this.context.toStringUtf8())!! 43 | val options = moshi.adapter(PlayFromContextPlayerData::class.java).fromJson(this.options.toStringUtf8())!!.options!! 44 | 45 | return PlayFromContextData( 46 | uri = context.uri, 47 | player = PlayFromContextPlayerData( 48 | context = context, 49 | options = options 50 | ) 51 | ) 52 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/playback/sp/LowToHighQualityPicker.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.playback.sp 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.util.Log 4 | import com.spotify.metadata.Metadata 5 | import xyz.gianlu.librespot.audio.decoders.AudioQuality 6 | import xyz.gianlu.librespot.audio.decoders.VorbisOnlyAudioQuality 7 | import xyz.gianlu.librespot.audio.format.AudioQualityPicker 8 | import xyz.gianlu.librespot.audio.format.SuperAudioFormat 9 | 10 | /** 11 | * key features: 12 | * - support not only vorbis (Android also plays MP3) 13 | * - adaptive quality: don't use the first suitable file if quality is not found 14 | */ 15 | class LowToHighQualityPicker( 16 | private val preferredQualityProvider: () -> AudioQuality 17 | ): AudioQualityPicker { 18 | companion object { 19 | private val ORDER = arrayOf(AudioQuality.LOW, AudioQuality.NORMAL, AudioQuality.HIGH, AudioQuality.VERY_HIGH, AudioQuality.FLAC) 20 | private val SUPPORTED_FORMATS = listOf(SuperAudioFormat.VORBIS, SuperAudioFormat.MP3) 21 | } 22 | 23 | private fun List.filterSupported() = filter { it.hasFileId() && it.hasFormat() }.filter { 24 | SUPPORTED_FORMATS.contains(SuperAudioFormat.get(it.format)) 25 | } 26 | 27 | override fun getFile(files: MutableList): Metadata.AudioFile? { 28 | Log.d("LtHQP", "available quality: ${files.joinToString { it.format.name }}, preferred: ${preferredQualityProvider().name}") 29 | val preferredFiles = preferredQualityProvider().getMatches(files).filterSupported() 30 | if (preferredFiles.isEmpty()) { 31 | Log.d("LtHQP", "=> not found, searching for other") 32 | ORDER.forEach { aq -> 33 | val ordered = aq.getMatches(files).filterSupported() 34 | if (ordered.isNotEmpty()) { 35 | Log.d("LtHQP", "=> [${aq.name}] found: ${ordered.joinToString { it.format.name }}") 36 | return ordered[0] 37 | } 38 | } 39 | Log.d("LtHQP", "=> still not found") 40 | return null 41 | } else { 42 | Log.d("LtHQP", "=> found: ${preferredFiles.joinToString { it.format.name }}") 43 | return preferredFiles[0] 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/history/ListeningHistoryScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.history 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import androidx.compose.ui.res.stringResource 7 | import androidx.hilt.navigation.compose.hiltViewModel 8 | import bruhcollective.itaysonlab.jetispot.R 9 | import bruhcollective.itaysonlab.jetispot.core.SpPlayerServiceManager 10 | import bruhcollective.itaysonlab.jetispot.core.api.SpInternalApi 11 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextData 12 | import bruhcollective.itaysonlab.jetispot.ui.hub.HubScreenDelegate 13 | import bruhcollective.itaysonlab.jetispot.ui.screens.hub.AbsHubViewModel 14 | import bruhcollective.itaysonlab.jetispot.ui.screens.hub.HubScaffold 15 | import bruhcollective.itaysonlab.jetispot.ui.screens.hub.ToolbarOptions 16 | import dagger.hilt.android.lifecycle.HiltViewModel 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | 20 | @Composable 21 | fun ListeningHistoryScreen( 22 | viewModel: HistoryViewModel = hiltViewModel() 23 | ) { 24 | val scope = rememberCoroutineScope() 25 | 26 | LaunchedEffect(Unit) { 27 | viewModel.load() 28 | } 29 | 30 | HubScaffold( 31 | appBarTitle = stringResource(id = R.string.listening_history), 32 | state = viewModel.state, 33 | viewModel = viewModel, 34 | reloadFunc = { scope.launch { viewModel.reload() } }, 35 | toolbarOptions = ToolbarOptions(big = true, alwaysVisible = true) 36 | ) 37 | } 38 | 39 | @HiltViewModel 40 | class HistoryViewModel @Inject constructor( 41 | private val spInternalApi: SpInternalApi, 42 | private val spPlayerServiceManager: SpPlayerServiceManager 43 | ) : AbsHubViewModel(), HubScreenDelegate { 44 | suspend fun load() = load { 45 | spInternalApi.getListeningHistory() 46 | } 47 | 48 | suspend fun reload() = reload { 49 | spInternalApi.getListeningHistory() 50 | } 51 | 52 | override fun play(data: PlayFromContextData) { 53 | spPlayerServiceManager.play(data.uri, data.player) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/objs/external/ExternalPersonalizedRecs.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.objs.external 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | class PersonalizedRecommendationsRequest( 8 | @Json(name = "external_accessory_description") val accessory: PersonalizedRecommendationsAccessory, 9 | @Json(name = "contextual_signals") val signals: List, 10 | @Json(name = "client_date_time") val dateTime: String, // ISO time 11 | ) 12 | 13 | @JsonClass(generateAdapter = true) 14 | class PersonalizedRecommendationsAccessory( 15 | val integration: String, 16 | @Json(name = "client_id") val clientId: String, 17 | val name: String, 18 | @Json(name = "transport_type") val transportType: String, 19 | val category: String, 20 | val company: String, 21 | val model: String, 22 | val version: String, 23 | val protocol: String, 24 | @Json(name = "sender_id") val senderId: String, 25 | ) { 26 | companion object { 27 | val Auto = PersonalizedRecommendationsAccessory( 28 | integration = "android_auto", 29 | transportType = "bluetooth_or_usb", 30 | category = "car", 31 | protocol = "media_session", 32 | senderId = "com.google.android.projection.gearhead", 33 | clientId = "", 34 | name = "", 35 | company = "", 36 | model = "", 37 | version = "" 38 | ) 39 | } 40 | } 41 | 42 | @JsonClass(generateAdapter = true) 43 | class PersonalizedRecommendationsResponse( 44 | @Json(name = "section_content") val content: List 45 | ) 46 | 47 | @JsonClass(generateAdapter = true) 48 | class PersonalizedRecommendationsSection( 49 | val name: String, 50 | val title: String, 51 | val uri: String, 52 | @Json(name = "section_items") val items: List 53 | ) 54 | 55 | @JsonClass(generateAdapter = true) 56 | class PersonalizedRecommendationsItem( 57 | val title: String, 58 | val subtitle: String?, 59 | val uri: String, 60 | @Json(name = "image_url") val image: String? 61 | ) -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/playback/service/AudioFocusManager.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.playback.service 2 | 3 | import android.content.Context 4 | import android.media.AudioManager 5 | import androidx.core.content.getSystemService 6 | import androidx.media.AudioAttributesCompat 7 | import androidx.media.AudioFocusRequestCompat 8 | import androidx.media.AudioManagerCompat 9 | import bruhcollective.itaysonlab.jetispot.core.SpPlayerManager 10 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class AudioFocusManager @Inject constructor( 17 | @ApplicationContext private val appContext: Context, 18 | private val spPlayerManager: SpPlayerManager, 19 | ) : AudioManager.OnAudioFocusChangeListener { 20 | private val audioManager = appContext.getSystemService()!! 21 | 22 | private val audioAttributes = AudioAttributesCompat.Builder().apply { 23 | setUsage(AudioAttributesCompat.USAGE_MEDIA) 24 | setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) 25 | }.build() 26 | 27 | private val focusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN).apply { 28 | setAudioAttributes(audioAttributes) 29 | setOnAudioFocusChangeListener(this@AudioFocusManager) 30 | }.build() 31 | 32 | fun requestFocus() { 33 | AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) 34 | } 35 | 36 | fun abandonFocus() { 37 | AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest) 38 | } 39 | 40 | override fun onAudioFocusChange(focusChange: Int) { 41 | when (focusChange) { 42 | AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { 43 | spPlayerManager.playerNullable()?.pause() 44 | } 45 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { 46 | spPlayerManager.playerNullable()?.volumeDown(24) // 64 total 47 | } 48 | AudioManager.AUDIOFOCUS_GAIN -> { 49 | spPlayerManager.playerNullable()?.play() 50 | spPlayerManager.playerNullable()?.volumeUp(24) // 64 total 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/GridMediumCard.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.text.style.TextAlign 13 | import androidx.compose.ui.unit.dp 14 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 15 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 18 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 19 | 20 | @Composable 21 | fun GridMediumCard( 22 | item: HubItem 23 | ) { 24 | val size = 160.dp 25 | 26 | Column(Modifier.fillMaxWidth().clickableHub(item)) { 27 | var drawnTitle = false 28 | 29 | PreviewableAsyncImage(imageUrl = item.images?.main?.uri, placeholderType = item.images?.main?.placeholder, modifier = Modifier.size(size).clip( 30 | RoundedCornerShape(if (item.images?.main?.isRounded == true) 12.dp else 0.dp) 31 | ).align(Alignment.CenterHorizontally)) 32 | 33 | if (!item.text?.title.isNullOrEmpty()) { 34 | drawnTitle = true 35 | MediumText(item.text!!.title!!, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = 16.dp)) 36 | } 37 | 38 | if (!item.text?.subtitle.isNullOrEmpty()) { 39 | Subtext(item.text!!.subtitle!!, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = if (drawnTitle) 2.dp else 8.dp, bottom = 12.dp).padding(horizontal = 16.dp)) 40 | } else if (!item.text?.description.isNullOrEmpty()) { 41 | Subtext(item.text!!.description!!, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = if (drawnTitle) 2.dp else 8.dp, bottom = 12.dp).padding(horizontal = 16.dp)) 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Jetispot 2 | _probably usable __UNOFFICIAL__ Spotify client for Android, built with Jetpack Compose and librespot-java_ 3 | 4 | #### Spotify Premium account is REQUIRED*. Offline caching, DRM bypassing or raw file downloading is prohibted by ToS and will NEVER be implemented in Jetispot. Don't waste your time trying to request these features. 5 | 6 | __What's working:__ 7 | - sign in (login/pass only, no FB/Meta/whatsoever support, no Smart Lock either) 8 | - "browse", "home", album, premium plan, artist and genre screens (some of the blocks might be unsupported) 9 | - library: "liked songs" w/ tag&sort support, rootlist (liked playlists) + pins + artist/album support w/ nice animations, delta updates + pub/sub processing support 10 | - basic playback w/ Spotify Connect support (connect support is very WIP) 11 | - fairly optimized R8 rules, providing __approx. 5-6 megabytes__ release APK size (with the playback and protobuf parts!) 12 | 13 | __What's in progress:__ 14 | - "Now Playing" improvements 15 | - better service (notification improvements) 16 | 17 | __Application stack:__ 18 | - playback: librespot-java as the core + sinks/decoders from librespot-android + Media2 for the mediasession support 19 | - UI: Jetpack Compose 20 | - DI: Hilt/Dagger 21 | - network: Retrofit w/ Moshi+Protobuf converters 22 | - pictures: Coil 23 | - storage: Room (collection), MMKV (metadata) 24 | - arch: MVVM 25 | - preferences: Jetpack Datastore (proto) 26 | 27 | __Credits:__ 28 | - [librespot-java](https://github.com/librespot-org/librespot-java) for the core API part and playback 29 | - [librespot-android](https://github.com/devgianlu/librespot-android) for sink and decoder source (in Jetispot they are rewritten to Kotlin) 30 | - [moshi](https://github.com/square/moshi/) and [moshix](https://github.com/ZacSweers/MoshiX/) for the undocumented API JSON parsing 31 | - [VK Icons](https://github.com/VKCOM/icons) for the amazing icon set used in the application's icon 32 | - [MMKV](https://github.com/Tencent/MMKV) for ultra-fast way to cache entity extended metadata 33 | - Google for Android/Jetpack/Hilt 34 | 35 | _* I heard some people can log in with a free account, but I won't provide any assistance to people without premium subscription. There is a possibility that a subscription check may be added to the client side in the future._ 36 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/hub/AbsHubViewModel.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.hub 2 | 3 | import androidx.compose.runtime.mutableStateOf 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.lifecycle.ViewModel 6 | import bruhcollective.itaysonlab.jetispot.core.SpPlayerServiceManager 7 | import bruhcollective.itaysonlab.jetispot.core.api.SpPartnersApi 8 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubResponse 9 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextData 10 | import bruhcollective.itaysonlab.jetispot.ui.hub.HubScreenDelegate 11 | 12 | abstract class AbsHubViewModel: ViewModel(), HubScreenDelegate { 13 | private val _state = mutableStateOf(HubState.Loading) 14 | val state: HubState get() = _state.value 15 | 16 | val mainAddedState = mutableStateOf(false) 17 | val imageCache = mutableMapOf() 18 | 19 | val hubTitle get() = (_state.value as? HubState.Loaded)?.data?.title ?: "" 20 | 21 | suspend fun load(loader: suspend () -> HubResponse) { 22 | _state.value = try { 23 | HubState.Loaded(loader()) 24 | } catch (e: Exception) { 25 | e.printStackTrace() 26 | HubState.Error(e) 27 | } 28 | } 29 | 30 | suspend fun reload(loader: suspend () -> HubResponse) { 31 | _state.value = HubState.Loading 32 | load(loader) 33 | } 34 | 35 | fun play(spPlayerServiceManager: SpPlayerServiceManager, data: PlayFromContextData) { 36 | spPlayerServiceManager.play(data.uri, data.player) 37 | } 38 | 39 | override fun isSurroundedWithPadding() = false 40 | override fun getMainObjectAddedState() = mainAddedState 41 | 42 | suspend fun calculateDominantColor(partnersApi: SpPartnersApi, url: String, dark: Boolean): Color { 43 | return try { 44 | if (imageCache.containsKey(url)) { 45 | return imageCache[url]!! 46 | } 47 | 48 | val apiResult = partnersApi.fetchExtractedColors(variables = "{\"uris\":[\"$url\"]}").data.extractedColors[0].let { 49 | if (dark) it.colorRaw else it.colorDark 50 | }.hex 51 | 52 | Color(android.graphics.Color.parseColor(apiResult)).also { imageCache[url] = it } 53 | } catch (e: Exception) { 54 | // e.printStackTrace() 55 | Color.Transparent 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_home/ToolbarComponentBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_home 2 | 3 | import androidx.compose.foundation.layout.WindowInsets 4 | import androidx.compose.foundation.layout.statusBarsPadding 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.rounded.History 7 | import androidx.compose.material.icons.rounded.Notifications 8 | import androidx.compose.material.icons.rounded.Settings 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.vector.ImageVector 13 | import androidx.compose.ui.unit.dp 14 | import bruhcollective.itaysonlab.jetispot.ui.ext.dynamicUnpack 15 | import bruhcollective.itaysonlab.jetispot.ui.navigation.LocalNavigationController 16 | import com.spotify.home.dac.component.v1.proto.ToolbarComponent 17 | import com.spotify.home.dac.component.v1.proto.ToolbarItemFeedComponent 18 | import com.spotify.home.dac.component.v1.proto.ToolbarItemListeningHistoryComponent 19 | import com.spotify.home.dac.component.v1.proto.ToolbarItemSettingsComponent 20 | 21 | @OptIn(ExperimentalMaterial3Api::class) 22 | @Composable 23 | fun ToolbarComponentBinder( 24 | item: ToolbarComponent 25 | ) { 26 | TopAppBar(title = { 27 | Text(item.dayPartMessage) 28 | }, actions = { 29 | item.itemsList.forEach { 30 | when (val protoItem = it.dynamicUnpack()) { 31 | is ToolbarItemFeedComponent -> ToolbarItem(Icons.Rounded.Notifications, protoItem.navigateUri, protoItem.title) 32 | is ToolbarItemListeningHistoryComponent -> ToolbarItem(Icons.Rounded.History, protoItem.navigateUri, protoItem.title) 33 | is ToolbarItemSettingsComponent -> ToolbarItem(Icons.Rounded.Settings, protoItem.navigateUri, protoItem.title) 34 | } 35 | } 36 | }, modifier = Modifier.statusBarsPadding(), windowInsets = WindowInsets(top = 0.dp)) 37 | } 38 | 39 | @Composable 40 | private fun ToolbarItem( 41 | icon: ImageVector, 42 | navigateTo: String, 43 | contentDesc: String 44 | ) { 45 | val navController = LocalNavigationController.current 46 | 47 | IconButton(onClick = { 48 | navController.navigate(navigateTo) 49 | }) { 50 | Icon(icon, contentDescription = contentDesc) 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/monet/ColorToScheme.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.monet 2 | 3 | import androidx.annotation.ColorInt 4 | import androidx.compose.material3.ColorScheme 5 | import androidx.compose.ui.graphics.Color 6 | import bruhcollective.itaysonlab.jetispot.ui.monet.google.scheme.Scheme 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | 10 | object ColorToScheme { 11 | suspend fun convert(@ColorInt color: Int, dark: Boolean) = withContext(Dispatchers.Default) { 12 | val scheme = if (dark) { Scheme.dark(color) } else { Scheme.light(color) } 13 | 14 | ColorScheme( 15 | primary = scheme.primary.color(), 16 | onPrimary = scheme.onPrimary.color(), 17 | primaryContainer = scheme.primaryContainer.color(), 18 | onPrimaryContainer = scheme.onPrimaryContainer.color(), 19 | inversePrimary = scheme.inversePrimary.color(), 20 | secondary = scheme.secondary.color(), 21 | onSecondary = scheme.onSecondary.color(), 22 | secondaryContainer = scheme.secondaryContainer.color(), 23 | onSecondaryContainer = scheme.onSecondaryContainer.color(), 24 | tertiary = scheme.tertiary.color(), 25 | onTertiary = scheme.onTertiary.color(), 26 | tertiaryContainer = scheme.tertiaryContainer.color(), 27 | onTertiaryContainer = scheme.onTertiaryContainer.color(), 28 | background = scheme.background.color(), 29 | onBackground = scheme.onBackground.color(), 30 | surface = scheme.surface.color(), 31 | onSurface = scheme.onSurface.color(), 32 | surfaceVariant = scheme.surfaceVariant.color(), 33 | onSurfaceVariant = scheme.onSurfaceVariant.color(), 34 | surfaceTint = scheme.primary.color(), // defaults to primary, source: https://github.com/flutter/flutter/pull/100153 35 | inverseSurface = scheme.inverseSurface.color(), 36 | inverseOnSurface = scheme.inverseOnSurface.color(), 37 | error = scheme.error.color(), 38 | onError = scheme.onError.color(), 39 | errorContainer = scheme.errorContainer.color(), 40 | onErrorContainer = scheme.onErrorContainer.color(), 41 | outline = scheme.outline.color(), 42 | outlineVariant = scheme.outlineVariant.color(), 43 | scrim = scheme.scrim.color(), 44 | ) 45 | } 46 | } 47 | 48 | private fun Int.color() = Color(this) -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/virt/CollectionEntityView.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.virt 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 4 | import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionDao 5 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.* 6 | import bruhcollective.itaysonlab.jetispot.core.objs.player.* 7 | 8 | object CollectionEntityView { 9 | suspend fun create( 10 | spSessionManager: SpSessionManager, 11 | spDao: LocalCollectionDao, 12 | sort: LocalCollectionDao.TrackSorts, 13 | invert: Boolean, 14 | tag: String? 15 | ): HubResponse { 16 | val uri = "spotify:user:${spSessionManager.session.username()}:collection" 17 | 18 | val hubBody = mutableListOf() 19 | var tags = spDao.getContentFilters() 20 | 21 | if (tag != null) tags = tags.filter { it.query == tag } 22 | 23 | val tracks = spDao.getTracks(tag?.removePrefix("tags contains "), sort, invert) 24 | 25 | tracks.forEach { track -> 26 | hubBody.add(HubItem( 27 | HubComponent.PlaylistTrackRow, 28 | id = track.id, 29 | text = HubText( 30 | title = track.name, 31 | subtitle = track.rawArtistsData.split("|").joinToString { it.split("=").getOrElse(1) { "" } } + " • " + track.albumName 32 | ), 33 | images = HubImages( 34 | main = HubImage( 35 | "https://i.scdn.co/image/${track.picture}" 36 | ) 37 | ), 38 | events = HubEvents( 39 | HubEvent.PlayFromContext( 40 | PlayFromContextData( 41 | track.uri, 42 | PlayFromContextPlayerData( 43 | PfcContextData( 44 | url = "context://$uri", 45 | uri = uri 46 | ), 47 | PfcOptions(skip_to = PfcOptSkipTo(track_uri = track.uri)) 48 | ) 49 | ) 50 | ) 51 | ) 52 | )) 53 | } 54 | 55 | return HubResponse( 56 | header = HubItem( 57 | HubComponent.CollectionHeader, 58 | id = "synth_collectionHeader", 59 | custom = mapOf( 60 | "count" to tracks.size, 61 | "cfr" to tags, 62 | "cfr_cur" to (tag ?: "") 63 | ) 64 | ), 65 | body = hubBody 66 | ) 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/PodcastTopicsStrip.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.lazy.LazyRow 6 | import androidx.compose.foundation.lazy.items 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.rounded.Star 9 | import androidx.compose.material3.ElevatedSuggestionChip 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.ui.unit.dp 16 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 17 | import bruhcollective.itaysonlab.jetispot.ui.navigation.LocalNavigationController 18 | import com.spotify.podcastcreatorinteractivity.v1.PodcastRating 19 | import com.spotify.podcastextensions.proto.PodcastTopic 20 | import com.spotify.podcastextensions.proto.PodcastTopics 21 | 22 | @OptIn(ExperimentalMaterial3Api::class) 23 | @Composable 24 | fun PodcastTopicsStrip ( 25 | item: HubItem 26 | ) { 27 | val navController = LocalNavigationController.current 28 | val topics = remember { item.custom!!["topics"] as PodcastTopics } 29 | val rating = remember { item.custom!!["ratings"] as PodcastRating } 30 | 31 | LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 16.dp)) { 32 | item { 33 | ElevatedSuggestionChip(onClick = { 34 | // TODO 35 | }, icon = { 36 | Icon(imageVector = Icons.Rounded.Star, contentDescription = null) 37 | }, label = { 38 | Text(text = "${String.format("%.2f", rating.averageRating.average)} (${rating.averageRating.totalRatings})") 39 | }) 40 | } 41 | 42 | items(topics.topicsList) { topic -> 43 | PodcastTopic(topic = topic, onClick = navController::navigate) 44 | } 45 | } 46 | } 47 | 48 | @OptIn(ExperimentalMaterial3Api::class) 49 | @Composable 50 | private fun PodcastTopic ( 51 | topic: PodcastTopic, 52 | onClick: (String) -> Unit 53 | ) { 54 | ElevatedSuggestionChip(onClick = { 55 | onClick(topic.uri) 56 | }, label = { 57 | Text(text = topic.title) 58 | }) 59 | } -------------------------------------------------------------------------------- /app/src/main/proto/sp/home/ActionCardsComponents.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/any.proto"; 4 | import "sp/DacPlayer.proto"; 5 | 6 | package com.spotify.home.dac.component.v1.proto; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.spotify.home.dac.component.v1.proto"; 10 | 11 | message AlbumCardActionsSmallComponent { 12 | string title = 1; 13 | string subtitle = 2; 14 | string navigate_uri = 3; 15 | string like_uri = 4; 16 | string image_uri = 5; 17 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 6; 18 | } 19 | 20 | message ArtistCardActionsSmallComponent { 21 | string title = 1; 22 | string subtitle = 2; 23 | string navigate_uri = 3; 24 | string follow_uri = 4; 25 | string image_uri = 5; 26 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 6; 27 | } 28 | 29 | message PlaylistCardActionsSmallComponent { 30 | string title = 1; 31 | string subtitle = 2; 32 | string navigate_uri = 3; 33 | string like_uri = 4; 34 | string image_uri = 5; 35 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 6; 36 | } 37 | 38 | message AlbumCardActionsMediumComponent { 39 | string title = 1; 40 | string image_uri = 2; 41 | string artist_name = 3; 42 | string artist_image_uri = 4; 43 | string navigate_uri = 5; 44 | string like_uri = 6; 45 | string gradient_color = 7; 46 | string concise_fact = 8; 47 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 9; 48 | google.protobuf.Any context_menu = 10; 49 | string content_type = 11; 50 | string description = 12; 51 | } 52 | 53 | message PlaylistCardActionsMediumComponent { 54 | string title = 1; 55 | string description = 2; 56 | string navigate_uri = 3; 57 | string like_uri = 4; 58 | string image_uri = 5; 59 | string gradient_color = 6; 60 | string concise_fact = 7; 61 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 8; 62 | google.protobuf.Any context_menu = 9; 63 | string content_type = 10; 64 | } 65 | 66 | message ArtistCardActionsMediumComponent { 67 | string title = 1; 68 | string description = 2; 69 | string navigate_uri = 3; 70 | string follow_uri = 4; 71 | string image_uri = 5; 72 | string gradient_color = 6; 73 | com.spotify.dac.player.v1.proto.PlayCommand play_command = 7; 74 | google.protobuf.Any context_menu = 8; 75 | string content_type = 9; 76 | string concise_fact = 10; 77 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/dynamic/DynamicSpIdScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.dynamic 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import bruhcollective.itaysonlab.jetispot.ui.screens.blend.BlendCreateInvitationScreen 11 | import bruhcollective.itaysonlab.jetispot.ui.screens.config.ConfigScreen 12 | import bruhcollective.itaysonlab.jetispot.ui.screens.history.ListeningHistoryScreen 13 | import bruhcollective.itaysonlab.jetispot.ui.screens.hub.* 14 | 15 | @Composable 16 | fun DynamicSpIdScreen( 17 | uri: String, 18 | fullUri: String, 19 | ) { 20 | var uriSeparated = uri.split(":") 21 | if (uriSeparated[0] == "user" && uriSeparated.size > 2) uriSeparated = uriSeparated.drop(2) 22 | val id = uriSeparated.getOrElse(1) { "" } 23 | val argument = uriSeparated.getOrElse(2) { "" } 24 | 25 | when (uriSeparated[0]) { 26 | "genre" -> BrowseScreen(id) 27 | 28 | "artist" -> HubScreen( 29 | needContentPadding = false, 30 | loader = { 31 | if (argument == "releases") { 32 | getReleasesView(id) 33 | } else { 34 | getArtistView(id) 35 | } 36 | } 37 | ) 38 | 39 | "show" -> PodcastShowScreen(id) 40 | "album" -> AlbumScreen(id) 41 | "playlist" -> PlaylistScreen(id) 42 | "config" -> ConfigScreen() 43 | "radio" -> BrowseRadioScreen() 44 | 45 | "collection" -> when (id) { 46 | "artist" -> LikedSongsScreen( 47 | id = argument, 48 | fullUri = fullUri 49 | ) 50 | "" -> CollectionScreen() 51 | /* else -> { TODO } */ 52 | } 53 | 54 | "internal" -> when (id) { 55 | "listeninghistory" -> ListeningHistoryScreen() 56 | } 57 | 58 | "blend" -> when (id) { 59 | "invitation" -> BlendCreateInvitationScreen() 60 | } 61 | 62 | else -> { 63 | Box(Modifier.fillMaxSize()) { 64 | Column( 65 | modifier = Modifier 66 | .align(Alignment.Center) 67 | ) { 68 | Text(fullUri) 69 | Text(uriSeparated.joinToString(":")) 70 | } 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/PlaylistTrackRow.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.unit.dp 10 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 11 | import bruhcollective.itaysonlab.jetispot.ui.hub.clickableHub 12 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 13 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 15 | 16 | @Composable 17 | fun PlaylistTrackRow( 18 | item: HubItem 19 | ) { 20 | val artists = remember(item) { 21 | if (!item.text?.subtitle.isNullOrEmpty()) { 22 | item.text!!.subtitle!! 23 | } else if (item.custom?.get("artists") != null) { 24 | (item.custom["artists"] as List>).joinToString { it["name"].toString() } 25 | } else { 26 | "" 27 | } 28 | } 29 | 30 | Row( 31 | Modifier 32 | .clickableHub(item) 33 | .fillMaxWidth() 34 | .padding(horizontal = 16.dp, vertical = 8.dp) 35 | ) { 36 | PreviewableAsyncImage( 37 | imageUrl = item.images?.main?.uri, 38 | placeholderType = "track", 39 | modifier = Modifier 40 | .align( 41 | Alignment.CenterVertically 42 | ) 43 | .size(48.dp) 44 | ) 45 | 46 | Column( 47 | Modifier 48 | .padding( 49 | start = 16.dp 50 | ) 51 | .align(Alignment.CenterVertically) 52 | ) { 53 | var drawnTitle = false 54 | 55 | if (!item.text?.title.isNullOrEmpty()) { 56 | drawnTitle = true 57 | MediumText(item.text!!.title!!, fontWeight = FontWeight.Normal) 58 | } 59 | 60 | Subtext( 61 | artists, 62 | modifier = Modifier.padding(top = if (drawnTitle) 4.dp else 8.dp), 63 | maxLines = 1, 64 | ) 65 | } 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/virt/ShowEntityView.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.virt 2 | 3 | import bruhcollective.itaysonlab.jetispot.core.SpMetadataRequester 4 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 5 | import bruhcollective.itaysonlab.jetispot.core.episodes 6 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubComponent 7 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 8 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubResponse 9 | import com.spotify.extendedmetadata.ExtensionKindOuterClass 10 | import xyz.gianlu.librespot.common.Utils 11 | import xyz.gianlu.librespot.metadata.EpisodeId 12 | 13 | object ShowEntityView { 14 | suspend fun create( 15 | spSessionManager: SpSessionManager, 16 | metadataRequester: SpMetadataRequester, 17 | id: String 18 | ): HubResponse { 19 | val spId = "spotify:show:$id" 20 | 21 | val showMetadata = metadataRequester.request { 22 | add( 23 | spId to listOf( 24 | ExtensionKindOuterClass.ExtensionKind.SHOW_V4, 25 | ExtensionKindOuterClass.ExtensionKind.PODCAST_TOPICS, 26 | ExtensionKindOuterClass.ExtensionKind.PODCAST_RATING, 27 | ) 28 | ) 29 | } 30 | 31 | val show = showMetadata.shows[spId]!! 32 | val topics = showMetadata.podcastTopics[spId]!! 33 | val ratings = showMetadata.podcastRatings[spId]!! 34 | 35 | val episodeMetadata = metadataRequester.request { 36 | episodes(show.episodeList.map { EpisodeId.fromHex(Utils.bytesToHex(it.gid)).toSpotifyUri() }) 37 | } 38 | 39 | return HubResponse( 40 | title = show.name, 41 | header = HubItem( 42 | component = HubComponent.ShowHeader, 43 | custom = mapOf("show" to show) 44 | ), 45 | body = buildList { 46 | add( 47 | HubItem( 48 | component = HubComponent.PodcastTopics, 49 | custom = mapOf("topics" to topics, "ratings" to ratings) 50 | ) 51 | ) 52 | 53 | addAll( 54 | show.episodeList.map { 55 | val sid = EpisodeId.fromHex(Utils.bytesToHex(it.gid)).toSpotifyUri() 56 | val episode = episodeMetadata.episodes[sid]!! 57 | 58 | HubItem( 59 | component = HubComponent.EpisodeListItem, 60 | custom = mapOf("episode" to episode) 61 | ) 62 | } 63 | ) 64 | } 65 | ) 66 | } 67 | } -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/SpConfigurationManager.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.CorruptionException 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.core.DataStoreFactory 7 | import androidx.datastore.core.Serializer 8 | import bruhcollective.itaysonlab.jetispot.proto.AppConfig 9 | import bruhcollective.itaysonlab.jetispot.proto.AudioNormalization 10 | import bruhcollective.itaysonlab.jetispot.proto.AudioQuality 11 | import bruhcollective.itaysonlab.jetispot.proto.PlayerConfig 12 | import com.google.protobuf.InvalidProtocolBufferException 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.emptyFlow 16 | import kotlinx.coroutines.flow.first 17 | import kotlinx.coroutines.runBlocking 18 | import java.io.File 19 | import java.io.InputStream 20 | import java.io.OutputStream 21 | import javax.inject.Inject 22 | import javax.inject.Singleton 23 | 24 | @Singleton 25 | class SpConfigurationManager @Inject constructor( 26 | @ApplicationContext private val appContext: Context 27 | ) { 28 | companion object { 29 | val EMPTY = object: DataStore { 30 | override val data: Flow get() = emptyFlow() 31 | override suspend fun updateData(transform: suspend (t: AppConfig) -> AppConfig) = TODO("This is an empty DataStore!") 32 | } 33 | 34 | val DEFAULT = AppConfig.newBuilder().apply { 35 | setPlayerConfig(PlayerConfig.newBuilder().apply { 36 | autoplay = true 37 | normalization = true 38 | preferredQuality = AudioQuality.VERY_HIGH 39 | normalizationLevel = AudioNormalization.BALANCED 40 | crossfade = 0 41 | preload = true 42 | useTremolo = false 43 | }) 44 | }.build() 45 | } 46 | 47 | val dataStore = DataStoreFactory.create(object: Serializer { 48 | override val defaultValue = DEFAULT 49 | override suspend fun writeTo(t: AppConfig, output: OutputStream) = t.writeTo(output) 50 | 51 | override suspend fun readFrom(input: InputStream) = try { 52 | AppConfig.parseFrom(input) 53 | } catch (e: InvalidProtocolBufferException) { 54 | throw CorruptionException("proto parsing failed") 55 | } 56 | }) { File(appContext.filesDir, "spa_prefs") } 57 | 58 | fun syncPlayerConfig(): PlayerConfig = runBlocking { dataStore.data.first().playerConfig } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/dac/components_home/SmallActionCardBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.dac.components_home 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.Card 5 | import androidx.compose.material3.CardDefaults 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import bruhcollective.itaysonlab.jetispot.ui.ext.compositeSurfaceElevation 12 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 13 | import bruhcollective.itaysonlab.jetispot.ui.shared.PreviewableAsyncImage 14 | import bruhcollective.itaysonlab.jetispot.ui.shared.Subtext 15 | import bruhcollective.itaysonlab.jetispot.ui.shared.dynamic_blocks.DynamicLikeButton 16 | import bruhcollective.itaysonlab.jetispot.ui.shared.dynamic_blocks.DynamicPlayButton 17 | import bruhcollective.itaysonlab.jetispot.ui.shared.navClickable 18 | import com.spotify.dac.player.v1.proto.PlayCommand 19 | 20 | @Composable 21 | fun SmallActionCardBinder( 22 | title: String, 23 | subtitle: String, 24 | navigateUri: String, 25 | likeUri: String, 26 | imageUri: String, 27 | imagePlaceholder: String, 28 | playCommand: PlayCommand 29 | ) { 30 | Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.compositeSurfaceElevation(3.dp)), modifier = Modifier 31 | .padding(horizontal = 16.dp) 32 | .height(120.dp) 33 | .fillMaxWidth() 34 | .navClickable { navController -> 35 | navController.navigate(navigateUri) 36 | }) { 37 | Row { 38 | PreviewableAsyncImage(imageUrl = imageUri, placeholderType = imagePlaceholder, modifier = Modifier 39 | .fillMaxHeight() 40 | .width(120.dp)) 41 | Box( 42 | Modifier 43 | .fillMaxSize() 44 | .padding(horizontal = 16.dp, vertical = 12.dp)) { 45 | Column(Modifier.align(Alignment.TopStart)) { 46 | MediumText(text = title) 47 | Subtext(text = subtitle) 48 | } 49 | 50 | Box(modifier = Modifier.offset(y = 4.dp).fillMaxWidth().align(Alignment.BottomStart)) { 51 | DynamicLikeButton(objectUrl = likeUri, Modifier.offset(x = (-8).dp).size(42.dp).align(Alignment.CenterStart)) 52 | DynamicPlayButton(command = playCommand, Modifier.offset(x = 8.dp).size(42.dp).align(Alignment.CenterEnd)) 53 | } 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/components/ShowHeader.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Brush 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.layout.ContentScale 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.unit.sp 16 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 17 | import bruhcollective.itaysonlab.jetispot.core.util.SpUtils 18 | import bruhcollective.itaysonlab.jetispot.ui.shared.MediumText 19 | import coil.compose.AsyncImage 20 | import com.spotify.metadata.Metadata 21 | 22 | @Composable 23 | fun ShowHeader( 24 | item: HubItem 25 | ) { 26 | val show = remember { item.custom!!["show"] as Metadata.Show } 27 | val imageUrl = remember { SpUtils.getImageUrl(show.coverImage.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId) } 28 | 29 | Column { 30 | Box( 31 | Modifier 32 | .fillMaxWidth() 33 | .height(240.dp) 34 | ) { 35 | AsyncImage( 36 | model = imageUrl, 37 | contentScale = ContentScale.Crop, 38 | contentDescription = null, 39 | modifier = Modifier 40 | .fillMaxSize() 41 | ) 42 | 43 | Box( 44 | Modifier 45 | .background( 46 | brush = Brush.verticalGradient( 47 | colors = listOf(Color.Transparent, MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), MaterialTheme.colorScheme.surface) 48 | ) 49 | ) 50 | .fillMaxSize() 51 | ) 52 | 53 | MediumText( 54 | text = show.name, 55 | fontSize = 48.sp, 56 | lineHeight = 52.sp, 57 | maxLines = 2, 58 | modifier = Modifier 59 | .align(Alignment.BottomStart) 60 | .padding(horizontal = 16.dp) 61 | .padding(bottom = 8.dp) 62 | ) 63 | } 64 | 65 | Text( 66 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), 67 | fontSize = 12.sp, 68 | lineHeight = 18.sp, 69 | text = show.description, modifier = Modifier 70 | .padding(horizontal = 16.dp) 71 | ) 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/hub/PlaylistScreen.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.screens.hub 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.hilt.navigation.compose.hiltViewModel 7 | import bruhcollective.itaysonlab.jetispot.core.SpMetadataRequester 8 | import bruhcollective.itaysonlab.jetispot.core.SpPlayerServiceManager 9 | import bruhcollective.itaysonlab.jetispot.core.SpSessionManager 10 | import bruhcollective.itaysonlab.jetispot.core.api.SpInternalApi 11 | import bruhcollective.itaysonlab.jetispot.core.api.SpPartnersApi 12 | import bruhcollective.itaysonlab.jetispot.core.objs.player.PlayFromContextData 13 | import bruhcollective.itaysonlab.jetispot.ui.hub.virt.PlaylistEntityView 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import javax.inject.Inject 16 | 17 | @Composable 18 | fun PlaylistScreen( 19 | id: String, 20 | viewModel: PlaylistViewModel = hiltViewModel() 21 | ) { 22 | LaunchedEffect(Unit) { 23 | viewModel.loadPlaylist(id) 24 | // TODO: Load likes 25 | } 26 | 27 | HubScaffold( 28 | appBarTitle = viewModel.title.value, 29 | state = viewModel.state, 30 | viewModel = viewModel 31 | ) { 32 | viewModel.reloadPlaylist(id) 33 | } 34 | } 35 | 36 | @HiltViewModel 37 | class PlaylistViewModel @Inject constructor( 38 | private val spSessionManager: SpSessionManager, 39 | private val spInternalApi: SpInternalApi, 40 | private val spPartnersApi: SpPartnersApi, 41 | private val spPlayerServiceManager: SpPlayerServiceManager, 42 | private val spMetadataRequester: SpMetadataRequester 43 | ) : AbsHubViewModel() { 44 | val title = mutableStateOf("") 45 | 46 | private val _playlistMetadata = mutableStateOf(null) 47 | val playlistMetadata: PlaylistEntityView.ApiPlaylist? get() = _playlistMetadata.value 48 | 49 | suspend fun loadPlaylist(id: String) = load { loadPlaylistInternal(id) } 50 | suspend fun reloadPlaylist(id: String) = reload { loadPlaylistInternal(id) } 51 | suspend fun loadPlaylistInternal(id: String) = PlaylistEntityView.getPlaylistView(id, spSessionManager, spInternalApi, spMetadataRequester).also { _playlistMetadata.value = it; title.value = it.playlist.attributes.name; }.hubResponse 52 | 53 | override fun play(data: PlayFromContextData) = play(spPlayerServiceManager, data) 54 | override suspend fun calculateDominantColor(url: String, dark: Boolean) = calculateDominantColor(spPartnersApi, url, dark) 55 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/playback/helpers/MediaItemBuilder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.playback.helpers 2 | 3 | import android.graphics.Bitmap 4 | import androidx.core.os.bundleOf 5 | import androidx.media2.common.MediaItem 6 | import androidx.media2.common.MediaMetadata 7 | 8 | fun MutableList.addMediaItem (metadataBuilder: MediaMetadata.Builder.() -> Unit) = add(mediaItem(metadataBuilder = metadataBuilder)) 9 | fun mediaItem (startTime: Long = MediaItem.POSITION_UNKNOWN, endTime: Long = MediaItem.POSITION_UNKNOWN, metadataBuilder: MediaMetadata.Builder.() -> Unit) = MediaItem.Builder().setMetadata(MediaMetadata.Builder().also(metadataBuilder).build()).build() 10 | 11 | fun MediaMetadata.Builder.extras (vararg extras: Pair) = setExtras(bundleOf(*extras)) 12 | fun MediaMetadata.Builder.browsable (type: Long = MediaMetadata.BROWSABLE_TYPE_MIXED) = putLong(MediaMetadata.METADATA_KEY_BROWSABLE, type) 13 | fun MediaMetadata.Builder.playable () = putLong(MediaMetadata.METADATA_KEY_PLAYABLE, 1) 14 | 15 | private fun throwOnlyWrite(): Nothing = error("This property is designed only for writing") 16 | 17 | var MediaMetadata.Builder.id: String? 18 | get() = throwOnlyWrite() 19 | set(value) { putText(MediaMetadata.METADATA_KEY_MEDIA_ID, value) } 20 | 21 | var MediaMetadata.Builder.title: String? 22 | get() = throwOnlyWrite() 23 | set(value) { putText(MediaMetadata.METADATA_KEY_TITLE, value) } 24 | 25 | var MediaMetadata.Builder.artist: String? 26 | get() = throwOnlyWrite() 27 | set(value) { putText(MediaMetadata.METADATA_KEY_ARTIST, value) } 28 | 29 | var MediaMetadata.Builder.album: String? 30 | get() = throwOnlyWrite() 31 | set(value) { putText(MediaMetadata.METADATA_KEY_ALBUM, value) } 32 | 33 | var MediaMetadata.Builder.duration: Long 34 | get() = throwOnlyWrite() 35 | set(value) { putLong(MediaMetadata.METADATA_KEY_DURATION, value) } 36 | 37 | var MediaMetadata.Builder.subtitle: String? 38 | get() = throwOnlyWrite() 39 | set(value) { putText(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, value) } 40 | 41 | var MediaMetadata.Builder.iconUri: String? 42 | get() = throwOnlyWrite() 43 | set(value) { putText(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, value) } 44 | 45 | var MediaMetadata.Builder.iconBitmap: Bitmap? 46 | get() = throwOnlyWrite() 47 | set(value) { putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, value) } 48 | 49 | var MediaMetadata.Builder.artBitmap: Bitmap? 50 | get() = throwOnlyWrite() 51 | set(value) { putBitmap(MediaMetadata.METADATA_KEY_ART, value) } 52 | 53 | -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/core/lyrics/SpLyricsController.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.core.lyrics 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableIntStateOf 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import com.spotify.lyrics.v2.lyrics.proto.LyricsResponse.LyricsLine 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.MainScope 11 | import kotlinx.coroutines.launch 12 | import xyz.gianlu.librespot.common.Base62 13 | import xyz.gianlu.librespot.common.Utils 14 | import xyz.gianlu.librespot.metadata.TrackId 15 | import javax.inject.Inject 16 | 17 | class SpLyricsController @Inject constructor( 18 | private val requester: SpLyricsRequester 19 | ): CoroutineScope by MainScope() { 20 | private val base62 = Base62.createInstanceWithInvertedCharacterSet() 21 | private var _songJob: Job? = null 22 | 23 | var currentSongLine by mutableStateOf("") 24 | private set 25 | 26 | var currentSongLineIndex by mutableIntStateOf(-1) 27 | private set 28 | 29 | var currentLyricsLines by mutableStateOf>(emptyList()) 30 | private set 31 | 32 | var currentLyricsState by mutableStateOf(LyricsState.Loading) 33 | private set 34 | 35 | fun setSong(track: com.spotify.metadata.Metadata.Track) { 36 | _songJob?.cancel() 37 | _songJob = launch { 38 | currentSongLineIndex = -1 39 | 40 | if (track.hasLyrics) { 41 | currentLyricsState = LyricsState.Loading 42 | 43 | val response = requester.request( 44 | base62.encode(track.gid.toByteArray(), 22).decodeToString() 45 | ) 46 | 47 | if (response == null || response.lyrics == null) { 48 | currentLyricsState = LyricsState.Unavailable 49 | } else { 50 | currentLyricsLines = response.lyrics.linesList ?: emptyList() 51 | } 52 | } else { 53 | currentLyricsState = LyricsState.Unavailable 54 | } 55 | } 56 | } 57 | 58 | fun setProgress(pos: Long) { 59 | currentSongLine = currentLyricsLines.firstOrNull { 60 | it.startTimeMs >= pos 61 | }?.words ?: "" 62 | 63 | currentSongLineIndex = currentLyricsLines.indexOfFirst { 64 | it.startTimeMs >= pos 65 | } 66 | } 67 | 68 | enum class LyricsState { 69 | Loading, 70 | Unavailable, 71 | Ready 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/hub/HubBinder.kt: -------------------------------------------------------------------------------- 1 | package bruhcollective.itaysonlab.jetispot.ui.hub 2 | 3 | import androidx.compose.foundation.layout.Spacer 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubComponent 10 | import bruhcollective.itaysonlab.jetispot.core.objs.hub.HubItem 11 | import bruhcollective.itaysonlab.jetispot.ui.hub.components.* 12 | 13 | @Composable 14 | fun HubBinder ( 15 | item: HubItem, 16 | isRenderingInGrid: Boolean = false, 17 | ) { 18 | when (item.component) { 19 | HubComponent.HomeShortSectionHeader -> HomeSectionHeader(item.text!!) 20 | HubComponent.HomeLargeSectionHeader -> HomeSectionLargeHeader(item) 21 | HubComponent.GlueSectionHeader -> SectionHeader(item.text!!) 22 | HubComponent.ShortcutsContainer -> ShortcutsContainer(item.children!!) 23 | HubComponent.ShortcutsCard -> ShortcutsCard(item) 24 | HubComponent.FindCard -> FindCard(item) 25 | 26 | HubComponent.SingleFocusCard -> SingleFocusCard(item) 27 | 28 | HubComponent.Carousel -> Carousel(item) 29 | 30 | HubComponent.MediumCard -> { 31 | if (isRenderingInGrid) { 32 | GridMediumCard(item) 33 | } else { 34 | MediumCard(item) 35 | } 36 | } 37 | 38 | HubComponent.ArtistLikedSongs -> LikedSongsRow(item) 39 | 40 | HubComponent.AlbumTrackRow -> AlbumTrackRow(item) 41 | HubComponent.ArtistTrackRow -> ArtistTrackRow(item) 42 | HubComponent.PlaylistTrackRow -> PlaylistTrackRow(item) 43 | 44 | HubComponent.ArtistPinnedItem -> ArtistPinnedItem(item) 45 | HubComponent.AlbumHeader -> AlbumHeader(item) 46 | HubComponent.ArtistHeader -> ArtistHeader(item) 47 | HubComponent.LargerRow -> LargerRow(item) 48 | 49 | HubComponent.PlaylistHeader -> PlaylistHeader(item) 50 | HubComponent.LargePlaylistHeader -> LargePlaylistHeader(item) 51 | HubComponent.CollectionHeader -> CollectionHeader(item) 52 | 53 | HubComponent.TextRow -> TextRow(item.text!!) 54 | HubComponent.ImageRow -> ImageRow(item) 55 | 56 | HubComponent.ShowHeader -> ShowHeader(item) 57 | HubComponent.EpisodeListItem -> EpisodeListItem(item) 58 | HubComponent.PodcastTopics -> PodcastTopicsStrip(item) 59 | 60 | HubComponent.OutlinedButton -> OutlineButton(item) 61 | HubComponent.EmptySpace, HubComponent.Ignored -> {} 62 | 63 | else -> { 64 | Text("Unsupported, id = ${item.id}") 65 | Spacer(modifier = Modifier.height(8.dp)) 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/monet/google/palettes/TonalPalette.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package bruhcollective.itaysonlab.jetispot.ui.monet.google.palettes; 18 | 19 | import bruhcollective.itaysonlab.jetispot.ui.monet.google.hct.Hct; 20 | import java.util.HashMap; 21 | import java.util.Map; 22 | 23 | /** 24 | * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. 25 | */ 26 | public final class TonalPalette { 27 | Map cache; 28 | double hue; 29 | double chroma; 30 | 31 | /** 32 | * Create tones using the HCT hue and chroma from a color. 33 | * 34 | * @param argb ARGB representation of a color 35 | * @return Tones matching that color's hue and chroma. 36 | */ 37 | public static final TonalPalette fromInt(int argb) { 38 | Hct hct = Hct.fromInt(argb); 39 | return TonalPalette.fromHueAndChroma(hct.getHue(), hct.getChroma()); 40 | } 41 | 42 | /** 43 | * Create tones from a defined HCT hue and chroma. 44 | * 45 | * @param hue HCT hue 46 | * @param chroma HCT chroma 47 | * @return Tones matching hue and chroma. 48 | */ 49 | public static final TonalPalette fromHueAndChroma(double hue, double chroma) { 50 | return new TonalPalette(hue, chroma); 51 | } 52 | 53 | private TonalPalette(double hue, double chroma) { 54 | cache = new HashMap<>(); 55 | this.hue = hue; 56 | this.chroma = chroma; 57 | } 58 | 59 | /** 60 | * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. 61 | * 62 | * @param tone HCT tone, measured from 0 to 100. 63 | * @return ARGB representation of a color with that tone. 64 | */ 65 | // AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923) 66 | @SuppressWarnings("ComputeIfAbsentUseValue") 67 | public int tone(int tone) { 68 | Integer color = cache.get(tone); 69 | if (color == null) { 70 | color = Hct.from(this.hue, this.chroma, tone).toInt(); 71 | cache.put(tone, color); 72 | } 73 | return color; 74 | } 75 | } 76 | --------------------------------------------------------------------------------