Repository: vfsfitvnm/ViMusic Branch: master Commit: 6e83b8b83da1 Files: 328 Total size: 1.1 MB Directory structure: gitextract_awx7pwzw/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ └── config.yml │ └── workflows/ │ └── android.yml ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── it.vfsfitvnm.vimusic.DatabaseInitializer/ │ │ ├── 1.json │ │ ├── 10.json │ │ ├── 11.json │ │ ├── 12.json │ │ ├── 13.json │ │ ├── 14.json │ │ ├── 15.json │ │ ├── 16.json │ │ ├── 17.json │ │ ├── 18.json │ │ ├── 19.json │ │ ├── 2.json │ │ ├── 20.json │ │ ├── 21.json │ │ ├── 22.json │ │ ├── 23.json │ │ ├── 3.json │ │ ├── 4.json │ │ ├── 5.json │ │ ├── 6.json │ │ ├── 7.json │ │ ├── 8.json │ │ └── 9.json │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── kotlin/ │ │ └── it/ │ │ └── vfsfitvnm/ │ │ └── vimusic/ │ │ ├── Database.kt │ │ ├── MainActivity.kt │ │ ├── MainApplication.kt │ │ ├── enums/ │ │ │ ├── AlbumSortBy.kt │ │ │ ├── ArtistSortBy.kt │ │ │ ├── BuiltInPlaylist.kt │ │ │ ├── CoilDiskCacheSize.kt │ │ │ ├── ColorPaletteMode.kt │ │ │ ├── ColorPaletteName.kt │ │ │ ├── ExoPlayerDiskCacheMaxSize.kt │ │ │ ├── PlaylistSortBy.kt │ │ │ ├── SongSortBy.kt │ │ │ ├── SortOrder.kt │ │ │ └── ThumbnailRoundness.kt │ │ ├── models/ │ │ │ ├── Album.kt │ │ │ ├── Artist.kt │ │ │ ├── Event.kt │ │ │ ├── Format.kt │ │ │ ├── Info.kt │ │ │ ├── Lyrics.kt │ │ │ ├── Playlist.kt │ │ │ ├── PlaylistPreview.kt │ │ │ ├── PlaylistWithSongs.kt │ │ │ ├── QueuedMediaItem.kt │ │ │ ├── SearchQuery.kt │ │ │ ├── Song.kt │ │ │ ├── SongAlbumMap.kt │ │ │ ├── SongArtistMap.kt │ │ │ ├── SongPlaylistMap.kt │ │ │ ├── SongWithContentLength.kt │ │ │ └── SortedSongPlaylistMap.kt │ │ ├── service/ │ │ │ ├── BitmapProvider.kt │ │ │ ├── PlaybackExceptions.kt │ │ │ ├── PlayerMediaBrowserService.kt │ │ │ └── PlayerService.kt │ │ ├── ui/ │ │ │ ├── components/ │ │ │ │ ├── BottomSheet.kt │ │ │ │ ├── Menu.kt │ │ │ │ ├── MusicBars.kt │ │ │ │ ├── SeekBar.kt │ │ │ │ ├── ShimmerHost.kt │ │ │ │ └── themed/ │ │ │ │ ├── Dialog.kt │ │ │ │ ├── DialogTextButton.kt │ │ │ │ ├── FloatingActionsContainer.kt │ │ │ │ ├── Header.kt │ │ │ │ ├── IconButton.kt │ │ │ │ ├── LayoutWithAdaptiveThumbnail.kt │ │ │ │ ├── MediaItemMenu.kt │ │ │ │ ├── Menu.kt │ │ │ │ ├── NavigationRail.kt │ │ │ │ ├── PrimaryButton.kt │ │ │ │ ├── Scaffold.kt │ │ │ │ ├── SecondaryButton.kt │ │ │ │ ├── SecondaryTextButton.kt │ │ │ │ ├── Switch.kt │ │ │ │ └── TextPlaceholder.kt │ │ │ ├── items/ │ │ │ │ ├── AlbumItem.kt │ │ │ │ ├── ArtistItem.kt │ │ │ │ ├── ItemContainer.kt │ │ │ │ ├── PlaylistItem.kt │ │ │ │ ├── SongItem.kt │ │ │ │ └── VideoItem.kt │ │ │ ├── screens/ │ │ │ │ ├── Routes.kt │ │ │ │ ├── album/ │ │ │ │ │ ├── AlbumScreen.kt │ │ │ │ │ └── AlbumSongs.kt │ │ │ │ ├── artist/ │ │ │ │ │ ├── ArtistLocalSongs.kt │ │ │ │ │ ├── ArtistOverview.kt │ │ │ │ │ └── ArtistScreen.kt │ │ │ │ ├── builtinplaylist/ │ │ │ │ │ ├── BuiltInPlaylistScreen.kt │ │ │ │ │ └── BuiltInPlaylistSongs.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── HomeAlbums.kt │ │ │ │ │ ├── HomeArtists.kt │ │ │ │ │ ├── HomePlaylists.kt │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ ├── HomeSongs.kt │ │ │ │ │ └── QuickPicks.kt │ │ │ │ ├── localplaylist/ │ │ │ │ │ ├── LocalPlaylistScreen.kt │ │ │ │ │ └── LocalPlaylistSongs.kt │ │ │ │ ├── player/ │ │ │ │ │ ├── Controls.kt │ │ │ │ │ ├── Lyrics.kt │ │ │ │ │ ├── PlaybackError.kt │ │ │ │ │ ├── Player.kt │ │ │ │ │ ├── Queue.kt │ │ │ │ │ ├── StatsForNerds.kt │ │ │ │ │ └── Thumbnail.kt │ │ │ │ ├── playlist/ │ │ │ │ │ ├── PlaylistScreen.kt │ │ │ │ │ └── PlaylistSongList.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── LocalSongSearch.kt │ │ │ │ │ ├── OnlineSearch.kt │ │ │ │ │ └── SearchScreen.kt │ │ │ │ ├── searchresult/ │ │ │ │ │ ├── ItemsPage.kt │ │ │ │ │ └── SearchResultScreen.kt │ │ │ │ └── settings/ │ │ │ │ ├── About.kt │ │ │ │ ├── AppearanceSettings.kt │ │ │ │ ├── CacheSettings.kt │ │ │ │ ├── DatabaseSettings.kt │ │ │ │ ├── OtherSettings.kt │ │ │ │ ├── PlayerSettings.kt │ │ │ │ └── SettingsScreen.kt │ │ │ └── styling/ │ │ │ ├── Appearance.kt │ │ │ ├── ColorPalette.kt │ │ │ ├── Dimensions.kt │ │ │ └── Typography.kt │ │ └── utils/ │ │ ├── Configuration.kt │ │ ├── Context.kt │ │ ├── DrawScope.kt │ │ ├── FadingEdge.kt │ │ ├── InvincibleService.kt │ │ ├── LazyGridSnapLayoutInfoProvider.kt │ │ ├── Player.kt │ │ ├── PlayerState.kt │ │ ├── Preferences.kt │ │ ├── RingBuffer.kt │ │ ├── ScrollingInfo.kt │ │ ├── SmoothScrollToTop.kt │ │ ├── SynchronizedLyrics.kt │ │ ├── TextStyle.kt │ │ ├── TimerJob.kt │ │ ├── Utils.kt │ │ └── YoutubeRadio.kt │ └── res/ │ ├── drawable/ │ │ ├── add.xml │ │ ├── airplane.xml │ │ ├── alarm.xml │ │ ├── alert_circle.xml │ │ ├── app_icon.xml │ │ ├── arrow_down.xml │ │ ├── arrow_forward.xml │ │ ├── arrow_up.xml │ │ ├── bookmark.xml │ │ ├── bookmark_outline.xml │ │ ├── calendar.xml │ │ ├── checkmark.xml │ │ ├── chevron_back.xml │ │ ├── chevron_down.xml │ │ ├── chevron_forward.xml │ │ ├── chevron_up.xml │ │ ├── close.xml │ │ ├── color_palette.xml │ │ ├── disc.xml │ │ ├── download.xml │ │ ├── ellipsis_horizontal.xml │ │ ├── ellipsis_vertical.xml │ │ ├── enqueue.xml │ │ ├── equalizer.xml │ │ ├── film.xml │ │ ├── globe.xml │ │ ├── heart.xml │ │ ├── heart_dislike.xml │ │ ├── heart_outline.xml │ │ ├── ic_banner_foreground.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── infinite.xml │ │ ├── information.xml │ │ ├── library.xml │ │ ├── link.xml │ │ ├── medical.xml │ │ ├── musical_notes.xml │ │ ├── notifications.xml │ │ ├── pause.xml │ │ ├── pencil.xml │ │ ├── person.xml │ │ ├── play.xml │ │ ├── play_skip_back.xml │ │ ├── play_skip_forward.xml │ │ ├── playlist.xml │ │ ├── radio.xml │ │ ├── reorder.xml │ │ ├── search.xml │ │ ├── server.xml │ │ ├── shapes.xml │ │ ├── share_social.xml │ │ ├── shuffle.xml │ │ ├── sort.xml │ │ ├── sparkles.xml │ │ ├── star.xml │ │ ├── sync.xml │ │ ├── text.xml │ │ ├── time.xml │ │ ├── trash.xml │ │ └── trending.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_banner.xml │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ └── themes.xml │ ├── values-night/ │ │ └── themes.xml │ └── xml/ │ └── automotive_app_desc.xml ├── build.gradle.kts ├── compose-persist/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── it/ │ └── vfsfitvnm/ │ └── compose/ │ └── persist/ │ ├── Persist.kt │ ├── PersistMap.kt │ ├── PersistMapCleanup.kt │ ├── PersistMapOwner.kt │ └── Utils.kt ├── compose-reordering/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── it/ │ └── vfsfitvnm/ │ └── compose/ │ └── reordering/ │ ├── AnimatablesPool.kt │ ├── AnimateItemPlacement.kt │ ├── DraggedItem.kt │ ├── Reorder.kt │ ├── ReorderingLazyColumn.kt │ ├── ReorderingLazyList.kt │ └── ReorderingState.kt ├── compose-routing/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── it/ │ └── vfsfitvnm/ │ └── compose/ │ └── routing/ │ ├── GlobalRoute.kt │ ├── Route.kt │ ├── RouteHandler.kt │ ├── RouteHandlerScope.kt │ └── Transitions.kt ├── fastlane/ │ └── metadata/ │ └── android/ │ └── en-US/ │ ├── changelogs/ │ │ ├── 10.txt │ │ ├── 11.txt │ │ ├── 12.txt │ │ ├── 13.txt │ │ ├── 14.txt │ │ ├── 15.txt │ │ ├── 16.txt │ │ ├── 17.txt │ │ ├── 18.txt │ │ ├── 19.txt │ │ ├── 20.txt │ │ └── 9.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── innertube/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── it/ │ │ └── vfsfitvnm/ │ │ └── innertube/ │ │ ├── Innertube.kt │ │ ├── models/ │ │ │ ├── BrowseResponse.kt │ │ │ ├── ButtonRenderer.kt │ │ │ ├── Context.kt │ │ │ ├── Continuation.kt │ │ │ ├── ContinuationResponse.kt │ │ │ ├── GetQueueResponse.kt │ │ │ ├── GridRenderer.kt │ │ │ ├── MusicCarouselShelfRenderer.kt │ │ │ ├── MusicResponsiveListItemRenderer.kt │ │ │ ├── MusicShelfRenderer.kt │ │ │ ├── MusicTwoRowItemRenderer.kt │ │ │ ├── NavigationEndpoint.kt │ │ │ ├── NextResponse.kt │ │ │ ├── PlayerResponse.kt │ │ │ ├── PlaylistPanelVideoRenderer.kt │ │ │ ├── Runs.kt │ │ │ ├── SearchResponse.kt │ │ │ ├── SearchSuggestionsResponse.kt │ │ │ ├── SectionListRenderer.kt │ │ │ ├── Tabs.kt │ │ │ ├── Thumbnail.kt │ │ │ ├── ThumbnailRenderer.kt │ │ │ └── bodies/ │ │ │ ├── BrowseBody.kt │ │ │ ├── ContinuationBody.kt │ │ │ ├── NextBody.kt │ │ │ ├── PlayerBody.kt │ │ │ ├── QueueBody.kt │ │ │ ├── SearchBody.kt │ │ │ └── SearchSuggestionsBody.kt │ │ ├── requests/ │ │ │ ├── AlbumPage.kt │ │ │ ├── ArtistPage.kt │ │ │ ├── ItemsPage.kt │ │ │ ├── Lyrics.kt │ │ │ ├── NextPage.kt │ │ │ ├── Player.kt │ │ │ ├── PlaylistPage.kt │ │ │ ├── Queue.kt │ │ │ ├── RelatedPage.kt │ │ │ ├── SearchPage.kt │ │ │ └── SearchSuggestions.kt │ │ └── utils/ │ │ ├── FromMusicResponsiveListItemRenderer.kt │ │ ├── FromMusicShelfRendererContent.kt │ │ ├── FromMusicTwoRowItemRenderer.kt │ │ ├── FromPlaylistPanelVideoRenderer.kt │ │ └── Utils.kt │ └── test/ │ └── kotlin/ │ └── Test.kt ├── ktor-client-brotli/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── io/ │ └── ktor/ │ └── client/ │ └── plugins/ │ └── compression/ │ ├── BrotliEncoder.kt │ └── brotli.kt ├── kugou/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── it/ │ │ └── vfsfitvnm/ │ │ └── kugou/ │ │ ├── KuGou.kt │ │ ├── Result.kt │ │ └── models/ │ │ ├── DownloadLyricsResponse.kt │ │ ├── SearchLyricsResponse.kt │ │ └── SearchSongResponse.kt │ └── test/ │ └── kotlin/ │ └── Test.kt └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: 🐛 Bug report description: Something isn't working, uh? labels: [bug] body: - type: markdown attributes: value: | ## ⚠️ Make sure you are able to reproduce the bug with the [latest version](https://github.com/vfsfitvnm/vimusic/releases/latest). ## ⚠️ Make sure there is no issue about this bug already. - type: textarea id: reproduce-steps attributes: label: Steps to reproduce the bug description: What did you do for the bug to show up? placeholder: | Example: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior placeholder: | Example: "This should happen..." validations: required: true - type: textarea id: actual-behavior attributes: label: Actual behavior placeholder: | Example: "This happened instead..." validations: required: true - type: textarea id: sreen-media attributes: label: Screenshots/Screen recordings description: | A picture or video helps us understand the bug more. You can upload them directly in the text box. - type: textarea id: logs attributes: label: Logs description: | If your bug includes a crash, please use `adb logcat` or other ways to provide logs. - type: input id: vimusic-version attributes: label: ViMusic version placeholder: | Example: "0.5.4" validations: required: true - type: input id: android-version attributes: label: Android version description: | You can find this somewhere in your Android settings. placeholder: | Example: "Android 12" validations: required: true - type: textarea id: additional-information attributes: label: Additional information placeholder: | Additional details and attachments. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/workflows/android.yml ================================================ name: CI on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up JDK 11 uses: actions/setup-java@v3 with: java-version: "11" distribution: "temurin" cache: gradle - name: Build with Gradle run: ./gradlew assembleDebug - uses: actions/upload-artifact@v2 with: name: app-debug.apk path: app/build/outputs/apk/debug/app-debug.apk ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties .idea .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

ViMusic

An Android application for streaming music from YouTube Music

---

## Features - Play (almost) any song or video from YouTube Music - Background playback - Cache audio chunks for offline playback - Search for songs, albums, artists videos and playlists - Bookmark artists and albums - Import playlists - Fetch, display and edit songs lyrics or synchronized lyrics - Local playlist management - Reorder songs in playlist or queue - Light/Dark/Dynamic theme - Skip silence - Sleep timer - Audio normalization - Android Auto - Persistent queue - Open YouTube/YouTube Music links (`watch`, `playlist`, `channel`) - ... ## Installation [Get it on GitHub](https://github.com/vfsfitvnm/ViMusic/releases/latest) [Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/it.vfsfitvnm.vimusic) [Get it on F-Droid](https://f-droid.org/packages/it.vfsfitvnm.vimusic/) ## Acknowledgments - [**YouTube-Internal-Clients**](https://github.com/zerodytrash/YouTube-Internal-Clients): A python script that discovers hidden YouTube API clients. Just a research project. - [**ionicons**](https://github.com/ionic-team/ionicons): Premium hand-crafted icons built by Ionic, for Ionic apps and web apps everywhere. App icon based on icon created by Ilham Fitrotul Hayat - Flaticon ## Disclaimer This project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way associated with YouTube, Google LLC or any of its affiliates and subsidiaries. Any trademark, service mark, trade name, or other intellectual property rights used in this project are owned by the respective owners. ================================================ FILE: app/.gitignore ================================================ /build /release ================================================ FILE: app/build.gradle.kts ================================================ plugins { id("com.android.application") kotlin("android") kotlin("kapt") } android { compileSdk = 33 defaultConfig { applicationId = "it.vfsfitvnm.vimusic" minSdk = 21 targetSdk = 33 versionCode = 20 versionName = "0.5.4" } splits { abi { reset() isUniversalApk = true } } namespace = "it.vfsfitvnm.vimusic" buildTypes { debug { applicationIdSuffix = ".debug" manifestPlaceholders["appName"] = "Debug" } release { isMinifyEnabled = true isShrinkResources = true manifestPlaceholders["appName"] = "ViMusic" signingConfig = signingConfigs.getByName("debug") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } sourceSets.all { kotlin.srcDir("src/$name/kotlin") } buildFeatures { compose = true } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } kotlinOptions { freeCompilerArgs += "-Xcontext-receivers" jvmTarget = "1.8" } } kapt { arguments { arg("room.schemaLocation", "$projectDir/schemas") } } dependencies { implementation(projects.composePersist) implementation(projects.composeRouting) implementation(projects.composeReordering) implementation(libs.compose.activity) implementation(libs.compose.foundation) implementation(libs.compose.ui) implementation(libs.compose.ui.util) implementation(libs.compose.ripple) implementation(libs.compose.shimmer) implementation(libs.compose.coil) implementation(libs.palette) implementation(libs.exoplayer) implementation(libs.room) kapt(libs.room.compiler) implementation(projects.innertube) implementation(projects.kugou) coreLibraryDesugaring(libs.desugaring) } ================================================ FILE: app/proguard-rules.pro ================================================ -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation -if @kotlinx.serialization.Serializable class ** -keepclassmembers class <1> { static <1>$Companion Companion; } -if @kotlinx.serialization.Serializable class ** { static **$* *; } -keepclassmembers class <2>$<3> { kotlinx.serialization.KSerializer serializer(...); } -if @kotlinx.serialization.Serializable class ** { public static ** INSTANCE; } -keepclassmembers class <1> { public static <1> INSTANCE; kotlinx.serialization.KSerializer serializer(...); } -keepattributes RuntimeVisibleAnnotations,AnnotationDefault -dontwarn org.bouncycastle.jsse.BCSSLParameters -dontwarn org.bouncycastle.jsse.BCSSLSocket -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider -dontwarn org.conscrypt.Conscrypt$Version -dontwarn org.conscrypt.Conscrypt -dontwarn org.conscrypt.ConscryptHostnameVerifier -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE -dontwarn org.slf4j.impl.StaticLoggerBinder ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "b93575bd08c10513f0bfc997b832c280", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b93575bd08c10513f0bfc997b832c280')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/10.json ================================================ { "formatVersion": 1, "database": { "version": 10, "identityHash": "b4ab81f091f9f0d359631c1426b04c49", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumId` TEXT, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [ { "table": "Album", "onDelete": "SET NULL", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4ab81f091f9f0d359631c1426b04c49')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/11.json ================================================ { "formatVersion": 1, "database": { "version": 11, "identityHash": "b621c39ef38afe8991277568a67d5f3d", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b621c39ef38afe8991277568a67d5f3d')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/12.json ================================================ { "formatVersion": 1, "database": { "version": 12, "identityHash": "fe9703c1e23ef700d9698e0440e4ad7f", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fe9703c1e23ef700d9698e0440e4ad7f')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json ================================================ { "formatVersion": 1, "database": { "version": 13, "identityHash": "61cd3db93beeafd3ca398be54544c752", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61cd3db93beeafd3ca398be54544c752')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/14.json ================================================ { "formatVersion": 1, "database": { "version": 14, "identityHash": "6bc345258fdae98dcae16e60ab7a7f2f", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6bc345258fdae98dcae16e60ab7a7f2f')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/15.json ================================================ { "formatVersion": 1, "database": { "version": 15, "identityHash": "19f6f6ce7ce279de7853df4b8bd77180", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '19f6f6ce7ce279de7853df4b8bd77180')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json ================================================ { "formatVersion": 1, "database": { "version": 16, "identityHash": "0cbca5b4016755ebf227461349581201", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0cbca5b4016755ebf227461349581201')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/17.json ================================================ { "formatVersion": 1, "database": { "version": 17, "identityHash": "8f32fc7dcf9836d05d1ba4acbee7f57e", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8f32fc7dcf9836d05d1ba4acbee7f57e')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json ================================================ { "formatVersion": 1, "database": { "version": 18, "identityHash": "c8f776e899b181081f0230bffec99ac5", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c8f776e899b181081f0230bffec99ac5')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json ================================================ { "formatVersion": 1, "database": { "version": 19, "identityHash": "b9a9bb1674c7c50be2fab48de5afed43", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_Event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b9a9bb1674c7c50be2fab48de5afed43')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "a595020ea35da1c5de6c6ee75ec234fe", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a595020ea35da1c5de6c6ee75ec234fe')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json ================================================ { "formatVersion": 1, "database": { "version": 20, "identityHash": "251e713953aacd84fd33b471ed4af391", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleVideoId", "columnName": "shuffleVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shufflePlaylistId", "columnName": "shufflePlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioVideoId", "columnName": "radioVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioPlaylistId", "columnName": "radioPlaylistId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_Event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '251e713953aacd84fd33b471ed4af391')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json ================================================ { "formatVersion": 1, "database": { "version": 21, "identityHash": "5afda34f61cc45ecd6102a7285ec92d2", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `info` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_Event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5afda34f61cc45ecd6102a7285ec92d2')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json ================================================ { "formatVersion": 1, "database": { "version": 22, "identityHash": "ca98e767afd3ae8c801377ee3d18c71e", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synchronizedLyrics", "columnName": "synchronizedLyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_Event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca98e767afd3ae8c801377ee3d18c71e')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json ================================================ { "formatVersion": 1, "database": { "version": 23, "identityHash": "205c24811149a247279bcbfdc2d6c396", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongPlaylistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongPlaylistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongPlaylistMap_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shareUrl", "columnName": "shareUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongAlbumMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "albumId" ] }, "indices": [ { "name": "index_SongAlbumMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongAlbumMap_albumId", "unique": false, "columnNames": [ "albumId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Album", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "albumId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Format", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "itag", "columnName": "itag", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "mimeType", "columnName": "mimeType", "affinity": "TEXT", "notNull": false }, { "fieldPath": "bitrate", "columnName": "bitrate", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastModified", "columnName": "lastModified", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_Event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "fixed", "columnName": "fixed", "affinity": "TEXT", "notNull": false }, { "fieldPath": "synced", "columnName": "synced", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId" ] }, "indices": [], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] } ], "views": [ { "viewName": "SortedSongPlaylistMap", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '205c24811149a247279bcbfdc2d6c396')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "f2169b1328eebb0c7f353018e2ae4bd3", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f2169b1328eebb0c7f353018e2ae4bd3')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "d5720e465abdf99b583c183298f18340", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5720e465abdf99b583c183298f18340')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "c16206386ea59ba9109b1e116ec61ea0", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c16206386ea59ba9109b1e116ec61ea0')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "7d53e052483019da2b9d7056072cea79", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d53e052483019da2b9d7056072cea79')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/7.json ================================================ { "formatVersion": 1, "database": { "version": 7, "identityHash": "5f75673891ab82a14afcb6d95cc6e1e4", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumInfoId", "columnName": "albumInfoId", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f75673891ab82a14afcb6d95cc6e1e4')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/8.json ================================================ { "formatVersion": 1, "database": { "version": 8, "identityHash": "446e2ef392a547f6b2d4318c9f5dd4cf", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumId` TEXT, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Info", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "browseId", "columnName": "browseId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongWithAuthors", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "authorInfoId", "columnName": "authorInfoId", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "authorInfoId" ] }, "indices": [ { "name": "index_SongWithAuthors_authorInfoId", "unique": false, "columnNames": [ "authorInfoId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Info", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "authorInfoId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '446e2ef392a547f6b2d4318c9f5dd4cf')" ] } } ================================================ FILE: app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/9.json ================================================ { "formatVersion": 1, "database": { "version": 9, "identityHash": "22e88f327e3340760100610939e9a158", "entities": [ { "tableName": "Song", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumId` TEXT, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "artistsText", "columnName": "artistsText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "durationText", "columnName": "durationText", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": false }, { "fieldPath": "likedAt", "columnName": "likedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "totalPlayTimeMs", "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL", "notNull": false }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongInPlaylist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "playlistId" ] }, "indices": [ { "name": "index_SongInPlaylist_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongInPlaylist_playlistId", "unique": false, "columnNames": [ "playlistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Playlist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "playlistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Playlist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "Artist", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "info", "columnName": "info", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SongArtistMap", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artistId", "columnName": "artistId", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "songId", "artistId" ] }, "indices": [ { "name": "index_SongArtistMap_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" }, { "name": "index_SongArtistMap_artistId", "unique": false, "columnNames": [ "artistId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" } ], "foreignKeys": [ { "table": "Song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] }, { "table": "Artist", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "artistId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "Album", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "year", "columnName": "year", "affinity": "TEXT", "notNull": false }, { "fieldPath": "authorsText", "columnName": "authorsText", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "SearchQuery", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_SearchQuery_query", "unique": true, "columnNames": [ "query" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" } ], "foreignKeys": [] }, { "tableName": "QueuedMediaItem", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaItem", "columnName": "mediaItem", "affinity": "BLOB", "notNull": true }, { "fieldPath": "position", "columnName": "position", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [ { "viewName": "SortedSongInPlaylist", "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '22e88f327e3340760100610939e9a158')" ] } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt ================================================ package it.vfsfitvnm.vimusic import android.content.ContentValues import android.content.Context import android.database.SQLException import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE import android.os.Parcel import androidx.core.database.getFloatOrNull import androidx.media3.common.MediaItem import androidx.room.AutoMigration import androidx.room.Dao import androidx.room.Delete import androidx.room.DeleteColumn import androidx.room.DeleteTable import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RawQuery import androidx.room.RenameColumn import androidx.room.RenameTable import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.Transaction import androidx.room.TypeConverter import androidx.room.TypeConverters import androidx.room.Update import androidx.room.Upsert import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteQuery import it.vfsfitvnm.vimusic.enums.AlbumSortBy import it.vfsfitvnm.vimusic.enums.ArtistSortBy import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SongSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.SongWithContentLength import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.Format import it.vfsfitvnm.vimusic.models.Info import it.vfsfitvnm.vimusic.models.Lyrics import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.models.PlaylistWithSongs import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.models.SearchQuery import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongAlbumMap import it.vfsfitvnm.vimusic.models.SongArtistMap import it.vfsfitvnm.vimusic.models.SongPlaylistMap import it.vfsfitvnm.vimusic.models.SortedSongPlaylistMap import kotlin.jvm.Throws import kotlinx.coroutines.flow.Flow @Dao interface Database { companion object : Database by DatabaseInitializer.Instance.database @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC") @RewriteQueriesToDropUnusedColumns fun songsByRowIdAsc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC") @RewriteQueriesToDropUnusedColumns fun songsByRowIdDesc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title ASC") @RewriteQueriesToDropUnusedColumns fun songsByTitleAsc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title DESC") @RewriteQueriesToDropUnusedColumns fun songsByTitleDesc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC") @RewriteQueriesToDropUnusedColumns fun songsByPlayTimeAsc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC") @RewriteQueriesToDropUnusedColumns fun songsByPlayTimeDesc(): Flow> fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow> { return when (sortBy) { SongSortBy.PlayTime -> when (sortOrder) { SortOrder.Ascending -> songsByPlayTimeAsc() SortOrder.Descending -> songsByPlayTimeDesc() } SongSortBy.Title -> when (sortOrder) { SortOrder.Ascending -> songsByTitleAsc() SortOrder.Descending -> songsByTitleDesc() } SongSortBy.DateAdded -> when (sortOrder) { SortOrder.Ascending -> songsByRowIdAsc() SortOrder.Descending -> songsByRowIdDesc() } } } @Transaction @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC") @RewriteQueriesToDropUnusedColumns fun favorites(): Flow> @Query("SELECT * FROM QueuedMediaItem") fun queue(): List @Query("DELETE FROM QueuedMediaItem") fun clearQueue() @Query("SELECT * FROM SearchQuery WHERE query LIKE :query ORDER BY id DESC") fun queries(query: String): Flow> @Query("SELECT COUNT (*) FROM SearchQuery") fun queriesCount(): Flow @Query("DELETE FROM SearchQuery") fun clearQueries() @Query("SELECT * FROM Song WHERE id = :id") fun song(id: String): Flow @Query("SELECT likedAt FROM Song WHERE id = :songId") fun likedAt(songId: String): Flow @Query("UPDATE Song SET likedAt = :likedAt WHERE id = :songId") fun like(songId: String, likedAt: Long?): Int @Query("UPDATE Song SET durationText = :durationText WHERE id = :songId") fun updateDurationText(songId: String, durationText: String): Int @Query("SELECT * FROM Lyrics WHERE songId = :songId") fun lyrics(songId: String): Flow @Query("SELECT * FROM Artist WHERE id = :id") fun artist(id: String): Flow @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC") fun artistsByNameDesc(): Flow> @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC") fun artistsByNameAsc(): Flow> @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") fun artistsByRowIdDesc(): Flow> @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") fun artistsByRowIdAsc(): Flow> fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow> { return when (sortBy) { ArtistSortBy.Name -> when (sortOrder) { SortOrder.Ascending -> artistsByNameAsc() SortOrder.Descending -> artistsByNameDesc() } ArtistSortBy.DateAdded -> when (sortOrder) { SortOrder.Ascending -> artistsByRowIdAsc() SortOrder.Descending -> artistsByRowIdDesc() } } } @Query("SELECT * FROM Album WHERE id = :id") fun album(id: String): Flow @Query("SELECT timestamp FROM Album WHERE id = :id") fun albumTimestamp(id: String): Long? @Transaction @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") @RewriteQueriesToDropUnusedColumns fun albumSongs(albumId: String): Flow> @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title ASC") fun albumsByTitleAsc(): Flow> @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year ASC") fun albumsByYearAsc(): Flow> @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") fun albumsByRowIdAsc(): Flow> @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title DESC") fun albumsByTitleDesc(): Flow> @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year DESC") fun albumsByYearDesc(): Flow> @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") fun albumsByRowIdDesc(): Flow> fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder): Flow> { return when (sortBy) { AlbumSortBy.Title -> when (sortOrder) { SortOrder.Ascending -> albumsByTitleAsc() SortOrder.Descending -> albumsByTitleDesc() } AlbumSortBy.Year -> when (sortOrder) { SortOrder.Ascending -> albumsByYearAsc() SortOrder.Descending -> albumsByYearDesc() } AlbumSortBy.DateAdded -> when (sortOrder) { SortOrder.Ascending -> albumsByRowIdAsc() SortOrder.Descending -> albumsByRowIdDesc() } } } @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") fun incrementTotalPlayTimeMs(id: String, addition: Long) @Transaction @Query("SELECT * FROM Playlist WHERE id = :id") fun playlistWithSongs(id: Long): Flow @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name ASC") fun playlistPreviewsByNameAsc(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID ASC") fun playlistPreviewsByDateAddedAsc(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount ASC") fun playlistPreviewsByDateSongCountAsc(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name DESC") fun playlistPreviewsByNameDesc(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID DESC") fun playlistPreviewsByDateAddedDesc(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount DESC") fun playlistPreviewsByDateSongCountDesc(): Flow> fun playlistPreviews( sortBy: PlaylistSortBy, sortOrder: SortOrder ): Flow> { return when (sortBy) { PlaylistSortBy.Name -> when (sortOrder) { SortOrder.Ascending -> playlistPreviewsByNameAsc() SortOrder.Descending -> playlistPreviewsByNameDesc() } PlaylistSortBy.SongCount -> when (sortOrder) { SortOrder.Ascending -> playlistPreviewsByDateSongCountAsc() SortOrder.Descending -> playlistPreviewsByDateSongCountDesc() } PlaylistSortBy.DateAdded -> when (sortOrder) { SortOrder.Ascending -> playlistPreviewsByDateAddedAsc() SortOrder.Descending -> playlistPreviewsByDateAddedDesc() } } } @Query("SELECT thumbnailUrl FROM Song JOIN SongPlaylistMap ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4") fun playlistThumbnailUrls(id: Long): Flow> @Transaction @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") @RewriteQueriesToDropUnusedColumns fun artistSongs(artistId: String): Flow> @Query("SELECT * FROM Format WHERE songId = :songId") fun format(songId: String): Flow @Transaction @Query("SELECT Song.*, contentLength FROM Song JOIN Format ON id = songId WHERE contentLength IS NOT NULL AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") fun songsWithContentLength(): Flow> @Query(""" UPDATE SongPlaylistMap SET position = CASE WHEN position < :fromPosition THEN position + 1 WHEN position > :fromPosition THEN position - 1 ELSE :toPosition END WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition,:toPosition) and MAX(:fromPosition,:toPosition) """) fun move(playlistId: Long, fromPosition: Int, toPosition: Int) @Query("DELETE FROM SongPlaylistMap WHERE playlistId = :id") fun clearPlaylist(id: Long) @Query("DELETE FROM SongAlbumMap WHERE albumId = :id") fun clearAlbum(id: String) @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") fun loudnessDb(songId: String): Flow @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") fun search(query: String): Flow> @Query("SELECT albumId AS id, NULL AS name FROM SongAlbumMap WHERE songId = :songId") fun songAlbumInfo(songId: String): Info @Query("SELECT id, name FROM Artist LEFT JOIN SongArtistMap ON id = artistId WHERE songId = :songId") fun songArtistInfo(songId: String): List @Transaction @Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(CAST(playTime AS REAL) / (((:now - timestamp) / 86400000) + 1)) DESC LIMIT 1") @RewriteQueriesToDropUnusedColumns fun trending(now: Long = System.currentTimeMillis()): Flow @Query("SELECT COUNT (*) FROM Event") fun eventsCount(): Flow @Query("DELETE FROM Event") fun clearEvents() @Query("DELETE FROM Event WHERE songId = :songId") fun clearEventsFor(songId: String) @Insert(onConflict = OnConflictStrategy.IGNORE) @Throws(SQLException::class) fun insert(event: Event) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(format: Format) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(searchQuery: SearchQuery) @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(playlist: Playlist): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(songPlaylistMap: SongPlaylistMap): Long @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(songArtistMap: SongArtistMap): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(song: Song): Long @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(queuedMediaItems: List) @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertSongPlaylistMaps(songPlaylistMaps: List) @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(album: Album, songAlbumMap: SongAlbumMap) @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(artists: List, songArtistMaps: List) @Transaction fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { val song = Song( id = mediaItem.mediaId, title = mediaItem.mediaMetadata.title!!.toString(), artistsText = mediaItem.mediaMetadata.artist?.toString(), durationText = mediaItem.mediaMetadata.extras?.getString("durationText"), thumbnailUrl = mediaItem.mediaMetadata.artworkUri?.toString() ).let(block).also { song -> if (insert(song) == -1L) return } mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> insert( Album(id = albumId, title = mediaItem.mediaMetadata.albumTitle?.toString()), SongAlbumMap(songId = song.id, albumId = albumId, position = null) ) } mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds -> if (artistNames.size == artistIds.size) { insert( artistNames.mapIndexed { index, artistName -> Artist(id = artistIds[index], name = artistName) }, artistIds.map { artistId -> SongArtistMap(songId = song.id, artistId = artistId) } ) } } } } @Update fun update(artist: Artist) @Update fun update(album: Album) @Update fun update(playlist: Playlist) @Upsert fun upsert(lyrics: Lyrics) @Upsert fun upsert(album: Album, songAlbumMaps: List) @Upsert fun upsert(songAlbumMap: SongAlbumMap) @Upsert fun upsert(artist: Artist) @Delete fun delete(searchQuery: SearchQuery) @Delete fun delete(playlist: Playlist) @Delete fun delete(songPlaylistMap: SongPlaylistMap) @RawQuery fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int fun checkpoint() { raw(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) } } @androidx.room.Database( entities = [ Song::class, SongPlaylistMap::class, Playlist::class, Artist::class, SongArtistMap::class, Album::class, SongAlbumMap::class, SearchQuery::class, QueuedMediaItem::class, Format::class, Event::class, Lyrics::class, ], views = [ SortedSongPlaylistMap::class ], version = 23, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4, spec = DatabaseInitializer.From3To4Migration::class), AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class), AutoMigration(from = 9, to = 10), AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class), AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), AutoMigration(from = 17, to = 18), AutoMigration(from = 18, to = 19), AutoMigration(from = 19, to = 20), AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class), AutoMigration(from = 21, to = 22, spec = DatabaseInitializer.From21To22Migration::class), ], ) @TypeConverters(Converters::class) abstract class DatabaseInitializer protected constructor() : RoomDatabase() { abstract val database: Database companion object { lateinit var Instance: DatabaseInitializer context(Context) operator fun invoke() { if (!::Instance.isInitialized) { Instance = Room .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") .addMigrations( From8To9Migration(), From10To11Migration(), From14To15Migration(), From22To23Migration() ) .build() } } } @DeleteTable.Entries(DeleteTable(tableName = "QueuedMediaItem")) class From3To4Migration : AutoMigrationSpec @RenameColumn.Entries(RenameColumn("Song", "albumInfoId", "albumId")) class From7To8Migration : AutoMigrationSpec class From8To9Migration : Migration(8, 9) { override fun migrate(it: SupportSQLiteDatabase) { it.query(SimpleSQLiteQuery("SELECT DISTINCT browseId, text, Info.id FROM Info JOIN Song ON Info.id = Song.albumId;")) .use { cursor -> val albumValues = ContentValues(2) while (cursor.moveToNext()) { albumValues.put("id", cursor.getString(0)) albumValues.put("title", cursor.getString(1)) it.insert("Album", CONFLICT_IGNORE, albumValues) it.execSQL( "UPDATE Song SET albumId = '${cursor.getString(0)}' WHERE albumId = ${ cursor.getLong( 2 ) }" ) } } it.query(SimpleSQLiteQuery("SELECT GROUP_CONCAT(text, ''), SongWithAuthors.songId FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId GROUP BY songId;")) .use { cursor -> val songValues = ContentValues(1) while (cursor.moveToNext()) { songValues.put("artistsText", cursor.getString(0)) it.update( "Song", CONFLICT_IGNORE, songValues, "id = ?", arrayOf(cursor.getString(1)) ) } } it.query(SimpleSQLiteQuery("SELECT browseId, text, Info.id FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId WHERE browseId NOT NULL;")) .use { cursor -> val artistValues = ContentValues(2) while (cursor.moveToNext()) { artistValues.put("id", cursor.getString(0)) artistValues.put("name", cursor.getString(1)) it.insert("Artist", CONFLICT_IGNORE, artistValues) it.execSQL( "UPDATE SongWithAuthors SET authorInfoId = '${cursor.getString(0)}' WHERE authorInfoId = ${ cursor.getLong( 2 ) }" ) } } it.execSQL("INSERT INTO SongArtistMap(songId, artistId) SELECT songId, authorInfoId FROM SongWithAuthors") it.execSQL("DROP TABLE Info;") it.execSQL("DROP TABLE SongWithAuthors;") } } class From10To11Migration : Migration(10, 11) { override fun migrate(it: SupportSQLiteDatabase) { it.query(SimpleSQLiteQuery("SELECT id, albumId FROM Song;")).use { cursor -> val songAlbumMapValues = ContentValues(2) while (cursor.moveToNext()) { songAlbumMapValues.put("songId", cursor.getString(0)) songAlbumMapValues.put("albumId", cursor.getString(1)) it.insert("SongAlbumMap", CONFLICT_IGNORE, songAlbumMapValues) } } it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))") it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength FROM Song;") it.execSQL("DROP TABLE Song;") it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } @RenameTable("SongInPlaylist", "SongPlaylistMap") @RenameTable("SortedSongInPlaylist", "SortedSongPlaylistMap") class From11To12Migration : AutoMigrationSpec class From14To15Migration : Migration(14, 15) { override fun migrate(it: SupportSQLiteDatabase) { it.query(SimpleSQLiteQuery("SELECT id, loudnessDb, contentLength FROM Song;")) .use { cursor -> val formatValues = ContentValues(3) while (cursor.moveToNext()) { formatValues.put("songId", cursor.getString(0)) formatValues.put("loudnessDb", cursor.getFloatOrNull(1)) formatValues.put("contentLength", cursor.getFloatOrNull(2)) it.insert("Format", CONFLICT_IGNORE, formatValues) } } it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs FROM Song;") it.execSQL("DROP TABLE Song;") it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } @DeleteColumn.Entries( DeleteColumn("Artist", "shuffleVideoId"), DeleteColumn("Artist", "shufflePlaylistId"), DeleteColumn("Artist", "radioVideoId"), DeleteColumn("Artist", "radioPlaylistId"), ) class From20To21Migration : AutoMigrationSpec @DeleteColumn.Entries(DeleteColumn("Artist", "info")) class From21To22Migration : AutoMigrationSpec class From22To23Migration : Migration(22, 23) { override fun migrate(it: SupportSQLiteDatabase) { it.execSQL("CREATE TABLE IF NOT EXISTS Lyrics (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)") it.query(SimpleSQLiteQuery("SELECT id, lyrics, synchronizedLyrics FROM Song;")).use { cursor -> val lyricsValues = ContentValues(3) while (cursor.moveToNext()) { lyricsValues.put("songId", cursor.getString(0)) lyricsValues.put("fixed", cursor.getString(1)) lyricsValues.put("synced", cursor.getString(2)) it.insert("Lyrics", CONFLICT_IGNORE, lyricsValues) } } it.execSQL("CREATE TABLE IF NOT EXISTS Song_new (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs FROM Song;") it.execSQL("DROP TABLE Song;") it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } } @TypeConverters object Converters { @TypeConverter fun mediaItemFromByteArray(value: ByteArray?): MediaItem? { return value?.let { byteArray -> runCatching { val parcel = Parcel.obtain() parcel.unmarshall(byteArray, 0, byteArray.size) parcel.setDataPosition(0) val bundle = parcel.readBundle(MediaItem::class.java.classLoader) parcel.recycle() bundle?.let(MediaItem.CREATOR::fromBundle) }.getOrNull() } } @TypeConverter fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? { return mediaItem?.toBundle()?.let { persistableBundle -> val parcel = Parcel.obtain() parcel.writeBundle(persistableBundle) val bytes = parcel.marshall() parcel.recycle() bytes } } } val Database.internal: RoomDatabase get() = DatabaseInitializer.Instance fun query(block: () -> Unit) = DatabaseInitializer.Instance.queryExecutor.execute(block) fun transaction(block: () -> Unit) = with(DatabaseInitializer.Instance) { transactionExecutor.execute { runInTransaction(block) } } val RoomDatabase.path: String? get() = openHelper.writableDatabase.path ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt ================================================ package it.vfsfitvnm.vimusic import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.SharedPreferences import android.graphics.Bitmap import android.os.Bundle import android.os.IBinder import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.systemBars import androidx.compose.material.ripple.LocalRippleTheme import androidx.compose.material.ripple.RippleAlpha import androidx.compose.material.ripple.RippleTheme import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.valentinilk.shimmer.LocalShimmerTheme import com.valentinilk.shimmer.defaultShimmerTheme import it.vfsfitvnm.compose.persist.PersistMap import it.vfsfitvnm.compose.persist.PersistMapOwner import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.requests.playlistPage import it.vfsfitvnm.innertube.requests.song import it.vfsfitvnm.vimusic.enums.ColorPaletteMode import it.vfsfitvnm.vimusic.enums.ColorPaletteName import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.service.PlayerService import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.artistRoute import it.vfsfitvnm.vimusic.ui.screens.home.HomeScreen import it.vfsfitvnm.vimusic.ui.screens.player.Player import it.vfsfitvnm.vimusic.ui.screens.playlistRoute import it.vfsfitvnm.vimusic.ui.styling.Appearance import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.colorPaletteOf import it.vfsfitvnm.vimusic.ui.styling.dynamicColorPaletteOf import it.vfsfitvnm.vimusic.ui.styling.typographyOf import it.vfsfitvnm.vimusic.utils.applyFontPaddingKey import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.getEnum import it.vfsfitvnm.vimusic.utils.intent import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid8 import it.vfsfitvnm.vimusic.utils.preferences import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey import it.vfsfitvnm.vimusic.utils.useSystemFontKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class MainActivity : ComponentActivity(), PersistMapOwner { private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service is PlayerService.Binder) { this@MainActivity.binder = service } } override fun onServiceDisconnected(name: ComponentName?) { binder = null } } private var binder by mutableStateOf(null) override lateinit var persistMap: PersistMap override fun onStart() { super.onStart() bindService(intent(), serviceConnection, Context.BIND_AUTO_CREATE) } @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @Suppress("DEPRECATION", "UNCHECKED_CAST") persistMap = lastCustomNonConfigurationInstance as? PersistMap ?: PersistMap() WindowCompat.setDecorFitsSystemWindows(window, false) val launchedFromNotification = intent?.extras?.getBoolean("expandPlayerBottomSheet") == true setContent { val coroutineScope = rememberCoroutineScope() val isSystemInDarkTheme = isSystemInDarkTheme() var appearance by rememberSaveable( isSystemInDarkTheme, stateSaver = Appearance.Companion ) { with(preferences) { val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) val colorPaletteMode = getEnum(colorPaletteModeKey, ColorPaletteMode.System) val thumbnailRoundness = getEnum(thumbnailRoundnessKey, ThumbnailRoundness.Light) val useSystemFont = getBoolean(useSystemFontKey, false) val applyFontPadding = getBoolean(applyFontPaddingKey, false) val colorPalette = colorPaletteOf(colorPaletteName, colorPaletteMode, isSystemInDarkTheme) setSystemBarAppearance(colorPalette.isDark) mutableStateOf( Appearance( colorPalette = colorPalette, typography = typographyOf(colorPalette.text, useSystemFont, applyFontPadding), thumbnailShape = thumbnailRoundness.shape() ) ) } } DisposableEffect(binder, isSystemInDarkTheme) { var bitmapListenerJob: Job? = null fun setDynamicPalette(colorPaletteMode: ColorPaletteMode) { val isDark = colorPaletteMode == ColorPaletteMode.Dark || (colorPaletteMode == ColorPaletteMode.System && isSystemInDarkTheme) binder?.setBitmapListener { bitmap: Bitmap? -> if (bitmap == null) { val colorPalette = colorPaletteOf( ColorPaletteName.Dynamic, colorPaletteMode, isSystemInDarkTheme ) setSystemBarAppearance(colorPalette.isDark) appearance = appearance.copy( colorPalette = colorPalette, typography = appearance.typography.copy(colorPalette.text) ) return@setBitmapListener } bitmapListenerJob = coroutineScope.launch(Dispatchers.IO) { dynamicColorPaletteOf(bitmap, isDark)?.let { withContext(Dispatchers.Main) { setSystemBarAppearance(it.isDark) } appearance = appearance.copy( colorPalette = it, typography = appearance.typography.copy(it.text) ) } } } } val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> when (key) { colorPaletteNameKey, colorPaletteModeKey -> { val colorPaletteName = sharedPreferences.getEnum( colorPaletteNameKey, ColorPaletteName.Dynamic ) val colorPaletteMode = sharedPreferences.getEnum( colorPaletteModeKey, ColorPaletteMode.System ) if (colorPaletteName == ColorPaletteName.Dynamic) { setDynamicPalette(colorPaletteMode) } else { bitmapListenerJob?.cancel() binder?.setBitmapListener(null) val colorPalette = colorPaletteOf( colorPaletteName, colorPaletteMode, isSystemInDarkTheme ) setSystemBarAppearance(colorPalette.isDark) appearance = appearance.copy( colorPalette = colorPalette, typography = appearance.typography.copy(colorPalette.text), ) } } thumbnailRoundnessKey -> { val thumbnailRoundness = sharedPreferences.getEnum(key, ThumbnailRoundness.Light) appearance = appearance.copy( thumbnailShape = thumbnailRoundness.shape() ) } useSystemFontKey, applyFontPaddingKey -> { val useSystemFont = sharedPreferences.getBoolean(useSystemFontKey, false) val applyFontPadding = sharedPreferences.getBoolean(applyFontPaddingKey, false) appearance = appearance.copy( typography = typographyOf(appearance.colorPalette.text, useSystemFont, applyFontPadding), ) } } } with(preferences) { registerOnSharedPreferenceChangeListener(listener) val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) if (colorPaletteName == ColorPaletteName.Dynamic) { setDynamicPalette(getEnum(colorPaletteModeKey, ColorPaletteMode.System)) } onDispose { bitmapListenerJob?.cancel() binder?.setBitmapListener(null) unregisterOnSharedPreferenceChangeListener(listener) } } } val rippleTheme = remember(appearance.colorPalette.text, appearance.colorPalette.isDark) { object : RippleTheme { @Composable override fun defaultColor(): Color = RippleTheme.defaultRippleColor( contentColor = appearance.colorPalette.text, lightTheme = !appearance.colorPalette.isDark ) @Composable override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( contentColor = appearance.colorPalette.text, lightTheme = !appearance.colorPalette.isDark ) } } val shimmerTheme = remember { defaultShimmerTheme.copy( animationSpec = infiniteRepeatable( animation = tween( durationMillis = 800, easing = LinearEasing, delayMillis = 250, ), repeatMode = RepeatMode.Restart ), shaderColors = listOf( Color.Unspecified.copy(alpha = 0.25f), Color.White.copy(alpha = 0.50f), Color.Unspecified.copy(alpha = 0.25f), ), ) } BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(appearance.colorPalette.background0) ) { val density = LocalDensity.current val windowsInsets = WindowInsets.systemBars val bottomDp = with(density) { windowsInsets.getBottom(density).toDp() } val playerBottomSheetState = rememberBottomSheetState( dismissedBound = 0.dp, collapsedBound = Dimensions.collapsedPlayer + bottomDp, expandedBound = maxHeight, ) val playerAwareWindowInsets by remember(bottomDp, playerBottomSheetState.value) { derivedStateOf { val bottom = playerBottomSheetState.value.coerceIn(bottomDp, playerBottomSheetState.collapsedBound) windowsInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) .add(WindowInsets(bottom = bottom)) } } CompositionLocalProvider( LocalAppearance provides appearance, LocalIndication provides rememberRipple(bounded = true), LocalRippleTheme provides rippleTheme, LocalShimmerTheme provides shimmerTheme, LocalPlayerServiceBinder provides binder, LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, LocalLayoutDirection provides LayoutDirection.Ltr ) { HomeScreen( onPlaylistUrl = { url -> onNewIntent(Intent.parseUri(url, 0)) } ) Player( layoutState = playerBottomSheetState, modifier = Modifier .align(Alignment.BottomCenter) ) BottomSheetMenu( state = LocalMenuState.current, modifier = Modifier .align(Alignment.BottomCenter) ) } DisposableEffect(binder?.player) { val player = binder?.player ?: return@DisposableEffect onDispose { } if (player.currentMediaItem == null) { if (!playerBottomSheetState.isDismissed) { playerBottomSheetState.dismiss() } } else { if (playerBottomSheetState.isDismissed) { if (launchedFromNotification) { intent.replaceExtras(Bundle()) playerBottomSheetState.expand(tween(700)) } else { playerBottomSheetState.collapse(tween(700)) } } } val listener = object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null) { if (mediaItem.mediaMetadata.extras?.getBoolean("isFromPersistentQueue") != true) { playerBottomSheetState.expand(tween(500)) } else { playerBottomSheetState.collapse(tween(700)) } } } } player.addListener(listener) onDispose { player.removeListener(listener) } } } } onNewIntent(intent) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) val uri = intent?.data ?: return intent.data = null this.intent = null Toast.makeText(this, "Opening url...", Toast.LENGTH_SHORT).show() lifecycleScope.launch(Dispatchers.IO) { when (val path = uri.pathSegments.firstOrNull()) { "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> val browseId = "VL$playlistId" if (playlistId.startsWith("OLAK5uy_")) { Innertube.playlistPage(BrowseBody(browseId = browseId))?.getOrNull()?.let { it.songsPage?.items?.firstOrNull()?.album?.endpoint?.browseId?.let { browseId -> albumRoute.ensureGlobal(browseId) } } } else { playlistRoute.ensureGlobal(browseId) } } "channel", "c" -> uri.lastPathSegment?.let { channelId -> artistRoute.ensureGlobal(channelId) } else -> when { path == "watch" -> uri.getQueryParameter("v") uri.host == "youtu.be" -> path else -> null }?.let { videoId -> Innertube.song(videoId)?.getOrNull()?.let { song -> val binder = snapshotFlow { binder }.filterNotNull().first() withContext(Dispatchers.Main) { binder.player.forcePlay(song.asMediaItem) } } } } } } override fun onRetainCustomNonConfigurationInstance() = persistMap override fun onStop() { unbindService(serviceConnection) super.onStop() } override fun onDestroy() { if (!isChangingConfigurations) { persistMap.clear() } super.onDestroy() } private fun setSystemBarAppearance(isDark: Boolean) { with(WindowCompat.getInsetsController(window, window.decorView.rootView)) { isAppearanceLightStatusBars = !isDark isAppearanceLightNavigationBars = !isDark } if (!isAtLeastAndroid6) { window.statusBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() } if (!isAtLeastAndroid8) { window.navigationBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() } } } val LocalPlayerServiceBinder = staticCompositionLocalOf { null } val LocalPlayerAwareWindowInsets = staticCompositionLocalOf { TODO() } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt ================================================ package it.vfsfitvnm.vimusic import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey import it.vfsfitvnm.vimusic.utils.getEnum import it.vfsfitvnm.vimusic.utils.preferences class MainApplication : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() DatabaseInitializer() } override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(this) .crossfade(true) .respectCacheHeaders(false) .diskCache( DiskCache.Builder() .directory(cacheDir.resolve("coil")) .maxSizeBytes( preferences.getEnum( coilDiskCacheMaxSizeKey, CoilDiskCacheMaxSize.`128MB` ).bytes ) .build() ) .build() } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class AlbumSortBy { Title, Year, DateAdded } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class ArtistSortBy { Name, DateAdded } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/BuiltInPlaylist.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class BuiltInPlaylist { Favorites, Offline } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/CoilDiskCacheSize.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class CoilDiskCacheMaxSize { `128MB`, `256MB`, `512MB`, `1GB`, `2GB`; val bytes: Long get() = when (this) { `128MB` -> 128 `256MB` -> 256 `512MB` -> 512 `1GB` -> 1024 `2GB` -> 2048 } * 1000 * 1000L } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteMode.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class ColorPaletteMode { Light, Dark, System } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteName.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class ColorPaletteName { Default, Dynamic, PureBlack } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ExoPlayerDiskCacheMaxSize.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class ExoPlayerDiskCacheMaxSize { `32MB`, `512MB`, `1GB`, `2GB`, `4GB`, `8GB`, Unlimited; val bytes: Long get() = when (this) { `32MB` -> 32 `512MB` -> 512 `1GB` -> 1024 `2GB` -> 2048 `4GB` -> 4096 `8GB` -> 8192 Unlimited -> 0 } * 1000 * 1000L } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/PlaylistSortBy.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class PlaylistSortBy { Name, DateAdded, SongCount } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongSortBy.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class SongSortBy { PlayTime, Title, DateAdded } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SortOrder.kt ================================================ package it.vfsfitvnm.vimusic.enums enum class SortOrder { Ascending, Descending; operator fun not() = when (this) { Ascending -> Descending Descending -> Ascending } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt ================================================ package it.vfsfitvnm.vimusic.enums import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance enum class ThumbnailRoundness { None, Light, Medium, Heavy; fun shape(): Shape { return when (this) { None -> RectangleShape Light -> RoundedCornerShape(2.dp) Medium -> RoundedCornerShape(4.dp) Heavy -> RoundedCornerShape(8.dp) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey @Immutable @Entity data class Album( @PrimaryKey val id: String, val title: String? = null, val thumbnailUrl: String? = null, val year: String? = null, val authorsText: String? = null, val shareUrl: String? = null, val timestamp: Long? = null, val bookmarkedAt: Long? = null ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey @Immutable @Entity data class Artist( @PrimaryKey val id: String, val name: String? = null, val thumbnailUrl: String? = null, val timestamp: Long? = null, val bookmarkedAt: Long? = null, ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Immutable @Entity( foreignKeys = [ ForeignKey( entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], onDelete = ForeignKey.CASCADE ) ] ) data class Event( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(index = true) val songId: String, val timestamp: Long, val playTime: Long ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Format.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Immutable @Entity( foreignKeys = [ ForeignKey( entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], onDelete = ForeignKey.CASCADE ) ] ) data class Format( @PrimaryKey val songId: String, val itag: Int? = null, val mimeType: String? = null, val bitrate: Long? = null, val contentLength: Long? = null, val lastModified: Long? = null, val loudnessDb: Float? = null ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt ================================================ package it.vfsfitvnm.vimusic.models data class Info( val id: String, val name: String? ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Immutable @Entity( foreignKeys = [ ForeignKey( entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], onDelete = ForeignKey.CASCADE, ) ] ) class Lyrics( @PrimaryKey val songId: String, val fixed: String?, val synced: String?, ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey @Immutable @Entity data class Playlist( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, val browseId: String? = null ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Embedded @Immutable data class PlaylistPreview( @Embedded val playlist: Playlist, val songCount: Int ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation @Immutable data class PlaylistWithSongs( @Embedded val playlist: Playlist, @Relation( entity = Song::class, parentColumn = "id", entityColumn = "id", associateBy = Junction( value = SortedSongPlaylistMap::class, parentColumn = "playlistId", entityColumn = "songId" ) ) val songs: List ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.media3.common.MediaItem import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Immutable @Entity class QueuedMediaItem( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(typeAffinity = ColumnInfo.BLOB) val mediaItem: MediaItem, var position: Long? ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Immutable @Entity( indices = [ Index( value = ["query"], unique = true ) ] ) data class SearchQuery( @PrimaryKey(autoGenerate = true) val id: Long = 0, val query: String ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey @Immutable @Entity data class Song( @PrimaryKey val id: String, val title: String, val artistsText: String? = null, val durationText: String?, val thumbnailUrl: String?, val likedAt: Long? = null, val totalPlayTimeMs: Long = 0 ) { val formattedTotalPlayTime: String get() { val seconds = totalPlayTimeMs / 1000 val hours = seconds / 3600 return when { hours == 0L -> "${seconds / 60}m" hours < 24L -> "${hours}h" else -> "${hours / 24}d" } } fun toggleLike(): Song { return copy( likedAt = if (likedAt == null) System.currentTimeMillis() else null ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongAlbumMap.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @Immutable @Entity( primaryKeys = ["songId", "albumId"], foreignKeys = [ ForeignKey( entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], onDelete = ForeignKey.CASCADE ), ForeignKey( entity = Album::class, parentColumns = ["id"], childColumns = ["albumId"], onDelete = ForeignKey.CASCADE ) ] ) data class SongAlbumMap( @ColumnInfo(index = true) val songId: String, @ColumnInfo(index = true) val albumId: String, val position: Int? ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongArtistMap.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @Immutable @Entity( primaryKeys = ["songId", "artistId"], foreignKeys = [ ForeignKey( entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], onDelete = ForeignKey.CASCADE ), ForeignKey( entity = Artist::class, parentColumns = ["id"], childColumns = ["artistId"], onDelete = ForeignKey.CASCADE ) ] ) data class SongArtistMap( @ColumnInfo(index = true) val songId: String, @ColumnInfo(index = true) val artistId: String ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongPlaylistMap.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @Immutable @Entity( primaryKeys = ["songId", "playlistId"], foreignKeys = [ ForeignKey( entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], onDelete = ForeignKey.CASCADE ), ForeignKey( entity = Playlist::class, parentColumns = ["id"], childColumns = ["playlistId"], onDelete = ForeignKey.CASCADE ) ] ) data class SongPlaylistMap( @ColumnInfo(index = true) val songId: String, @ColumnInfo(index = true) val playlistId: Long, val position: Int ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithContentLength.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.Embedded @Immutable data class SongWithContentLength( @Embedded val song: Song, val contentLength: Long? ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongPlaylistMap.kt ================================================ package it.vfsfitvnm.vimusic.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.DatabaseView @Immutable @DatabaseView("SELECT * FROM SongPlaylistMap ORDER BY position") data class SortedSongPlaylistMap( @ColumnInfo(index = true) val songId: String, @ColumnInfo(index = true) val playlistId: Long, val position: Int ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/service/BitmapProvider.kt ================================================ package it.vfsfitvnm.vimusic.service import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.net.Uri import androidx.core.graphics.applyCanvas import coil.imageLoader import coil.request.Disposable import coil.request.ImageRequest import it.vfsfitvnm.vimusic.utils.thumbnail context(Context) class BitmapProvider( private val bitmapSize: Int, private val colorProvider: (isSystemInDarkMode: Boolean) -> Int ) { var lastUri: Uri? = null private set var lastBitmap: Bitmap? = null private var lastIsSystemInDarkMode = false private var lastEnqueued: Disposable? = null private lateinit var defaultBitmap: Bitmap val bitmap: Bitmap get() = lastBitmap ?: defaultBitmap var listener: ((Bitmap?) -> Unit)? = null set(value) { field = value value?.invoke(lastBitmap) } init { setDefaultBitmap() } fun setDefaultBitmap(): Boolean { val isSystemInDarkMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES if (::defaultBitmap.isInitialized && isSystemInDarkMode == lastIsSystemInDarkMode) return false lastIsSystemInDarkMode = isSystemInDarkMode defaultBitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888).applyCanvas { drawColor(colorProvider(isSystemInDarkMode)) } return lastBitmap == null } fun load(uri: Uri?, onDone: (Bitmap) -> Unit) { if (lastUri == uri) return lastEnqueued?.dispose() lastUri = uri lastEnqueued = applicationContext.imageLoader.enqueue( ImageRequest.Builder(applicationContext) .data(uri.thumbnail(bitmapSize)) .allowHardware(false) .listener( onError = { _, _ -> lastBitmap = null onDone(bitmap) listener?.invoke(lastBitmap) }, onSuccess = { _, result -> lastBitmap = (result.drawable as BitmapDrawable).bitmap onDone(bitmap) listener?.invoke(lastBitmap) } ) .build() ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt ================================================ package it.vfsfitvnm.vimusic.service import androidx.media3.common.PlaybackException class PlayableFormatNotFoundException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) class UnplayableException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) class LoginRequiredException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) class VideoIdMismatchException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt ================================================ package it.vfsfitvnm.vimusic.service import android.media.MediaDescription as BrowserMediaDescription import android.media.browse.MediaBrowser.MediaItem as BrowserMediaItem import android.content.ComponentName import android.content.ContentResolver import android.content.Context import android.content.ServiceConnection import android.media.session.MediaSession import android.net.Uri import android.os.Bundle import android.os.IBinder import android.os.Process import android.service.media.MediaBrowserService import androidx.annotation.DrawableRes import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.media3.common.Player import androidx.media3.datasource.cache.Cache import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongWithContentLength import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forceSeekToNext import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious import it.vfsfitvnm.vimusic.utils.intent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val coroutineScope = CoroutineScope(Dispatchers.IO) private var lastSongs = emptyList() private var bound = false override fun onDestroy() { if (bound) { unbindService(this) } super.onDestroy() } override fun onServiceConnected(className: ComponentName, service: IBinder) { if (service is PlayerService.Binder) { bound = true sessionToken = service.mediaSession.sessionToken service.mediaSession.setCallback(SessionCallback(service.player, service.cache)) } } override fun onServiceDisconnected(name: ComponentName) = Unit override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? ): BrowserRoot? { return if (clientUid == Process.myUid() || clientUid == Process.SYSTEM_UID || clientPackageName == "com.google.android.projection.gearhead" ) { bindService(intent(), this, Context.BIND_AUTO_CREATE) BrowserRoot( MediaId.root, bundleOf("android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" to 1) ) } else { null } } override fun onLoadChildren(parentId: String, result: Result>) { runBlocking(Dispatchers.IO) { result.sendResult( when (parentId) { MediaId.root -> mutableListOf( songsBrowserMediaItem, playlistsBrowserMediaItem, albumsBrowserMediaItem ) MediaId.songs -> Database .songsByPlayTimeDesc() .first() .take(30) .also { lastSongs = it } .map { it.asBrowserMediaItem } .toMutableList() .apply { if (isNotEmpty()) add(0, shuffleBrowserMediaItem) } MediaId.playlists -> Database .playlistPreviewsByDateAddedDesc() .first() .map { it.asBrowserMediaItem } .toMutableList() .apply { add(0, favoritesBrowserMediaItem) add(1, offlineBrowserMediaItem) } MediaId.albums -> Database .albumsByRowIdDesc() .first() .map { it.asBrowserMediaItem } .toMutableList() else -> mutableListOf() } ) } } private fun uriFor(@DrawableRes id: Int) = Uri.Builder() .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) .authority(resources.getResourcePackageName(id)) .appendPath(resources.getResourceTypeName(id)) .appendPath(resources.getResourceEntryName(id)) .build() private val shuffleBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.shuffle) .setTitle("Shuffle") .setIconUri(uriFor(R.drawable.shuffle)) .build(), BrowserMediaItem.FLAG_PLAYABLE ) private val songsBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.songs) .setTitle("Songs") .setIconUri(uriFor(R.drawable.musical_notes)) .build(), BrowserMediaItem.FLAG_BROWSABLE ) private val playlistsBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.playlists) .setTitle("Playlists") .setIconUri(uriFor(R.drawable.playlist)) .build(), BrowserMediaItem.FLAG_BROWSABLE ) private val albumsBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.albums) .setTitle("Albums") .setIconUri(uriFor(R.drawable.disc)) .build(), BrowserMediaItem.FLAG_BROWSABLE ) private val favoritesBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.favorites) .setTitle("Favorites") .setIconUri(uriFor(R.drawable.heart)) .build(), BrowserMediaItem.FLAG_PLAYABLE ) private val offlineBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.offline) .setTitle("Offline") .setIconUri(uriFor(R.drawable.airplane)) .build(), BrowserMediaItem.FLAG_PLAYABLE ) private val Song.asBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.forSong(id)) .setTitle(title) .setSubtitle(artistsText) .setIconUri(thumbnailUrl?.toUri()) .build(), BrowserMediaItem.FLAG_PLAYABLE ) private val PlaylistPreview.asBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.forPlaylist(playlist.id)) .setTitle(playlist.name) .setSubtitle("$songCount songs") .setIconUri(uriFor(R.drawable.playlist)) .build(), BrowserMediaItem.FLAG_PLAYABLE ) private val Album.asBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() .setMediaId(MediaId.forAlbum(id)) .setTitle(title) .setSubtitle(authorsText) .setIconUri(thumbnailUrl?.toUri()) .build(), BrowserMediaItem.FLAG_PLAYABLE ) private inner class SessionCallback(private val player: Player, private val cache: Cache) : MediaSession.Callback() { override fun onPlay() = player.play() override fun onPause() = player.pause() override fun onSkipToPrevious() = player.forceSeekToPrevious() override fun onSkipToNext() = player.forceSeekToNext() override fun onSeekTo(pos: Long) = player.seekTo(pos) override fun onSkipToQueueItem(id: Long) = player.seekToDefaultPosition(id.toInt()) override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { val data = mediaId?.split('/') ?: return var index = 0 coroutineScope.launch { val mediaItems = when (data.getOrNull(0)) { MediaId.shuffle -> lastSongs MediaId.songs -> data .getOrNull(1) ?.let { songId -> index = lastSongs.indexOfFirst { it.id == songId } lastSongs } MediaId.favorites -> Database .favorites() .first() .shuffled() MediaId.offline -> Database .songsWithContentLength() .first() .filter { song -> song.contentLength?.let { cache.isCached(song.song.id, 0, it) } ?: false } .map(SongWithContentLength::song) .shuffled() MediaId.playlists -> data .getOrNull(1) ?.toLongOrNull() ?.let(Database::playlistWithSongs) ?.first() ?.songs ?.shuffled() MediaId.albums -> data .getOrNull(1) ?.let(Database::albumSongs) ?.first() else -> emptyList() }?.map(Song::asMediaItem) ?: return@launch withContext(Dispatchers.Main) { player.forcePlayAtIndex(mediaItems, index.coerceIn(0, mediaItems.size)) } } } } private object MediaId { const val root = "root" const val songs = "songs" const val playlists = "playlists" const val albums = "albums" const val favorites = "favorites" const val offline = "offline" const val shuffle = "shuffle" fun forSong(id: String) = "songs/$id" fun forPlaylist(id: Long) = "playlists/$id" fun forAlbum(id: String) = "albums/$id" } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt ================================================ package it.vfsfitvnm.vimusic.service import android.os.Binder as AndroidBinder import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.res.Configuration import android.database.SQLException import android.graphics.Bitmap import android.graphics.Color import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.MediaDescription import android.media.MediaMetadata import android.media.audiofx.AudioEffect import android.media.audiofx.LoudnessEnhancer import android.media.session.MediaSession import android.media.session.PlaybackState import android.net.Uri import android.os.Handler import android.text.format.DateUtils import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat.startForegroundService import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.core.text.isDigitsOnly import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.PlaybackStats import androidx.media3.exoplayer.analytics.PlaybackStatsListener import androidx.media3.exoplayer.audio.AudioRendererEventListener import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor import androidx.media3.exoplayer.audio.SonicAudioProcessor import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.extractor.ExtractorsFactory import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.mp4.FragmentedMp4Extractor import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.innertube.models.bodies.PlayerBody import it.vfsfitvnm.innertube.requests.player import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.MainActivity import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.utils.InvincibleService import it.vfsfitvnm.vimusic.utils.RingBuffer import it.vfsfitvnm.vimusic.utils.TimerJob import it.vfsfitvnm.vimusic.utils.YouTubeRadio import it.vfsfitvnm.vimusic.utils.activityPendingIntent import it.vfsfitvnm.vimusic.utils.broadCastPendingIntent import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey import it.vfsfitvnm.vimusic.utils.findNextMediaItemById import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning import it.vfsfitvnm.vimusic.utils.forceSeekToNext import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious import it.vfsfitvnm.vimusic.utils.getEnum import it.vfsfitvnm.vimusic.utils.intent import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid13 import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid8 import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey import it.vfsfitvnm.vimusic.utils.mediaItems import it.vfsfitvnm.vimusic.utils.persistentQueueKey import it.vfsfitvnm.vimusic.utils.preferences import it.vfsfitvnm.vimusic.utils.queueLoopEnabledKey import it.vfsfitvnm.vimusic.utils.resumePlaybackWhenDeviceConnectedKey import it.vfsfitvnm.vimusic.utils.shouldBePlaying import it.vfsfitvnm.vimusic.utils.skipSilenceKey import it.vfsfitvnm.vimusic.utils.timer import it.vfsfitvnm.vimusic.utils.trackLoopEnabledKey import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey import kotlin.math.roundToInt import kotlin.system.exitProcess import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking @Suppress("DEPRECATION") class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback, SharedPreferences.OnSharedPreferenceChangeListener { private lateinit var mediaSession: MediaSession private lateinit var cache: SimpleCache private lateinit var player: ExoPlayer private val stateBuilder = PlaybackState.Builder() .setActions( PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP or PlaybackState.ACTION_SKIP_TO_PREVIOUS or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_REWIND ) private val metadataBuilder = MediaMetadata.Builder() private var notificationManager: NotificationManager? = null private var timerJob: TimerJob? = null private var radio: YouTubeRadio? = null private lateinit var bitmapProvider: BitmapProvider private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() private var volumeNormalizationJob: Job? = null private var isPersistentQueueEnabled = false private var isShowingThumbnailInLockscreen = true override var isInvincibilityEnabled = false private var audioManager: AudioManager? = null private var audioDeviceCallback: AudioDeviceCallback? = null private var loudnessEnhancer: LoudnessEnhancer? = null private val binder = Binder() private var isNotificationStarted = false override val notificationId: Int get() = NotificationId private lateinit var notificationActionReceiver: NotificationActionReceiver override fun onBind(intent: Intent?): AndroidBinder { super.onBind(intent) return binder } override fun onCreate() { super.onCreate() bitmapProvider = BitmapProvider( bitmapSize = (256 * resources.displayMetrics.density).roundToInt(), colorProvider = { isSystemInDarkMode -> if (isSystemInDarkMode) Color.BLACK else Color.WHITE } ) createNotificationChannel() preferences.registerOnSharedPreferenceChangeListener(this) val preferences = preferences isPersistentQueueEnabled = preferences.getBoolean(persistentQueueKey, false) isInvincibilityEnabled = preferences.getBoolean(isInvincibilityEnabledKey, false) isShowingThumbnailInLockscreen = preferences.getBoolean(isShowingThumbnailInLockscreenKey, false) val cacheEvictor = when (val size = preferences.getEnum(exoPlayerDiskCacheMaxSizeKey, ExoPlayerDiskCacheMaxSize.`2GB`)) { ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor() else -> LeastRecentlyUsedCacheEvictor(size.bytes) } // TODO: Remove in a future release val directory = cacheDir.resolve("exoplayer").also { directory -> if (directory.exists()) return@also directory.mkdir() cacheDir.listFiles()?.forEach { file -> if (file.isDirectory && file.name.length == 1 && file.name.isDigitsOnly() || file.extension == "uid") { if (!file.renameTo(directory.resolve(file.name))) { file.deleteRecursively() } } } filesDir.resolve("coil").deleteRecursively() } cache = SimpleCache(directory, cacheEvictor, StandaloneDatabaseProvider(this)) player = ExoPlayer.Builder(this, createRendersFactory(), createMediaSourceFactory()) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_LOCAL) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build(), true ) .setUsePlatformDiagnostics(false) .build() player.repeatMode = when { preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL else -> Player.REPEAT_MODE_OFF } player.skipSilenceEnabled = preferences.getBoolean(skipSilenceKey, false) player.addListener(this) player.addAnalyticsListener(PlaybackStatsListener(false, this)) maybeRestorePlayerQueue() mediaSession = MediaSession(baseContext, "PlayerService") mediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS) mediaSession.setCallback(SessionCallback(player)) mediaSession.setPlaybackState(stateBuilder.build()) mediaSession.isActive = true notificationActionReceiver = NotificationActionReceiver(player) val filter = IntentFilter().apply { addAction(Action.play.value) addAction(Action.pause.value) addAction(Action.next.value) addAction(Action.previous.value) } registerReceiver(notificationActionReceiver, filter) maybeResumePlaybackWhenDeviceConnected() } override fun onTaskRemoved(rootIntent: Intent?) { if (!player.shouldBePlaying) { broadCastPendingIntent().send() } super.onTaskRemoved(rootIntent) } override fun onDestroy() { maybeSavePlayerQueue() preferences.unregisterOnSharedPreferenceChangeListener(this) player.removeListener(this) player.stop() player.release() unregisterReceiver(notificationActionReceiver) mediaSession.isActive = false mediaSession.release() cache.release() loudnessEnhancer?.release() super.onDestroy() } override fun shouldBeInvincible(): Boolean { return !player.shouldBePlaying } override fun onConfigurationChanged(newConfig: Configuration) { if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) { notificationManager?.notify(NotificationId, notification()) } super.onConfigurationChanged(newConfig) } override fun onPlaybackStatsReady( eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats ) { val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem val totalPlayTimeMs = playbackStats.totalPlayTimeMs if (totalPlayTimeMs > 5000) { query { Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) } } if (totalPlayTimeMs > 30000) { query { try { Database.insert( Event( songId = mediaItem.mediaId, timestamp = System.currentTimeMillis(), playTime = totalPlayTimeMs ) ) } catch (_: SQLException) { } } } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { maybeRecoverPlaybackError() maybeNormalizeVolume() maybeProcessRadio() if (mediaItem == null) { bitmapProvider.listener?.invoke(null) } else if (mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri) { bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap) } if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { updateMediaSessionQueue(player.currentTimeline) } } override fun onTimelineChanged(timeline: Timeline, reason: Int) { if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { updateMediaSessionQueue(timeline) } } private fun updateMediaSessionQueue(timeline: Timeline) { val builder = MediaDescription.Builder() val currentMediaItemIndex = player.currentMediaItemIndex val lastIndex = timeline.windowCount - 1 var startIndex = currentMediaItemIndex - 7 var endIndex = currentMediaItemIndex + 7 if (startIndex < 0) { endIndex -= startIndex } if (endIndex > lastIndex) { startIndex -= (endIndex - lastIndex) endIndex = lastIndex } startIndex = startIndex.coerceAtLeast(0) mediaSession.setQueue( List(endIndex - startIndex + 1) { index -> val mediaItem = timeline.getWindow(index + startIndex, Timeline.Window()).mediaItem MediaSession.QueueItem( builder .setMediaId(mediaItem.mediaId) .setTitle(mediaItem.mediaMetadata.title) .setSubtitle(mediaItem.mediaMetadata.artist) .setIconUri(mediaItem.mediaMetadata.artworkUri) .build(), (index + startIndex).toLong() ) } ) } private fun maybeRecoverPlaybackError() { if (player.playerError != null) { player.prepare() } } private fun maybeProcessRadio() { radio?.let { radio -> if (player.mediaItemCount - player.currentMediaItemIndex <= 3) { coroutineScope.launch(Dispatchers.Main) { player.addMediaItems(radio.process()) } } } } private fun maybeSavePlayerQueue() { if (!isPersistentQueueEnabled) return val mediaItems = player.currentTimeline.mediaItems val mediaItemIndex = player.currentMediaItemIndex val mediaItemPosition = player.currentPosition mediaItems.mapIndexed { index, mediaItem -> QueuedMediaItem( mediaItem = mediaItem, position = if (index == mediaItemIndex) mediaItemPosition else null ) }.let { queuedMediaItems -> query { Database.clearQueue() Database.insert(queuedMediaItems) } } } private fun maybeRestorePlayerQueue() { if (!isPersistentQueueEnabled) return query { val queuedSong = Database.queue() Database.clearQueue() if (queuedSong.isEmpty()) return@query val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0) runBlocking(Dispatchers.Main) { player.setMediaItems( queuedSong.map { mediaItem -> mediaItem.mediaItem.buildUpon() .setUri(mediaItem.mediaItem.mediaId) .setCustomCacheKey(mediaItem.mediaItem.mediaId) .build().apply { mediaMetadata.extras?.putBoolean("isFromPersistentQueue", true) } }, index, queuedSong[index].position ?: C.TIME_UNSET ) player.prepare() isNotificationStarted = true startForegroundService(this@PlayerService, intent()) startForeground(NotificationId, notification()) } } } private fun maybeNormalizeVolume() { if (!preferences.getBoolean(volumeNormalizationKey, false)) { loudnessEnhancer?.enabled = false loudnessEnhancer?.release() loudnessEnhancer = null volumeNormalizationJob?.cancel() player.volume = 1f return } if (loudnessEnhancer == null) { loudnessEnhancer = LoudnessEnhancer(player.audioSessionId) } player.currentMediaItem?.mediaId?.let { songId -> volumeNormalizationJob?.cancel() volumeNormalizationJob = coroutineScope.launch(Dispatchers.Main) { Database.loudnessDb(songId).cancellable().collectLatest { loudnessDb -> try { loudnessEnhancer?.setTargetGain(-((loudnessDb ?: 0f) * 100).toInt() + 500) loudnessEnhancer?.enabled = true } catch (_: Exception) { } } } } } private fun maybeShowSongCoverInLockScreen() { val bitmap = if (isAtLeastAndroid13 || isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap) if (isAtLeastAndroid13 && player.currentMediaItemIndex == 0) { metadataBuilder.putText( MediaMetadata.METADATA_KEY_TITLE, "${player.mediaMetadata.title} " ) } mediaSession.setMetadata(metadataBuilder.build()) } @SuppressLint("NewApi") private fun maybeResumePlaybackWhenDeviceConnected() { if (!isAtLeastAndroid6) return if (preferences.getBoolean(resumePlaybackWhenDeviceConnectedKey, false)) { if (audioManager == null) { audioManager = getSystemService(AUDIO_SERVICE) as AudioManager? } audioDeviceCallback = object : AudioDeviceCallback() { private fun canPlayMusic(audioDeviceInfo: AudioDeviceInfo): Boolean { if (!audioDeviceInfo.isSink) return false return audioDeviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || audioDeviceInfo.type == AudioDeviceInfo.TYPE_USB_HEADSET } override fun onAudioDevicesAdded(addedDevices: Array) { if (!player.isPlaying && addedDevices.any(::canPlayMusic)) { player.play() } } override fun onAudioDevicesRemoved(removedDevices: Array) = Unit } audioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler) } else { audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback) audioDeviceCallback = null } } private fun sendOpenEqualizerIntent() { sendBroadcast( Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) } ) } private fun sendCloseEqualizerIntent() { sendBroadcast( Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) } ) } private val Player.androidPlaybackState: Int get() = when (playbackState) { Player.STATE_BUFFERING -> if (playWhenReady) PlaybackState.STATE_BUFFERING else PlaybackState.STATE_PAUSED Player.STATE_READY -> if (playWhenReady) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED Player.STATE_ENDED -> PlaybackState.STATE_STOPPED Player.STATE_IDLE -> PlaybackState.STATE_NONE else -> PlaybackState.STATE_NONE } override fun onEvents(player: Player, events: Player.Events) { if (player.duration != C.TIME_UNSET) { mediaSession.setMetadata( metadataBuilder .putText(MediaMetadata.METADATA_KEY_TITLE, player.mediaMetadata.title) .putText(MediaMetadata.METADATA_KEY_ARTIST, player.mediaMetadata.artist) .putText(MediaMetadata.METADATA_KEY_ALBUM, player.mediaMetadata.albumTitle) .putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration) .build() ) } stateBuilder .setState(player.androidPlaybackState, player.currentPosition, 1f) .setBufferedPosition(player.bufferedPosition) mediaSession.setPlaybackState(stateBuilder.build()) if (events.containsAny( Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY ) ) { val notification = notification() if (notification == null) { isNotificationStarted = false makeInvincible(false) stopForeground(false) sendCloseEqualizerIntent() notificationManager?.cancel(NotificationId) return } if (player.shouldBePlaying && !isNotificationStarted) { isNotificationStarted = true startForegroundService(this@PlayerService, intent()) startForeground(NotificationId, notification) makeInvincible(false) sendOpenEqualizerIntent() } else { if (!player.shouldBePlaying) { isNotificationStarted = false stopForeground(false) makeInvincible(true) sendCloseEqualizerIntent() } notificationManager?.notify(NotificationId, notification) } } } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { when (key) { persistentQueueKey -> isPersistentQueueEnabled = sharedPreferences.getBoolean(key, isPersistentQueueEnabled) volumeNormalizationKey -> maybeNormalizeVolume() resumePlaybackWhenDeviceConnectedKey -> maybeResumePlaybackWhenDeviceConnected() isInvincibilityEnabledKey -> isInvincibilityEnabled = sharedPreferences.getBoolean(key, isInvincibilityEnabled) skipSilenceKey -> player.skipSilenceEnabled = sharedPreferences.getBoolean(key, false) isShowingThumbnailInLockscreenKey -> { isShowingThumbnailInLockscreen = sharedPreferences.getBoolean(key, true) maybeShowSongCoverInLockScreen() } trackLoopEnabledKey, queueLoopEnabledKey -> { player.repeatMode = when { preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL else -> Player.REPEAT_MODE_OFF } } } } override fun notification(): Notification? { if (player.currentMediaItem == null) return null val playIntent = Action.play.pendingIntent val pauseIntent = Action.pause.pendingIntent val nextIntent = Action.next.pendingIntent val prevIntent = Action.previous.pendingIntent val mediaMetadata = player.mediaMetadata val builder = if (isAtLeastAndroid8) { Notification.Builder(applicationContext, NotificationChannelId) } else { Notification.Builder(applicationContext) } .setContentTitle(mediaMetadata.title) .setContentText(mediaMetadata.artist) .setSubText(player.playerError?.message) .setLargeIcon(bitmapProvider.bitmap) .setAutoCancel(false) .setOnlyAlertOnce(true) .setShowWhen(false) .setSmallIcon(player.playerError?.let { R.drawable.alert_circle } ?: R.drawable.app_icon) .setOngoing(false) .setContentIntent(activityPendingIntent( flags = PendingIntent.FLAG_UPDATE_CURRENT ) { putExtra("expandPlayerBottomSheet", true) }) .setDeleteIntent(broadCastPendingIntent()) .setVisibility(Notification.VISIBILITY_PUBLIC) .setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setStyle( Notification.MediaStyle() .setShowActionsInCompactView(0, 1, 2) .setMediaSession(mediaSession.sessionToken) ) .addAction(R.drawable.play_skip_back, "Skip back", prevIntent) .addAction( if (player.shouldBePlaying) R.drawable.pause else R.drawable.play, if (player.shouldBePlaying) "Pause" else "Play", if (player.shouldBePlaying) pauseIntent else playIntent ) .addAction(R.drawable.play_skip_forward, "Skip forward", nextIntent) bitmapProvider.load(mediaMetadata.artworkUri) { bitmap -> maybeShowSongCoverInLockScreen() notificationManager?.notify(NotificationId, builder.setLargeIcon(bitmap).build()) } return builder.build() } private fun createNotificationChannel() { notificationManager = getSystemService() if (!isAtLeastAndroid8) return notificationManager?.run { if (getNotificationChannel(NotificationChannelId) == null) { createNotificationChannel( NotificationChannel( NotificationChannelId, "Now playing", NotificationManager.IMPORTANCE_LOW ).apply { setSound(null, null) enableLights(false) enableVibration(false) } ) } if (getNotificationChannel(SleepTimerNotificationChannelId) == null) { createNotificationChannel( NotificationChannel( SleepTimerNotificationChannelId, "Sleep timer", NotificationManager.IMPORTANCE_LOW ).apply { setSound(null, null) enableLights(false) enableVibration(false) } ) } } } private fun createCacheDataSource(): DataSource.Factory { return CacheDataSource.Factory().setCache(cache).apply { setUpstreamDataSourceFactory( DefaultHttpDataSource.Factory() .setConnectTimeoutMs(16000) .setReadTimeoutMs(8000) .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") ) } } private fun createDataSourceFactory(): DataSource.Factory { val chunkLength = 512 * 1024L val ringBuffer = RingBuffer?>(2) { null } return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> val videoId = dataSpec.key ?: error("A key must be set") if (cache.isCached(videoId, dataSpec.position, chunkLength)) { dataSpec } else { when (videoId) { ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) else -> { val urlResult = runBlocking(Dispatchers.IO) { Innertube.player(PlayerBody(videoId = videoId)) }?.mapCatching { body -> if (body.videoDetails?.videoId != videoId) { throw VideoIdMismatchException() } when (val status = body.playabilityStatus?.status) { "OK" -> body.streamingData?.highestQualityFormat?.let { format -> val mediaItem = runBlocking(Dispatchers.Main) { player.findNextMediaItemById(videoId) } if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) { format.approxDurationMs?.div(1000) ?.let(DateUtils::formatElapsedTime)?.removePrefix("0") ?.let { durationText -> mediaItem?.mediaMetadata?.extras?.putString( "durationText", durationText ) Database.updateDurationText(videoId, durationText) } } query { mediaItem?.let(Database::insert) Database.insert( it.vfsfitvnm.vimusic.models.Format( songId = videoId, itag = format.itag, mimeType = format.mimeType, bitrate = format.bitrate, loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, contentLength = format.contentLength, lastModified = format.lastModified ) ) } format.url } ?: throw PlayableFormatNotFoundException() "UNPLAYABLE" -> throw UnplayableException() "LOGIN_REQUIRED" -> throw LoginRequiredException() else -> throw PlaybackException( status, null, PlaybackException.ERROR_CODE_REMOTE_ERROR ) } } urlResult?.getOrThrow()?.let { url -> ringBuffer.append(videoId to url.toUri()) dataSpec.withUri(url.toUri()) .subrange(dataSpec.uriPositionOffset, chunkLength) } ?: throw PlaybackException( null, urlResult?.exceptionOrNull(), PlaybackException.ERROR_CODE_REMOTE_ERROR ) } } } } } private fun createMediaSourceFactory(): MediaSource.Factory { return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) } private fun createExtractorsFactory(): ExtractorsFactory { return ExtractorsFactory { arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) } } private fun createRendersFactory(): RenderersFactory { val audioSink = DefaultAudioSink.Builder() .setEnableFloatOutput(false) .setEnableAudioTrackPlaybackParams(false) .setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED) .setAudioProcessorChain( DefaultAudioProcessorChain( emptyArray(), SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), SonicAudioProcessor() ) ) .build() return RenderersFactory { handler: Handler?, _, audioListener: AudioRendererEventListener?, _, _ -> arrayOf( MediaCodecAudioRenderer( this, MediaCodecSelector.DEFAULT, handler, audioListener, audioSink ) ) } } inner class Binder : AndroidBinder() { val player: ExoPlayer get() = this@PlayerService.player val cache: Cache get() = this@PlayerService.cache val mediaSession get() = this@PlayerService.mediaSession val sleepTimerMillisLeft: StateFlow? get() = timerJob?.millisLeft private var radioJob: Job? = null var isLoadingRadio by mutableStateOf(false) private set fun setBitmapListener(listener: ((Bitmap?) -> Unit)?) { bitmapProvider.listener = listener } fun startSleepTimer(delayMillis: Long) { timerJob?.cancel() timerJob = coroutineScope.timer(delayMillis) { val notification = NotificationCompat .Builder(this@PlayerService, SleepTimerNotificationChannelId) .setContentTitle("Sleep timer ended") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setOnlyAlertOnce(true) .setShowWhen(true) .setSmallIcon(R.drawable.app_icon) .build() notificationManager?.notify(SleepTimerNotificationId, notification) stopSelf() exitProcess(0) } } fun cancelSleepTimer() { timerJob?.cancel() timerJob = null } fun setupRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) = startRadio(endpoint = endpoint, justAdd = true) fun playRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) = startRadio(endpoint = endpoint, justAdd = false) private fun startRadio(endpoint: NavigationEndpoint.Endpoint.Watch?, justAdd: Boolean) { radioJob?.cancel() radio = null YouTubeRadio( endpoint?.videoId, endpoint?.playlistId, endpoint?.playlistSetVideoId, endpoint?.params ).let { isLoadingRadio = true radioJob = coroutineScope.launch(Dispatchers.Main) { if (justAdd) { player.addMediaItems(it.process().drop(1)) } else { player.forcePlayFromBeginning(it.process()) } radio = it isLoadingRadio = false } } } fun stopRadio() { isLoadingRadio = false radioJob?.cancel() radio = null } } private class SessionCallback(private val player: Player) : MediaSession.Callback() { override fun onPlay() = player.play() override fun onPause() = player.pause() override fun onSkipToPrevious() = runCatching(player::forceSeekToPrevious).let { } override fun onSkipToNext() = runCatching(player::forceSeekToNext).let { } override fun onSeekTo(pos: Long) = player.seekTo(pos) override fun onStop() = player.pause() override fun onRewind() = player.seekToDefaultPosition() override fun onSkipToQueueItem(id: Long) = runCatching { player.seekToDefaultPosition(id.toInt()) }.let { } } private class NotificationActionReceiver(private val player: Player) : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { Action.pause.value -> player.pause() Action.play.value -> player.play() Action.next.value -> player.forceSeekToNext() Action.previous.value -> player.forceSeekToPrevious() } } } class NotificationDismissReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { context.stopService(context.intent()) } } @JvmInline private value class Action(val value: String) { context(Context) val pendingIntent: PendingIntent get() = PendingIntent.getBroadcast( this@Context, 100, Intent(value).setPackage(packageName), PendingIntent.FLAG_UPDATE_CURRENT.or(if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) ) companion object { val pause = Action("it.vfsfitvnm.vimusic.pause") val play = Action("it.vfsfitvnm.vimusic.play") val next = Action("it.vfsfitvnm.vimusic.next") val previous = Action("it.vfsfitvnm.vimusic.previous") } } private companion object { const val NotificationId = 1001 const val NotificationChannelId = "default_channel_id" const val SleepTimerNotificationId = 1002 const val SleepTimerNotificationChannelId = "sleep_timer_channel_id" } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt ================================================ package it.vfsfitvnm.vimusic.ui.components import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.DraggableState import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Composable fun BottomSheet( state: BottomSheetState, modifier: Modifier = Modifier, onDismiss: (() -> Unit)? = null, collapsedContent: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier .offset { val y = (state.expandedBound - state.value) .roundToPx() .coerceAtLeast(0) IntOffset(x = 0, y = y) } .pointerInput(state) { val velocityTracker = VelocityTracker() detectVerticalDragGestures( onVerticalDrag = { change, dragAmount -> velocityTracker.addPointerInputChange(change) state.dispatchRawDelta(dragAmount) }, onDragCancel = { velocityTracker.resetTracking() state.snapTo(state.collapsedBound) }, onDragEnd = { val velocity = -velocityTracker.calculateVelocity().y velocityTracker.resetTracking() state.performFling(velocity, onDismiss) } ) } .fillMaxSize() ) { if (!state.isCollapsed) { BackHandler(onBack = state::collapseSoft) content() } if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { Box( modifier = Modifier .graphicsLayer { alpha = 1f - (state.progress * 16).coerceAtMost(1f) } .clickable(onClick = state::expandSoft) .fillMaxWidth() .height(state.collapsedBound), content = collapsedContent ) } } } @Stable class BottomSheetState( draggableState: DraggableState, private val coroutineScope: CoroutineScope, private val animatable: Animatable, private val onAnchorChanged: (Int) -> Unit, val collapsedBound: Dp, ) : DraggableState by draggableState { val dismissedBound: Dp get() = animatable.lowerBound!! val expandedBound: Dp get() = animatable.upperBound!! val value by animatable.asState() val isDismissed by derivedStateOf { value == animatable.lowerBound!! } val isCollapsed by derivedStateOf { value == collapsedBound } val isExpanded by derivedStateOf { value == animatable.upperBound } val progress by derivedStateOf { 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound) } fun collapse(animationSpec: AnimationSpec) { onAnchorChanged(collapsedAnchor) coroutineScope.launch { animatable.animateTo(collapsedBound, animationSpec) } } fun expand(animationSpec: AnimationSpec) { onAnchorChanged(expandedAnchor) coroutineScope.launch { animatable.animateTo(animatable.upperBound!!, animationSpec) } } private fun collapse() { collapse(SpringSpec()) } private fun expand() { expand(SpringSpec()) } fun collapseSoft() { collapse(tween(300)) } fun expandSoft() { expand(tween(300)) } fun dismiss() { onAnchorChanged(dismissedAnchor) coroutineScope.launch { animatable.animateTo(animatable.lowerBound!!) } } fun snapTo(value: Dp) { coroutineScope.launch { animatable.snapTo(value) } } fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { if (velocity > 250) { expand() } else if (velocity < -250) { if (value < collapsedBound && onDismiss != null) { dismiss() onDismiss.invoke() } else { collapse() } } else { val l0 = dismissedBound val l1 = (collapsedBound - dismissedBound) / 2 val l2 = (expandedBound - collapsedBound) / 2 val l3 = expandedBound when (value) { in l0..l1 -> { if (onDismiss != null) { dismiss() onDismiss.invoke() } else { collapse() } } in l1..l2 -> collapse() in l2..l3 -> expand() else -> Unit } } } val preUpPostDownNestedScrollConnection get() = object : NestedScrollConnection { var isTopReached = false override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (isExpanded && available.y < 0) { isTopReached = false } return if (isTopReached && available.y < 0 && source == NestedScrollSource.Drag) { dispatchRawDelta(available.y) available } else { Offset.Zero } } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { if (!isTopReached) { isTopReached = consumed.y == 0f && available.y > 0 } return if (isTopReached && source == NestedScrollSource.Drag) { dispatchRawDelta(available.y) available } else { Offset.Zero } } override suspend fun onPreFling(available: Velocity): Velocity { return if (isTopReached) { val velocity = -available.y performFling(velocity, null) available } else { Velocity.Zero } } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { isTopReached = false return Velocity.Zero } } } const val expandedAnchor = 2 const val collapsedAnchor = 1 const val dismissedAnchor = 0 @Composable fun rememberBottomSheetState( dismissedBound: Dp, expandedBound: Dp, collapsedBound: Dp = dismissedBound, initialAnchor: Int = dismissedAnchor ): BottomSheetState { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() var previousAnchor by rememberSaveable { mutableStateOf(initialAnchor) } return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { val initialValue = when (previousAnchor) { expandedAnchor -> expandedBound collapsedAnchor -> collapsedBound dismissedAnchor -> dismissedBound else -> error("Unknown BottomSheet anchor") } val animatable = Animatable(initialValue, Dp.VectorConverter).also { it.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) } BottomSheetState( draggableState = DraggableState { delta -> coroutineScope.launch { animatable.snapTo(animatable.value - with(density) { delta.toDp() }) } }, onAnchorChanged = { previousAnchor = it }, coroutineScope = coroutineScope, animatable = animatable, collapsedBound = collapsedBound ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt ================================================ package it.vfsfitvnm.vimusic.ui.components import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput val LocalMenuState = staticCompositionLocalOf { MenuState() } @Stable class MenuState { var isDisplayed by mutableStateOf(false) private set var content by mutableStateOf<@Composable () -> Unit>({}) private set fun display(content: @Composable () -> Unit) { this.content = content isDisplayed = true } fun hide() { isDisplayed = false } } @Composable fun BottomSheetMenu( state: MenuState, modifier: Modifier = Modifier ) { AnimatedVisibility( visible = state.isDisplayed, enter = fadeIn(), exit = fadeOut() ) { BackHandler(onBack = state::hide) Spacer( modifier = Modifier .pointerInput(Unit) { detectTapGestures { state.hide() } } .background(Color.Black.copy(alpha = 0.5f)) .fillMaxSize() ) } AnimatedVisibility( visible = state.isDisplayed, enter = slideInVertically { it }, exit = slideOutVertically { it }, modifier = modifier ) { state.content() } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt ================================================ package it.vfsfitvnm.vimusic.ui.components import androidx.compose.animation.core.Animatable import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @Composable fun MusicBars( color: Color, modifier: Modifier = Modifier, barWidth: Dp = 4.dp, cornerRadius: Dp = 16.dp ) { val animatablesWithSteps = remember { listOf( Animatable(0f) to listOf( 0.2f, 0.8f, 0.1f, 0.1f, 0.3f, 0.1f, 0.2f, 0.8f, 0.7f, 0.2f, 0.4f, 0.9f, 0.7f, 0.6f, 0.1f, 0.3f, 0.1f, 0.4f, 0.1f, 0.8f, 0.7f, 0.9f, 0.5f, 0.6f, 0.3f, 0.1f ), Animatable(0f) to listOf( 0.2f, 0.5f, 1.0f, 0.5f, 0.3f, 0.1f, 0.2f, 0.3f, 0.5f, 0.1f, 0.6f, 0.5f, 0.3f, 0.7f, 0.8f, 0.9f, 0.3f, 0.1f, 0.5f, 0.3f, 0.6f, 1.0f, 0.6f, 0.7f, 0.4f, 0.1f ), Animatable(0f) to listOf( 0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.5f, 0.6f, 0.7f, 0.2f, 0.3f, 0.1f, 0.5f, 0.4f, 0.6f, 0.7f, 0.1f, 0.4f, 0.3f, 0.1f, 0.4f, 0.3f, 0.7f ) ) } LaunchedEffect(Unit) { animatablesWithSteps.forEach { (animatable, steps) -> launch { while (true) { steps.forEach { step -> animatable.animateTo(step) } } } } } Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom, modifier = modifier ) { animatablesWithSteps.forEach { (animatable) -> Canvas( modifier = Modifier .fillMaxHeight() .width(barWidth) ) { drawRoundRect( color = color, topLeft = Offset(x = 0f, y = size.height * (1 - animatable.value)), size = size.copy(height = animatable.value * size.height), cornerRadius = CornerRadius(cornerRadius.toPx()) ) } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/SeekBar.kt ================================================ package it.vfsfitvnm.vimusic.ui.components import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.roundToLong @Composable fun SeekBar( value: Long, minimumValue: Long, maximumValue: Long, onDragStart: (Long) -> Unit, onDrag: (Long) -> Unit, onDragEnd: () -> Unit, color: Color, backgroundColor: Color, modifier: Modifier = Modifier, barHeight: Dp = 3.dp, scrubberColor: Color = color, scrubberRadius: Dp = 6.dp, shape: Shape = RectangleShape, drawSteps: Boolean = false, ) { val isDragging = remember { MutableTransitionState(false) } val transition = updateTransition(transitionState = isDragging, label = null) val currentBarHeight by transition.animateDp(label = "") { if (it) scrubberRadius else barHeight } val currentScrubberRadius by transition.animateDp(label = "") { if (it) 0.dp else scrubberRadius } Box( modifier = modifier .pointerInput(minimumValue, maximumValue) { if (maximumValue < minimumValue) return@pointerInput var acc = 0f detectHorizontalDragGestures( onDragStart = { isDragging.targetState = true }, onHorizontalDrag = { _, delta -> acc += delta / size.width * (maximumValue - minimumValue) if (acc !in -1f..1f) { onDrag(acc.toLong()) acc -= acc.toLong() } }, onDragEnd = { isDragging.targetState = false acc = 0f onDragEnd() }, onDragCancel = { isDragging.targetState = false acc = 0f onDragEnd() } ) } .pointerInput(minimumValue, maximumValue) { if (maximumValue < minimumValue) return@pointerInput detectTapGestures( onPress = { offset -> onDragStart((offset.x / size.width * (maximumValue - minimumValue) + minimumValue).roundToLong()) }, onTap = { onDragEnd() } ) } .padding(horizontal = scrubberRadius) .drawWithContent { drawContent() val scrubberPosition = if (maximumValue < minimumValue) { 0f } else { (value.toFloat() - minimumValue) / (maximumValue - minimumValue) * size.width } drawCircle( color = scrubberColor, radius = currentScrubberRadius.toPx(), center = center.copy(x = scrubberPosition) ) if (drawSteps) { for (i in value + 1..maximumValue) { val stepPosition = (i.toFloat() - minimumValue) / (maximumValue - minimumValue) * size.width drawCircle( color = scrubberColor, radius = scrubberRadius.toPx() / 2, center = center.copy(x = stepPosition), ) } } } .height(scrubberRadius) ) { Spacer( modifier = Modifier .height(currentBarHeight) .fillMaxWidth() .background(color = backgroundColor, shape = shape) .align(Alignment.Center) ) Spacer( modifier = Modifier .height(currentBarHeight) .fillMaxWidth((value.toFloat() - minimumValue) / (maximumValue - minimumValue)) .background(color = color, shape = shape) .align(Alignment.CenterStart) ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt ================================================ package it.vfsfitvnm.vimusic.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import com.valentinilk.shimmer.shimmer @Composable fun ShimmerHost( modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalArrangement: Arrangement.Vertical = Arrangement.Top, content: @Composable ColumnScope.() -> Unit ) { Column( horizontalAlignment = horizontalAlignment, verticalArrangement = verticalArrangement, modifier = modifier .shimmer() .graphicsLayer(alpha = 0.99f) .drawWithContent { drawContent() drawRect( brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), blendMode = BlendMode.DstIn ) }, content = content ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.drawCircle import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import kotlinx.coroutines.delay @Composable fun TextFieldDialog( hintText: String, onDismiss: () -> Unit, onDone: (String) -> Unit, modifier: Modifier = Modifier, cancelText: String = "Cancel", doneText: String = "Done", initialTextInput: String = "", singleLine: Boolean = true, maxLines: Int = 1, onCancel: () -> Unit = onDismiss, isTextInputValid: (String) -> Boolean = { it.isNotEmpty() } ) { val focusRequester = remember { FocusRequester() } val (colorPalette, typography) = LocalAppearance.current var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) { mutableStateOf( TextFieldValue( text = initialTextInput, selection = TextRange(initialTextInput.length) ) ) } DefaultDialog( onDismiss = onDismiss, modifier = modifier ) { BasicTextField( value = textFieldValue, onValueChange = { textFieldValue = it }, textStyle = typography.xs.semiBold.center, singleLine = singleLine, maxLines = maxLines, keyboardOptions = KeyboardOptions(imeAction = if (singleLine) ImeAction.Done else ImeAction.None), keyboardActions = KeyboardActions( onDone = { if (isTextInputValid(textFieldValue.text)) { onDismiss() onDone(textFieldValue.text) } } ), cursorBrush = SolidColor(colorPalette.text), decorationBox = { innerTextField -> Box( contentAlignment = Alignment.Center, modifier = Modifier .weight(1f) ) { androidx.compose.animation.AnimatedVisibility( visible = textFieldValue.text.isEmpty(), enter = fadeIn(tween(100)), exit = fadeOut(tween(100)), ) { BasicText( text = hintText, maxLines = 1, overflow = TextOverflow.Ellipsis, style = typography.xs.semiBold.secondary, ) } innerTextField() } }, modifier = Modifier .padding(all = 16.dp) .weight(weight = 1f, fill = false) .focusRequester(focusRequester) ) Row( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier .fillMaxWidth() ) { DialogTextButton( text = cancelText, onClick = onCancel ) DialogTextButton( primary = true, text = doneText, onClick = { if (isTextInputValid(textFieldValue.text)) { onDismiss() onDone(textFieldValue.text) } } ) } } LaunchedEffect(Unit) { delay(300) focusRequester.requestFocus() } } @Composable fun ConfirmationDialog( text: String, onDismiss: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, cancelText: String = "Cancel", confirmText: String = "Confirm", onCancel: () -> Unit = onDismiss ) { val (_, typography) = LocalAppearance.current DefaultDialog( onDismiss = onDismiss, modifier = modifier ) { BasicText( text = text, style = typography.xs.medium.center, modifier = Modifier .padding(all = 16.dp) ) Row( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier .fillMaxWidth() ) { DialogTextButton( text = cancelText, onClick = onCancel ) DialogTextButton( text = confirmText, primary = true, onClick = { onConfirm() onDismiss() } ) } } } @OptIn(ExperimentalComposeUiApi::class) @Composable inline fun DefaultDialog( noinline onDismiss: () -> Unit, modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, crossinline content: @Composable ColumnScope.() -> Unit ) { val (colorPalette) = LocalAppearance.current Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { Column( horizontalAlignment = horizontalAlignment, modifier = modifier .padding(all = 48.dp) .background( color = colorPalette.background1, shape = RoundedCornerShape(8.dp) ) .padding(horizontal = 24.dp, vertical = 16.dp), content = content ) } } @Composable inline fun ValueSelectorDialog( noinline onDismiss: () -> Unit, title: String, selectedValue: T, values: List, crossinline onValueSelected: (T) -> Unit, modifier: Modifier = Modifier, crossinline valueText: (T) -> String = { it.toString() } ) { val (colorPalette, typography) = LocalAppearance.current Dialog(onDismissRequest = onDismiss) { Column( modifier = modifier .padding(all = 48.dp) .background(color = colorPalette.background1, shape = RoundedCornerShape(8.dp)) .padding(vertical = 16.dp), ) { BasicText( text = title, style = typography.s.semiBold, modifier = Modifier .padding(vertical = 8.dp, horizontal = 24.dp) ) Column( modifier = Modifier .verticalScroll(rememberScrollState()) ) { values.forEach { value -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .clickable( onClick = { onDismiss() onValueSelected(value) } ) .padding(vertical = 12.dp, horizontal = 24.dp) .fillMaxWidth() ) { if (selectedValue == value) { Canvas( modifier = Modifier .size(18.dp) .background( color = colorPalette.accent, shape = CircleShape ) ) { drawCircle( color = colorPalette.onAccent, radius = 4.dp.toPx(), center = size.center, shadow = Shadow( color = Color.Black.copy(alpha = 0.4f), blurRadius = 4.dp.toPx(), offset = Offset(x = 0f, y = 1.dp.toPx()) ) ) } } else { Spacer( modifier = Modifier .size(18.dp) .border( width = 1.dp, color = colorPalette.textDisabled, shape = CircleShape ) ) } BasicText( text = valueText(value), style = typography.xs.medium ) } } } Box( modifier = Modifier .align(Alignment.End) .padding(end = 24.dp) ) { DialogTextButton( text = "Cancel", onClick = onDismiss, modifier = Modifier ) } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.medium @Composable fun DialogTextButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, primary: Boolean = false, ) { val (colorPalette, typography) = LocalAppearance.current val textColor = when { !enabled -> colorPalette.textDisabled primary -> colorPalette.onAccent else -> colorPalette.text } BasicText( text = text, style = typography.xs.medium.color(textColor), modifier = modifier .clip(RoundedCornerShape(36.dp)) .background(if (primary) colorPalette.accent else Color.Transparent) .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 20.dp, vertical = 16.dp) ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.utils.ScrollingInfo import it.vfsfitvnm.vimusic.utils.scrollingInfo import it.vfsfitvnm.vimusic.utils.smoothScrollToTop import kotlinx.coroutines.launch @ExperimentalAnimationApi @Composable fun BoxScope.FloatingActionsContainerWithScrollToTop( lazyGridState: LazyGridState, modifier: Modifier = Modifier, visible: Boolean = true, iconId: Int? = null, onClick: (() -> Unit)? = null, windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current ) { val transitionState = remember { MutableTransitionState(ScrollingInfo()) }.apply { targetState = if (visible) lazyGridState.scrollingInfo() else null } FloatingActions( transitionState = transitionState, onScrollToTop = lazyGridState::smoothScrollToTop, iconId = iconId, onClick = onClick, windowInsets = windowInsets, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun BoxScope.FloatingActionsContainerWithScrollToTop( lazyListState: LazyListState, modifier: Modifier = Modifier, visible: Boolean = true, iconId: Int? = null, onClick: (() -> Unit)? = null, windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current ) { val transitionState = remember { MutableTransitionState(ScrollingInfo()) }.apply { targetState = if (visible) lazyListState.scrollingInfo() else null } FloatingActions( transitionState = transitionState, onScrollToTop = lazyListState::smoothScrollToTop, iconId = iconId, onClick = onClick, windowInsets = windowInsets, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun BoxScope.FloatingActionsContainerWithScrollToTop( scrollState: ScrollState, modifier: Modifier = Modifier, visible: Boolean = true, iconId: Int? = null, onClick: (() -> Unit)? = null, windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current ) { val transitionState = remember { MutableTransitionState(ScrollingInfo()) }.apply { targetState = if (visible) scrollState.scrollingInfo() else null } FloatingActions( transitionState = transitionState, iconId = iconId, onClick = onClick, windowInsets = windowInsets, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun BoxScope.FloatingActions( transitionState: MutableTransitionState, windowInsets: WindowInsets, modifier: Modifier = Modifier, onScrollToTop: (suspend () -> Unit)? = null, iconId: Int? = null, onClick: (() -> Unit)? = null ) { val transition = updateTransition(transitionState, "") val bottomPaddingValues = windowInsets.only(WindowInsetsSides.Bottom).asPaddingValues() Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Bottom, modifier = modifier .align(Alignment.BottomEnd) .padding(end = 16.dp) .padding(windowInsets.only(WindowInsetsSides.End).asPaddingValues()) ) { onScrollToTop?.let { transition.AnimatedVisibility( visible = { it?.isScrollingDown == false && it.isFar }, enter = slideInVertically(tween(500, if (iconId == null) 0 else 100)) { it }, exit = slideOutVertically(tween(500, 0)) { it }, ) { val coroutineScope = rememberCoroutineScope() SecondaryButton( onClick = { coroutineScope.launch { onScrollToTop() } }, enabled = transition.targetState?.isScrollingDown == false && transition.targetState?.isFar == true, iconId = R.drawable.chevron_up, modifier = Modifier .padding(bottom = 16.dp) .padding(bottomPaddingValues) ) } } iconId?.let { onClick?.let { transition.AnimatedVisibility( visible = { it?.isScrollingDown == false }, enter = slideInVertically(tween(500, 0)) { it }, exit = slideOutVertically(tween(500, 100)) { it }, ) { PrimaryButton( iconId = iconId, onClick = onClick, enabled = transition.targetState?.isScrollingDown == false, modifier = Modifier .padding(bottom = 16.dp) .padding(bottomPaddingValues) ) } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.medium import kotlin.random.Random @Composable fun Header( title: String, modifier: Modifier = Modifier, actionsContent: @Composable RowScope.() -> Unit = {}, ) { val typography = LocalAppearance.current.typography Header( modifier = modifier, titleContent = { BasicText( text = title, style = typography.xxl.medium, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, actionsContent = actionsContent ) } @Composable fun Header( modifier: Modifier = Modifier, titleContent: @Composable () -> Unit, actionsContent: @Composable RowScope.() -> Unit, ) { Box( contentAlignment = Alignment.CenterEnd, modifier = modifier .padding(horizontal = 16.dp) .height(Dimensions.headerHeight) .fillMaxWidth() ) { titleContent() Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .align(Alignment.BottomEnd) .heightIn(min = 48.dp), content = actionsContent, ) } } @Composable fun HeaderPlaceholder( modifier: Modifier = Modifier, ) { val (colorPalette, typography) = LocalAppearance.current Box( contentAlignment = Alignment.CenterEnd, modifier = modifier .padding(horizontal = 16.dp) .height(Dimensions.headerHeight) .fillMaxWidth() ) { Box( modifier = Modifier .background(colorPalette.shimmer) .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) ) { BasicText( text = "", style = typography.xxl.medium, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.Indication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @Composable fun HeaderIconButton( onClick: () -> Unit, @DrawableRes icon: Int, color: Color, modifier: Modifier = Modifier, enabled: Boolean = true, indication: Indication? = null ) { IconButton( icon = icon, color = color, onClick = onClick, enabled = enabled, indication = indication, modifier = modifier .padding(all = 4.dp) .size(18.dp) ) } @Composable fun IconButton( onClick: () -> Unit, @DrawableRes icon: Int, color: Color, modifier: Modifier = Modifier, enabled: Boolean = true, indication: Indication? = null ) { Image( painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(color), modifier = Modifier .clickable( indication = indication ?: rememberRipple(bounded = false), interactionSource = remember { MutableInteractionSource() }, enabled = enabled, onClick = onClick ) .then(modifier) ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.isLandscape import it.vfsfitvnm.vimusic.utils.thumbnail @Composable inline fun LayoutWithAdaptiveThumbnail( thumbnailContent: @Composable () -> Unit, content: @Composable () -> Unit ) { val isLandscape = isLandscape if (isLandscape) { Row(verticalAlignment = Alignment.CenterVertically) { thumbnailContent() content() } } else { content() } } fun adaptiveThumbnailContent( isLoading: Boolean, url: String?, shape: Shape? = null ): @Composable () -> Unit = { val (colorPalette, _, thumbnailShape) = LocalAppearance.current BoxWithConstraints(contentAlignment = Alignment.Center) { val thumbnailSizeDp = if (isLandscape) (maxHeight - 128.dp) else (maxWidth - 64.dp) val thumbnailSizePx = thumbnailSizeDp.px val modifier = Modifier .padding(all = 16.dp) .clip(shape ?: thumbnailShape) .size(thumbnailSizeDp) if (isLoading) { Spacer( modifier = modifier .shimmer() .background(colorPalette.shimmer) ) } else { AsyncImage( model = url?.thumbnail(thumbnailSizePx), contentDescription = null, modifier = modifier ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween import androidx.compose.animation.with import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Info import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongPlaylistMap import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.transaction import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.artistRoute import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.addNext import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.formatAsDuration import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail import kotlin.system.measureTimeMillis import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.withContext @ExperimentalAnimationApi @Composable fun InHistoryMediaItemMenu( onDismiss: () -> Unit, song: Song, modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current var isHiding by remember { mutableStateOf(false) } if (isHiding) { ConfirmationDialog( text = "Do you really want to hide this song? Its playback time and cache will be wiped.\nThis action is irreversible.", onDismiss = { isHiding = false }, onConfirm = { onDismiss() query { // Not sure we can to this here binder?.cache?.removeResource(song.id) Database.incrementTotalPlayTimeMs(song.id, -song.totalPlayTimeMs) } } ) } NonQueuedMediaItemMenu( mediaItem = song.asMediaItem, onDismiss = onDismiss, onHideFromDatabase = { isHiding = true }, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun InPlaylistMediaItemMenu( onDismiss: () -> Unit, playlistId: Long, positionInPlaylist: Int, song: Song, modifier: Modifier = Modifier ) { NonQueuedMediaItemMenu( mediaItem = song.asMediaItem, onDismiss = onDismiss, onRemoveFromPlaylist = { transaction { Database.move(playlistId, positionInPlaylist, Int.MAX_VALUE) Database.delete(SongPlaylistMap(song.id, playlistId, Int.MAX_VALUE)) } }, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun NonQueuedMediaItemMenu( onDismiss: () -> Unit, mediaItem: MediaItem, modifier: Modifier = Modifier, onRemoveFromPlaylist: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, onRemoveFromQuickPicks: (() -> Unit)? = null, ) { val binder = LocalPlayerServiceBinder.current BaseMediaItemMenu( mediaItem = mediaItem, onDismiss = onDismiss, onStartRadio = { binder?.stopRadio() binder?.player?.forcePlay(mediaItem) binder?.setupRadio( NavigationEndpoint.Endpoint.Watch( videoId = mediaItem.mediaId, playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId") ) ) }, onPlayNext = { binder?.player?.addNext(mediaItem) }, onEnqueue = { binder?.player?.enqueue(mediaItem) }, onRemoveFromPlaylist = onRemoveFromPlaylist, onHideFromDatabase = onHideFromDatabase, onRemoveFromQuickPicks = onRemoveFromQuickPicks, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun QueuedMediaItemMenu( onDismiss: () -> Unit, mediaItem: MediaItem, indexInQueue: Int?, modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current BaseMediaItemMenu( mediaItem = mediaItem, onDismiss = onDismiss, onRemoveFromQueue = if (indexInQueue != null) ({ binder?.player?.removeMediaItem(indexInQueue) }) else null, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun BaseMediaItemMenu( onDismiss: () -> Unit, mediaItem: MediaItem, modifier: Modifier = Modifier, onGoToEqualizer: (() -> Unit)? = null, onShowSleepTimer: (() -> Unit)? = null, onStartRadio: (() -> Unit)? = null, onPlayNext: (() -> Unit)? = null, onEnqueue: (() -> Unit)? = null, onRemoveFromQueue: (() -> Unit)? = null, onRemoveFromPlaylist: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, onRemoveFromQuickPicks: (() -> Unit)? = null, ) { val context = LocalContext.current MediaItemMenu( mediaItem = mediaItem, onDismiss = onDismiss, onGoToEqualizer = onGoToEqualizer, onShowSleepTimer = onShowSleepTimer, onStartRadio = onStartRadio, onPlayNext = onPlayNext, onEnqueue = onEnqueue, onAddToPlaylist = { playlist, position -> transaction { Database.insert(mediaItem) Database.insert( SongPlaylistMap( songId = mediaItem.mediaId, playlistId = Database.insert(playlist).takeIf { it != -1L } ?: playlist.id, position = position ) ) } }, onHideFromDatabase = onHideFromDatabase, onRemoveFromPlaylist = onRemoveFromPlaylist, onRemoveFromQueue = onRemoveFromQueue, onGoToAlbum = albumRoute::global, onGoToArtist = artistRoute::global, onShare = { val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra( Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}" ) } context.startActivity(Intent.createChooser(sendIntent, null)) }, onRemoveFromQuickPicks = onRemoveFromQuickPicks, modifier = modifier ) } @ExperimentalAnimationApi @Composable fun MediaItemMenu( onDismiss: () -> Unit, mediaItem: MediaItem, modifier: Modifier = Modifier, onGoToEqualizer: (() -> Unit)? = null, onShowSleepTimer: (() -> Unit)? = null, onStartRadio: (() -> Unit)? = null, onPlayNext: (() -> Unit)? = null, onEnqueue: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, onRemoveFromQueue: (() -> Unit)? = null, onRemoveFromPlaylist: (() -> Unit)? = null, onAddToPlaylist: ((Playlist, Int) -> Unit)? = null, onGoToAlbum: ((String) -> Unit)? = null, onGoToArtist: ((String) -> Unit)? = null, onRemoveFromQuickPicks: (() -> Unit)? = null, onShare: () -> Unit ) { val (colorPalette) = LocalAppearance.current val density = LocalDensity.current var isViewingPlaylists by remember { mutableStateOf(false) } var height by remember { mutableStateOf(0.dp) } var albumInfo by remember { mutableStateOf(mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> Info(albumId, null) }) } var artistsInfo by remember { mutableStateOf( mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds -> artistNames.zip(artistIds).map { (authorName, authorId) -> Info(authorId, authorName) } } } ) } var likedAt by remember { mutableStateOf(null) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { if (albumInfo == null) albumInfo = Database.songAlbumInfo(mediaItem.mediaId) if (artistsInfo == null) artistsInfo = Database.songArtistInfo(mediaItem.mediaId) Database.likedAt(mediaItem.mediaId).collect { likedAt = it } } } AnimatedContent( targetState = isViewingPlaylists, transitionSpec = { val animationSpec = tween(400) val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right slideIntoContainer(slideDirection, animationSpec) with slideOutOfContainer(slideDirection, animationSpec) } ) { currentIsViewingPlaylists -> if (currentIsViewingPlaylists) { val playlistPreviews by remember { Database.playlistPreviews(PlaylistSortBy.DateAdded, SortOrder.Descending) }.collectAsState(initial = emptyList(), context = Dispatchers.IO) var isCreatingNewPlaylist by rememberSaveable { mutableStateOf(false) } if (isCreatingNewPlaylist && onAddToPlaylist != null) { TextFieldDialog( hintText = "Enter the playlist name", onDismiss = { isCreatingNewPlaylist = false }, onDone = { text -> onDismiss() onAddToPlaylist(Playlist(name = text), 0) } ) } BackHandler { isViewingPlaylists = false } Menu( modifier = modifier .requiredHeight(height) ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(horizontal = 16.dp, vertical = 8.dp) .fillMaxWidth() ) { IconButton( onClick = { isViewingPlaylists = false }, icon = R.drawable.chevron_back, color = colorPalette.textSecondary, modifier = Modifier .padding(all = 4.dp) .size(20.dp) ) if (onAddToPlaylist != null) { SecondaryTextButton( text = "New playlist", onClick = { isCreatingNewPlaylist = true }, alternative = true ) } } onAddToPlaylist?.let { onAddToPlaylist -> playlistPreviews.forEach { playlistPreview -> MenuEntry( icon = R.drawable.playlist, text = playlistPreview.playlist.name, secondaryText = "${playlistPreview.songCount} songs", onClick = { onDismiss() onAddToPlaylist(playlistPreview.playlist, playlistPreview.songCount) } ) } } } } else { Menu( modifier = modifier .onPlaced { height = with(density) { it.size.height.toDp() } } ) { val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(end = 12.dp) ) { SongItem( thumbnailUrl = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx) ?.toString(), title = mediaItem.mediaMetadata.title.toString(), authors = mediaItem.mediaMetadata.artist.toString(), duration = null, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .weight(1f) ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { IconButton( icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, color = colorPalette.favoritesIcon, onClick = { query { if (Database.like( mediaItem.mediaId, if (likedAt == null) System.currentTimeMillis() else null ) == 0 ) { Database.insert(mediaItem, Song::toggleLike) } } }, modifier = Modifier .padding(all = 4.dp) .size(18.dp) ) IconButton( icon = R.drawable.share_social, color = colorPalette.text, onClick = onShare, modifier = Modifier .padding(all = 4.dp) .size(17.dp) ) } } Spacer( modifier = Modifier .height(8.dp) ) Spacer( modifier = Modifier .alpha(0.5f) .align(Alignment.CenterHorizontally) .background(colorPalette.textDisabled) .height(1.dp) .fillMaxWidth(1f) ) Spacer( modifier = Modifier .height(8.dp) ) onStartRadio?.let { onStartRadio -> MenuEntry( icon = R.drawable.radio, text = "Start radio", onClick = { onDismiss() onStartRadio() } ) } onPlayNext?.let { onPlayNext -> MenuEntry( icon = R.drawable.play_skip_forward, text = "Play next", onClick = { onDismiss() onPlayNext() } ) } onEnqueue?.let { onEnqueue -> MenuEntry( icon = R.drawable.enqueue, text = "Enqueue", onClick = { onDismiss() onEnqueue() } ) } onGoToEqualizer?.let { onGoToEqualizer -> MenuEntry( icon = R.drawable.equalizer, text = "Equalizer", onClick = { onDismiss() onGoToEqualizer() } ) } // TODO: find solution to this shit onShowSleepTimer?.let { val binder = LocalPlayerServiceBinder.current val (_, typography) = LocalAppearance.current var isShowingSleepTimerDialog by remember { mutableStateOf(false) } val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft ?: flowOf(null)) .collectAsState(initial = null) if (isShowingSleepTimerDialog) { if (sleepTimerMillisLeft != null) { ConfirmationDialog( text = "Do you want to stop the sleep timer?", cancelText = "No", confirmText = "Stop", onDismiss = { isShowingSleepTimerDialog = false }, onConfirm = { binder?.cancelSleepTimer() onDismiss() } ) } else { DefaultDialog( onDismiss = { isShowingSleepTimerDialog = false } ) { var amount by remember { mutableStateOf(1) } BasicText( text = "Set sleep timer", style = typography.s.semiBold, modifier = Modifier .padding(vertical = 8.dp, horizontal = 24.dp) ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( space = 16.dp, alignment = Alignment.CenterHorizontally ), modifier = Modifier .padding(vertical = 16.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .alpha(if (amount <= 1) 0.5f else 1f) .clip(CircleShape) .clickable(enabled = amount > 1) { amount-- } .size(48.dp) .background(colorPalette.background0) ) { BasicText( text = "-", style = typography.xs.semiBold ) } Box(contentAlignment = Alignment.Center) { BasicText( text = "88h 88m", style = typography.s.semiBold, modifier = Modifier .alpha(0f) ) BasicText( text = "${amount / 6}h ${(amount % 6) * 10}m", style = typography.s.semiBold ) } Box( contentAlignment = Alignment.Center, modifier = Modifier .alpha(if (amount >= 60) 0.5f else 1f) .clip(CircleShape) .clickable(enabled = amount < 60) { amount++ } .size(48.dp) .background(colorPalette.background0) ) { BasicText( text = "+", style = typography.xs.semiBold ) } } Row( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier .fillMaxWidth() ) { DialogTextButton( text = "Cancel", onClick = { isShowingSleepTimerDialog = false } ) DialogTextButton( text = "Set", enabled = amount > 0, primary = true, onClick = { binder?.startSleepTimer(amount * 10 * 60 * 1000L) isShowingSleepTimerDialog = false } ) } } } } MenuEntry( icon = R.drawable.alarm, text = "Sleep timer", onClick = { isShowingSleepTimerDialog = true }, trailingContent = sleepTimerMillisLeft?.let { { BasicText( text = "${formatAsDuration(it)} left", style = typography.xxs.medium, modifier = modifier .background( color = colorPalette.background0, shape = RoundedCornerShape(16.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) .animateContentSize() ) } } ) } if (onAddToPlaylist != null) { MenuEntry( icon = R.drawable.playlist, text = "Add to playlist", onClick = { isViewingPlaylists = true }, trailingContent = { Image( painter = painterResource(R.drawable.chevron_forward), contentDescription = null, colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( colorPalette.textSecondary ), modifier = Modifier .size(16.dp) ) } ) } onGoToAlbum?.let { onGoToAlbum -> albumInfo?.let { (albumId) -> MenuEntry( icon = R.drawable.disc, text = "Go to album", onClick = { onDismiss() onGoToAlbum(albumId) } ) } } onGoToArtist?.let { onGoToArtist -> artistsInfo?.forEach { (authorId, authorName) -> MenuEntry( icon = R.drawable.person, text = "More from $authorName", onClick = { onDismiss() onGoToArtist(authorId) } ) } } onRemoveFromQueue?.let { onRemoveFromQueue -> MenuEntry( icon = R.drawable.trash, text = "Remove from queue", onClick = { onDismiss() onRemoveFromQueue() } ) } onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> MenuEntry( icon = R.drawable.trash, text = "Remove from playlist", onClick = { onDismiss() onRemoveFromPlaylist() } ) } onHideFromDatabase?.let { onHideFromDatabase -> MenuEntry( icon = R.drawable.trash, text = "Hide", onClick = onHideFromDatabase ) } onRemoveFromQuickPicks?.let { MenuEntry( icon = R.drawable.trash, text = "Hide from \"Quick picks\"", onClick = { onDismiss() onRemoveFromQuickPicks() } ) } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary @Composable inline fun Menu( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit ) { val (colorPalette) = LocalAppearance.current Column( modifier = modifier .padding(top = 48.dp) .verticalScroll(rememberScrollState()) .fillMaxWidth() .background(colorPalette.background1) .padding(top = 2.dp) .padding(vertical = 8.dp) .navigationBarsPadding(), content = content ) } @Composable fun MenuEntry( @DrawableRes icon: Int, text: String, onClick: () -> Unit, secondaryText: String? = null, enabled: Boolean = true, trailingContent: (@Composable () -> Unit)? = null ) { val (colorPalette, typography) = LocalAppearance.current Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier .clickable(enabled = enabled, onClick = onClick) .fillMaxWidth() .alpha(if (enabled) 1f else 0.4f) .padding(horizontal = 24.dp) ) { Image( painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .size(15.dp) ) Column( modifier = Modifier .padding(vertical = 16.dp) .weight(1f) ) { BasicText( text = text, style = typography.xs.medium ) secondaryText?.let { secondaryText -> BasicText( text = secondaryText, style = typography.xxs.medium.secondary ) } } trailingContent?.invoke() } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.animation.animateColor import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.isLandscape import it.vfsfitvnm.vimusic.utils.semiBold @Composable inline fun NavigationRail( topIconButtonId: Int, noinline onTopIconButtonClick: () -> Unit, tabIndex: Int, crossinline onTabIndexChanged: (Int) -> Unit, content: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit, modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current val isLandscape = isLandscape val paddingValues = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.Start).asPaddingValues() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .verticalScroll(rememberScrollState()) .padding(paddingValues) ) { Box( contentAlignment = Alignment.TopCenter, modifier = Modifier .size( width = if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth, height = Dimensions.headerHeight ) ) { Image( painter = painterResource(topIconButtonId), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.textSecondary), modifier = Modifier .offset( x = if (isLandscape) 0.dp else Dimensions.navigationRailIconOffset, y = 48.dp ) .clip(CircleShape) .clickable(onClick = onTopIconButtonClick) .padding(all = 12.dp) .size(22.dp) ) } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .width(if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth) ) { val transition = updateTransition(targetState = tabIndex, label = null) content { index, text, icon -> val dothAlpha by transition.animateFloat(label = "") { if (it == index) 1f else 0f } val textColor by transition.animateColor(label = "") { if (it == index) colorPalette.text else colorPalette.textDisabled } val iconContent: @Composable () -> Unit = { Image( painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .vertical(enabled = !isLandscape) .graphicsLayer { alpha = dothAlpha translationX = (1f - dothAlpha) * -48.dp.toPx() rotationZ = if (isLandscape) 0f else -90f } .size(Dimensions.navigationRailIconOffset * 2) ) } val textContent: @Composable () -> Unit = { BasicText( text = text, style = typography.xs.semiBold.center.color(textColor), modifier = Modifier .vertical(enabled = !isLandscape) .rotate(if (isLandscape) 0f else -90f) .padding(horizontal = 16.dp) ) } val contentModifier = Modifier .clip(RoundedCornerShape(24.dp)) .clickable(onClick = { onTabIndexChanged(index) }) if (isLandscape) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = contentModifier .padding(vertical = 8.dp) ) { iconContent() textContent() } } else { Row( verticalAlignment = Alignment.CenterVertically, modifier = contentModifier .padding(horizontal = 8.dp) ) { iconContent() textContent() } } } } } } fun Modifier.vertical(enabled: Boolean = true) = if (enabled) layout { measurable, constraints -> val placeable = measurable.measure(constraints.copy(maxWidth = Int.MAX_VALUE)) layout(placeable.height, placeable.width) { placeable.place( x = -(placeable.width / 2 - placeable.height / 2), y = -(placeable.height / 2 - placeable.width / 2) ) } } else this ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.primaryButton @Composable fun PrimaryButton( onClick: () -> Unit, @DrawableRes iconId: Int, modifier: Modifier = Modifier, enabled: Boolean = true, ) { val (colorPalette) = LocalAppearance.current Box( modifier = modifier .clip(RoundedCornerShape(16.dp)) .clickable(enabled = enabled, onClick = onClick) .background(colorPalette.primaryButton) .size(62.dp) ) { Image( painter = painterResource(iconId), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .align(Alignment.Center) .size(20.dp) ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.with import androidx.compose.foundation.background import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance @ExperimentalAnimationApi @Composable fun Scaffold( topIconButtonId: Int, onTopIconButtonClick: () -> Unit, tabIndex: Int, onTabChanged: (Int) -> Unit, tabColumnContent: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit, modifier: Modifier = Modifier, content: @Composable AnimatedVisibilityScope.(Int) -> Unit ) { val (colorPalette) = LocalAppearance.current Row( modifier = modifier .background(colorPalette.background0) .fillMaxSize() ) { NavigationRail( topIconButtonId = topIconButtonId, onTopIconButtonClick = onTopIconButtonClick, tabIndex = tabIndex, onTabIndexChanged = onTabChanged, content = tabColumnContent ) AnimatedContent( targetState = tabIndex, transitionSpec = { val slideDirection = when (targetState > initialState) { true -> AnimatedContentScope.SlideDirection.Up false -> AnimatedContentScope.SlideDirection.Down } val animationSpec = spring( dampingRatio = 0.9f, stiffness = Spring.StiffnessLow, visibilityThreshold = IntOffset.VisibilityThreshold ) slideIntoContainer(slideDirection, animationSpec) with slideOutOfContainer(slideDirection, animationSpec) }, content = content ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.primaryButton @Composable fun SecondaryButton( onClick: () -> Unit, @DrawableRes iconId: Int, modifier: Modifier = Modifier, enabled: Boolean = true, ) { val (colorPalette) = LocalAppearance.current Box( modifier = modifier .clip(CircleShape) .clickable(enabled = enabled, onClick = onClick) .background(colorPalette.primaryButton) .size(48.dp) ) { Image( painter = painterResource(iconId), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .align(Alignment.Center) .size(18.dp) ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.primaryButton import it.vfsfitvnm.vimusic.utils.medium @Composable fun SecondaryTextButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, alternative: Boolean = false ) { val (colorPalette, typography) = LocalAppearance.current BasicText( text = text, style = typography.xxs.medium, modifier = modifier .clip(RoundedCornerShape(16.dp)) .clickable(enabled = enabled, onClick = onClick) .background(if (alternative) colorPalette.background0 else colorPalette.primaryButton) .padding(all = 8.dp) .padding(horizontal = 8.dp) ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Switch.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.animation.animateColor import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.drawCircle @Composable fun Switch( isChecked: Boolean, modifier: Modifier = Modifier, ) { val (colorPalette) = LocalAppearance.current val transition = updateTransition(targetState = isChecked, label = null) val backgroundColor by transition.animateColor(label = "") { if (it) colorPalette.accent else colorPalette.background1 } val color by transition.animateColor(label = "") { if (it) colorPalette.onAccent else colorPalette.textDisabled } val offset by transition.animateDp(label = "") { if (it) 36.dp else 12.dp } Canvas( modifier = modifier .size(width = 48.dp, height = 24.dp) ) { drawRoundRect( color = backgroundColor, cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()), ) drawCircle( color = color, radius = 8.dp.toPx(), center = size.center.copy(x = offset.toPx()), shadow = Shadow( color = Color.Black.copy(alpha = if (isChecked) 0.4f else 0.1f), blurRadius = 8.dp.toPx(), offset = Offset(x = -1.dp.toPx(), y = 1.dp.toPx()) ) ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt ================================================ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.shimmer import kotlin.random.Random @Composable fun TextPlaceholder( modifier: Modifier = Modifier, color: Color = LocalAppearance.current.colorPalette.shimmer ) { Spacer( modifier = modifier .padding(vertical = 4.dp) .background(color) .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) .height(16.dp) ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt ================================================ package it.vfsfitvnm.vimusic.ui.items import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.innertube.Innertube @Composable fun AlbumItem( album: Album, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false ) { AlbumItem( thumbnailUrl = album.thumbnailUrl, title = album.title, authors = album.authorsText, year = album.year, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, alternative = alternative, modifier = modifier ) } @Composable fun AlbumItem( album: Innertube.AlbumItem, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false ) { AlbumItem( thumbnailUrl = album.thumbnail?.url, title = album.info?.name, authors = album.authors?.joinToString("") { it.name ?: "" }, year = album.year, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, alternative = alternative, modifier = modifier ) } @Composable fun AlbumItem( thumbnailUrl: String?, title: String?, authors: String?, year: String?, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false ) { val (_, typography, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = alternative, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier ) { AsyncImage( model = thumbnailUrl?.thumbnail(thumbnailSizePx), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .clip(thumbnailShape) .size(thumbnailSizeDp) ) ItemInfoContainer { BasicText( text = title ?: "", style = typography.xs.semiBold, maxLines = if (alternative) 1 else 2, overflow = TextOverflow.Ellipsis, ) if (!alternative) { authors?.let { BasicText( text = authors, style = typography.xs.semiBold.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } BasicText( text = year ?: "", style = typography.xxs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(top = 4.dp) ) } } } @Composable fun AlbumItemPlaceholder( thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false ) { val (colorPalette, _, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = alternative, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier ) { Spacer( modifier = Modifier .background(color = colorPalette.shimmer, shape = thumbnailShape) .size(thumbnailSizeDp) ) ItemInfoContainer { TextPlaceholder() if (!alternative) { TextPlaceholder() } TextPlaceholder( modifier = Modifier .padding(top = 4.dp) ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt ================================================ package it.vfsfitvnm.vimusic.ui.items import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.innertube.Innertube @Composable fun ArtistItem( artist: Artist, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { ArtistItem( thumbnailUrl = artist.thumbnailUrl, name = artist.name, subscribersCount = null, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, alternative = alternative ) } @Composable fun ArtistItem( artist: Innertube.ArtistItem, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { ArtistItem( thumbnailUrl = artist.thumbnail?.url, name = artist.info?.name, subscribersCount = artist.subscribersCountText, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, alternative = alternative ) } @Composable fun ArtistItem( thumbnailUrl: String?, name: String?, subscribersCount: String?, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { val (_, typography) = LocalAppearance.current ItemContainer( alternative = alternative, thumbnailSizeDp = thumbnailSizeDp, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { AsyncImage( model = thumbnailUrl?.thumbnail(thumbnailSizePx), contentDescription = null, modifier = Modifier .clip(CircleShape) .requiredSize(thumbnailSizeDp) ) ItemInfoContainer( horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, ) { BasicText( text = name ?: "", style = typography.xs.semiBold, maxLines = if (alternative) 1 else 2, overflow = TextOverflow.Ellipsis ) subscribersCount?.let { BasicText( text = subscribersCount, style = typography.xxs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(top = 4.dp) ) } } } } @Composable fun ArtistItemPlaceholder( thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { val (colorPalette) = LocalAppearance.current ItemContainer( alternative = alternative, thumbnailSizeDp = thumbnailSizeDp, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { Spacer( modifier = Modifier .background(color = colorPalette.shimmer, shape = CircleShape) .size(thumbnailSizeDp) ) ItemInfoContainer( horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, ) { TextPlaceholder() TextPlaceholder( modifier = Modifier .padding(top = 4.dp) ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt ================================================ package it.vfsfitvnm.vimusic.ui.items import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.Dimensions @Composable inline fun ItemContainer( alternative: Boolean, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, content: @Composable (centeredModifier: Modifier) -> Unit ) { if (alternative) { Column( horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) .width(thumbnailSizeDp) ) { content( centeredModifier = Modifier .align(Alignment.CenterHorizontally) ) } } else { Row( verticalAlignment = verticalAlignment, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) .fillMaxWidth() ) { content( centeredModifier = Modifier .align(Alignment.CenterVertically) ) } } } @Composable inline fun ItemInfoContainer( modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit ) { Column( horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, content = content ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt ================================================ package it.vfsfitvnm.vimusic.ui.items import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.onOverlay import it.vfsfitvnm.vimusic.ui.styling.overlay import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.innertube.Innertube import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @Composable fun PlaylistItem( @DrawableRes icon: Int, colorTint: Color, name: String?, songCount: Int?, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { PlaylistItem( thumbnailContent = { Image( painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(colorTint), modifier = Modifier .align(Alignment.Center) .size(24.dp) ) }, songCount = songCount, name = name, channelName = null, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, alternative = alternative ) } @Composable fun PlaylistItem( playlist: PlaylistPreview, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { val thumbnails by remember { Database.playlistThumbnailUrls(playlist.playlist.id).distinctUntilChanged().map { it.map { url -> url.thumbnail(thumbnailSizePx / 2) } } }.collectAsState(initial = emptyList(), context = Dispatchers.IO) PlaylistItem( thumbnailContent = { if (thumbnails.toSet().size == 1) { AsyncImage( model = thumbnails.first().thumbnail(thumbnailSizePx), contentDescription = null, contentScale = ContentScale.Crop, modifier = it ) } else { Box( modifier = it .fillMaxSize() ) { listOf( Alignment.TopStart, Alignment.TopEnd, Alignment.BottomStart, Alignment.BottomEnd ).forEachIndexed { index, alignment -> AsyncImage( model = thumbnails.getOrNull(index), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .align(alignment) .size(thumbnailSizeDp / 2) ) } } } }, songCount = playlist.songCount, name = playlist.playlist.name, channelName = null, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, alternative = alternative ) } @Composable fun PlaylistItem( playlist: Innertube.PlaylistItem, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { PlaylistItem( thumbnailUrl = playlist.thumbnail?.url, songCount = playlist.songCount, name = playlist.info?.name, channelName = playlist.channel?.name, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, alternative = alternative ) } @Composable fun PlaylistItem( thumbnailUrl: String?, songCount: Int?, name: String?, channelName: String?, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { PlaylistItem( thumbnailContent = { AsyncImage( model = thumbnailUrl?.thumbnail(thumbnailSizePx), contentDescription = null, contentScale = ContentScale.Crop, modifier = it ) }, songCount = songCount, name = name, channelName = channelName, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, alternative = alternative, ) } @Composable fun PlaylistItem( thumbnailContent: @Composable BoxScope.(modifier: Modifier) -> Unit, songCount: Int?, name: String?, channelName: String?, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { val (colorPalette, typography, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = alternative, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier ) { centeredModifier -> Box( modifier = centeredModifier .clip(thumbnailShape) .background(color = colorPalette.background1) .requiredSize(thumbnailSizeDp) ) { thumbnailContent( modifier = Modifier .fillMaxSize() ) songCount?.let { BasicText( text = "$songCount", style = typography.xxs.medium.color(colorPalette.onOverlay), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(all = 4.dp) .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) .padding(horizontal = 4.dp, vertical = 2.dp) .align(Alignment.BottomEnd) ) } } ItemInfoContainer( horizontalAlignment = if (alternative && channelName == null) Alignment.CenterHorizontally else Alignment.Start, ) { BasicText( text = name ?: "", style = typography.xs.semiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, ) channelName?.let { BasicText( text = channelName, style = typography.xs.semiBold.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } } } @Composable fun PlaylistItemPlaceholder( thumbnailSizeDp: Dp, modifier: Modifier = Modifier, alternative: Boolean = false, ) { val (colorPalette, _, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = alternative, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier ) { Spacer( modifier = Modifier .background(color = colorPalette.shimmer, shape = thumbnailShape) .size(thumbnailSizeDp) ) ItemInfoContainer( horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, ) { TextPlaceholder() TextPlaceholder() } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt ================================================ package it.vfsfitvnm.vimusic.ui.items import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.vimusic.models.Song @Composable fun SongItem( song: Innertube.SongItem, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier ) { SongItem( thumbnailUrl = song.thumbnail?.size(thumbnailSizePx), title = song.info?.name, authors = song.authors?.joinToString("") { it.name ?: "" }, duration = song.durationText, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier, ) } @Composable fun SongItem( song: MediaItem, thumbnailSizeDp: Dp, thumbnailSizePx: Int, modifier: Modifier = Modifier, onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null ) { SongItem( thumbnailUrl = song.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)?.toString(), title = song.mediaMetadata.title.toString(), authors = song.mediaMetadata.artist.toString(), duration = song.mediaMetadata.extras?.getString("durationText"), thumbnailSizeDp = thumbnailSizeDp, onThumbnailContent = onThumbnailContent, trailingContent = trailingContent, modifier = modifier, ) } @Composable fun SongItem( song: Song, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null ) { SongItem( thumbnailUrl = song.thumbnailUrl?.thumbnail(thumbnailSizePx), title = song.title, authors = song.artistsText, duration = song.durationText, thumbnailSizeDp = thumbnailSizeDp, onThumbnailContent = onThumbnailContent, trailingContent = trailingContent, modifier = modifier, ) } @Composable fun SongItem( thumbnailUrl: String?, title: String?, authors: String?, duration: String?, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null ) { SongItem( title = title, authors = authors, duration = duration, thumbnailSizeDp = thumbnailSizeDp, thumbnailContent = { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .clip(LocalAppearance.current.thumbnailShape) .fillMaxSize() ) onThumbnailContent?.invoke(this) }, modifier = modifier, trailingContent = trailingContent ) } @Composable fun SongItem( thumbnailContent: @Composable BoxScope.() -> Unit, title: String?, authors: String?, duration: String?, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, trailingContent: @Composable (() -> Unit)? = null, ) { val (_, typography) = LocalAppearance.current ItemContainer( alternative = false, thumbnailSizeDp = thumbnailSizeDp, modifier = modifier ) { Box( modifier = Modifier .size(thumbnailSizeDp) ) { thumbnailContent() } ItemInfoContainer { trailingContent?.let { Row(verticalAlignment = Alignment.CenterVertically) { BasicText( text = title ?: "", style = typography.xs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .weight(1f) ) it() } } ?: BasicText( text = title ?: "", style = typography.xs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Row(verticalAlignment = Alignment.CenterVertically) { BasicText( text = authors ?: "", style = typography.xs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Clip, modifier = Modifier .weight(1f) ) duration?.let { BasicText( text = duration, style = typography.xxs.secondary.medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(top = 4.dp) ) } } } } } @Composable fun SongItemPlaceholder( thumbnailSizeDp: Dp, modifier: Modifier = Modifier ) { val (colorPalette, _, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = false, thumbnailSizeDp =thumbnailSizeDp, modifier = modifier ) { Spacer( modifier = Modifier .background(color = colorPalette.shimmer, shape = thumbnailShape) .size(thumbnailSizeDp) ) ItemInfoContainer { TextPlaceholder() TextPlaceholder() } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt ================================================ package it.vfsfitvnm.vimusic.ui.items import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.onOverlay import it.vfsfitvnm.vimusic.ui.styling.overlay import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.innertube.Innertube @Composable fun VideoItem( video: Innertube.VideoItem, thumbnailHeightDp: Dp, thumbnailWidthDp: Dp, modifier: Modifier = Modifier ) { VideoItem( thumbnailUrl = video.thumbnail?.url, duration = video.durationText, title = video.info?.name, uploader = video.authors?.joinToString("") { it.name ?: "" }, views = video.viewsText, thumbnailHeightDp = thumbnailHeightDp, thumbnailWidthDp = thumbnailWidthDp, modifier = modifier ) } @Composable fun VideoItem( thumbnailUrl: String?, duration: String?, title: String?, uploader: String?, views: String?, thumbnailHeightDp: Dp, thumbnailWidthDp: Dp, modifier: Modifier = Modifier ) { val (colorPalette, typography, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = false, thumbnailSizeDp = 0.dp, modifier = modifier ) { Box { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .clip(thumbnailShape) .size(width = thumbnailWidthDp, height = thumbnailHeightDp) ) duration?.let { BasicText( text = duration, style = typography.xxs.medium.color(colorPalette.onOverlay), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(all = 4.dp) .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) .padding(horizontal = 4.dp, vertical = 2.dp) .align(Alignment.BottomEnd) ) } } ItemInfoContainer { BasicText( text = title ?: "", style = typography.xs.semiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, ) BasicText( text = uploader ?: "", style = typography.xs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) views?.let { BasicText( text = views, style = typography.xxs.medium.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(top = 4.dp) ) } } } } @Composable fun VideoItemPlaceholder( thumbnailHeightDp: Dp, thumbnailWidthDp: Dp, modifier: Modifier = Modifier ) { val (colorPalette, _, thumbnailShape) = LocalAppearance.current ItemContainer( alternative = false, thumbnailSizeDp = 0.dp, modifier = modifier ) { Spacer( modifier = Modifier .background(color = colorPalette.shimmer, shape = thumbnailShape) .size(width = thumbnailWidthDp, height = thumbnailHeightDp) ) ItemInfoContainer { TextPlaceholder() TextPlaceholder() TextPlaceholder( modifier = Modifier .padding(top = 8.dp) ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens import android.annotation.SuppressLint import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import it.vfsfitvnm.compose.routing.Route0 import it.vfsfitvnm.compose.routing.Route1 import it.vfsfitvnm.compose.routing.RouteHandlerScope import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist import it.vfsfitvnm.vimusic.ui.screens.album.AlbumScreen import it.vfsfitvnm.vimusic.ui.screens.artist.ArtistScreen import it.vfsfitvnm.vimusic.ui.screens.playlist.PlaylistScreen val albumRoute = Route1("albumRoute") val artistRoute = Route1("artistRoute") val builtInPlaylistRoute = Route1("builtInPlaylistRoute") val localPlaylistRoute = Route1("localPlaylistRoute") val playlistRoute = Route1("playlistRoute") val searchResultRoute = Route1("searchResultRoute") val searchRoute = Route1("searchRoute") val settingsRoute = Route0("settingsRoute") @SuppressLint("ComposableNaming") @Suppress("NOTHING_TO_INLINE") @ExperimentalAnimationApi @ExperimentalFoundationApi @Composable inline fun RouteHandlerScope.globalRoutes() { albumRoute { browseId -> AlbumScreen( browseId = browseId ?: error("browseId cannot be null") ) } artistRoute { browseId -> ArtistScreen( browseId = browseId ?: error("browseId cannot be null") ) } playlistRoute { browseId -> PlaylistScreen( browseId = browseId ?: error("browseId cannot be null") ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.album import android.content.Intent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.requests.albumPage import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.models.SongAlbumMap import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent import it.vfsfitvnm.vimusic.ui.items.AlbumItem import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.withContext @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun AlbumScreen(browseId: String) { val saveableStateHolder = rememberSaveableStateHolder() var tabIndex by rememberSaveable { mutableStateOf(0) } var album by persist("album/$browseId/album") var albumPage by persist("album/$browseId/albumPage") PersistMapCleanup(tagPrefix = "album/$browseId/") LaunchedEffect(Unit) { Database .album(browseId) .combine(snapshotFlow { tabIndex }) { album, tabIndex -> album to tabIndex } .collect { (currentAlbum, tabIndex) -> album = currentAlbum if (albumPage == null && (currentAlbum?.timestamp == null || tabIndex == 1)) { withContext(Dispatchers.IO) { Innertube.albumPage(BrowseBody(browseId = browseId)) ?.onSuccess { currentAlbumPage -> albumPage = currentAlbumPage Database.clearAlbum(browseId) Database.upsert( Album( id = browseId, title = currentAlbumPage.title, thumbnailUrl = currentAlbumPage.thumbnail?.url, year = currentAlbumPage.year, authorsText = currentAlbumPage.authors ?.joinToString("") { it.name ?: "" }, shareUrl = currentAlbumPage.url, timestamp = System.currentTimeMillis(), bookmarkedAt = album?.bookmarkedAt ), currentAlbumPage .songsPage ?.items ?.map(Innertube.SongItem::asMediaItem) ?.onEach(Database::insert) ?.mapIndexed { position, mediaItem -> SongAlbumMap( songId = mediaItem.mediaId, albumId = browseId, position = position ) } ?: emptyList() ) } } } } } RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { textButton -> if (album?.timestamp == null) { HeaderPlaceholder( modifier = Modifier .shimmer() ) } else { val (colorPalette) = LocalAppearance.current val context = LocalContext.current Header(title = album?.title ?: "Unknown") { textButton?.invoke() Spacer( modifier = Modifier .weight(1f) ) HeaderIconButton( icon = if (album?.bookmarkedAt == null) { R.drawable.bookmark_outline } else { R.drawable.bookmark }, color = colorPalette.accent, onClick = { val bookmarkedAt = if (album?.bookmarkedAt == null) System.currentTimeMillis() else null query { album ?.copy(bookmarkedAt = bookmarkedAt) ?.let(Database::update) } } ) HeaderIconButton( icon = R.drawable.share_social, color = colorPalette.text, onClick = { album?.shareUrl?.let { url -> val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, url) } context.startActivity( Intent.createChooser( sendIntent, null ) ) } } ) } } } val thumbnailContent = adaptiveThumbnailContent(album?.timestamp == null, album?.thumbnailUrl) Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = { tabIndex = it }, tabColumnContent = { Item -> Item(0, "Songs", R.drawable.musical_notes) Item(1, "Other versions", R.drawable.disc) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { 0 -> AlbumSongs( browseId = browseId, headerContent = headerContent, thumbnailContent = thumbnailContent, ) 1 -> { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "album/$browseId/alternatives", headerContent = headerContent, initialPlaceholderCount = 1, continuationPlaceholderCount = 1, emptyItemsText = "This album doesn't have any alternative version", itemsPageProvider = albumPage?.let { ({ Result.success( Innertube.ItemsPage( items = albumPage?.otherVersions, continuation = null ) ) }) }, itemContent = { album -> AlbumItem( album = album, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable { albumRoute(album.key) } ) }, itemPlaceholderContent = { AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.album import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning import it.vfsfitvnm.vimusic.utils.isLandscape import it.vfsfitvnm.vimusic.utils.semiBold @ExperimentalAnimationApi @ExperimentalFoundationApi @Composable fun AlbumSongs( browseId: String, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, thumbnailContent: @Composable () -> Unit, ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current var songs by persistList("album/$browseId/songs") LaunchedEffect(Unit) { Database.albumSongs(browseId).collect { songs = it } } val thumbnailSizeDp = Dimensions.thumbnails.song val lazyListState = rememberLazyListState() LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { headerContent { SecondaryTextButton( text = "Enqueue", enabled = songs.isNotEmpty(), onClick = { binder?.player?.enqueue(songs.map(Song::asMediaItem)) } ) } if (!isLandscape) { thumbnailContent() } } } itemsIndexed( items = songs, key = { _, song -> song.id } ) { index, song -> SongItem( title = song.title, authors = song.artistsText, duration = song.durationText, thumbnailSizeDp = thumbnailSizeDp, thumbnailContent = { BasicText( text = "${index + 1}", style = typography.s.semiBold.center.color(colorPalette.textDisabled), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .width(thumbnailSizeDp) .align(Alignment.Center) ) }, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, ) } }, onClick = { binder?.stopRadio() binder?.player?.forcePlayAtIndex( songs.map(Song::asMediaItem), index ) } ) ) } if (songs.isEmpty()) { item(key = "loading") { ShimmerHost( modifier = Modifier .fillParentMaxSize() ) { repeat(4) { SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) } } } } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.shuffle, onClick = { if (songs.isNotEmpty()) { binder?.stopRadio() binder?.player?.forcePlayFromBeginning( songs.shuffled().map(Song::asMediaItem) ) } } ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.artist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun ArtistLocalSongs( browseId: String, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, thumbnailContent: @Composable () -> Unit, ) { val binder = LocalPlayerServiceBinder.current val (colorPalette) = LocalAppearance.current val menuState = LocalMenuState.current var songs by persist?>("artist/$browseId/localSongs") LaunchedEffect(Unit) { Database.artistSongs(browseId).collect { songs = it } } val songThumbnailSizeDp = Dimensions.thumbnails.song val songThumbnailSizePx = songThumbnailSizeDp.px val lazyListState = rememberLazyListState() LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { headerContent { SecondaryTextButton( text = "Enqueue", enabled = !songs.isNullOrEmpty(), onClick = { binder?.player?.enqueue(songs!!.map(Song::asMediaItem)) } ) } thumbnailContent() } } songs?.let { songs -> itemsIndexed( items = songs, key = { _, song -> song.id } ) { index, song -> SongItem( song = song, thumbnailSizeDp = songThumbnailSizeDp, thumbnailSizePx = songThumbnailSizePx, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, ) } }, onClick = { binder?.stopRadio() binder?.player?.forcePlayAtIndex( songs.map(Song::asMediaItem), index ) } ) ) } } ?: item(key = "loading") { ShimmerHost { repeat(4) { SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) } } } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.shuffle, onClick = { songs?.let { songs -> if (songs.isNotEmpty()) { binder?.stopRadio() binder?.player?.forcePlayFromBeginning( songs.shuffled().map(Song::asMediaItem) ) } } } ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.artist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.items.AlbumItem import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.align import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun ArtistOverview( youtubeArtistPage: Innertube.ArtistPage?, onViewAllSongsClick: () -> Unit, onViewAllAlbumsClick: () -> Unit, onViewAllSinglesClick: () -> Unit, onAlbumClick: (String) -> Unit, thumbnailContent: @Composable () -> Unit, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val windowInsets = LocalPlayerAwareWindowInsets.current val songThumbnailSizeDp = Dimensions.thumbnails.song val songThumbnailSizePx = songThumbnailSizeDp.px val albumThumbnailSizeDp = 108.dp val albumThumbnailSizePx = albumThumbnailSizeDp.px val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() val sectionTextModifier = Modifier .padding(horizontal = 16.dp) .padding(top = 24.dp, bottom = 8.dp) val scrollState = rememberScrollState() LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { Box { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(scrollState) .padding( windowInsets .only(WindowInsetsSides.Vertical) .asPaddingValues() ) ) { Box( modifier = Modifier .padding(endPaddingValues) ) { headerContent { youtubeArtistPage?.shuffleEndpoint?.let { endpoint -> SecondaryTextButton( text = "Shuffle", onClick = { binder?.stopRadio() binder?.playRadio(endpoint) } ) } } } thumbnailContent() if (youtubeArtistPage != null) { youtubeArtistPage.songs?.let { songs -> Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxSize() .padding(endPaddingValues) ) { BasicText( text = "Songs", style = typography.m.semiBold, modifier = sectionTextModifier ) youtubeArtistPage.songsEndpoint?.let { BasicText( text = "View all", style = typography.xs.secondary, modifier = sectionTextModifier .clickable(onClick = onViewAllSongsClick), ) } } songs.forEach { song -> SongItem( song = song, thumbnailSizeDp = songThumbnailSizeDp, thumbnailSizePx = songThumbnailSizePx, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, ) } }, onClick = { val mediaItem = song.asMediaItem binder?.stopRadio() binder?.player?.forcePlay(mediaItem) binder?.setupRadio( NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) ) } ) .padding(endPaddingValues) ) } } youtubeArtistPage.albums?.let { albums -> Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxSize() .padding(endPaddingValues) ) { BasicText( text = "Albums", style = typography.m.semiBold, modifier = sectionTextModifier ) youtubeArtistPage.albumsEndpoint?.let { BasicText( text = "View all", style = typography.xs.secondary, modifier = sectionTextModifier .clickable(onClick = onViewAllAlbumsClick), ) } } LazyRow( contentPadding = endPaddingValues, modifier = Modifier .fillMaxWidth() ) { items( items = albums, key = Innertube.AlbumItem::key ) { album -> AlbumItem( album = album, thumbnailSizePx = albumThumbnailSizePx, thumbnailSizeDp = albumThumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onAlbumClick(album.key) }) ) } } } youtubeArtistPage.singles?.let { singles -> Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxSize() .padding(endPaddingValues) ) { BasicText( text = "Singles", style = typography.m.semiBold, modifier = sectionTextModifier ) youtubeArtistPage.singlesEndpoint?.let { BasicText( text = "View all", style = typography.xs.secondary, modifier = sectionTextModifier .clickable(onClick = onViewAllSinglesClick), ) } } LazyRow( contentPadding = endPaddingValues, modifier = Modifier .fillMaxWidth() ) { items( items = singles, key = Innertube.AlbumItem::key ) { album -> AlbumItem( album = album, thumbnailSizePx = albumThumbnailSizePx, thumbnailSizeDp = albumThumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onAlbumClick(album.key) }) ) } } } youtubeArtistPage.description?.let { description -> val attributionsIndex = description.lastIndexOf("\n\nFrom Wikipedia") Row( modifier = Modifier .padding(top = 16.dp) .padding(vertical = 16.dp, horizontal = 8.dp) .padding(endPaddingValues) ) { BasicText( text = "“", style = typography.xxl.semiBold, modifier = Modifier .offset(y = (-8).dp) .align(Alignment.Top) ) BasicText( text = if (attributionsIndex == -1) { description } else { description.substring(0, attributionsIndex) }, style = typography.xxs.secondary, modifier = Modifier .padding(horizontal = 8.dp) .weight(1f) ) BasicText( text = "„", style = typography.xxl.semiBold, modifier = Modifier .offset(y = 4.dp) .align(Alignment.Bottom) ) } if (attributionsIndex != -1) { BasicText( text = "From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0", style = typography.xxs.color(colorPalette.textDisabled).align(TextAlign.End), modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = 16.dp) .padding(endPaddingValues) ) } } } else { ShimmerHost { TextPlaceholder(modifier = sectionTextModifier) repeat(5) { SongItemPlaceholder( thumbnailSizeDp = songThumbnailSizeDp, ) } repeat(2) { TextPlaceholder(modifier = sectionTextModifier) Row { repeat(2) { AlbumItemPlaceholder( thumbnailSizeDp = albumThumbnailSizeDp, alternative = true ) } } } } } } youtubeArtistPage?.radioEndpoint?.let { endpoint -> FloatingActionsContainerWithScrollToTop( scrollState = scrollState, iconId = R.drawable.radio, onClick = { binder?.stopRadio() binder?.playRadio(endpoint) } ) } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.artist import android.content.Intent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.requests.artistPage import it.vfsfitvnm.innertube.requests.itemsPage import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent import it.vfsfitvnm.vimusic.ui.items.AlbumItem import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun ArtistScreen(browseId: String) { val saveableStateHolder = rememberSaveableStateHolder() var tabIndex by rememberPreference(artistScreenTabIndexKey, defaultValue = 0) PersistMapCleanup(tagPrefix = "artist/$browseId/") var artist by persist("artist/$browseId/artist") var artistPage by persist("artist/$browseId/artistPage") LaunchedEffect(Unit) { Database .artist(browseId) .combine(snapshotFlow { tabIndex }.map { it != 4 }) { artist, mustFetch -> artist to mustFetch } .distinctUntilChanged() .collect { (currentArtist, mustFetch) -> artist = currentArtist if (artistPage == null && (currentArtist?.timestamp == null || mustFetch)) { withContext(Dispatchers.IO) { Innertube.artistPage(BrowseBody(browseId = browseId)) ?.onSuccess { currentArtistPage -> artistPage = currentArtistPage Database.upsert( Artist( id = browseId, name = currentArtistPage.name, thumbnailUrl = currentArtistPage.thumbnail?.url, timestamp = System.currentTimeMillis(), bookmarkedAt = currentArtist?.bookmarkedAt ) ) } } } } } RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { val thumbnailContent = adaptiveThumbnailContent( artist?.timestamp == null, artist?.thumbnailUrl, CircleShape ) val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { textButton -> if (artist?.timestamp == null) { HeaderPlaceholder( modifier = Modifier .shimmer() ) } else { val (colorPalette) = LocalAppearance.current val context = LocalContext.current Header(title = artist?.name ?: "Unknown") { textButton?.invoke() Spacer( modifier = Modifier .weight(1f) ) HeaderIconButton( icon = if (artist?.bookmarkedAt == null) { R.drawable.bookmark_outline } else { R.drawable.bookmark }, color = colorPalette.accent, onClick = { val bookmarkedAt = if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null query { artist ?.copy(bookmarkedAt = bookmarkedAt) ?.let(Database::update) } } ) HeaderIconButton( icon = R.drawable.share_social, color = colorPalette.text, onClick = { val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra( Intent.EXTRA_TEXT, "https://music.youtube.com/channel/$browseId" ) } context.startActivity(Intent.createChooser(sendIntent, null)) } ) } } } Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = { tabIndex = it }, tabColumnContent = { Item -> Item(0, "Overview", R.drawable.sparkles) Item(1, "Songs", R.drawable.musical_notes) Item(2, "Albums", R.drawable.disc) Item(3, "Singles", R.drawable.disc) Item(4, "Library", R.drawable.library) }, ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { 0 -> ArtistOverview( youtubeArtistPage = artistPage, thumbnailContent = thumbnailContent, headerContent = headerContent, onAlbumClick = { albumRoute(it) }, onViewAllSongsClick = { tabIndex = 1 }, onViewAllAlbumsClick = { tabIndex = 2 }, onViewAllSinglesClick = { tabIndex = 3 }, ) 1 -> { val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "artist/$browseId/songs", headerContent = headerContent, itemsPageProvider = artistPage?.let { ({ continuation -> continuation?.let { Innertube.itemsPage( body = ContinuationBody(continuation = continuation), fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, ) } ?: artistPage ?.songsEndpoint ?.takeIf { it.browseId != null } ?.let { endpoint -> Innertube.itemsPage( body = BrowseBody( browseId = endpoint.browseId!!, params = endpoint.params, ), fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, ) } ?: Result.success( Innertube.ItemsPage( items = artistPage?.songs, continuation = null ) ) }) }, itemContent = { song -> SongItem( song = song, thumbnailSizeDp = thumbnailSizeDp, thumbnailSizePx = thumbnailSizePx, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, ) } }, onClick = { binder?.stopRadio() binder?.player?.forcePlay(song.asMediaItem) binder?.setupRadio(song.info?.endpoint) } ) ) }, itemPlaceholderContent = { SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } 2 -> { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "artist/$browseId/albums", headerContent = headerContent, emptyItemsText = "This artist didn't release any album", itemsPageProvider = artistPage?.let { ({ continuation -> continuation?.let { Innertube.itemsPage( body = ContinuationBody(continuation = continuation), fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, ) } ?: artistPage ?.albumsEndpoint ?.takeIf { it.browseId != null } ?.let { endpoint -> Innertube.itemsPage( body = BrowseBody( browseId = endpoint.browseId!!, params = endpoint.params, ), fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, ) } ?: Result.success( Innertube.ItemsPage( items = artistPage?.albums, continuation = null ) ) }) }, itemContent = { album -> AlbumItem( album = album, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable(onClick = { albumRoute(album.key) }) ) }, itemPlaceholderContent = { AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } 3 -> { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "artist/$browseId/singles", headerContent = headerContent, emptyItemsText = "This artist didn't release any single", itemsPageProvider = artistPage?.let { ({ continuation -> continuation?.let { Innertube.itemsPage( body = ContinuationBody(continuation = continuation), fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, ) } ?: artistPage ?.singlesEndpoint ?.takeIf { it.browseId != null } ?.let { endpoint -> Innertube.itemsPage( body = BrowseBody( browseId = endpoint.browseId!!, params = endpoint.params, ), fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, ) } ?: Result.success( Innertube.ItemsPage( items = artistPage?.singles, continuation = null ) ) }) }, itemContent = { album -> AlbumItem( album = album, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable(onClick = { albumRoute(album.key) }) ) }, itemPlaceholderContent = { AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } 4 -> ArtistLocalSongs( browseId = browseId, headerContent = headerContent, thumbnailContent = thumbnailContent, ) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.builtinplaylist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.globalRoutes @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) { val saveableStateHolder = rememberSaveableStateHolder() val (tabIndex, onTabIndexChanged) = rememberSaveable { mutableStateOf(when (builtInPlaylist) { BuiltInPlaylist.Favorites -> 0 BuiltInPlaylist.Offline -> 1 }) } PersistMapCleanup(tagPrefix = "${builtInPlaylist.name}/") RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabIndexChanged, tabColumnContent = { Item -> Item(0, "Favorites", R.drawable.heart) Item(1, "Offline", R.drawable.airplane) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { 0 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Favorites) 1 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Offline) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.builtinplaylist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongWithContentLength import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) { val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current var songs by persistList("${builtInPlaylist.name}/songs") LaunchedEffect(Unit) { when (builtInPlaylist) { BuiltInPlaylist.Favorites -> Database .favorites() BuiltInPlaylist.Offline -> Database .songsWithContentLength() .flowOn(Dispatchers.IO) .map { songs -> songs.filter { song -> song.contentLength?.let { binder?.cache?.isCached(song.song.id, 0, song.contentLength) } ?: false }.map(SongWithContentLength::song) } }.collect { songs = it } } val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSize = thumbnailSizeDp.px val lazyListState = rememberLazyListState() Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Header( title = when (builtInPlaylist) { BuiltInPlaylist.Favorites -> "Favorites" BuiltInPlaylist.Offline -> "Offline" }, modifier = Modifier .padding(bottom = 8.dp) ) { SecondaryTextButton( text = "Enqueue", enabled = songs.isNotEmpty(), onClick = { binder?.player?.enqueue(songs.map(Song::asMediaItem)) } ) Spacer( modifier = Modifier .weight(1f) ) } } itemsIndexed( items = songs, key = { _, song -> song.id }, contentType = { _, song -> song }, ) { index, song -> SongItem( song = song, thumbnailSizeDp = thumbnailSizeDp, thumbnailSizePx = thumbnailSize, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { when (builtInPlaylist) { BuiltInPlaylist.Favorites -> NonQueuedMediaItemMenu( mediaItem = song.asMediaItem, onDismiss = menuState::hide ) BuiltInPlaylist.Offline -> InHistoryMediaItemMenu( song = song, onDismiss = menuState::hide ) } } }, onClick = { binder?.stopRadio() binder?.player?.forcePlayAtIndex( songs.map(Song::asMediaItem), index ) } ) .animateItemPlacement() ) } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.shuffle, onClick = { if (songs.isNotEmpty()) { binder?.stopRadio() binder?.player?.forcePlayFromBeginning( songs.shuffled().map(Song::asMediaItem) ) } } ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.AlbumSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.items.AlbumItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.albumSortByKey import it.vfsfitvnm.vimusic.utils.albumSortOrderKey import it.vfsfitvnm.vimusic.utils.rememberPreference @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun HomeAlbums( onAlbumClick: (Album) -> Unit, onSearchClick: () -> Unit, ) { val (colorPalette) = LocalAppearance.current var sortBy by rememberPreference(albumSortByKey, AlbumSortBy.DateAdded) var sortOrder by rememberPreference(albumSortOrderKey, SortOrder.Descending) var items by persist>(tag = "home/albums", emptyList()) LaunchedEffect(sortBy, sortOrder) { Database.albums(sortBy, sortOrder).collect { items = it } } val thumbnailSizeDp = Dimensions.thumbnails.song * 2 val thumbnailSizePx = thumbnailSizeDp.px val sortOrderIconRotation by animateFloatAsState( targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, animationSpec = tween(durationMillis = 400, easing = LinearEasing) ) val lazyListState = rememberLazyListState() Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Header(title = "Albums") { HeaderIconButton( icon = R.drawable.calendar, color = if (sortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = AlbumSortBy.Year } ) HeaderIconButton( icon = R.drawable.text, color = if (sortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = AlbumSortBy.Title } ) HeaderIconButton( icon = R.drawable.time, color = if (sortBy == AlbumSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = AlbumSortBy.DateAdded } ) Spacer( modifier = Modifier .width(2.dp) ) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, onClick = { sortOrder = !sortOrder }, modifier = Modifier .graphicsLayer { rotationZ = sortOrderIconRotation } ) } } items( items = items, key = Album::id ) { album -> AlbumItem( album = album, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable(onClick = { onAlbumClick(album) }) .animateItemPlacement() ) } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.search, onClick = onSearchClick ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.ArtistSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.items.ArtistItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.artistSortByKey import it.vfsfitvnm.vimusic.utils.artistSortOrderKey import it.vfsfitvnm.vimusic.utils.rememberPreference @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun HomeArtistList( onArtistClick: (Artist) -> Unit, onSearchClick: () -> Unit, ) { val (colorPalette) = LocalAppearance.current var sortBy by rememberPreference(artistSortByKey, ArtistSortBy.DateAdded) var sortOrder by rememberPreference(artistSortOrderKey, SortOrder.Descending) var items by persistList("home/artists") LaunchedEffect(sortBy, sortOrder) { Database.artists(sortBy, sortOrder).collect { items = it } } val thumbnailSizeDp = Dimensions.thumbnails.song * 2 val thumbnailSizePx = thumbnailSizeDp.px val sortOrderIconRotation by animateFloatAsState( targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, animationSpec = tween(durationMillis = 400, easing = LinearEasing) ) val lazyGridState = rememberLazyGridState() Box { LazyVerticalGrid( state = lazyGridState, columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), horizontalArrangement = Arrangement.spacedBy( space = Dimensions.itemsVerticalPadding * 2, alignment = Alignment.CenterHorizontally ), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) } ) { Header(title = "Artists") { HeaderIconButton( icon = R.drawable.text, color = if (sortBy == ArtistSortBy.Name) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = ArtistSortBy.Name } ) HeaderIconButton( icon = R.drawable.time, color = if (sortBy == ArtistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = ArtistSortBy.DateAdded } ) Spacer( modifier = Modifier .width(2.dp) ) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, onClick = { sortOrder = !sortOrder }, modifier = Modifier .graphicsLayer { rotationZ = sortOrderIconRotation } ) } } items(items = items, key = Artist::id) { artist -> ArtistItem( artist = artist, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onArtistClick(artist) }) .animateItemPlacement() ) } } FloatingActionsContainerWithScrollToTop( lazyGridState = lazyGridState, iconId = R.drawable.search, onClick = onSearchClick ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog import it.vfsfitvnm.vimusic.ui.items.PlaylistItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.playlistSortByKey import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey import it.vfsfitvnm.vimusic.utils.rememberPreference @ExperimentalAnimationApi @ExperimentalFoundationApi @Composable fun HomePlaylists( onBuiltInPlaylist: (BuiltInPlaylist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onSearchClick: () -> Unit, ) { val (colorPalette) = LocalAppearance.current var isCreatingANewPlaylist by rememberSaveable { mutableStateOf(false) } if (isCreatingANewPlaylist) { TextFieldDialog( hintText = "Enter the playlist name", onDismiss = { isCreatingANewPlaylist = false }, onDone = { text -> query { Database.insert(Playlist(name = text)) } } ) } var sortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded) var sortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending) var items by persistList("home/playlists") LaunchedEffect(sortBy, sortOrder) { Database.playlistPreviews(sortBy, sortOrder).collect { items = it } } val sortOrderIconRotation by animateFloatAsState( targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, animationSpec = tween(durationMillis = 400, easing = LinearEasing) ) val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px val lazyGridState = rememberLazyGridState() Box { LazyVerticalGrid( state = lazyGridState, columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), horizontalArrangement = Arrangement.spacedBy( space = Dimensions.itemsVerticalPadding * 2, alignment = Alignment.CenterHorizontally ), modifier = Modifier .fillMaxSize() .background(colorPalette.background0) ) { item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) { Header(title = "Playlists") { SecondaryTextButton( text = "New playlist", onClick = { isCreatingANewPlaylist = true } ) Spacer( modifier = Modifier .weight(1f) ) HeaderIconButton( icon = R.drawable.medical, color = if (sortBy == PlaylistSortBy.SongCount) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = PlaylistSortBy.SongCount } ) HeaderIconButton( icon = R.drawable.text, color = if (sortBy == PlaylistSortBy.Name) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = PlaylistSortBy.Name } ) HeaderIconButton( icon = R.drawable.time, color = if (sortBy == PlaylistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = PlaylistSortBy.DateAdded } ) Spacer( modifier = Modifier .width(2.dp) ) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, onClick = { sortOrder = !sortOrder }, modifier = Modifier .graphicsLayer { rotationZ = sortOrderIconRotation } ) } } item(key = "favorites") { PlaylistItem( icon = R.drawable.heart, colorTint = colorPalette.red, name = "Favorites", songCount = null, thumbnailSizeDp = thumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }) .animateItemPlacement() ) } item(key = "offline") { PlaylistItem( icon = R.drawable.airplane, colorTint = colorPalette.blue, name = "Offline", songCount = null, thumbnailSizeDp = thumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }) .animateItemPlacement() ) } items(items = items, key = { it.playlist.id }) { playlistPreview -> PlaylistItem( playlist = playlistPreview, thumbnailSizeDp = thumbnailSizeDp, thumbnailSizePx = thumbnailSizePx, alternative = true, modifier = Modifier .clickable(onClick = { onPlaylistClick(playlistPreview.playlist) }) .animateItemPlacement() ) } } FloatingActionsContainerWithScrollToTop( lazyGridState = lazyGridState, iconId = R.drawable.search, onClick = onSearchClick ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.platform.LocalContext import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.compose.routing.defaultStacking import it.vfsfitvnm.compose.routing.defaultStill import it.vfsfitvnm.compose.routing.defaultUnstacking import it.vfsfitvnm.compose.routing.isStacking import it.vfsfitvnm.compose.routing.isUnknown import it.vfsfitvnm.compose.routing.isUnstacking import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.SearchQuery import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.artistRoute import it.vfsfitvnm.vimusic.ui.screens.builtInPlaylistRoute import it.vfsfitvnm.vimusic.ui.screens.builtinplaylist.BuiltInPlaylistScreen import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.localPlaylistRoute import it.vfsfitvnm.vimusic.ui.screens.localplaylist.LocalPlaylistScreen import it.vfsfitvnm.vimusic.ui.screens.playlistRoute import it.vfsfitvnm.vimusic.ui.screens.search.SearchScreen import it.vfsfitvnm.vimusic.ui.screens.searchResultRoute import it.vfsfitvnm.vimusic.ui.screens.searchRoute import it.vfsfitvnm.vimusic.ui.screens.searchresult.SearchResultScreen import it.vfsfitvnm.vimusic.ui.screens.settings.SettingsScreen import it.vfsfitvnm.vimusic.ui.screens.settingsRoute import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey import it.vfsfitvnm.vimusic.utils.preferences import it.vfsfitvnm.vimusic.utils.rememberPreference @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun HomeScreen(onPlaylistUrl: (String) -> Unit) { val saveableStateHolder = rememberSaveableStateHolder() PersistMapCleanup("home/") RouteHandler( listenToGlobalEmitter = true, transitionSpec = { when { isStacking -> defaultStacking isUnstacking -> defaultUnstacking isUnknown -> when { initialState.route == searchRoute && targetState.route == searchResultRoute -> defaultStacking initialState.route == searchResultRoute && targetState.route == searchRoute -> defaultUnstacking else -> defaultStill } else -> defaultStill } } ) { globalRoutes() settingsRoute { SettingsScreen() } localPlaylistRoute { playlistId -> LocalPlaylistScreen( playlistId = playlistId ?: error("playlistId cannot be null") ) } builtInPlaylistRoute { builtInPlaylist -> BuiltInPlaylistScreen( builtInPlaylist = builtInPlaylist ) } searchResultRoute { query -> SearchResultScreen( query = query, onSearchAgain = { searchRoute(query) } ) } searchRoute { initialTextInput -> val context = LocalContext.current SearchScreen( initialTextInput = initialTextInput, onSearch = { query -> pop() searchResultRoute(query) if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) { query { Database.insert(SearchQuery(query = query)) } } }, onViewPlaylist = onPlaylistUrl ) } host { val (tabIndex, onTabChanged) = rememberPreference( homeScreenTabIndexKey, defaultValue = 0 ) Scaffold( topIconButtonId = R.drawable.equalizer, onTopIconButtonClick = { settingsRoute() }, tabIndex = tabIndex, onTabChanged = onTabChanged, tabColumnContent = { Item -> Item(0, "Quick picks", R.drawable.sparkles) Item(1, "Songs", R.drawable.musical_notes) Item(2, "Playlists", R.drawable.playlist) Item(3, "Artists", R.drawable.person) Item(4, "Albums", R.drawable.disc) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { 0 -> QuickPicks( onAlbumClick = { albumRoute(it) }, onArtistClick = { artistRoute(it) }, onPlaylistClick = { playlistRoute(it) }, onSearchClick = { searchRoute("") } ) 1 -> HomeSongs( onSearchClick = { searchRoute("") } ) 2 -> HomePlaylists( onBuiltInPlaylist = { builtInPlaylistRoute(it) }, onPlaylistClick = { localPlaylistRoute(it.id) }, onSearchClick = { searchRoute("") } ) 3 -> HomeArtistList( onArtistClick = { artistRoute(it.id) }, onSearchClick = { searchRoute("") } ) 4 -> HomeAlbums( onAlbumClick = { albumRoute(it.id) }, onSearchClick = { searchRoute("") } ) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.SongSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.onOverlay import it.vfsfitvnm.vimusic.ui.styling.overlay import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.songSortByKey import it.vfsfitvnm.vimusic.utils.songSortOrderKey @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun HomeSongs( onSearchClick: () -> Unit ) { val (colorPalette, typography, thumbnailShape) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px var sortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded) var sortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending) var items by persistList("home/songs") LaunchedEffect(sortBy, sortOrder) { Database.songs(sortBy, sortOrder).collect { items = it } } val sortOrderIconRotation by animateFloatAsState( targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, animationSpec = tween(durationMillis = 400, easing = LinearEasing) ) val lazyListState = rememberLazyListState() Box( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), ) { item( key = "header", contentType = 0 ) { Header(title = "Songs") { HeaderIconButton( icon = R.drawable.trending, color = if (sortBy == SongSortBy.PlayTime) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = SongSortBy.PlayTime } ) HeaderIconButton( icon = R.drawable.text, color = if (sortBy == SongSortBy.Title) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = SongSortBy.Title } ) HeaderIconButton( icon = R.drawable.time, color = if (sortBy == SongSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, onClick = { sortBy = SongSortBy.DateAdded } ) Spacer( modifier = Modifier .width(2.dp) ) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, onClick = { sortOrder = !sortOrder }, modifier = Modifier .graphicsLayer { rotationZ = sortOrderIconRotation } ) } } itemsIndexed( items = items, key = { _, song -> song.id } ) { index, song -> SongItem( song = song, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, onThumbnailContent = if (sortBy == SongSortBy.PlayTime) ({ BasicText( text = song.formattedTotalPlayTime, style = typography.xxs.semiBold.center.color(colorPalette.onOverlay), maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier .fillMaxWidth() .background( brush = Brush.verticalGradient( colors = listOf(Color.Transparent, colorPalette.overlay) ), shape = thumbnailShape ) .padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.BottomCenter) ) }) else null, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { InHistoryMediaItemMenu( song = song, onDismiss = menuState::hide ) } }, onClick = { binder?.stopRadio() binder?.player?.forcePlayAtIndex( items.map(Song::asMediaItem), index ) } ) .animateItemPlacement() ) } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.search, onClick = onSearchClick ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.innertube.models.bodies.NextBody import it.vfsfitvnm.innertube.requests.relatedPage import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.items.AlbumItem import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.ArtistItem import it.vfsfitvnm.vimusic.ui.items.ArtistItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.PlaylistItem import it.vfsfitvnm.vimusic.ui.items.PlaylistItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.SnapLayoutInfoProvider import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.isLandscape import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import kotlinx.coroutines.flow.distinctUntilChanged @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun QuickPicks( onAlbumClick: (String) -> Unit, onArtistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit, onSearchClick: () -> Unit, ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val windowInsets = LocalPlayerAwareWindowInsets.current var trending by persist("home/trending") var relatedPageResult by persist?>(tag = "home/relatedPageResult") LaunchedEffect(Unit) { Database.trending().distinctUntilChanged().collect { song -> if ((song == null && relatedPageResult == null) || trending?.id != song?.id) { relatedPageResult = Innertube.relatedPage(NextBody(videoId = (song?.id ?: "J7p4bzqLvCw"))) } trending = song } } val songThumbnailSizeDp = Dimensions.thumbnails.song val songThumbnailSizePx = songThumbnailSizeDp.px val albumThumbnailSizeDp = 108.dp val albumThumbnailSizePx = albumThumbnailSizeDp.px val artistThumbnailSizeDp = 92.dp val artistThumbnailSizePx = artistThumbnailSizeDp.px val playlistThumbnailSizeDp = 108.dp val playlistThumbnailSizePx = playlistThumbnailSizeDp.px val scrollState = rememberScrollState() val quickPicksLazyGridState = rememberLazyGridState() val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() val sectionTextModifier = Modifier .padding(horizontal = 16.dp) .padding(top = 24.dp, bottom = 8.dp) .padding(endPaddingValues) BoxWithConstraints { val quickPicksLazyGridItemWidthFactor = if (isLandscape && maxWidth * 0.475f >= 320.dp) { 0.475f } else { 0.9f } val snapLayoutInfoProvider = remember(quickPicksLazyGridState) { SnapLayoutInfoProvider( lazyGridState = quickPicksLazyGridState, positionInLayout = { layoutSize, itemSize -> (layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f) } ) } val itemInHorizontalGridWidth = maxWidth * quickPicksLazyGridItemWidthFactor Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(scrollState) .padding( windowInsets .only(WindowInsetsSides.Vertical) .asPaddingValues() ) ) { Header( title = "Quick picks", modifier = Modifier .padding(endPaddingValues) ) relatedPageResult?.getOrNull()?.let { related -> LazyHorizontalGrid( state = quickPicksLazyGridState, rows = GridCells.Fixed(4), flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), contentPadding = endPaddingValues, modifier = Modifier .fillMaxWidth() .height((songThumbnailSizeDp + Dimensions.itemsVerticalPadding * 2) * 4) ) { trending?.let { song -> item { SongItem( song = song, thumbnailSizePx = songThumbnailSizePx, thumbnailSizeDp = songThumbnailSizeDp, trailingContent = { Image( painter = painterResource(R.drawable.star), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.accent), modifier = Modifier .size(16.dp) ) }, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, onRemoveFromQuickPicks = { query { Database.clearEventsFor(song.id) } } ) } }, onClick = { val mediaItem = song.asMediaItem binder?.stopRadio() binder?.player?.forcePlay(mediaItem) binder?.setupRadio( NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) ) } ) .animateItemPlacement() .width(itemInHorizontalGridWidth) ) } } items( items = related.songs?.dropLast(if (trending == null) 0 else 1) ?: emptyList(), key = Innertube.SongItem::key ) { song -> SongItem( song = song, thumbnailSizePx = songThumbnailSizePx, thumbnailSizeDp = songThumbnailSizeDp, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem ) } }, onClick = { val mediaItem = song.asMediaItem binder?.stopRadio() binder?.player?.forcePlay(mediaItem) binder?.setupRadio( NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) ) } ) .animateItemPlacement() .width(itemInHorizontalGridWidth) ) } } related.albums?.let { albums -> BasicText( text = "Related albums", style = typography.m.semiBold, modifier = sectionTextModifier ) LazyRow(contentPadding = endPaddingValues) { items( items = albums, key = Innertube.AlbumItem::key ) { album -> AlbumItem( album = album, thumbnailSizePx = albumThumbnailSizePx, thumbnailSizeDp = albumThumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onAlbumClick(album.key) }) ) } } } related.artists?.let { artists -> BasicText( text = "Similar artists", style = typography.m.semiBold, modifier = sectionTextModifier ) LazyRow(contentPadding = endPaddingValues) { items( items = artists, key = Innertube.ArtistItem::key, ) { artist -> ArtistItem( artist = artist, thumbnailSizePx = artistThumbnailSizePx, thumbnailSizeDp = artistThumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onArtistClick(artist.key) }) ) } } } related.playlists?.let { playlists -> BasicText( text = "Playlists you might like", style = typography.m.semiBold, modifier = Modifier .padding(horizontal = 16.dp) .padding(top = 24.dp, bottom = 8.dp) ) LazyRow(contentPadding = endPaddingValues) { items( items = playlists, key = Innertube.PlaylistItem::key, ) { playlist -> PlaylistItem( playlist = playlist, thumbnailSizePx = playlistThumbnailSizePx, thumbnailSizeDp = playlistThumbnailSizeDp, alternative = true, modifier = Modifier .clickable(onClick = { onPlaylistClick(playlist.key) }) ) } } } Unit } ?: relatedPageResult?.exceptionOrNull()?.let { BasicText( text = "An error has occurred", style = typography.s.secondary.center, modifier = Modifier .align(Alignment.CenterHorizontally) .padding(all = 16.dp) ) } ?: ShimmerHost { repeat(4) { SongItemPlaceholder( thumbnailSizeDp = songThumbnailSizeDp, ) } TextPlaceholder(modifier = sectionTextModifier) Row { repeat(2) { AlbumItemPlaceholder( thumbnailSizeDp = albumThumbnailSizeDp, alternative = true ) } } TextPlaceholder(modifier = sectionTextModifier) Row { repeat(2) { ArtistItemPlaceholder( thumbnailSizeDp = albumThumbnailSizeDp, alternative = true ) } } TextPlaceholder(modifier = sectionTextModifier) Row { repeat(2) { PlaylistItemPlaceholder( thumbnailSizeDp = albumThumbnailSizeDp, alternative = true ) } } } } FloatingActionsContainerWithScrollToTop( scrollState = scrollState, iconId = R.drawable.search, onClick = onSearchClick ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.localplaylist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.globalRoutes @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun LocalPlaylistScreen(playlistId: Long) { val saveableStateHolder = rememberSaveableStateHolder() PersistMapCleanup(tagPrefix = "localPlaylist/$playlistId/") RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = 0, onTabChanged = { }, tabColumnContent = { Item -> Item(0, "Songs", R.drawable.musical_notes) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { when (currentTabIndex) { 0 -> LocalPlaylistSongs( playlistId = playlistId, onDelete = pop ) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.localplaylist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.requests.playlistPage import it.vfsfitvnm.compose.reordering.ReorderingLazyColumn import it.vfsfitvnm.compose.reordering.animateItemPlacement import it.vfsfitvnm.compose.reordering.draggedItem import it.vfsfitvnm.compose.reordering.rememberReorderingState import it.vfsfitvnm.compose.reordering.reorder import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.PlaylistWithSongs import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongPlaylistMap import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.transaction import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.components.themed.IconButton import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.Menu import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.completed import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @ExperimentalAnimationApi @ExperimentalFoundationApi @Composable fun LocalPlaylistSongs( playlistId: Long, onDelete: () -> Unit, ) { val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current var playlistWithSongs by persist("localPlaylist/$playlistId/playlistWithSongs") LaunchedEffect(Unit) { Database.playlistWithSongs(playlistId).filterNotNull().collect { playlistWithSongs = it } } val lazyListState = rememberLazyListState() val reorderingState = rememberReorderingState( lazyListState = lazyListState, key = playlistWithSongs?.songs ?: emptyList(), onDragEnd = { fromIndex, toIndex -> query { Database.move(playlistId, fromIndex, toIndex) } }, extraItemCount = 1 ) var isRenaming by rememberSaveable { mutableStateOf(false) } if (isRenaming) { TextFieldDialog( hintText = "Enter the playlist name", initialTextInput = playlistWithSongs?.playlist?.name ?: "", onDismiss = { isRenaming = false }, onDone = { text -> query { playlistWithSongs?.playlist?.copy(name = text)?.let(Database::update) } } ) } var isDeleting by rememberSaveable { mutableStateOf(false) } if (isDeleting) { ConfirmationDialog( text = "Do you really want to delete this playlist?", onDismiss = { isDeleting = false }, onConfirm = { query { playlistWithSongs?.playlist?.let(Database::delete) } onDelete() } ) } val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px val rippleIndication = rememberRipple(bounded = false) Box { ReorderingLazyColumn( reorderingState = reorderingState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Header( title = playlistWithSongs?.playlist?.name ?: "Unknown", modifier = Modifier .padding(bottom = 8.dp) ) { SecondaryTextButton( text = "Enqueue", enabled = playlistWithSongs?.songs?.isNotEmpty() == true, onClick = { playlistWithSongs?.songs ?.map(Song::asMediaItem) ?.let { mediaItems -> binder?.player?.enqueue(mediaItems) } } ) Spacer( modifier = Modifier .weight(1f) ) HeaderIconButton( icon = R.drawable.ellipsis_horizontal, color = colorPalette.text, onClick = { menuState.display { Menu { playlistWithSongs?.playlist?.browseId?.let { browseId -> MenuEntry( icon = R.drawable.sync, text = "Sync", onClick = { menuState.hide() transaction { runBlocking(Dispatchers.IO) { withContext(Dispatchers.IO) { Innertube.playlistPage(BrowseBody(browseId = browseId)) ?.completed() } }?.getOrNull()?.let { remotePlaylist -> Database.clearPlaylist(playlistId) remotePlaylist.songsPage ?.items ?.map(Innertube.SongItem::asMediaItem) ?.onEach(Database::insert) ?.mapIndexed { position, mediaItem -> SongPlaylistMap( songId = mediaItem.mediaId, playlistId = playlistId, position = position ) }?.let(Database::insertSongPlaylistMaps) } } } ) } MenuEntry( icon = R.drawable.pencil, text = "Rename", onClick = { menuState.hide() isRenaming = true } ) MenuEntry( icon = R.drawable.trash, text = "Delete", onClick = { menuState.hide() isDeleting = true } ) } } } ) } } itemsIndexed( items = playlistWithSongs?.songs ?: emptyList(), key = { _, song -> song.id }, contentType = { _, song -> song }, ) { index, song -> SongItem( song = song, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, trailingContent = { IconButton( icon = R.drawable.reorder, color = colorPalette.textDisabled, indication = rippleIndication, onClick = {}, modifier = Modifier .reorder(reorderingState = reorderingState, index = index) .size(18.dp) ) }, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { InPlaylistMediaItemMenu( playlistId = playlistId, positionInPlaylist = index, song = song, onDismiss = menuState::hide ) } }, onClick = { playlistWithSongs?.songs ?.map(Song::asMediaItem) ?.let { mediaItems -> binder?.stopRadio() binder?.player?.forcePlayAtIndex(mediaItems, index) } } ) .animateItemPlacement(reorderingState = reorderingState) .draggedItem(reorderingState = reorderingState, index = index) ) } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.shuffle, visible = !reorderingState.isDragging, onClick = { playlistWithSongs?.songs?.let { songs -> if (songs.isNotEmpty()) { binder?.stopRadio() binder?.player?.forcePlayFromBeginning( songs.shuffled().map(Song::asMediaItem) ) } } } ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.Player import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.SeekBar import it.vfsfitvnm.vimusic.ui.components.themed.IconButton import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon import it.vfsfitvnm.vimusic.utils.bold import it.vfsfitvnm.vimusic.utils.forceSeekToNext import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious import it.vfsfitvnm.vimusic.utils.formatAsDuration import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.trackLoopEnabledKey import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun Controls( mediaId: String, title: String?, artist: String?, shouldBePlaying: Boolean, position: Long, duration: Long, modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current binder?.player ?: return var trackLoopEnabled by rememberPreference(trackLoopEnabledKey, defaultValue = false) var scrubbingPosition by remember(mediaId) { mutableStateOf(null) } var likedAt by rememberSaveable { mutableStateOf(null) } LaunchedEffect(mediaId) { Database.likedAt(mediaId).distinctUntilChanged().collect { likedAt = it } } val shouldBePlayingTransition = updateTransition(shouldBePlaying, label = "shouldBePlaying") val playPauseRoundness by shouldBePlayingTransition.animateDp( transitionSpec = { tween(durationMillis = 100, easing = LinearEasing) }, label = "playPauseRoundness", targetValueByState = { if (it) 32.dp else 16.dp } ) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxWidth() .padding(horizontal = 32.dp) ) { Spacer( modifier = Modifier .weight(1f) ) BasicText( text = title ?: "", style = typography.l.bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) BasicText( text = artist ?: "", style = typography.s.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis ) Spacer( modifier = Modifier .weight(0.5f) ) SeekBar( value = scrubbingPosition ?: position, minimumValue = 0, maximumValue = duration, onDragStart = { scrubbingPosition = it }, onDrag = { delta -> scrubbingPosition = if (duration != C.TIME_UNSET) { scrubbingPosition?.plus(delta)?.coerceIn(0, duration) } else { null } }, onDragEnd = { scrubbingPosition?.let(binder.player::seekTo) scrubbingPosition = null }, color = colorPalette.text, backgroundColor = colorPalette.background2, shape = RoundedCornerShape(8.dp) ) Spacer( modifier = Modifier .height(8.dp) ) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() ) { BasicText( text = formatAsDuration(scrubbingPosition ?: position), style = typography.xxs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (duration != C.TIME_UNSET) { BasicText( text = formatAsDuration(duration), style = typography.xxs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } Spacer( modifier = Modifier .weight(1f) ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() ) { IconButton( icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, color = colorPalette.favoritesIcon, onClick = { val currentMediaItem = binder.player.currentMediaItem query { if (Database.like( mediaId, if (likedAt == null) System.currentTimeMillis() else null ) == 0 ) { currentMediaItem ?.takeIf { it.mediaId == mediaId } ?.let { Database.insert(currentMediaItem, Song::toggleLike) } } } }, modifier = Modifier .weight(1f) .size(24.dp) ) IconButton( icon = R.drawable.play_skip_back, color = colorPalette.text, onClick = binder.player::forceSeekToPrevious, modifier = Modifier .weight(1f) .size(24.dp) ) Spacer( modifier = Modifier .width(8.dp) ) Box( modifier = Modifier .clip(RoundedCornerShape(playPauseRoundness)) .clickable { if (shouldBePlaying) { binder.player.pause() } else { if (binder.player.playbackState == Player.STATE_IDLE) { binder.player.prepare() } binder.player.play() } } .background(colorPalette.background2) .size(64.dp) ) { Image( painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .align(Alignment.Center) .size(28.dp) ) } Spacer( modifier = Modifier .width(8.dp) ) IconButton( icon = R.drawable.play_skip_forward, color = colorPalette.text, onClick = binder.player::forceSeekToNext, modifier = Modifier .weight(1f) .size(24.dp) ) IconButton( icon = R.drawable.infinite, color = if (trackLoopEnabled) colorPalette.text else colorPalette.textDisabled, onClick = { trackLoopEnabled = !trackLoopEnabled }, modifier = Modifier .weight(1f) .size(24.dp) ) } Spacer( modifier = Modifier .weight(1f) ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import android.app.SearchManager import android.content.ActivityNotFoundException import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.MediaMetadata import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.NextBody import it.vfsfitvnm.innertube.requests.lyrics import it.vfsfitvnm.kugou.KuGou import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Lyrics import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.Menu import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.DefaultDarkColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.PureBlackColorPalette import it.vfsfitvnm.vimusic.ui.styling.onOverlayShimmer import it.vfsfitvnm.vimusic.utils.SynchronizedLyrics import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.toast import it.vfsfitvnm.vimusic.utils.verticalFadingEdge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @Composable fun Lyrics( mediaId: String, isDisplayed: Boolean, onDismiss: () -> Unit, size: Dp, mediaMetadataProvider: () -> MediaMetadata, durationProvider: () -> Long, ensureSongInserted: () -> Unit, modifier: Modifier = Modifier ) { AnimatedVisibility( visible = isDisplayed, enter = fadeIn(), exit = fadeOut(), ) { val (colorPalette, typography) = LocalAppearance.current val context = LocalContext.current val menuState = LocalMenuState.current val currentView = LocalView.current var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false) var isEditing by remember(mediaId, isShowingSynchronizedLyrics) { mutableStateOf(false) } var lyrics by remember { mutableStateOf(null) } val text = if (isShowingSynchronizedLyrics) lyrics?.synced else lyrics?.fixed var isError by remember(mediaId, isShowingSynchronizedLyrics) { mutableStateOf(false) } LaunchedEffect(mediaId, isShowingSynchronizedLyrics) { withContext(Dispatchers.IO) { Database.lyrics(mediaId).collect { if (isShowingSynchronizedLyrics && it?.synced == null) { val mediaMetadata = mediaMetadataProvider() var duration = withContext(Dispatchers.Main) { durationProvider() } while (duration == C.TIME_UNSET) { delay(100) duration = withContext(Dispatchers.Main) { durationProvider() } } KuGou.lyrics( artist = mediaMetadata.artist?.toString() ?: "", title = mediaMetadata.title?.toString() ?: "", duration = duration / 1000 )?.onSuccess { syncedLyrics -> Database.upsert( Lyrics( songId = mediaId, fixed = it?.fixed, synced = syncedLyrics?.value ?: "" ) ) }?.onFailure { isError = true } } else if (!isShowingSynchronizedLyrics && it?.fixed == null) { Innertube.lyrics(NextBody(videoId = mediaId))?.onSuccess { fixedLyrics -> Database.upsert( Lyrics( songId = mediaId, fixed = fixedLyrics ?: "", synced = it?.synced ) ) }?.onFailure { isError = true } } else { lyrics = it } } } } if (isEditing) { TextFieldDialog( hintText = "Enter the lyrics", initialTextInput = text ?: "", singleLine = false, maxLines = 10, isTextInputValid = { true }, onDismiss = { isEditing = false }, onDone = { query { ensureSongInserted() Database.upsert( Lyrics( songId = mediaId, fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else it, synced = if (isShowingSynchronizedLyrics) it else lyrics?.synced, ) ) } } ) } if (isShowingSynchronizedLyrics) { DisposableEffect(Unit) { currentView.keepScreenOn = true onDispose { currentView.keepScreenOn = false } } } Box( contentAlignment = Alignment.Center, modifier = modifier .pointerInput(Unit) { detectTapGestures( onTap = { onDismiss() } ) } .fillMaxSize() .background(Color.Black.copy(0.8f)) ) { AnimatedVisibility( visible = isError && text == null, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier .align(Alignment.TopCenter) ) { BasicText( text = "An error has occurred while fetching the ${if (isShowingSynchronizedLyrics) "synchronized " else ""}lyrics", style = typography.xs.center.medium.color(PureBlackColorPalette.text), modifier = Modifier .background(Color.Black.copy(0.4f)) .padding(all = 8.dp) .fillMaxWidth() ) } AnimatedVisibility( visible = text?.let(String::isEmpty) ?: false, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier .align(Alignment.TopCenter) ) { BasicText( text = "${if (isShowingSynchronizedLyrics) "Synchronized l" else "L"}yrics are not available for this song", style = typography.xs.center.medium.color(PureBlackColorPalette.text), modifier = Modifier .background(Color.Black.copy(0.4f)) .padding(all = 8.dp) .fillMaxWidth() ) } if (text?.isNotEmpty() == true) { if (isShowingSynchronizedLyrics) { val density = LocalDensity.current val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility val synchronizedLyrics = remember(text) { SynchronizedLyrics(KuGou.Lyrics(text).sentences) { player.currentPosition + 50 } } val lazyListState = rememberLazyListState( synchronizedLyrics.index, with(density) { size.roundToPx() } / 6) LaunchedEffect(synchronizedLyrics) { val center = with(density) { size.roundToPx() } / 6 while (isActive) { delay(50) if (synchronizedLyrics.update()) { lazyListState.animateScrollToItem( synchronizedLyrics.index, center ) } } } LazyColumn( state = lazyListState, userScrollEnabled = false, contentPadding = PaddingValues(vertical = size / 2), horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .verticalFadingEdge() ) { itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence -> BasicText( text = sentence.second, style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled), modifier = Modifier .padding(vertical = 4.dp, horizontal = 32.dp) ) } } } else { BasicText( text = text, style = typography.xs.center.medium.color(PureBlackColorPalette.text), modifier = Modifier .verticalFadingEdge() .verticalScroll(rememberScrollState()) .fillMaxWidth() .padding(vertical = size / 4, horizontal = 32.dp) ) } } if (text == null && !isError) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .shimmer() ) { repeat(4) { TextPlaceholder( color = colorPalette.onOverlayShimmer, modifier = Modifier .alpha(1f - it * 0.2f) ) } } } Image( painter = painterResource(R.drawable.ellipsis_horizontal), contentDescription = null, colorFilter = ColorFilter.tint(DefaultDarkColorPalette.text), modifier = Modifier .padding(all = 4.dp) .clickable( indication = rememberRipple(bounded = false), interactionSource = remember { MutableInteractionSource() }, onClick = { menuState.display { Menu { MenuEntry( icon = R.drawable.time, text = "Show ${if (isShowingSynchronizedLyrics) "un" else ""}synchronized lyrics", secondaryText = if (isShowingSynchronizedLyrics) null else "Provided by kugou.com", onClick = { menuState.hide() isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics } ) MenuEntry( icon = R.drawable.pencil, text = "Edit lyrics", onClick = { menuState.hide() isEditing = true } ) MenuEntry( icon = R.drawable.search, text = "Search lyrics online", onClick = { menuState.hide() val mediaMetadata = mediaMetadataProvider() try { context.startActivity( Intent(Intent.ACTION_WEB_SEARCH).apply { putExtra( SearchManager.QUERY, "${mediaMetadata.title} ${mediaMetadata.artist} lyrics" ) } ) } catch (e: ActivityNotFoundException) { context.toast("Couldn't find an application to browse the Internet") } } ) MenuEntry( icon = R.drawable.download, text = "Fetch lyrics again", enabled = lyrics != null, onClick = { menuState.hide() query { Database.upsert( Lyrics( songId = mediaId, fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else null, synced = if (isShowingSynchronizedLyrics) null else lyrics?.synced, ) ) } } ) } } } ) .padding(all = 8.dp) .size(20.dp) .align(Alignment.BottomEnd) ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.PureBlackColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.medium @Composable fun PlaybackError( isDisplayed: Boolean, messageProvider: () -> String, onDismiss: () -> Unit, modifier: Modifier = Modifier ) { val (_, typography) = LocalAppearance.current Box { AnimatedVisibility( visible = isDisplayed, enter = fadeIn(), exit = fadeOut(), ) { Spacer( modifier = modifier .pointerInput(Unit) { detectTapGestures( onTap = { onDismiss() } ) } .fillMaxSize() .background(Color.Black.copy(0.8f)) ) } AnimatedVisibility( visible = isDisplayed, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier .align(Alignment.TopCenter) ) { BasicText( text = remember { messageProvider() }, style = typography.xs.center.medium.color(PureBlackColorPalette.text), modifier = Modifier .background(Color.Black.copy(0.4f)) .padding(all = 8.dp) .fillMaxWidth() ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import android.content.ActivityNotFoundException import android.content.Intent import android.media.audiofx.AudioEffect import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.Player import coil.compose.AsyncImage import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.compose.routing.OnGlobalRoute import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.service.PlayerService import it.vfsfitvnm.vimusic.ui.components.BottomSheet import it.vfsfitvnm.vimusic.ui.components.BottomSheetState import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.IconButton import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.collapsedPlayerProgressBar import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.DisposableListener import it.vfsfitvnm.vimusic.utils.forceSeekToNext import it.vfsfitvnm.vimusic.utils.isLandscape import it.vfsfitvnm.vimusic.utils.positionAndDurationState import it.vfsfitvnm.vimusic.utils.seamlessPlay import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.shouldBePlaying import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.vimusic.utils.toast import kotlin.math.absoluteValue @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun Player( layoutState: BottomSheetState, modifier: Modifier = Modifier, ) { val menuState = LocalMenuState.current val (colorPalette, typography, thumbnailShape) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current binder?.player ?: return var nullableMediaItem by remember { mutableStateOf(binder.player.currentMediaItem, neverEqualPolicy()) } var shouldBePlaying by remember { mutableStateOf(binder.player.shouldBePlaying) } binder.player.DisposableListener { object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { nullableMediaItem = mediaItem } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { shouldBePlaying = binder.player.shouldBePlaying } override fun onPlaybackStateChanged(playbackState: Int) { shouldBePlaying = binder.player.shouldBePlaying } } } val mediaItem = nullableMediaItem ?: return val positionAndDuration by binder.player.positionAndDurationState() val windowInsets = WindowInsets.systemBars val horizontalBottomPaddingValues = windowInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() OnGlobalRoute { layoutState.collapseSoft() } BottomSheet( state = layoutState, modifier = modifier, onDismiss = { binder.stopRadio() binder.player.clearMediaItems() }, collapsedContent = { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, modifier = Modifier .background(colorPalette.background1) .fillMaxSize() .padding(horizontalBottomPaddingValues) .drawBehind { val progress = positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue drawLine( color = colorPalette.collapsedPlayerProgressBar, start = Offset(x = 0f, y = 1.dp.toPx()), end = Offset(x = size.width * progress, y = 1.dp.toPx()), strokeWidth = 2.dp.toPx() ) } ) { Spacer( modifier = Modifier .width(2.dp) ) Box( contentAlignment = Alignment.Center, modifier = Modifier .height(Dimensions.collapsedPlayer) ) { AsyncImage( model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.song.px), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .clip(thumbnailShape) .size(48.dp) ) } Column( verticalArrangement = Arrangement.Center, modifier = Modifier .height(Dimensions.collapsedPlayer) .weight(1f) ) { BasicText( text = mediaItem.mediaMetadata.title?.toString() ?: "", style = typography.xs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) BasicText( text = mediaItem.mediaMetadata.artist?.toString() ?: "", style = typography.xs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Spacer( modifier = Modifier .width(2.dp) ) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .height(Dimensions.collapsedPlayer) ) { IconButton( icon = if (shouldBePlaying) R.drawable.pause else R.drawable.play, color = colorPalette.text, onClick = { if (shouldBePlaying) { binder.player.pause() } else { if (binder.player.playbackState == Player.STATE_IDLE) { binder.player.prepare() } binder.player.play() } }, modifier = Modifier .padding(horizontal = 4.dp, vertical = 8.dp) .size(20.dp) ) IconButton( icon = R.drawable.play_skip_forward, color = colorPalette.text, onClick = binder.player::forceSeekToNext, modifier = Modifier .padding(horizontal = 4.dp, vertical = 8.dp) .size(20.dp) ) } Spacer( modifier = Modifier .width(2.dp) ) } } ) { var isShowingLyrics by rememberSaveable { mutableStateOf(false) } var isShowingStatsForNerds by rememberSaveable { mutableStateOf(false) } val playerBottomSheetState = rememberBottomSheetState( 64.dp + horizontalBottomPaddingValues.calculateBottomPadding(), layoutState.expandedBound ) val containerModifier = Modifier .background(colorPalette.background1) .padding( windowInsets .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) .asPaddingValues() ) .padding(bottom = playerBottomSheetState.collapsedBound) val thumbnailContent: @Composable (modifier: Modifier) -> Unit = { modifier -> Thumbnail( isShowingLyrics = isShowingLyrics, onShowLyrics = { isShowingLyrics = it }, isShowingStatsForNerds = isShowingStatsForNerds, onShowStatsForNerds = { isShowingStatsForNerds = it }, modifier = modifier .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) ) } val controlsContent: @Composable (modifier: Modifier) -> Unit = { modifier -> Controls( mediaId = mediaItem.mediaId, title = mediaItem.mediaMetadata.title?.toString(), artist = mediaItem.mediaMetadata.artist?.toString(), shouldBePlaying = shouldBePlaying, position = positionAndDuration.first, duration = positionAndDuration.second, modifier = modifier ) } if (isLandscape) { Row( verticalAlignment = Alignment.CenterVertically, modifier = containerModifier .padding(top = 32.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .weight(0.66f) .padding(bottom = 16.dp) ) { thumbnailContent( modifier = Modifier .padding(horizontal = 16.dp) ) } controlsContent( modifier = Modifier .padding(vertical = 8.dp) .fillMaxHeight() .weight(1f) ) } } else { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = containerModifier .padding(top = 54.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .weight(1.25f) ) { thumbnailContent( modifier = Modifier .padding(horizontal = 32.dp, vertical = 8.dp) ) } controlsContent( modifier = Modifier .padding(vertical = 8.dp) .fillMaxWidth() .weight(1f) ) } } Queue( layoutState = playerBottomSheetState, content = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, modifier = Modifier .align(Alignment.BottomEnd) .padding(horizontal = 8.dp) .fillMaxHeight() ) { IconButton( icon = R.drawable.ellipsis_horizontal, color = colorPalette.text, onClick = { menuState.display { PlayerMenu( onDismiss = menuState::hide, mediaItem = mediaItem, binder = binder ) } }, modifier = Modifier .padding(horizontal = 4.dp, vertical = 8.dp) .size(20.dp) ) Spacer( modifier = Modifier .width(4.dp) ) } }, backgroundColorProvider = { colorPalette.background2 }, modifier = Modifier .align(Alignment.BottomCenter) ) } } @ExperimentalAnimationApi @Composable private fun PlayerMenu( binder: PlayerService.Binder, mediaItem: MediaItem, onDismiss: () -> Unit ) { val context = LocalContext.current val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } BaseMediaItemMenu( mediaItem = mediaItem, onStartRadio = { binder.stopRadio() binder.player.seamlessPlay(mediaItem) binder.setupRadio(NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)) }, onGoToEqualizer = { try { activityResultLauncher.launch( Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder.player.audioSessionId) putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) } ) } catch (e: ActivityNotFoundException) { context.toast("Couldn't find an application to equalize audio") } }, onShowSleepTimer = {}, onDismiss = onDismiss ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.compose.reordering.ReorderingLazyColumn import it.vfsfitvnm.compose.reordering.animateItemPlacement import it.vfsfitvnm.compose.reordering.draggedItem import it.vfsfitvnm.compose.reordering.rememberReorderingState import it.vfsfitvnm.compose.reordering.reorder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.BottomSheet import it.vfsfitvnm.vimusic.ui.components.BottomSheetState import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.MusicBars import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.IconButton import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.onOverlay import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.DisposableListener import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.queueLoopEnabledKey import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.shouldBePlaying import it.vfsfitvnm.vimusic.utils.shuffleQueue import it.vfsfitvnm.vimusic.utils.smoothScrollToTop import it.vfsfitvnm.vimusic.utils.windows import kotlinx.coroutines.launch @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun Queue( backgroundColorProvider: () -> Color, layoutState: BottomSheetState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit, ) { val (colorPalette, typography, thumbnailShape) = LocalAppearance.current val windowInsets = WindowInsets.systemBars val horizontalBottomPaddingValues = windowInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() BottomSheet( state = layoutState, modifier = modifier, collapsedContent = { Box( modifier = Modifier .drawBehind { drawRect(backgroundColorProvider()) } .fillMaxSize() .padding(horizontalBottomPaddingValues) ) { Image( painter = painterResource(R.drawable.playlist), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .align(Alignment.Center) .size(18.dp) ) content() } } ) { val binder = LocalPlayerServiceBinder.current binder?.player ?: return@BottomSheet val player = binder.player var queueLoopEnabled by rememberPreference(queueLoopEnabledKey, defaultValue = true) val menuState = LocalMenuState.current val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px var mediaItemIndex by remember { mutableStateOf(if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) } var windows by remember { mutableStateOf(player.currentTimeline.windows) } var shouldBePlaying by remember { mutableStateOf(binder.player.shouldBePlaying) } player.DisposableListener { object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { mediaItemIndex = if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex } override fun onTimelineChanged(timeline: Timeline, reason: Int) { windows = timeline.windows mediaItemIndex = if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { shouldBePlaying = binder.player.shouldBePlaying } override fun onPlaybackStateChanged(playbackState: Int) { shouldBePlaying = binder.player.shouldBePlaying } } } val reorderingState = rememberReorderingState( lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex), key = windows, onDragEnd = player::moveMediaItem, extraItemCount = 0 ) val rippleIndication = rememberRipple(bounded = false) val musicBarsTransition = updateTransition(targetState = mediaItemIndex, label = "") Column { Box( modifier = Modifier .background(colorPalette.background1) .weight(1f) ) { ReorderingLazyColumn( reorderingState = reorderingState, contentPadding = windowInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) .asPaddingValues(), horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) ) { items( items = windows, key = { it.uid.hashCode() } ) { window -> val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex SongItem( song = window.mediaItem, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, onThumbnailContent = { musicBarsTransition.AnimatedVisibility( visible = { it == window.firstPeriodIndex }, enter = fadeIn(tween(800)), exit = fadeOut(tween(800)), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .background( color = Color.Black.copy(alpha = 0.25f), shape = thumbnailShape ) .size(Dimensions.thumbnails.song) ) { if (shouldBePlaying) { MusicBars( color = colorPalette.onOverlay, modifier = Modifier .height(24.dp) ) } else { Image( painter = painterResource(R.drawable.play), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.onOverlay), modifier = Modifier .size(24.dp) ) } } } }, trailingContent = { IconButton( icon = R.drawable.reorder, color = colorPalette.textDisabled, indication = rippleIndication, onClick = {}, modifier = Modifier .reorder( reorderingState = reorderingState, index = window.firstPeriodIndex ) .size(18.dp) ) }, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { QueuedMediaItemMenu( mediaItem = window.mediaItem, indexInQueue = if (isPlayingThisMediaItem) null else window.firstPeriodIndex, onDismiss = menuState::hide ) } }, onClick = { if (isPlayingThisMediaItem) { if (shouldBePlaying) { player.pause() } else { player.play() } } else { player.seekToDefaultPosition(window.firstPeriodIndex) player.playWhenReady = true } } ) .animateItemPlacement(reorderingState = reorderingState) .draggedItem( reorderingState = reorderingState, index = window.firstPeriodIndex ) ) } item { if (binder.isLoadingRadio) { Column( modifier = Modifier .shimmer() ) { repeat(3) { index -> SongItemPlaceholder( thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .alpha(1f - index * 0.125f) .fillMaxWidth() ) } } } } } FloatingActionsContainerWithScrollToTop( lazyListState = reorderingState.lazyListState, iconId = R.drawable.shuffle, visible = !reorderingState.isDragging, windowInsets = windowInsets.only(WindowInsetsSides.Horizontal), onClick = { reorderingState.coroutineScope.launch { reorderingState.lazyListState.smoothScrollToTop() }.invokeOnCompletion { player.shuffleQueue() } } ) } Box( modifier = Modifier .clickable(onClick = layoutState::collapseSoft) .background(colorPalette.background2) .fillMaxWidth() .padding(horizontal = 12.dp) .padding(horizontalBottomPaddingValues) .height(64.dp) ) { BasicText( text = "${windows.size} songs", style = typography.xxs.medium, modifier = Modifier .background( color = colorPalette.background1, shape = RoundedCornerShape(16.dp) ) .align(Alignment.CenterStart) .padding(all = 8.dp) ) Image( painter = painterResource(R.drawable.chevron_down), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .align(Alignment.Center) .size(18.dp) ) Row( modifier = Modifier .clip(RoundedCornerShape(16.dp)) .clickable { queueLoopEnabled = !queueLoopEnabled } .background(colorPalette.background1) .padding(horizontal = 16.dp, vertical = 8.dp) .align(Alignment.CenterEnd) .animateContentSize() ) { BasicText( text = "Queue loop ", style = typography.xxs.medium, ) AnimatedContent( targetState = queueLoopEnabled, transitionSpec = { val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Up else AnimatedContentScope.SlideDirection.Down ContentTransform( targetContentEnter = slideIntoContainer(slideDirection) + fadeIn(), initialContentExit = slideOutOfContainer(slideDirection) + fadeOut(), ) } ) { BasicText( text = if (it) "on" else "off", style = typography.xxs.medium, ) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import android.text.format.Formatter import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheSpan import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.PlayerBody import it.vfsfitvnm.innertube.requests.player import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.models.Format import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.onOverlay import it.vfsfitvnm.vimusic.ui.styling.overlay import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.medium import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.withContext @Composable fun StatsForNerds( mediaId: String, isDisplayed: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current val context = LocalContext.current val binder = LocalPlayerServiceBinder.current ?: return AnimatedVisibility( visible = isDisplayed, enter = fadeIn(), exit = fadeOut(), ) { var cachedBytes by remember(mediaId) { mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1)) } var format by remember { mutableStateOf(null) } LaunchedEffect(mediaId) { Database.format(mediaId).distinctUntilChanged().collectLatest { currentFormat -> if (currentFormat?.itag == null) { binder.player.currentMediaItem?.takeIf { it.mediaId == mediaId }?.let { mediaItem -> withContext(Dispatchers.IO) { delay(2000) Innertube.player(PlayerBody(videoId = mediaId))?.onSuccess { response -> response.streamingData?.highestQualityFormat?.let { format -> Database.insert(mediaItem) Database.insert( Format( songId = mediaId, itag = format.itag, mimeType = format.mimeType, bitrate = format.bitrate, loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb, contentLength = format.contentLength, lastModified = format.lastModified ) ) } } } } } else { format = currentFormat } } } DisposableEffect(mediaId) { val listener = object : Cache.Listener { override fun onSpanAdded(cache: Cache, span: CacheSpan) { cachedBytes += span.length } override fun onSpanRemoved(cache: Cache, span: CacheSpan) { cachedBytes -= span.length } override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) = Unit } binder.cache.addListener(mediaId, listener) onDispose { binder.cache.removeListener(mediaId, listener) } } Box( modifier = modifier .pointerInput(Unit) { detectTapGestures( onTap = { onDismiss() } ) } .background(colorPalette.overlay) .fillMaxSize() ) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .align(Alignment.Center) .padding(all = 16.dp) ) { Column(horizontalAlignment = Alignment.End) { BasicText( text = "Id", style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = "Itag", style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = "Bitrate", style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = "Size", style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = "Cached", style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = "Loudness", style = typography.xs.medium.color(colorPalette.onOverlay) ) } Column { BasicText( text = mediaId, maxLines = 1, style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = format?.itag?.toString() ?: "Unknown", maxLines = 1, style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = format?.bitrate?.let { "${it / 1000} kbps" } ?: "Unknown", maxLines = 1, style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = format?.contentLength ?.let { Formatter.formatShortFileSize(context, it) } ?: "Unknown", maxLines = 1, style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = buildString { append(Formatter.formatShortFileSize(context, cachedBytes)) format?.contentLength?.let { append(" (${(cachedBytes.toFloat() / it * 100).roundToInt()}%)") } }, maxLines = 1, style = typography.xs.medium.color(colorPalette.onOverlay) ) BasicText( text = format?.loudnessDb?.let { "%.2f dB".format(it) } ?: "Unknown", maxLines = 1, style = typography.xs.medium.color(colorPalette.onOverlay) ) } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.player import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import coil.compose.AsyncImage import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.service.LoginRequiredException import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException import it.vfsfitvnm.vimusic.service.UnplayableException import it.vfsfitvnm.vimusic.service.VideoIdMismatchException import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.currentWindow import it.vfsfitvnm.vimusic.utils.DisposableListener import it.vfsfitvnm.vimusic.utils.thumbnail import java.net.UnknownHostException import java.nio.channels.UnresolvedAddressException @ExperimentalAnimationApi @Composable fun Thumbnail( isShowingLyrics: Boolean, onShowLyrics: (Boolean) -> Unit, isShowingStatsForNerds: Boolean, onShowStatsForNerds: (Boolean) -> Unit, modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current val player = binder?.player ?: return val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let { it to (it - 64.dp).px } var nullableWindow by remember { mutableStateOf(player.currentWindow) } var error by remember { mutableStateOf(player.playerError) } player.DisposableListener { object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { nullableWindow = player.currentWindow } override fun onPlaybackStateChanged(playbackState: Int) { error = player.playerError } override fun onPlayerError(playbackException: PlaybackException) { error = playbackException } } } val window = nullableWindow ?: return AnimatedContent( targetState = window, transitionSpec = { val duration = 500 val slideDirection = if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right ContentTransform( targetContentEnter = slideIntoContainer( towards = slideDirection, animationSpec = tween(duration) ) + fadeIn( animationSpec = tween(duration) ) + scaleIn( initialScale = 0.85f, animationSpec = tween(duration) ), initialContentExit = slideOutOfContainer( towards = slideDirection, animationSpec = tween(duration) ) + fadeOut( animationSpec = tween(duration) ) + scaleOut( targetScale = 0.85f, animationSpec = tween(duration) ), sizeTransform = SizeTransform(clip = false) ) }, contentAlignment = Alignment.Center ) {currentWindow -> Box( modifier = modifier .aspectRatio(1f) .clip(LocalAppearance.current.thumbnailShape) .size(thumbnailSizeDp) ) { AsyncImage( model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .pointerInput(Unit) { detectTapGestures( onTap = { onShowLyrics(true) }, onLongPress = { onShowStatsForNerds(true) } ) } .fillMaxSize() ) Lyrics( mediaId = currentWindow.mediaItem.mediaId, isDisplayed = isShowingLyrics && error == null, onDismiss = { onShowLyrics(false) }, ensureSongInserted = { Database.insert(currentWindow.mediaItem) }, size = thumbnailSizeDp, mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata, durationProvider = player::getDuration, ) StatsForNerds( mediaId = currentWindow.mediaItem.mediaId, isDisplayed = isShowingStatsForNerds && error == null, onDismiss = { onShowStatsForNerds(false) } ) PlaybackError( isDisplayed = error != null, messageProvider = { when (error?.cause?.cause) { is UnresolvedAddressException, is UnknownHostException -> "A network error has occurred" is PlayableFormatNotFoundException -> "Couldn't find a playable audio format" is UnplayableException -> "The original video source of this song has been deleted" is LoginRequiredException -> "This song cannot be played due to server restrictions" is VideoIdMismatchException -> "The returned video id doesn't match the requested one" else -> "An unknown playback error has occurred" } }, onDismiss = player::prepare ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.playlist import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.globalRoutes @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun PlaylistScreen(browseId: String) { val saveableStateHolder = rememberSaveableStateHolder() PersistMapCleanup(tagPrefix = "playlist/$browseId") RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = 0, onTabChanged = { }, tabColumnContent = { Item -> Item(0, "Songs", R.drawable.musical_notes) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { 0 -> PlaylistSongList(browseId = browseId) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.playlist import android.content.Intent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.requests.playlistPage import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.SongPlaylistMap import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.transaction import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.completed import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning import it.vfsfitvnm.vimusic.utils.isLandscape import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun PlaylistSongList( browseId: String, ) { val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val context = LocalContext.current val menuState = LocalMenuState.current var playlistPage by persist("playlist/$browseId/playlistPage") LaunchedEffect(Unit) { if (playlistPage != null && playlistPage?.songsPage?.continuation == null) return@LaunchedEffect playlistPage = withContext(Dispatchers.IO) { Innertube.playlistPage(BrowseBody(browseId = browseId))?.completed()?.getOrNull() } } val songThumbnailSizeDp = Dimensions.thumbnails.song val songThumbnailSizePx = songThumbnailSizeDp.px var isImportingPlaylist by rememberSaveable { mutableStateOf(false) } if (isImportingPlaylist) { TextFieldDialog( hintText = "Enter the playlist name", initialTextInput = playlistPage?.title ?: "", onDismiss = { isImportingPlaylist = false }, onDone = { text -> query { transaction { val playlistId = Database.insert(Playlist(name = text, browseId = browseId)) playlistPage?.songsPage?.items ?.map(Innertube.SongItem::asMediaItem) ?.onEach(Database::insert) ?.mapIndexed { index, mediaItem -> SongPlaylistMap( songId = mediaItem.mediaId, playlistId = playlistId, position = index ) }?.let(Database::insertSongPlaylistMaps) } } } ) } val headerContent: @Composable () -> Unit = { if (playlistPage == null) { HeaderPlaceholder( modifier = Modifier .shimmer() ) } else { Header(title = playlistPage?.title ?: "Unknown") { SecondaryTextButton( text = "Enqueue", enabled = playlistPage?.songsPage?.items?.isNotEmpty() == true, onClick = { playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> binder?.player?.enqueue(mediaItems) } } ) Spacer( modifier = Modifier .weight(1f) ) HeaderIconButton( icon = R.drawable.add, color = colorPalette.text, onClick = { isImportingPlaylist = true } ) HeaderIconButton( icon = R.drawable.share_social, color = colorPalette.text, onClick = { (playlistPage?.url ?: "https://music.youtube.com/playlist?list=${browseId.removePrefix("VL")}").let { url -> val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, url) } context.startActivity(Intent.createChooser(sendIntent, null)) } } ) } } } val thumbnailContent = adaptiveThumbnailContent(playlistPage == null, playlistPage?.thumbnail?.url) val lazyListState = rememberLazyListState() LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { headerContent() if (!isLandscape) thumbnailContent() } } itemsIndexed(items = playlistPage?.songsPage?.items ?: emptyList()) { index, song -> SongItem( song = song, thumbnailSizePx = songThumbnailSizePx, thumbnailSizeDp = songThumbnailSizeDp, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, ) } }, onClick = { playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> binder?.stopRadio() binder?.player?.forcePlayAtIndex(mediaItems, index) } } ) ) } if (playlistPage == null) { item(key = "loading") { ShimmerHost( modifier = Modifier .fillParentMaxSize() ) { repeat(4) { SongItemPlaceholder(thumbnailSizeDp = songThumbnailSizeDp) } } } } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, iconId = R.drawable.shuffle, onClick = { playlistPage?.songsPage?.items?.let { songs -> if (songs.isNotEmpty()) { binder?.stopRadio() binder?.player?.forcePlayFromBeginning( songs.shuffled().map(Innertube.SongItem::asMediaItem) ) } } } ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.search import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.align import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.medium @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun LocalSongSearch( textFieldValue: TextFieldValue, onTextFieldValueChanged: (TextFieldValue) -> Unit, decorationBox: @Composable (@Composable () -> Unit) -> Unit ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current var items by persistList("search/local/songs") LaunchedEffect(textFieldValue.text) { if (textFieldValue.text.length > 1) { Database.search("%${textFieldValue.text}%").collect { items = it } } } val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px val lazyListState = rememberLazyListState() Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Header( titleContent = { BasicTextField( value = textFieldValue, onValueChange = onTextFieldValueChanged, textStyle = typography.xxl.medium.align(TextAlign.End), singleLine = true, maxLines = 1, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), cursorBrush = SolidColor(colorPalette.text), decorationBox = decorationBox ) }, actionsContent = { if (textFieldValue.text.isNotEmpty()) { SecondaryTextButton( text = "Clear", onClick = { onTextFieldValueChanged(TextFieldValue()) } ) } } ) } items( items = items, key = Song::id, ) { song -> SongItem( song = song, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { InHistoryMediaItemMenu( song = song, onDismiss = menuState::hide ) } }, onClick = { val mediaItem = song.asMediaItem binder?.stopRadio() binder?.player?.forcePlay(mediaItem) binder?.setupRadio( NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) ) } ) .animateItemPlacement() ) } } FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.search import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.paint import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.net.toUri import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.compose.persist.persistList import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.SearchSuggestionsBody import it.vfsfitvnm.innertube.requests.searchSuggestions import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.SearchQuery import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.align import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey import it.vfsfitvnm.vimusic.utils.preferences import it.vfsfitvnm.vimusic.utils.secondary import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @ExperimentalAnimationApi @Composable fun OnlineSearch( textFieldValue: TextFieldValue, onTextFieldValueChanged: (TextFieldValue) -> Unit, onSearch: (String) -> Unit, onViewPlaylist: (String) -> Unit, decorationBox: @Composable (@Composable () -> Unit) -> Unit ) { val context = LocalContext.current val (colorPalette, typography) = LocalAppearance.current var history by persistList("search/online/history") LaunchedEffect(textFieldValue.text) { if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) { Database.queries("%${textFieldValue.text}%") .distinctUntilChanged { old, new -> old.size == new.size } .collect { history = it } } } var suggestionsResult by persist?>?>("search/online/suggestionsResult") LaunchedEffect(textFieldValue.text) { if (textFieldValue.text.isNotEmpty()) { delay(200) suggestionsResult = Innertube.searchSuggestions(SearchSuggestionsBody(input = textFieldValue.text)) } } val playlistId = remember(textFieldValue.text) { val isPlaylistUrl = listOf( "https://www.youtube.com/playlist?", "https://youtube.com/playlist?", "https://music.youtube.com/playlist?", "https://m.youtube.com/playlist?" ).any(textFieldValue.text::startsWith) if (isPlaylistUrl) textFieldValue.text.toUri().getQueryParameter("list") else null } val rippleIndication = rememberRipple(bounded = false) val timeIconPainter = painterResource(R.drawable.time) val closeIconPainter = painterResource(R.drawable.close) val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) val focusRequester = remember { FocusRequester() } val lazyListState = rememberLazyListState() Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .fillMaxSize() ) { item( key = "header", contentType = 0 ) { Header( titleContent = { BasicTextField( value = textFieldValue, onValueChange = onTextFieldValueChanged, textStyle = typography.xxl.medium.align(TextAlign.End), singleLine = true, maxLines = 1, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions( onSearch = { if (textFieldValue.text.isNotEmpty()) { onSearch(textFieldValue.text) } } ), cursorBrush = SolidColor(colorPalette.text), decorationBox = decorationBox, modifier = Modifier .focusRequester(focusRequester) ) }, actionsContent = { if (playlistId != null) { val isAlbum = playlistId.startsWith("OLAK5uy_") SecondaryTextButton( text = "View ${if (isAlbum) "album" else "playlist"}", onClick = { onViewPlaylist(textFieldValue.text) } ) } Spacer( modifier = Modifier .weight(1f) ) if (textFieldValue.text.isNotEmpty()) { SecondaryTextButton( text = "Clear", onClick = { onTextFieldValueChanged(TextFieldValue()) } ) } } ) } items( items = history, key = SearchQuery::id ) { searchQuery -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(onClick = { onSearch(searchQuery.query) }) .fillMaxWidth() .padding(all = 16.dp) ) { Spacer( modifier = Modifier .padding(horizontal = 8.dp) .size(20.dp) .paint( painter = timeIconPainter, colorFilter = ColorFilter.tint(colorPalette.textDisabled) ) ) BasicText( text = searchQuery.query, style = typography.s.secondary, modifier = Modifier .padding(horizontal = 8.dp) .weight(1f) ) Image( painter = closeIconPainter, contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.textDisabled), modifier = Modifier .clickable( indication = rippleIndication, interactionSource = remember { MutableInteractionSource() }, onClick = { query { Database.delete(searchQuery) } } ) .padding(horizontal = 8.dp) .size(20.dp) ) Image( painter = arrowForwardIconPainter, contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.textDisabled), modifier = Modifier .clickable( indication = rippleIndication, interactionSource = remember { MutableInteractionSource() }, onClick = { onTextFieldValueChanged( TextFieldValue( text = searchQuery.query, selection = TextRange(searchQuery.query.length) ) ) } ) .rotate(225f) .padding(horizontal = 8.dp) .size(22.dp) ) } } suggestionsResult?.getOrNull()?.let { suggestions -> items(items = suggestions) { suggestion -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(onClick = { onSearch(suggestion) }) .fillMaxWidth() .padding(all = 16.dp) ) { Spacer( modifier = Modifier .padding(horizontal = 8.dp) .size(20.dp) ) BasicText( text = suggestion, style = typography.s.secondary, modifier = Modifier .padding(horizontal = 8.dp) .weight(1f) ) Image( painter = arrowForwardIconPainter, contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.textDisabled), modifier = Modifier .clickable( indication = rippleIndication, interactionSource = remember { MutableInteractionSource() }, onClick = { onTextFieldValueChanged( TextFieldValue( text = suggestion, selection = TextRange(suggestion.length) ) ) } ) .rotate(225f) .padding(horizontal = 8.dp) .size(22.dp) ) } } } ?: suggestionsResult?.exceptionOrNull()?.let { item { Box( modifier = Modifier .fillMaxSize() ) { BasicText( text = "An error has occurred.", style = typography.s.secondary.center, modifier = Modifier .align(Alignment.Center) ) } } } } FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) } LaunchedEffect(Unit) { delay(300) focusRequester.requestFocus() } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.search import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.secondary @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun SearchScreen( initialTextInput: String, onSearch: (String) -> Unit, onViewPlaylist: (String) -> Unit ) { val saveableStateHolder = rememberSaveableStateHolder() val (tabIndex, onTabChanged) = rememberSaveable { mutableStateOf(0) } val (textFieldValue, onTextFieldValueChanged) = rememberSaveable( initialTextInput, stateSaver = TextFieldValue.Saver ) { mutableStateOf( TextFieldValue( text = initialTextInput, selection = TextRange(initialTextInput.length) ) ) } PersistMapCleanup(tagPrefix = "search/") RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { val decorationBox: @Composable (@Composable () -> Unit) -> Unit = { innerTextField -> Box { AnimatedVisibility( visible = textFieldValue.text.isEmpty(), enter = fadeIn(tween(300)), exit = fadeOut(tween(300)), modifier = Modifier .align(Alignment.CenterEnd) ) { BasicText( text = "Enter a name", maxLines = 1, style = LocalAppearance.current.typography.xxl.secondary ) } innerTextField() } } Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabChanged, tabColumnContent = { Item -> Item(0, "Online", R.drawable.globe) Item(1, "Library", R.drawable.library) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { when (currentTabIndex) { 0 -> OnlineSearch( textFieldValue = textFieldValue, onTextFieldValueChanged = onTextFieldValueChanged, onSearch = onSearch, onViewPlaylist = onViewPlaylist, decorationBox = decorationBox ) 1 -> LocalSongSearch( textFieldValue = textFieldValue, onTextFieldValueChanged = onTextFieldValueChanged, decorationBox = decorationBox ) } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.searchresult import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.persist import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.utils.plus import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.ui.components.ShimmerHost import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.secondary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @ExperimentalAnimationApi @Composable inline fun ItemsPage( tag: String, crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, noinline itemPlaceholderContent: @Composable () -> Unit, modifier: Modifier = Modifier, initialPlaceholderCount: Int = 8, continuationPlaceholderCount: Int = 3, emptyItemsText: String = "No items found", noinline itemsPageProvider: (suspend (String?) -> Result?>?)? = null, ) { val (_, typography) = LocalAppearance.current val updatedItemsPageProvider by rememberUpdatedState(itemsPageProvider) val lazyListState = rememberLazyListState() var itemsPage by persist?>(tag) LaunchedEffect(lazyListState, updatedItemsPageProvider) { val currentItemsPageProvider = updatedItemsPageProvider ?: return@LaunchedEffect snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } } .collect { shouldLoadMore -> if (!shouldLoadMore) return@collect withContext(Dispatchers.IO) { currentItemsPageProvider(itemsPage?.continuation) }?.onSuccess { if (it == null) { if (itemsPage == null) { itemsPage = Innertube.ItemsPage(null, null) } } else { itemsPage += it } } } } Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = modifier .fillMaxSize() ) { item( key = "header", contentType = "header", ) { headerContent(null) } items( items = itemsPage?.items ?: emptyList(), key = Innertube.Item::key, itemContent = itemContent ) if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) { item(key = "empty") { BasicText( text = emptyItemsText, style = typography.xs.secondary.center, modifier = Modifier .padding(horizontal = 16.dp, vertical = 32.dp) .fillMaxWidth() ) } } if (!(itemsPage != null && itemsPage?.continuation == null)) { item(key = "loading") { val isFirstLoad = itemsPage?.items.isNullOrEmpty() ShimmerHost( modifier = Modifier .run { if (isFirstLoad) fillParentMaxSize() else this } ) { repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) { itemPlaceholderContent() } } } } } FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.searchresult import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.persist.PersistMapCleanup import it.vfsfitvnm.compose.persist.persistMap import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.models.bodies.SearchBody import it.vfsfitvnm.innertube.requests.searchPage import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.items.AlbumItem import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.ArtistItem import it.vfsfitvnm.vimusic.ui.items.ArtistItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.PlaylistItem import it.vfsfitvnm.vimusic.ui.items.PlaylistItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.SongItem import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder import it.vfsfitvnm.vimusic.ui.items.VideoItem import it.vfsfitvnm.vimusic.ui.items.VideoItemPlaceholder import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.artistRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.playlistRoute import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.searchResultScreenTabIndexKey @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { val context = LocalContext.current val saveableStateHolder = rememberSaveableStateHolder() val (tabIndex, onTabIndexChanges) = rememberPreference(searchResultScreenTabIndexKey, 0) PersistMapCleanup(tagPrefix = "searchResults/$query/") RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { Header( title = query, modifier = Modifier .pointerInput(Unit) { detectTapGestures { context.persistMap?.keys?.removeAll { it.startsWith("searchResults/$query/") } onSearchAgain() } } ) } val emptyItemsText = "No results found. Please try a different query or category" Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabIndexChanges, tabColumnContent = { Item -> Item(0, "Songs", R.drawable.musical_notes) Item(1, "Albums", R.drawable.disc) Item(2, "Artists", R.drawable.person) Item(3, "Videos", R.drawable.film) Item(4, "Playlists", R.drawable.playlist) Item(5, "Featured", R.drawable.playlist) } ) { tabIndex -> saveableStateHolder.SaveableStateProvider(tabIndex) { when (tabIndex) { 0 -> { val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "searchResults/$query/songs", itemsPageProvider = { continuation -> if (continuation == null) { Innertube.searchPage( body = SearchBody(query = query, params = Innertube.SearchFilter.Song.value), fromMusicShelfRendererContent = Innertube.SongItem.Companion::from ) } else { Innertube.searchPage( body = ContinuationBody(continuation = continuation), fromMusicShelfRendererContent = Innertube.SongItem.Companion::from ) } }, emptyItemsText = emptyItemsText, headerContent = headerContent, itemContent = { song -> SongItem( song = song, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, mediaItem = song.asMediaItem, ) } }, onClick = { binder?.stopRadio() binder?.player?.forcePlay(song.asMediaItem) binder?.setupRadio(song.info?.endpoint) } ) ) }, itemPlaceholderContent = { SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } 1 -> { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "searchResults/$query/albums", itemsPageProvider = { continuation -> if (continuation == null) { Innertube.searchPage( body = SearchBody(query = query, params = Innertube.SearchFilter.Album.value), fromMusicShelfRendererContent = Innertube.AlbumItem::from ) } else { Innertube.searchPage( body = ContinuationBody(continuation = continuation), fromMusicShelfRendererContent = Innertube.AlbumItem::from ) } }, emptyItemsText = emptyItemsText, headerContent = headerContent, itemContent = { album -> AlbumItem( album = album, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable(onClick = { albumRoute(album.key) }) ) }, itemPlaceholderContent = { AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } 2 -> { val thumbnailSizeDp = 64.dp val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "searchResults/$query/artists", itemsPageProvider = { continuation -> if (continuation == null) { Innertube.searchPage( body = SearchBody(query = query, params = Innertube.SearchFilter.Artist.value), fromMusicShelfRendererContent = Innertube.ArtistItem::from ) } else { Innertube.searchPage( body = ContinuationBody(continuation = continuation), fromMusicShelfRendererContent = Innertube.ArtistItem::from ) } }, emptyItemsText = emptyItemsText, headerContent = headerContent, itemContent = { artist -> ArtistItem( artist = artist, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable(onClick = { artistRoute(artist.key) }) ) }, itemPlaceholderContent = { ArtistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } 3 -> { val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val thumbnailHeightDp = 72.dp val thumbnailWidthDp = 128.dp ItemsPage( tag = "searchResults/$query/videos", itemsPageProvider = { continuation -> if (continuation == null) { Innertube.searchPage( body = SearchBody(query = query, params = Innertube.SearchFilter.Video.value), fromMusicShelfRendererContent = Innertube.VideoItem::from ) } else { Innertube.searchPage( body = ContinuationBody(continuation = continuation), fromMusicShelfRendererContent = Innertube.VideoItem::from ) } }, emptyItemsText = emptyItemsText, headerContent = headerContent, itemContent = { video -> VideoItem( video = video, thumbnailWidthDp = thumbnailWidthDp, thumbnailHeightDp = thumbnailHeightDp, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( mediaItem = video.asMediaItem, onDismiss = menuState::hide ) } }, onClick = { binder?.stopRadio() binder?.player?.forcePlay(video.asMediaItem) binder?.setupRadio(video.info?.endpoint) } ) ) }, itemPlaceholderContent = { VideoItemPlaceholder( thumbnailHeightDp = thumbnailHeightDp, thumbnailWidthDp = thumbnailWidthDp ) } ) } 4, 5 -> { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px ItemsPage( tag = "searchResults/$query/${if (tabIndex == 4) "playlists" else "featured"}", itemsPageProvider = { continuation -> if (continuation == null) { val filter = if (tabIndex == 4) { Innertube.SearchFilter.CommunityPlaylist } else { Innertube.SearchFilter.FeaturedPlaylist } Innertube.searchPage( body = SearchBody(query = query, params = filter.value), fromMusicShelfRendererContent = Innertube.PlaylistItem::from ) } else { Innertube.searchPage( body = ContinuationBody(continuation = continuation), fromMusicShelfRendererContent = Innertube.PlaylistItem::from ) } }, emptyItemsText = emptyItemsText, headerContent = headerContent, itemContent = { playlist -> PlaylistItem( playlist = playlist, thumbnailSizePx = thumbnailSizePx, thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .clickable(onClick = { playlistRoute(playlist.key) }) ) }, itemPlaceholderContent = { PlaylistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } ) } } } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import it.vfsfitvnm.vimusic.BuildConfig import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.secondary @ExperimentalAnimationApi @Composable fun About() { val (colorPalette, typography) = LocalAppearance.current val uriHandler = LocalUriHandler.current Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) .asPaddingValues() ) ) { Header(title = "About") { BasicText( text = "v${BuildConfig.VERSION_NAME} by vfsfitvnm", style = typography.s.secondary ) } SettingsEntryGroupText(title = "SOCIAL") SettingsEntry( title = "GitHub", text = "View the source code", onClick = { uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic") } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "TROUBLESHOOTING") SettingsEntry( title = "Report an issue", text = "You will be redirected to GitHub", onClick = { uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=bug&template=bug_report.yaml") } ) SettingsEntry( title = "Request a feature or suggest an idea", text = "You will be redirected to GitHub", onClick = { uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=enhancement&template=feature_request.yaml") } ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.enums.ColorPaletteMode import it.vfsfitvnm.vimusic.enums.ColorPaletteName import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.applyFontPaddingKey import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid13 import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey import it.vfsfitvnm.vimusic.utils.useSystemFontKey @ExperimentalAnimationApi @Composable fun AppearanceSettings() { val (colorPalette) = LocalAppearance.current var colorPaletteName by rememberPreference(colorPaletteNameKey, ColorPaletteName.Dynamic) var colorPaletteMode by rememberPreference(colorPaletteModeKey, ColorPaletteMode.System) var thumbnailRoundness by rememberPreference( thumbnailRoundnessKey, ThumbnailRoundness.Light ) var useSystemFont by rememberPreference(useSystemFontKey, false) var applyFontPadding by rememberPreference(applyFontPaddingKey, false) var isShowingThumbnailInLockscreen by rememberPreference( isShowingThumbnailInLockscreenKey, false ) Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) .asPaddingValues() ) ) { Header(title = "Appearance") SettingsEntryGroupText(title = "COLORS") EnumValueSelectorSettingsEntry( title = "Theme", selectedValue = colorPaletteName, onValueSelected = { colorPaletteName = it } ) EnumValueSelectorSettingsEntry( title = "Theme mode", selectedValue = colorPaletteMode, isEnabled = colorPaletteName != ColorPaletteName.PureBlack, onValueSelected = { colorPaletteMode = it } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "SHAPES") EnumValueSelectorSettingsEntry( title = "Thumbnail roundness", selectedValue = thumbnailRoundness, onValueSelected = { thumbnailRoundness = it }, trailingContent = { Spacer( modifier = Modifier .border(width = 1.dp, color = colorPalette.accent, shape = thumbnailRoundness.shape()) .background(color = colorPalette.background1, shape = thumbnailRoundness.shape()) .size(36.dp) ) } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "TEXT") SwitchSettingEntry( title = "Use system font", text = "Use the font applied by the system", isChecked = useSystemFont, onCheckedChange = { useSystemFont = it } ) SwitchSettingEntry( title = "Apply font padding", text = "Add spacing around texts", isChecked = applyFontPadding, onCheckedChange = { applyFontPadding = it } ) if (!isAtLeastAndroid13) { SettingsGroupSpacer() SettingsEntryGroupText(title = "LOCKSCREEN") SwitchSettingEntry( title = "Show song cover", text = "Use the playing song cover as the lockscreen wallpaper", isChecked = isShowingThumbnailInLockscreen, onCheckedChange = { isShowingThumbnailInLockscreen = it } ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import android.text.format.Formatter import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import coil.Coil import coil.annotation.ExperimentalCoilApi import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey import it.vfsfitvnm.vimusic.utils.rememberPreference @OptIn(ExperimentalCoilApi::class) @ExperimentalAnimationApi @Composable fun CacheSettings() { val context = LocalContext.current val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current var coilDiskCacheMaxSize by rememberPreference( coilDiskCacheMaxSizeKey, CoilDiskCacheMaxSize.`128MB` ) var exoPlayerDiskCacheMaxSize by rememberPreference( exoPlayerDiskCacheMaxSizeKey, ExoPlayerDiskCacheMaxSize.`2GB` ) Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) .asPaddingValues() ) ) { Header(title = "Cache") SettingsDescription(text = "When the cache runs out of space, the resources that haven't been accessed for the longest time are cleared") Coil.imageLoader(context).diskCache?.let { diskCache -> val diskCacheSize = remember(diskCache) { diskCache.size } SettingsGroupSpacer() SettingsEntryGroupText(title = "IMAGE CACHE") SettingsDescription( text = "${ Formatter.formatShortFileSize( context, diskCacheSize ) } used (${diskCacheSize * 100 / coilDiskCacheMaxSize.bytes.coerceAtLeast(1)}%)" ) EnumValueSelectorSettingsEntry( title = "Max size", selectedValue = coilDiskCacheMaxSize, onValueSelected = { coilDiskCacheMaxSize = it } ) } binder?.cache?.let { cache -> val diskCacheSize by remember { derivedStateOf { cache.cacheSpace } } SettingsGroupSpacer() SettingsEntryGroupText(title = "SONG CACHE") SettingsDescription( text = buildString { append(Formatter.formatShortFileSize(context, diskCacheSize)) append(" used") when (val size = exoPlayerDiskCacheMaxSize) { ExoPlayerDiskCacheMaxSize.Unlimited -> {} else -> append(" (${diskCacheSize * 100 / size.bytes}%)") } } ) EnumValueSelectorSettingsEntry( title = "Max size", selectedValue = exoPlayerDiskCacheMaxSize, onValueSelected = { exoPlayerDiskCacheMaxSize = it } ) } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import android.annotation.SuppressLint import android.content.ActivityNotFoundException import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.internal import it.vfsfitvnm.vimusic.path import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.service.PlayerService import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.intent import it.vfsfitvnm.vimusic.utils.toast import java.io.FileInputStream import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import kotlin.system.exitProcess import kotlinx.coroutines.flow.distinctUntilChanged @ExperimentalAnimationApi @Composable fun DatabaseSettings() { val context = LocalContext.current val (colorPalette) = LocalAppearance.current val eventsCount by remember { Database.eventsCount().distinctUntilChanged() }.collectAsState(initial = 0) val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri -> if (uri == null) return@rememberLauncherForActivityResult query { Database.checkpoint() context.applicationContext.contentResolver.openOutputStream(uri) ?.use { outputStream -> FileInputStream(Database.internal.path).use { inputStream -> inputStream.copyTo(outputStream) } } } } val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@rememberLauncherForActivityResult query { Database.checkpoint() Database.internal.close() context.applicationContext.contentResolver.openInputStream(uri) ?.use { inputStream -> FileOutputStream(Database.internal.path).use { outputStream -> inputStream.copyTo(outputStream) } } context.stopService(context.intent()) exitProcess(0) } } Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) .asPaddingValues() ) ) { Header(title = "Database") SettingsEntryGroupText(title = "CLEANUP") SettingsEntry( title = "Reset quick picks", text = if (eventsCount > 0) { "Delete $eventsCount playback events" } else { "Quick picks are cleared" }, isEnabled = eventsCount > 0, onClick = { query(Database::clearEvents) } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "BACKUP") SettingsDescription(text = "Personal preferences (i.e. the theme mode) and the cache are excluded.") SettingsEntry( title = "Backup", text = "Export the database to the external storage", onClick = { @SuppressLint("SimpleDateFormat") val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") try { backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db") } catch (e: ActivityNotFoundException) { context.toast("Couldn't find an application to create documents") } } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "RESTORE") ImportantSettingsDescription(text = "Existing data will be overwritten.\n${context.applicationInfo.nonLocalizedLabel} will automatically close itself after restoring the database.") SettingsEntry( title = "Restore", text = "Import the database from the external storage", onClick = { try { restoreLauncher.launch( arrayOf( "application/vnd.sqlite3", "application/x-sqlite3", "application/octet-stream" ) ) } catch (e: ActivityNotFoundException) { context.toast("Couldn't find an application to open documents") } } ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.service.PlayerMediaBrowserService import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid12 import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.toast import kotlinx.coroutines.flow.distinctUntilChanged @SuppressLint("BatteryLife") @ExperimentalAnimationApi @Composable fun OtherSettings() { val context = LocalContext.current val (colorPalette) = LocalAppearance.current var isAndroidAutoEnabled by remember { val component = ComponentName(context, PlayerMediaBrowserService::class.java) val disabledFlag = PackageManager.COMPONENT_ENABLED_STATE_DISABLED val enabledFlag = PackageManager.COMPONENT_ENABLED_STATE_ENABLED mutableStateOf( value = context.packageManager.getComponentEnabledSetting(component) == enabledFlag, policy = object : SnapshotMutationPolicy { override fun equivalent(a: Boolean, b: Boolean): Boolean { context.packageManager.setComponentEnabledSetting( component, if (b) enabledFlag else disabledFlag, PackageManager.DONT_KILL_APP ) return a == b } } ) } var isInvincibilityEnabled by rememberPreference(isInvincibilityEnabledKey, false) var isIgnoringBatteryOptimizations by remember { mutableStateOf(context.isIgnoringBatteryOptimizations) } val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations } var pauseSearchHistory by rememberPreference(pauseSearchHistoryKey, false) val queriesCount by remember { Database.queriesCount().distinctUntilChanged() }.collectAsState(initial = 0) Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) .asPaddingValues() ) ) { Header(title = "Other") SettingsEntryGroupText(title = "ANDROID AUTO") SettingsDescription(text = "Remember to enable \"Unknown sources\" in the Developer Settings of Android Auto.") SwitchSettingEntry( title = "Android Auto", text = "Enable Android Auto support", isChecked = isAndroidAutoEnabled, onCheckedChange = { isAndroidAutoEnabled = it } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "SEARCH HISTORY") SwitchSettingEntry( title = "Pause search history", text = "Neither save new searched queries nor show history", isChecked = pauseSearchHistory, onCheckedChange = { pauseSearchHistory = it } ) SettingsEntry( title = "Clear search history", text = if (queriesCount > 0) { "Delete $queriesCount search queries" } else { "History is empty" }, isEnabled = queriesCount > 0, onClick = { query(Database::clearQueries) } ) SettingsGroupSpacer() SettingsEntryGroupText(title = "SERVICE LIFETIME") ImportantSettingsDescription(text = "If battery optimizations are applied, the playback notification can suddenly disappear when paused.") if (isAtLeastAndroid12) { SettingsDescription(text = "Since Android 12, disabling battery optimizations is required for the \"Invincible service\" option to take effect.") } SettingsEntry( title = "Ignore battery optimizations", isEnabled = !isIgnoringBatteryOptimizations, text = if (isIgnoringBatteryOptimizations) { "Already unrestricted" } else { "Disable background restrictions" }, onClick = { if (!isAtLeastAndroid6) return@SettingsEntry try { activityResultLauncher.launch( Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { data = Uri.parse("package:${context.packageName}") } ) } catch (e: ActivityNotFoundException) { try { activityResultLauncher.launch( Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) ) } catch (e: ActivityNotFoundException) { context.toast("Couldn't find battery optimization settings, please whitelist ViMusic manually") } } } ) SwitchSettingEntry( title = "Invincible service", text = "When turning off battery optimizations is not enough", isChecked = isInvincibilityEnabled, onCheckedChange = { isInvincibilityEnabled = it } ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import android.content.ActivityNotFoundException import android.content.Intent import android.media.audiofx.AudioEffect import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 import it.vfsfitvnm.vimusic.utils.persistentQueueKey import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.resumePlaybackWhenDeviceConnectedKey import it.vfsfitvnm.vimusic.utils.skipSilenceKey import it.vfsfitvnm.vimusic.utils.toast import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey @ExperimentalAnimationApi @Composable fun PlayerSettings() { val context = LocalContext.current val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current var persistentQueue by rememberPreference(persistentQueueKey, false) var resumePlaybackWhenDeviceConnected by rememberPreference( resumePlaybackWhenDeviceConnectedKey, false ) var skipSilence by rememberPreference(skipSilenceKey, false) var volumeNormalization by rememberPreference(volumeNormalizationKey, false) val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } Column( modifier = Modifier .background(colorPalette.background0) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) .asPaddingValues() ) ) { Header(title = "Player & Audio") SettingsEntryGroupText(title = "PLAYER") SwitchSettingEntry( title = "Persistent queue", text = "Save and restore playing songs", isChecked = persistentQueue, onCheckedChange = { persistentQueue = it } ) if (isAtLeastAndroid6) { SwitchSettingEntry( title = "Resume playback", text = "When a wired or bluetooth device is connected", isChecked = resumePlaybackWhenDeviceConnected, onCheckedChange = { resumePlaybackWhenDeviceConnected = it } ) } SettingsGroupSpacer() SettingsEntryGroupText(title = "AUDIO") SwitchSettingEntry( title = "Skip silence", text = "Skip silent parts during playback", isChecked = skipSilence, onCheckedChange = { skipSilence = it } ) SwitchSettingEntry( title = "Loudness normalization", text = "Adjust the volume to a fixed level", isChecked = volumeNormalization, onCheckedChange = { volumeNormalization = it } ) SettingsEntry( title = "Equalizer", text = "Interact with the system equalizer", onClick = { val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder?.player?.audioSessionId) putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) } try { activityResultLauncher.launch(intent) } catch (e: ActivityNotFoundException) { context.toast("Couldn't find an application to equalize audio") } } ) } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt ================================================ package it.vfsfitvnm.vimusic.ui.screens.settings import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import it.vfsfitvnm.compose.routing.RouteHandler import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.components.themed.Switch import it.vfsfitvnm.vimusic.ui.components.themed.ValueSelectorDialog import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun SettingsScreen() { val saveableStateHolder = rememberSaveableStateHolder() val (tabIndex, onTabChanged) = rememberSaveable { mutableStateOf(0) } RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabChanged, tabColumnContent = { Item -> Item(0, "Appearance", R.drawable.color_palette) Item(1, "Player", R.drawable.play) Item(2, "Cache", R.drawable.server) Item(3, "Database", R.drawable.server) Item(4, "Other", R.drawable.shapes) Item(5, "About", R.drawable.information) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { when (currentTabIndex) { 0 -> AppearanceSettings() 1 -> PlayerSettings() 2 -> CacheSettings() 3 -> DatabaseSettings() 4 -> OtherSettings() 5 -> About() } } } } } } @Composable inline fun > EnumValueSelectorSettingsEntry( title: String, selectedValue: T, crossinline onValueSelected: (T) -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, crossinline valueText: (T) -> String = Enum::name, noinline trailingContent: (@Composable () -> Unit)? = null ) { ValueSelectorSettingsEntry( title = title, selectedValue = selectedValue, values = enumValues().toList(), onValueSelected = onValueSelected, modifier = modifier, isEnabled = isEnabled, valueText = valueText, trailingContent = trailingContent, ) } @Composable inline fun ValueSelectorSettingsEntry( title: String, selectedValue: T, values: List, crossinline onValueSelected: (T) -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, crossinline valueText: (T) -> String = { it.toString() }, noinline trailingContent: (@Composable () -> Unit)? = null ) { var isShowingDialog by remember { mutableStateOf(false) } if (isShowingDialog) { ValueSelectorDialog( onDismiss = { isShowingDialog = false }, title = title, selectedValue = selectedValue, values = values, onValueSelected = onValueSelected, valueText = valueText ) } SettingsEntry( title = title, text = valueText(selectedValue), modifier = modifier, isEnabled = isEnabled, onClick = { isShowingDialog = true }, trailingContent = trailingContent ) } @Composable fun SwitchSettingEntry( title: String, text: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true ) { SettingsEntry( title = title, text = text, isEnabled = isEnabled, onClick = { onCheckedChange(!isChecked) }, trailingContent = { Switch(isChecked = isChecked) }, modifier = modifier ) } @Composable fun SettingsEntry( title: String, text: String, modifier: Modifier = Modifier, onClick: () -> Unit, isEnabled: Boolean = true, trailingContent: (@Composable () -> Unit)? = null ) { val (colorPalette, typography) = LocalAppearance.current Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = modifier .clickable(enabled = isEnabled, onClick = onClick) .alpha(if (isEnabled) 1f else 0.5f) .padding(start = 16.dp) .padding(all = 16.dp) .fillMaxWidth() ) { Column( modifier = Modifier .weight(1f) ) { BasicText( text = title, style = typography.xs.semiBold.copy(color = colorPalette.text), ) BasicText( text = text, style = typography.xs.semiBold.copy(color = colorPalette.textSecondary), ) } trailingContent?.invoke() } } @Composable fun SettingsDescription( text: String, modifier: Modifier = Modifier, ) { val (_, typography) = LocalAppearance.current BasicText( text = text, style = typography.xxs.secondary, modifier = modifier .padding(start = 16.dp) .padding(horizontal = 16.dp, vertical = 8.dp) ) } @Composable fun ImportantSettingsDescription( text: String, modifier: Modifier = Modifier, ) { val (colorPalette, typography) = LocalAppearance.current BasicText( text = text, style = typography.xxs.semiBold.color(colorPalette.red), modifier = modifier .padding(start = 16.dp) .padding(horizontal = 16.dp, vertical = 8.dp) ) } @Composable fun SettingsEntryGroupText( title: String, modifier: Modifier = Modifier, ) { val (colorPalette, typography) = LocalAppearance.current BasicText( text = title.uppercase(), style = typography.xxs.semiBold.copy(colorPalette.accent), modifier = modifier .padding(start = 16.dp) .padding(horizontal = 16.dp) ) } @Composable fun SettingsGroupSpacer( modifier: Modifier = Modifier, ) { Spacer( modifier = modifier .height(24.dp) ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt ================================================ package it.vfsfitvnm.vimusic.ui.styling import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp data class Appearance( val colorPalette: ColorPalette, val typography: Typography, val thumbnailShape: Shape, ) { companion object : Saver> { @Suppress("UNCHECKED_CAST") override fun restore(value: List): Appearance { return Appearance( colorPalette = ColorPalette.restore(value[0] as List), typography = Typography.restore(value[1] as List), thumbnailShape = RoundedCornerShape((value[2] as Int).dp) ) } override fun SaverScope.save(value: Appearance) = listOf( with (ColorPalette.Companion) { save(value.colorPalette) }, with (Typography.Companion) { save(value.typography) }, when (value.thumbnailShape) { RoundedCornerShape(2.dp) -> 2 RoundedCornerShape(4.dp) -> 4 RoundedCornerShape(8.dp) -> 8 else -> 0 } ) } } val LocalAppearance = staticCompositionLocalOf { TODO() } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt ================================================ package it.vfsfitvnm.vimusic.ui.styling import android.graphics.Bitmap import androidx.compose.runtime.Immutable import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.graphics.ColorUtils import androidx.palette.graphics.Palette import it.vfsfitvnm.vimusic.enums.ColorPaletteMode import it.vfsfitvnm.vimusic.enums.ColorPaletteName @Immutable data class ColorPalette( val background0: Color, val background1: Color, val background2: Color, val accent: Color, val onAccent: Color, val red: Color = Color(0xffbf4040), val blue: Color = Color(0xff4472cf), val text: Color, val textSecondary: Color, val textDisabled: Color, val isDark: Boolean ) { companion object : Saver> { override fun restore(value: List) = when (val accent = value[0] as Int) { 0 -> DefaultDarkColorPalette 1 -> DefaultLightColorPalette 2 -> PureBlackColorPalette else -> dynamicColorPaletteOf( FloatArray(3).apply { ColorUtils.colorToHSL(accent, this) }, value[1] as Boolean ) } override fun SaverScope.save(value: ColorPalette) = listOf( when { value === DefaultDarkColorPalette -> 0 value === DefaultLightColorPalette -> 1 value === PureBlackColorPalette -> 2 else -> value.accent.toArgb() }, value.isDark ) } } val DefaultDarkColorPalette = ColorPalette( background0 = Color(0xff16171d), background1 = Color(0xff1f2029), background2 = Color(0xff2b2d3b), text = Color(0xffe1e1e2), textSecondary = Color(0xffa3a4a6), textDisabled = Color(0xff6f6f73), accent = Color(0xff5055c0), onAccent = Color.White, isDark = true ) val DefaultLightColorPalette = ColorPalette( background0 = Color(0xfffdfdfe), background1 = Color(0xfff8f8fc), background2 = Color(0xffeaeaf5), text = Color(0xff212121), textSecondary = Color(0xff656566), textDisabled = Color(0xff9d9d9d), accent = Color(0xff5055c0), onAccent = Color.White, isDark = false ) val PureBlackColorPalette = DefaultDarkColorPalette.copy( background0 = Color.Black, background1 = Color.Black, background2 = Color.Black ) fun colorPaletteOf( colorPaletteName: ColorPaletteName, colorPaletteMode: ColorPaletteMode, isSystemInDarkMode: Boolean ): ColorPalette { return when (colorPaletteName) { ColorPaletteName.Default, ColorPaletteName.Dynamic -> when (colorPaletteMode) { ColorPaletteMode.Light -> DefaultLightColorPalette ColorPaletteMode.Dark -> DefaultDarkColorPalette ColorPaletteMode.System -> when (isSystemInDarkMode) { true -> DefaultDarkColorPalette false -> DefaultLightColorPalette } } ColorPaletteName.PureBlack -> PureBlackColorPalette } } fun dynamicColorPaletteOf(bitmap: Bitmap, isDark: Boolean): ColorPalette? { val palette = Palette .from(bitmap) .maximumColorCount(8) .addFilter(if (isDark) ({ _, hsl -> hsl[0] !in 36f..100f }) else null) .generate() val hsl = if (isDark) { palette.dominantSwatch ?: Palette .from(bitmap) .maximumColorCount(8) .generate() .dominantSwatch } else { palette.dominantSwatch }?.hsl ?: return null return if (hsl[1] < 0.08) { val newHsl = palette.swatches .map(Palette.Swatch::getHsl) .sortedByDescending(FloatArray::component2) .find { it[1] != 0f } ?: hsl dynamicColorPaletteOf(newHsl, isDark) } else { dynamicColorPaletteOf(hsl, isDark) } } fun dynamicColorPaletteOf(hsl: FloatArray, isDark: Boolean): ColorPalette { return colorPaletteOf(ColorPaletteName.Dynamic, if (isDark) ColorPaletteMode.Dark else ColorPaletteMode.Light, false).copy( background0 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.10f else 0.925f), background1 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.3f), if (isDark) 0.15f else 0.90f), background2 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.4f), if (isDark) 0.2f else 0.85f), accent = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.5f), 0.5f), text = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.02f), if (isDark) 0.88f else 0.12f), textSecondary = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.65f else 0.40f), textDisabled = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.2f), if (isDark) 0.40f else 0.65f), ) } inline val ColorPalette.collapsedPlayerProgressBar: Color get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { text } else { accent } inline val ColorPalette.favoritesIcon: Color get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { red } else { accent } inline val ColorPalette.shimmer: Color get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { Color(0xff838383) } else { accent } inline val ColorPalette.primaryButton: Color get() = if (this === PureBlackColorPalette) { Color(0xFF272727) } else { background2 } inline val ColorPalette.overlay: Color get() = PureBlackColorPalette.background0.copy(alpha = 0.75f) inline val ColorPalette.onOverlay: Color get() = PureBlackColorPalette.text inline val ColorPalette.onOverlayShimmer: Color get() = PureBlackColorPalette.shimmer ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt ================================================ package it.vfsfitvnm.vimusic.ui.styling import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Suppress("ClassName") object Dimensions { val itemsVerticalPadding = 8.dp val navigationRailWidth = 64.dp val navigationRailWidthLandscape = 128.dp val navigationRailIconOffset = 6.dp val headerHeight = 140.dp object thumbnails { val album = 128.dp val artist = 192.dp val song = 54.dp val playlist = album object player { val song: Dp @Composable get() = with(LocalConfiguration.current) { minOf(screenHeightDp, screenWidthDp) }.dp } } val collapsedPlayer = 64.dp } inline val Dp.px: Int @Composable inline get() = with(LocalDensity.current) { roundToPx() } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt ================================================ package it.vfsfitvnm.vimusic.ui.styling import androidx.compose.runtime.Immutable import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import it.vfsfitvnm.vimusic.R @Immutable data class Typography( val xxs: TextStyle, val xs: TextStyle, val s: TextStyle, val m: TextStyle, val l: TextStyle, val xxl: TextStyle, ) { fun copy(color: Color) = Typography( xxs = xxs.copy(color = color), xs = xs.copy(color = color), s = s.copy(color = color), m = m.copy(color = color), l = l.copy(color = color), xxl = xxl.copy(color = color) ) companion object : Saver> { override fun restore(value: List) = typographyOf( Color((value[0] as Long).toULong()), value[1] as Boolean, value[2] as Boolean ) override fun SaverScope.save(value: Typography) = listOf( value.xxs.color.value.toLong(), value.xxs.fontFamily == FontFamily.Default, value.xxs.platformStyle?.paragraphStyle?.includeFontPadding ?: false ) } } fun typographyOf(color: Color, useSystemFont: Boolean, applyFontPadding: Boolean): Typography { val textStyle = TextStyle( fontFamily = if (useSystemFont) { FontFamily.Default } else { FontFamily( Font( resId = R.font.poppins_w300, weight = FontWeight.Light ), Font( resId = R.font.poppins_w400, weight = FontWeight.Normal ), Font( resId = R.font.poppins_w500, weight = FontWeight.Medium ), Font( resId = R.font.poppins_w600, weight = FontWeight.SemiBold ), Font( resId = R.font.poppins_w700, weight = FontWeight.Bold ), ) }, fontWeight = FontWeight.Normal, color = color, platformStyle = @Suppress("DEPRECATION") (PlatformTextStyle(includeFontPadding = applyFontPadding)) ) return Typography( xxs = textStyle.copy(fontSize = 12.sp), xs = textStyle.copy(fontSize = 14.sp), s = textStyle.copy(fontSize = 16.sp), m = textStyle.copy(fontSize = 18.sp), l = textStyle.copy(fontSize = 20.sp), xxl = textStyle.copy(fontSize = 32.sp) ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt ================================================ package it.vfsfitvnm.vimusic.utils import android.content.res.Configuration import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.platform.LocalConfiguration val isLandscape @Composable @ReadOnlyComposable get() = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt ================================================ package it.vfsfitvnm.vimusic.utils import android.app.Activity import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.PowerManager import android.widget.Toast import androidx.core.content.getSystemService inline fun Context.intent(): Intent = Intent(this@Context, T::class.java) inline fun Context.broadCastPendingIntent( requestCode: Int = 0, flags: Int = if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0, ): PendingIntent = PendingIntent.getBroadcast(this, requestCode, intent(), flags) inline fun Context.activityPendingIntent( requestCode: Int = 0, flags: Int = 0, block: Intent.() -> Unit = {}, ): PendingIntent = PendingIntent.getActivity( this, requestCode, intent().apply(block), (if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) or flags ) val Context.isIgnoringBatteryOptimizations: Boolean get() = if (isAtLeastAndroid6) { getSystemService()?.isIgnoringBatteryOptimizations(packageName) ?: true } else { true } fun Context.toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/DrawScope.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.DrawScope fun DrawScope.drawCircle( color: Color, shadow: Shadow, radius: Float = size.minDimension / 2.0f, center: Offset = this.center, alpha: Float = 1.0f, style: PaintingStyle = PaintingStyle.Fill, colorFilter: ColorFilter? = null, blendMode: BlendMode = DrawScope.DefaultBlendMode ) = drawContext.canvas.nativeCanvas.drawCircle( center.x, center.y, radius, Paint().also { it.color = color it.alpha = alpha it.blendMode = blendMode it.colorFilter = colorFilter it.style = style }.asFrameworkPaint().also { it.setShadowLayer( shadow.blurRadius, shadow.offset.x, shadow.offset.y, shadow.color.toArgb() ) } ) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/FadingEdge.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer fun Modifier.verticalFadingEdge() = graphicsLayer(alpha = 0.99f) .drawWithContent { drawContent() drawRect( brush = Brush.verticalGradient( listOf( Color.Transparent, Color.Black, Color.Black, Color.Black, Color.Transparent ) ), blendMode = BlendMode.DstIn ) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt ================================================ package it.vfsfitvnm.vimusic.utils import android.app.Notification import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Binder import android.os.Handler import android.os.Looper // https://stackoverflow.com/q/53502244/16885569 // I found four ways to make the system not kill the stopped foreground service: e.g. when // the player is paused: // 1 - Use the solution below - hacky; // 2 - Do not call stopForeground but provide a button to dismiss the notification - bad UX; // 3 - Lower the targetSdk (e.g. to 23) - security concerns; // 4 - Host the service in a separate process - overkill and pathetic. abstract class InvincibleService : Service() { protected val handler = Handler(Looper.getMainLooper()) protected abstract val isInvincibilityEnabled: Boolean protected abstract val notificationId: Int private var invincibility: Invincibility? = null private val isAllowedToStartForegroundServices: Boolean get() = !isAtLeastAndroid12 || isIgnoringBatteryOptimizations override fun onBind(intent: Intent?): Binder? { invincibility?.stop() invincibility = null return null } override fun onRebind(intent: Intent?) { invincibility?.stop() invincibility = null super.onRebind(intent) } override fun onUnbind(intent: Intent?): Boolean { if (isInvincibilityEnabled && isAllowedToStartForegroundServices) { invincibility = Invincibility() } return true } override fun onDestroy() { invincibility?.stop() invincibility = null super.onDestroy() } protected fun makeInvincible(isInvincible: Boolean = true) { if (isInvincible) { invincibility?.start() } else { invincibility?.stop() } } protected abstract fun shouldBeInvincible(): Boolean protected abstract fun notification(): Notification? private inner class Invincibility : BroadcastReceiver(), Runnable { private var isStarted = false private val intervalMs = 30_000L override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { Intent.ACTION_SCREEN_ON -> handler.post(this) Intent.ACTION_SCREEN_OFF -> notification()?.let { notification -> handler.removeCallbacks(this) startForeground(notificationId, notification) } } } @Synchronized fun start() { if (!isStarted) { isStarted = true handler.postDelayed(this, intervalMs) registerReceiver(this, IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) }) } } @Synchronized fun stop() { if (isStarted) { handler.removeCallbacks(this) unregisterReceiver(this) isStarted = false } } override fun run() { if (shouldBeInvincible() && isAllowedToStartForegroundServices) { notification()?.let { notification -> startForeground(notificationId, notification) stopForeground(false) handler.postDelayed(this, intervalMs) } } } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastSumBy fun Density.calculateDistanceToDesiredSnapPosition( layoutInfo: LazyGridLayoutInfo, item: LazyGridItemInfo, positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float ): Float { val containerSize = with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) val itemCurrentPosition = item.offset.x.toFloat() return itemCurrentPosition - desiredDistance } private val LazyGridLayoutInfo.singleAxisViewportSize: Int get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width @ExperimentalFoundationApi fun SnapLayoutInfoProvider( lazyGridState: LazyGridState, positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) } ): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { private val layoutInfo: LazyGridLayoutInfo get() = lazyGridState.layoutInfo // Single page snapping is the default override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { var lowerBoundOffset = Float.NEGATIVE_INFINITY var upperBoundOffset = Float.POSITIVE_INFINITY layoutInfo.visibleItemsInfo.fastForEach { item -> val offset = calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) // Find item that is closest to the center if (offset <= 0 && offset > lowerBoundOffset) { lowerBoundOffset = offset } // Find item that is closest to center, but after it if (offset >= 0 && offset < upperBoundOffset) { upperBoundOffset = offset } } return lowerBoundOffset.rangeTo(upperBoundOffset) } override fun Density.snapStepSize(): Float = with(layoutInfo) { if (visibleItemsInfo.isNotEmpty()) { visibleItemsInfo.fastSumBy { it.size.width } / visibleItemsInfo.size.toFloat() } else { 0f } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline val Player.currentWindow: Timeline.Window? get() = if (mediaItemCount == 0) null else currentTimeline.getWindow(currentMediaItemIndex, Timeline.Window()) val Timeline.mediaItems: List get() = List(windowCount) { getWindow(it, Timeline.Window()).mediaItem } inline val Timeline.windows: List get() = List(windowCount) { getWindow(it, Timeline.Window()) } val Player.shouldBePlaying: Boolean get() = !(playbackState == Player.STATE_ENDED || !playWhenReady) fun Player.seamlessPlay(mediaItem: MediaItem) { if (mediaItem.mediaId == currentMediaItem?.mediaId) { if (currentMediaItemIndex > 0) removeMediaItems(0, currentMediaItemIndex) if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) } else { forcePlay(mediaItem) } } fun Player.shuffleQueue() { val mediaItems = currentTimeline.mediaItems.toMutableList().apply { removeAt(currentMediaItemIndex) } if (currentMediaItemIndex > 0) removeMediaItems(0, currentMediaItemIndex) if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) addMediaItems(mediaItems.shuffled()) } fun Player.forcePlay(mediaItem: MediaItem) { setMediaItem(mediaItem, true) playWhenReady = true prepare() } fun Player.forcePlayAtIndex(mediaItems: List, mediaItemIndex: Int) { if (mediaItems.isEmpty()) return setMediaItems(mediaItems, mediaItemIndex, C.TIME_UNSET) playWhenReady = true prepare() } fun Player.forcePlayFromBeginning(mediaItems: List) = forcePlayAtIndex(mediaItems, 0) fun Player.forceSeekToPrevious() { if (hasPreviousMediaItem() || currentPosition > maxSeekToPreviousPosition) { seekToPrevious() } else if (mediaItemCount > 0) { seekTo(mediaItemCount - 1, C.TIME_UNSET) } } fun Player.forceSeekToNext() = if (hasNextMediaItem()) seekToNext() else seekTo(0, C.TIME_UNSET) fun Player.addNext(mediaItem: MediaItem) { if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { forcePlay(mediaItem) } else { addMediaItem(currentMediaItemIndex + 1, mediaItem) } } fun Player.enqueue(mediaItem: MediaItem) { if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { forcePlay(mediaItem) } else { addMediaItem(mediaItemCount, mediaItem) } } fun Player.enqueue(mediaItems: List) { if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { forcePlayFromBeginning(mediaItems) } else { addMediaItems(mediaItemCount, mediaItems) } } fun Player.findNextMediaItemById(mediaId: String): MediaItem? { for (i in currentMediaItemIndex until mediaItemCount) { if (getMediaItemAt(i).mediaId == mediaId) { return getMediaItemAt(i) } } return null } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.media3.common.MediaItem import androidx.media3.common.Player import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @Composable inline fun Player.DisposableListener(crossinline listenerProvider: () -> Player.Listener) { DisposableEffect(this) { val listener = listenerProvider() addListener(listener) onDispose { removeListener(listener) } } } @Composable fun Player.positionAndDurationState(): State> { val state = remember { mutableStateOf(currentPosition to duration) } LaunchedEffect(this) { var isSeeking = false val listener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_READY) { isSeeking = false } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { state.value = currentPosition to state.value.second } override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int ) { if (reason == Player.DISCONTINUITY_REASON_SEEK) { isSeeking = true state.value = currentPosition to duration } } } addListener(listener) val pollJob = launch { while (isActive) { delay(500) if (!isSeeking) { state.value = currentPosition to duration } } } try { suspendCancellableCoroutine { } } finally { pollJob.cancel() removeListener(listener) } } return state } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt ================================================ package it.vfsfitvnm.vimusic.utils import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit const val colorPaletteNameKey = "colorPaletteName" const val colorPaletteModeKey = "colorPaletteMode" const val thumbnailRoundnessKey = "thumbnailRoundness" const val coilDiskCacheMaxSizeKey = "coilDiskCacheMaxSize" const val exoPlayerDiskCacheMaxSizeKey = "exoPlayerDiskCacheMaxSize" const val isInvincibilityEnabledKey = "isInvincibilityEnabled" const val useSystemFontKey = "useSystemFont" const val applyFontPaddingKey = "applyFontPadding" const val songSortOrderKey = "songSortOrder" const val songSortByKey = "songSortBy" const val playlistSortOrderKey = "playlistSortOrder" const val playlistSortByKey = "playlistSortBy" const val albumSortOrderKey = "albumSortOrder" const val albumSortByKey = "albumSortBy" const val artistSortOrderKey = "artistSortOrder" const val artistSortByKey = "artistSortBy" const val trackLoopEnabledKey = "trackLoopEnabled" const val queueLoopEnabledKey = "queueLoopEnabled" const val skipSilenceKey = "skipSilence" const val volumeNormalizationKey = "volumeNormalization" const val resumePlaybackWhenDeviceConnectedKey = "resumePlaybackWhenDeviceConnected" const val persistentQueueKey = "persistentQueue" const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" const val homeScreenTabIndexKey = "homeScreenTabIndex" const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" const val artistScreenTabIndexKey = "artistScreenTabIndex" const val pauseSearchHistoryKey = "pauseSearchHistory" inline fun > SharedPreferences.getEnum( key: String, defaultValue: T ): T = getString(key, null)?.let { try { enumValueOf(it) } catch (e: IllegalArgumentException) { null } } ?: defaultValue inline fun > SharedPreferences.Editor.putEnum( key: String, value: T ): SharedPreferences.Editor = putString(key, value.name) val Context.preferences: SharedPreferences get() = getSharedPreferences("preferences", Context.MODE_PRIVATE) @Composable fun rememberPreference(key: String, defaultValue: Boolean): MutableState { val context = LocalContext.current return remember { mutableStatePreferenceOf(context.preferences.getBoolean(key, defaultValue)) { context.preferences.edit { putBoolean(key, it) } } } } @Composable fun rememberPreference(key: String, defaultValue: Int): MutableState { val context = LocalContext.current return remember { mutableStatePreferenceOf(context.preferences.getInt(key, defaultValue)) { context.preferences.edit { putInt(key, it) } } } } @Composable fun rememberPreference(key: String, defaultValue: String): MutableState { val context = LocalContext.current return remember { mutableStatePreferenceOf(context.preferences.getString(key, null) ?: defaultValue) { context.preferences.edit { putString(key, it) } } } } @Composable inline fun > rememberPreference(key: String, defaultValue: T): MutableState { val context = LocalContext.current return remember { mutableStatePreferenceOf(context.preferences.getEnum(key, defaultValue)) { context.preferences.edit { putEnum(key, it) } } } } inline fun mutableStatePreferenceOf( value: T, crossinline onStructuralInequality: (newValue: T) -> Unit ) = mutableStateOf( value = value, policy = object : SnapshotMutationPolicy { override fun equivalent(a: T, b: T): Boolean { val areEquals = a == b if (!areEquals) onStructuralInequality(b) return areEquals } }) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt ================================================ package it.vfsfitvnm.vimusic.utils class RingBuffer(val size: Int, init: (index: Int) -> T) { private val list = MutableList(size, init) private var index = 0 fun getOrNull(index: Int): T? = list.getOrNull(index) fun append(element: T) = list.set(index++ % size, element) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue data class ScrollingInfo( val isScrollingDown: Boolean = false, val isFar: Boolean = false ) { fun and(condition: Boolean) = // copy(isScrollingDown = isScrollingDown && condition, isFar = isFar && condition) if (condition) this else copy(isScrollingDown = !isScrollingDown, isFar = !isFar) } @Composable fun LazyListState.scrollingInfo(): ScrollingInfo { var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } return remember(this) { derivedStateOf { val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { firstVisibleItemScrollOffset > previousScrollOffset } else { firstVisibleItemIndex > previousIndex } val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size previousIndex = firstVisibleItemIndex previousScrollOffset = firstVisibleItemScrollOffset ScrollingInfo(isScrollingDown, isFar) } }.value } @Composable fun LazyGridState.scrollingInfo(): ScrollingInfo { var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } return remember(this) { derivedStateOf { val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { firstVisibleItemScrollOffset > previousScrollOffset } else { firstVisibleItemIndex > previousIndex } val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size previousIndex = firstVisibleItemIndex previousScrollOffset = firstVisibleItemScrollOffset ScrollingInfo(isScrollingDown, isFar) } }.value } @Composable fun ScrollState.scrollingInfo(): ScrollingInfo { var previousValue by remember(this) { mutableStateOf(value) } return remember(this) { derivedStateOf { val isScrollingDown = value > previousValue previousValue = value ScrollingInfo(isScrollingDown, false) } }.value } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue suspend fun LazyGridState.smoothScrollToTop() { if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { scrollToItem(layoutInfo.visibleItemsInfo.size) } animateScrollToItem(0) } suspend fun LazyListState.smoothScrollToTop() { if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { scrollToItem(layoutInfo.visibleItemsInfo.size) } animateScrollToItem(0) } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue class SynchronizedLyrics(val sentences: List>, private val positionProvider: () -> Long) { var index by mutableStateOf(currentIndex) private set private val currentIndex: Int get() { var index = -1 for (item in sentences) { if (item.first >= positionProvider()) break index++ } return if (index == -1) 0 else index } fun update(): Boolean { val newIndex = currentIndex return if (newIndex != index) { index = newIndex true } else { false } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance fun TextStyle.style(style: FontStyle) = copy(fontStyle = style) fun TextStyle.weight(weight: FontWeight) = copy(fontWeight = weight) fun TextStyle.align(align: TextAlign) = copy(textAlign = align) fun TextStyle.color(color: Color) = copy(color = color) inline val TextStyle.medium: TextStyle get() = weight(FontWeight.Medium) inline val TextStyle.semiBold: TextStyle get() = weight(FontWeight.SemiBold) inline val TextStyle.bold: TextStyle get() = weight(FontWeight.Bold) inline val TextStyle.center: TextStyle get() = align(TextAlign.Center) inline val TextStyle.secondary: TextStyle @Composable @ReadOnlyComposable get() = color(LocalAppearance.current.colorPalette.textSecondary) ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TimerJob.kt ================================================ package it.vfsfitvnm.vimusic.utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch interface TimerJob { val millisLeft: StateFlow fun cancel() } fun CoroutineScope.timer(delayMillis: Long, onCompletion: () -> Unit): TimerJob { val millisLeft = MutableStateFlow(delayMillis) val job = launch { while (isActive && millisLeft.value != null) { delay(1000) millisLeft.emit(millisLeft.value?.minus(1000)?.takeIf { it > 0 }) } } val disposableHandle = job.invokeOnCompletion { if (it == null) { onCompletion() } } return object : TimerJob { override val millisLeft: StateFlow get() = millisLeft.asStateFlow() override fun cancel() { millisLeft.value = null disposableHandle.dispose() job.cancel() } } } ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt ================================================ package it.vfsfitvnm.vimusic.utils import android.net.Uri import android.os.Build import android.text.format.DateUtils import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.requests.playlistPage import it.vfsfitvnm.innertube.utils.plus import it.vfsfitvnm.vimusic.models.Song val Innertube.SongItem.asMediaItem: MediaItem get() = MediaItem.Builder() .setMediaId(key) .setUri(key) .setCustomCacheKey(key) .setMediaMetadata( MediaMetadata.Builder() .setTitle(info?.name) .setArtist(authors?.joinToString("") { it.name ?: "" }) .setAlbumTitle(album?.name) .setArtworkUri(thumbnail?.url?.toUri()) .setExtras( bundleOf( "albumId" to album?.endpoint?.browseId, "durationText" to durationText, "artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name }, "artistIds" to authors?.mapNotNull { it.endpoint?.browseId }, ) ) .build() ) .build() val Innertube.VideoItem.asMediaItem: MediaItem get() = MediaItem.Builder() .setMediaId(key) .setUri(key) .setCustomCacheKey(key) .setMediaMetadata( MediaMetadata.Builder() .setTitle(info?.name) .setArtist(authors?.joinToString("") { it.name ?: "" }) .setArtworkUri(thumbnail?.url?.toUri()) .setExtras( bundleOf( "durationText" to durationText, "artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null, "artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null, ) ) .build() ) .build() val Song.asMediaItem: MediaItem get() = MediaItem.Builder() .setMediaMetadata( MediaMetadata.Builder() .setTitle(title) .setArtist(artistsText) .setArtworkUri(thumbnailUrl?.toUri()) .setExtras( bundleOf( "durationText" to durationText ) ) .build() ) .setMediaId(id) .setUri(id) .setCustomCacheKey(id) .build() fun String?.thumbnail(size: Int): String? { return when { this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$size-h$size" this?.startsWith("https://yt3.ggpht.com") == true -> "$this-w$size-h$size-s$size" else -> this } } fun Uri?.thumbnail(size: Int): Uri? { return toString().thumbnail(size)?.toUri() } fun formatAsDuration(millis: Long) = DateUtils.formatElapsedTime(millis / 1000).removePrefix("0") suspend fun Result.completed(): Result? { var playlistPage = getOrNull() ?: return null while (playlistPage.songsPage?.continuation != null) { val continuation = playlistPage.songsPage?.continuation!! val otherPlaylistPageResult = Innertube.playlistPage(ContinuationBody(continuation = continuation)) ?: break if (otherPlaylistPageResult.isFailure) break otherPlaylistPageResult.getOrNull()?.let { otherSongsPage -> playlistPage = playlistPage.copy(songsPage = playlistPage.songsPage + otherSongsPage) } } return Result.success(playlistPage) } inline val isAtLeastAndroid6 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M inline val isAtLeastAndroid8 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O inline val isAtLeastAndroid12 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S inline val isAtLeastAndroid13 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ================================================ FILE: app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt ================================================ package it.vfsfitvnm.vimusic.utils import androidx.media3.common.MediaItem import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.models.bodies.NextBody import it.vfsfitvnm.innertube.requests.nextPage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext data class YouTubeRadio( private val videoId: String? = null, private var playlistId: String? = null, private var playlistSetVideoId: String? = null, private var parameters: String? = null ) { private var nextContinuation: String? = null suspend fun process(): List { var mediaItems: List? = null nextContinuation = withContext(Dispatchers.IO) { val continuation = nextContinuation if (continuation == null) { Innertube.nextPage( NextBody( videoId = videoId, playlistId = playlistId, params = parameters, playlistSetVideoId = playlistSetVideoId ) )?.map { nextResult -> playlistId = nextResult.playlistId parameters = nextResult.params playlistSetVideoId = nextResult.playlistSetVideoId nextResult.itemsPage } } else { Innertube.nextPage(ContinuationBody(continuation = continuation)) }?.getOrNull()?.let { songsPage -> mediaItems = songsPage.items?.map(Innertube.SongItem::asMediaItem) songsPage.continuation?.takeUnless { nextContinuation == it } } } return mediaItems ?: emptyList() } } ================================================ FILE: app/src/main/res/drawable/add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/airplane.xml ================================================ ================================================ FILE: app/src/main/res/drawable/alarm.xml ================================================ ================================================ FILE: app/src/main/res/drawable/alert_circle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/app_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bookmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bookmark_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/calendar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/checkmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/chevron_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/chevron_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/chevron_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/chevron_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/color_palette.xml ================================================ ================================================ FILE: app/src/main/res/drawable/disc.xml ================================================ ================================================ FILE: app/src/main/res/drawable/download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ellipsis_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ellipsis_vertical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/enqueue.xml ================================================ ================================================ FILE: app/src/main/res/drawable/equalizer.xml ================================================ ================================================ FILE: app/src/main/res/drawable/film.xml ================================================ ================================================ FILE: app/src/main/res/drawable/globe.xml ================================================ ================================================ FILE: app/src/main/res/drawable/heart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/heart_dislike.xml ================================================ ================================================ FILE: app/src/main/res/drawable/heart_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_banner_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/infinite.xml ================================================ ================================================ FILE: app/src/main/res/drawable/information.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library.xml ================================================ ================================================ FILE: app/src/main/res/drawable/link.xml ================================================ ================================================ FILE: app/src/main/res/drawable/medical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/musical_notes.xml ================================================ ================================================ FILE: app/src/main/res/drawable/notifications.xml ================================================ ================================================ FILE: app/src/main/res/drawable/pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/pencil.xml ================================================ ================================================ FILE: app/src/main/res/drawable/person.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play_skip_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play_skip_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/playlist.xml ================================================ ================================================ FILE: app/src/main/res/drawable/radio.xml ================================================ ================================================ FILE: app/src/main/res/drawable/reorder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/server.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shapes.xml ================================================ ================================================ FILE: app/src/main/res/drawable/share_social.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shuffle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sort.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sparkles.xml ================================================ ================================================ FILE: app/src/main/res/drawable/star.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sync.xml ================================================ ================================================ FILE: app/src/main/res/drawable/text.xml ================================================ ================================================ FILE: app/src/main/res/drawable/time.xml ================================================ ================================================ FILE: app/src/main/res/drawable/trash.xml ================================================ ================================================ FILE: app/src/main/res/drawable/trending.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_banner.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #4046bf #ffffff ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: app/src/main/res/xml/automotive_app_desc.xml ================================================ ================================================ FILE: build.gradle.kts ================================================ buildscript { repositories { google() mavenCentral() gradlePluginPortal() } dependencies { classpath("com.android.tools.build", "gradle", "7.3.0") classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) } } tasks.register("clean", Delete::class) { delete(rootProject.buildDir) } subprojects { tasks.withType().configureEach { kotlinOptions { if (project.findProperty("enableComposeCompilerReports") == "true") { arrayOf("reports", "metrics").forEach { freeCompilerArgs = freeCompilerArgs + listOf( "-P", "plugin:androidx.compose.compiler.plugins.kotlin:${it}Destination=${project.buildDir.absolutePath}/compose_metrics" ) } } } } } ================================================ FILE: compose-persist/.gitignore ================================================ /build ================================================ FILE: compose-persist/build.gradle.kts ================================================ plugins { id("com.android.library") kotlin("android") } android { namespace = "it.vfsfitvnm.compose.persist" compileSdk = 33 defaultConfig { minSdk = 21 targetSdk = 33 } buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } sourceSets.all { kotlin.srcDir("src/$name/kotlin") } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } kotlinOptions { jvmTarget = "1.8" } } dependencies { implementation(libs.compose.foundation) } ================================================ FILE: compose-persist/src/main/AndroidManifest.xml ================================================ ================================================ FILE: compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Persist.kt ================================================ package it.vfsfitvnm.compose.persist import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @Suppress("UNCHECKED_CAST") @Composable fun persist(tag: String, initialValue: T): MutableState { val context = LocalContext.current return remember { context.persistMap?.getOrPut(tag) { mutableStateOf(initialValue) } as? MutableState ?: mutableStateOf(initialValue) } } @Composable fun persistList(tag: String): MutableState> = persist(tag = tag, initialValue = emptyList()) @Composable fun persist(tag: String): MutableState = persist(tag = tag, initialValue = null) ================================================ FILE: compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMap.kt ================================================ package it.vfsfitvnm.compose.persist typealias PersistMap = HashMap ================================================ FILE: compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapCleanup.kt ================================================ package it.vfsfitvnm.compose.persist import android.app.Activity import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.platform.LocalContext @Composable fun PersistMapCleanup(tagPrefix: String) { val context = LocalContext.current DisposableEffect(context) { onDispose { if (context.findOwner()?.isChangingConfigurations == false) { context.persistMap?.keys?.removeAll { it.startsWith(tagPrefix) } } } } } ================================================ FILE: compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapOwner.kt ================================================ package it.vfsfitvnm.compose.persist interface PersistMapOwner { val persistMap: PersistMap } ================================================ FILE: compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Utils.kt ================================================ package it.vfsfitvnm.compose.persist import android.content.Context import android.content.ContextWrapper val Context.persistMap: PersistMap? get() = findOwner()?.persistMap internal inline fun Context.findOwner(): T? { var context = this while (context is ContextWrapper) { if (context is T) return context context = context.baseContext } return null } ================================================ FILE: compose-reordering/.gitignore ================================================ /build ================================================ FILE: compose-reordering/build.gradle.kts ================================================ plugins { id("com.android.library") kotlin("android") } android { namespace = "it.vfsfitvnm.compose.reordering" compileSdk = 33 defaultConfig { minSdk = 21 targetSdk = 33 } buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } sourceSets.all { kotlin.srcDir("src/$name/kotlin") } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } kotlinOptions { freeCompilerArgs += "-Xcontext-receivers" jvmTarget = "1.8" } } dependencies { implementation(libs.compose.foundation) } ================================================ FILE: compose-reordering/src/main/AndroidManifest.xml ================================================ ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimatablesPool.kt ================================================ package it.vfsfitvnm.compose.reordering import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector import androidx.compose.animation.core.TwoWayConverter import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class AnimatablesPool( private val size: Int, private val initialValue: T, typeConverter: TwoWayConverter ) { private val values = MutableList(size) { Animatable(initialValue = initialValue, typeConverter = typeConverter) } private val mutex = Mutex() init { require(size > 0) } suspend fun acquire(): Animatable? { return mutex.withLock { if (values.isNotEmpty()) values.removeFirst() else null } } suspend fun release(animatable: Animatable) { mutex.withLock { if (values.size < size) { animatable.snapTo(initialValue) values.add(animatable) } } } } ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimateItemPlacement.kt ================================================ package it.vfsfitvnm.compose.reordering import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.ui.Modifier context(LazyItemScope) @ExperimentalFoundationApi fun Modifier.animateItemPlacement(reorderingState: ReorderingState) = if (reorderingState.draggingIndex == -1) animateItemPlacement() else this ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/DraggedItem.kt ================================================ package it.vfsfitvnm.compose.reordering import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.offset import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.zIndex fun Modifier.draggedItem( reorderingState: ReorderingState, index: Int ): Modifier = when (reorderingState.draggingIndex) { -1 -> this index -> offset { when (reorderingState.lazyListState.layoutInfo.orientation) { Orientation.Vertical -> IntOffset(0, reorderingState.offset.value) Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0) } }.zIndex(1f) else -> offset { val offset = when (index) { in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize in reorderingState.reachedIndex until reorderingState.draggingIndex -> reorderingState.draggingItemSize else -> 0 } when (reorderingState.lazyListState.layoutInfo.orientation) { Orientation.Vertical -> IntOffset(0, offset) Orientation.Horizontal -> IntOffset(offset, 0) } } } ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/Reorder.kt ================================================ package it.vfsfitvnm.compose.reordering import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput private fun Modifier.reorder( reorderingState: ReorderingState, index: Int, detectDragGestures: DetectDragGestures, ): Modifier = pointerInput(reorderingState) { with(detectDragGestures) { detectDragGestures( onDragStart = { reorderingState.onDragStart(index) }, onDrag = reorderingState::onDrag, onDragEnd = reorderingState::onDragEnd, onDragCancel = reorderingState::onDragEnd, ) } } fun Modifier.reorder( reorderingState: ReorderingState, index: Int, ): Modifier = reorder( reorderingState = reorderingState, index = index, detectDragGestures = PointerInputScope::detectDragGestures, ) fun Modifier.reorderAfterLongPress( reorderingState: ReorderingState, index: Int ): Modifier = reorder( reorderingState = reorderingState, index = index, detectDragGestures = PointerInputScope::detectDragGesturesAfterLongPress, ) private fun interface DetectDragGestures { suspend fun PointerInputScope.detectDragGestures( onDragStart: (Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit ) } ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyColumn.kt ================================================ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") package it.vfsfitvnm.compose.reordering import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun ReorderingLazyColumn( reorderingState: ReorderingState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, content: LazyListScope.() -> Unit ) { ReorderingLazyList( modifier = modifier, reorderingState = reorderingState, contentPadding = contentPadding, flingBehavior = flingBehavior, horizontalAlignment = horizontalAlignment, verticalArrangement = verticalArrangement, isVertical = true, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, content = content ) } ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyList.kt ================================================ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") package it.vfsfitvnm.compose.reordering import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.checkScrollableContainerConstraints import androidx.compose.foundation.clipScrollableContainer import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.lazy.DataIndex import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo import androidx.compose.foundation.lazy.LazyListItemPlacementAnimator import androidx.compose.foundation.lazy.LazyListItemProvider import androidx.compose.foundation.lazy.LazyListMeasureResult import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyMeasuredItem import androidx.compose.foundation.lazy.LazyMeasuredItemProvider import androidx.compose.foundation.lazy.layout.LazyLayout import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics import androidx.compose.foundation.lazy.lazyListBeyondBoundsModifier import androidx.compose.foundation.lazy.lazyListPinningModifier import androidx.compose.foundation.lazy.measureLazyList import androidx.compose.foundation.lazy.rememberLazyListItemProvider import androidx.compose.foundation.lazy.rememberLazyListSemanticState import androidx.compose.foundation.overscroll import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.offset @OptIn(ExperimentalFoundationApi::class) @Composable internal fun ReorderingLazyList( modifier: Modifier, reorderingState: ReorderingState, contentPadding: PaddingValues, reverseLayout: Boolean, isVertical: Boolean, flingBehavior: FlingBehavior, userScrollEnabled: Boolean, horizontalAlignment: Alignment.Horizontal? = null, verticalArrangement: Arrangement.Vertical? = null, verticalAlignment: Alignment.Vertical? = null, horizontalArrangement: Arrangement.Horizontal? = null, content: LazyListScope.() -> Unit ) { val overscrollEffect = ScrollableDefaults.overscrollEffect() val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) val semanticState = rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo val scope = rememberCoroutineScope() val placementAnimator = remember(reorderingState.lazyListState, isVertical) { LazyListItemPlacementAnimator(scope, isVertical) } reorderingState.lazyListState.placementAnimator = placementAnimator val measurePolicy = rememberLazyListMeasurePolicy( itemProvider, reorderingState.lazyListState, beyondBoundsInfo, overscrollEffect, contentPadding, reverseLayout, isVertical, horizontalAlignment, verticalAlignment, horizontalArrangement, verticalArrangement, placementAnimator ) val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal LazyLayout( modifier = modifier .then(reorderingState.lazyListState.remeasurementModifier) .then(reorderingState.lazyListState.awaitLayoutModifier) .lazyLayoutSemantics( itemProvider = itemProvider, state = semanticState, orientation = orientation, userScrollEnabled = userScrollEnabled ) .clipScrollableContainer(orientation) .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout) .lazyListPinningModifier(reorderingState.lazyListState, beyondBoundsInfo) .overscroll(overscrollEffect) .scrollable( orientation = orientation, reverseDirection = ScrollableDefaults.reverseDirection( LocalLayoutDirection.current, orientation, reverseLayout ), interactionSource = reorderingState.lazyListState.internalInteractionSource, flingBehavior = flingBehavior, state = reorderingState.lazyListState, overscrollEffect = overscrollEffect, enabled = userScrollEnabled ), prefetchState = reorderingState.lazyListState.prefetchState, measurePolicy = measurePolicy, itemProvider = itemProvider ) } @ExperimentalFoundationApi @Composable private fun rememberLazyListMeasurePolicy( itemProvider: LazyListItemProvider, state: LazyListState, beyondBoundsInfo: LazyListBeyondBoundsInfo, overscrollEffect: OverscrollEffect, contentPadding: PaddingValues, reverseLayout: Boolean, isVertical: Boolean, horizontalAlignment: Alignment.Horizontal? = null, verticalAlignment: Alignment.Vertical? = null, horizontalArrangement: Arrangement.Horizontal? = null, verticalArrangement: Arrangement.Vertical? = null, placementAnimator: LazyListItemPlacementAnimator ) = remember MeasureResult>( state, beyondBoundsInfo, overscrollEffect, contentPadding, reverseLayout, isVertical, horizontalAlignment, verticalAlignment, horizontalArrangement, verticalArrangement, placementAnimator ) { { containerConstraints -> checkScrollableContainerConstraints( containerConstraints, if (isVertical) Orientation.Vertical else Orientation.Horizontal ) val startPadding = if (isVertical) { contentPadding.calculateLeftPadding(layoutDirection).roundToPx() } else { contentPadding.calculateStartPadding(layoutDirection).roundToPx() } val endPadding = if (isVertical) { contentPadding.calculateRightPadding(layoutDirection).roundToPx() } else { contentPadding.calculateEndPadding(layoutDirection).roundToPx() } val topPadding = contentPadding.calculateTopPadding().roundToPx() val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() val totalVerticalPadding = topPadding + bottomPadding val totalHorizontalPadding = startPadding + endPadding val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding val beforeContentPadding = when { isVertical && !reverseLayout -> topPadding isVertical && reverseLayout -> bottomPadding !isVertical && !reverseLayout -> startPadding else -> endPadding } val afterContentPadding = totalMainAxisPadding - beforeContentPadding val contentConstraints = containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) state.density = this itemProvider.itemScope.setMaxSize( width = contentConstraints.maxWidth, height = contentConstraints.maxHeight ) val spaceBetweenItemsDp = if (isVertical) { requireNotNull(verticalArrangement).spacing } else { requireNotNull(horizontalArrangement).spacing } val spaceBetweenItems = spaceBetweenItemsDp.roundToPx() val itemsCount = itemProvider.itemCount val mainAxisAvailableSize = if (isVertical) { containerConstraints.maxHeight - totalVerticalPadding } else { containerConstraints.maxWidth - totalHorizontalPadding } val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { IntOffset(startPadding, topPadding) } else { IntOffset( if (isVertical) startPadding else startPadding + mainAxisAvailableSize, if (isVertical) topPadding + mainAxisAvailableSize else topPadding ) } val measuredItemProvider = LazyMeasuredItemProvider( contentConstraints, isVertical, itemProvider, this ) { index, key, placeables -> val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems LazyMeasuredItem( index = index.value, placeables = placeables, isVertical = isVertical, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, layoutDirection = layoutDirection, reverseLayout = reverseLayout, beforeContentPadding = beforeContentPadding, afterContentPadding = afterContentPadding, spacing = spacing, visualOffset = visualItemOffset, key = key, placementAnimator = placementAnimator ) } state.premeasureConstraints = measuredItemProvider.childConstraints val firstVisibleItemIndex: DataIndex val firstVisibleScrollOffset: Int Snapshot.withoutReadObservation { firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex) firstVisibleScrollOffset = state.firstVisibleItemScrollOffset } measureLazyList( itemsCount = itemsCount, itemProvider = measuredItemProvider, mainAxisAvailableSize = mainAxisAvailableSize, beforeContentPadding = beforeContentPadding, afterContentPadding = afterContentPadding, spaceBetweenItems = spaceBetweenItems, firstVisibleItemIndex = firstVisibleItemIndex, firstVisibleItemScrollOffset = firstVisibleScrollOffset, scrollToBeConsumed = state.scrollToBeConsumed, constraints = contentConstraints, isVertical = isVertical, headerIndexes = itemProvider.headerIndexes, verticalArrangement = verticalArrangement, horizontalArrangement = horizontalArrangement, reverseLayout = reverseLayout, density = this, placementAnimator = placementAnimator, beyondBoundsInfo = beyondBoundsInfo, layout = { width, height, placement -> layout( containerConstraints.constrainWidth(width + totalHorizontalPadding), containerConstraints.constrainHeight(height + totalVerticalPadding), emptyMap(), placement ) } ).also { state.applyMeasureResult(it) refreshOverscrollInfo(overscrollEffect, it) } } } @OptIn(ExperimentalFoundationApi::class) private fun refreshOverscrollInfo( overscrollEffect: OverscrollEffect, result: LazyListMeasureResult ) { val canScrollForward = result.canScrollForward val canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 || result.firstVisibleItemScrollOffset != 0 overscrollEffect.isEnabled = canScrollForward || canScrollBackward } ================================================ FILE: compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingState.kt ================================================ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") package it.vfsfitvnm.compose.reordering import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.VectorConverter import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Stable class ReorderingState( val lazyListState: LazyListState, val coroutineScope: CoroutineScope, private val lastIndex: Int, internal val onDragStart: () -> Unit, internal val onDragEnd: (Int, Int) -> Unit, private val extraItemCount: Int ) { private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo() internal val offset = Animatable(0, Int.VectorConverter) internal var draggingIndex by mutableStateOf(-1) internal var reachedIndex by mutableStateOf(-1) internal var draggingItemSize by mutableStateOf(0) lateinit var itemInfo: LazyListItemInfo private var previousItemSize = 0 private var nextItemSize = 0 private var overscrolled = 0 internal var indexesToAnimate = mutableStateMapOf>() private var animatablesPool: AnimatablesPool? = null val isDragging: Boolean get() = draggingIndex != -1 fun onDragStart(index: Int) { overscrolled = 0 itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { it.index == index + extraItemCount } ?: return onDragStart.invoke() draggingIndex = index reachedIndex = index draggingItemSize = itemInfo.size nextItemSize = draggingItemSize previousItemSize = -draggingItemSize offset.updateBounds( lowerBound = -index * draggingItemSize, upperBound = (lastIndex - index) * draggingItemSize ) lazyListBeyondBoundsInfoInterval = lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount) val size = lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset animatablesPool = AnimatablesPool(size / draggingItemSize + 2, 0, Int.VectorConverter) } fun onDrag(change: PointerInputChange, dragAmount: Offset) { if (!isDragging) return change.consume() val delta = when (lazyListState.layoutInfo.orientation) { Orientation.Vertical -> dragAmount.y Orientation.Horizontal -> dragAmount.x }.roundToInt() val targetOffset = offset.value + delta coroutineScope.launch { offset.snapTo(targetOffset) } if (targetOffset > nextItemSize) { if (reachedIndex < lastIndex) { reachedIndex += 1 nextItemSize += draggingItemSize previousItemSize += draggingItemSize val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 coroutineScope.launch { val animatable = indexesToAnimate.getOrPut(indexToAnimate) { animatablesPool?.acquire() ?: return@launch } if (draggingIndex < reachedIndex) { animatable.snapTo(0) animatable.animateTo(-draggingItemSize) } else { animatable.snapTo(draggingItemSize) animatable.animateTo(0) } indexesToAnimate.remove(indexToAnimate) animatablesPool?.release(animatable) } } } else if (targetOffset < previousItemSize) { if (reachedIndex > 0) { reachedIndex -= 1 previousItemSize -= draggingItemSize nextItemSize -= draggingItemSize val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 coroutineScope.launch { val animatable = indexesToAnimate.getOrPut(indexToAnimate) { animatablesPool?.acquire() ?: return@launch } if (draggingIndex > reachedIndex) { animatable.snapTo(0) animatable.animateTo(draggingItemSize) } else { animatable.snapTo(-draggingItemSize) animatable.animateTo(0) } indexesToAnimate.remove(indexToAnimate) animatablesPool?.release(animatable) } } } else { val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size if (topOverscroll > 0) { overscroll(topOverscroll) } else if (bottomOverscroll < 0) { overscroll(bottomOverscroll) } } } fun onDragEnd() { if (!isDragging) return coroutineScope.launch { offset.animateTo((previousItemSize + nextItemSize) / 2) withContext(Dispatchers.Main) { onDragEnd.invoke(draggingIndex, reachedIndex) } if (areEquals()) { draggingIndex = -1 reachedIndex = -1 draggingItemSize = 0 offset.snapTo(0) } lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval) animatablesPool = null } } private fun overscroll(overscroll: Int) { lazyListState.dispatchRawDelta(-overscroll.toFloat()) coroutineScope.launch { offset.snapTo(offset.value - overscroll) } overscrolled -= overscroll } private fun areEquals(): Boolean { return lazyListState.layoutInfo.visibleItemsInfo.find { it.index + extraItemCount == draggingIndex }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { it.index + extraItemCount == reachedIndex }?.key } } @Composable fun rememberReorderingState( lazyListState: LazyListState, key: Any, onDragEnd: (Int, Int) -> Unit, onDragStart: () -> Unit = {}, extraItemCount: Int = 0 ): ReorderingState { val coroutineScope = rememberCoroutineScope() return remember(key) { ReorderingState( lazyListState = lazyListState, coroutineScope = coroutineScope, lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, onDragStart = onDragStart, onDragEnd = onDragEnd, extraItemCount = extraItemCount, ) } } ================================================ FILE: compose-routing/.gitignore ================================================ /build ================================================ FILE: compose-routing/build.gradle.kts ================================================ plugins { id("com.android.library") kotlin("android") } android { namespace = "it.vfsfitvnm.compose.routing" compileSdk = 33 defaultConfig { minSdk = 21 targetSdk = 33 } buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } sourceSets.all { kotlin.srcDir("src/$name/kotlin") } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } kotlinOptions { freeCompilerArgs += "-Xcontext-receivers" jvmTarget = "1.8" } } dependencies { implementation(libs.compose.activity) implementation(libs.compose.foundation) } ================================================ FILE: compose-routing/src/main/AndroidManifest.xml ================================================ ================================================ FILE: compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/GlobalRoute.kt ================================================ package it.vfsfitvnm.compose.routing import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import kotlinx.coroutines.flow.MutableSharedFlow internal val globalRouteFlow = MutableSharedFlow>>(extraBufferCapacity = 1) @Composable fun OnGlobalRoute(block: suspend (Pair>) -> Unit) { LaunchedEffect(Unit) { globalRouteFlow.collect(block) } } ================================================ FILE: compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Route.kt ================================================ @file:Suppress("UNCHECKED_CAST") package it.vfsfitvnm.compose.routing import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.saveable.SaverScope import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @Immutable open class Route internal constructor(val tag: String) { override fun equals(other: Any?): Boolean { return when { this === other -> true other is Route -> tag == other.tag else -> false } } override fun hashCode(): Int { return tag.hashCode() } object Saver : androidx.compose.runtime.saveable.Saver { override fun restore(value: String): Route? = value.takeIf(String::isNotEmpty)?.let(::Route) override fun SaverScope.save(value: Route?): String = value?.tag ?: "" } } @Immutable class Route0(tag: String) : Route(tag) { context(RouteHandlerScope) @Composable operator fun invoke(content: @Composable () -> Unit) { if (this == route) { content() } } fun global() { globalRouteFlow.tryEmit(this to emptyArray()) } } @Immutable class Route1(tag: String) : Route(tag) { context(RouteHandlerScope) @Composable operator fun invoke(content: @Composable (P0) -> Unit) { if (this == route) { content(parameters[0] as P0) } } fun global(p0: P0) { globalRouteFlow.tryEmit(this to arrayOf(p0)) } suspend fun ensureGlobal(p0: P0) { globalRouteFlow.subscriptionCount.filter { it > 0 }.first() globalRouteFlow.emit(this to arrayOf(p0)) } } @Immutable class Route2(tag: String) : Route(tag) { context(RouteHandlerScope) @Composable operator fun invoke(content: @Composable (P0, P1) -> Unit) { if (this == route) { content(parameters[0] as P0, parameters[1] as P1) } } fun global(p0: P0, p1: P1) { globalRouteFlow.tryEmit(this to arrayOf(p0, p1)) } } ================================================ FILE: compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandler.kt ================================================ package it.vfsfitvnm.compose.routing import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @ExperimentalAnimationApi @Composable fun RouteHandler( modifier: Modifier = Modifier, listenToGlobalEmitter: Boolean = false, handleBackPress: Boolean = true, transitionSpec: AnimatedContentScope.() -> ContentTransform = { when { isStacking -> defaultStacking isStill -> defaultStill else -> defaultUnstacking } }, content: @Composable RouteHandlerScope.() -> Unit ) { var route by rememberSaveable(stateSaver = Route.Saver) { mutableStateOf(null) } RouteHandler( route = route, onRouteChanged = { route = it }, listenToGlobalEmitter = listenToGlobalEmitter, handleBackPress = handleBackPress, transitionSpec = transitionSpec, modifier = modifier, content = content ) } @ExperimentalAnimationApi @Composable fun RouteHandler( route: Route?, onRouteChanged: (Route?) -> Unit, modifier: Modifier = Modifier, listenToGlobalEmitter: Boolean = false, handleBackPress: Boolean = true, transitionSpec: AnimatedContentScope.() -> ContentTransform = { when { isStacking -> defaultStacking isStill -> defaultStill else -> defaultUnstacking } }, content: @Composable RouteHandlerScope.() -> Unit ) { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val parameters = rememberSaveable { arrayOfNulls(2) } val scope = remember(route) { RouteHandlerScope( route = route, parameters = parameters, push = onRouteChanged, pop = { if (handleBackPress) backDispatcher?.onBackPressed() else onRouteChanged(null) } ) } if (listenToGlobalEmitter && route == null) { OnGlobalRoute { (newRoute, newParameters) -> newParameters.forEachIndexed(parameters::set) onRouteChanged(newRoute) } } BackHandler(enabled = handleBackPress && route != null) { onRouteChanged(null) } updateTransition(targetState = scope, label = null).AnimatedContent( transitionSpec = transitionSpec, contentKey = RouteHandlerScope::route, modifier = modifier, ) { it.content() } } ================================================ FILE: compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandlerScope.kt ================================================ package it.vfsfitvnm.compose.routing import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @Stable class RouteHandlerScope( val route: Route?, val parameters: Array, private val push: (Route?) -> Unit, val pop: () -> Unit, ) { @SuppressLint("ComposableNaming") @Composable inline fun host(content: @Composable () -> Unit) { if (route == null) { content() } } operator fun Route.invoke() { push(this) } operator fun Route.invoke(p0: P0) { parameters[0] = p0 invoke() } operator fun Route.invoke(p0: P0, p1: P1) { parameters[1] = p1 invoke(p0) } } ================================================ FILE: compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Transitions.kt ================================================ package it.vfsfitvnm.compose.routing import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut @ExperimentalAnimationApi val defaultStacking = ContentTransform( initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), targetContentEnter = fadeIn(), targetContentZIndex = 1f ) @ExperimentalAnimationApi val defaultUnstacking = ContentTransform( initialContentExit = fadeOut(), targetContentEnter = EnterTransition.None, targetContentZIndex = 0f ) @ExperimentalAnimationApi val defaultStill = ContentTransform( initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), targetContentEnter = fadeIn(), targetContentZIndex = 1f ) @ExperimentalAnimationApi inline val AnimatedContentScope.isStacking: Boolean get() = initialState.route == null && targetState.route != null @ExperimentalAnimationApi inline val AnimatedContentScope.isUnstacking: Boolean get() = initialState.route != null && targetState.route == null @ExperimentalAnimationApi inline val AnimatedContentScope.isStill: Boolean get() = initialState.route == null && targetState.route == null @ExperimentalAnimationApi inline val AnimatedContentScope.isUnknown: Boolean get() = initialState.route != null && targetState.route != null ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10.txt ================================================ * The Offline (renamed from Cached) playlist is now displayed only if the playlist grid is expanded * The Sleep Timer functionality is moved to the playing song menu * The search screen text input now correctly handles playlist URLs * The 200 song limit problem when opening or importing a YouTube playlist is now fixed * The stats for nerds (long press the playing song thumbnail) also display the bitrate * The stats for nerds are now correctly updated when skipping songs * The song list can now be sorted also by title (other than play time and date) * The playlists can now be sorted by name, song count and date ================================================ FILE: fastlane/metadata/android/en-US/changelogs/11.txt ================================================ * The radio can now be started from the playing song * The queue can now be shuffled * The collapsed player can now be dismissed to stop the playback * The player is automatically expanded when a new queue is started * The player is automatically expanded when opening the app when clicking the playback notification * Many age restricted videos can now be played * The player and settings UIs have been redesigned ================================================ FILE: fastlane/metadata/android/en-US/changelogs/12.txt ================================================ * A dynamic new theme, which adapts its colors based on the playing song cover, is added * The ability to fetch and display synchronized lyrics (from a third party provider) is added * The song cover can now be displayed as lockscreen wallpaper ================================================ FILE: fastlane/metadata/android/en-US/changelogs/13.txt ================================================ * Fix crash when fetching album metadata ================================================ FILE: fastlane/metadata/android/en-US/changelogs/14.txt ================================================ * The screen now automatically scrolls when reordering the queue or playlists songs ================================================ FILE: fastlane/metadata/android/en-US/changelogs/15.txt ================================================ * Minor changes and fixes ================================================ FILE: fastlane/metadata/android/en-US/changelogs/16.txt ================================================ * New user interface * Added "Quick picks", "Albums" and "Artists" tabs to the home screen * Added "Songs", "Albums" and "Singles" tabs to the artist screen * Added "Other versions" tab to the album screen * Albums and artists can be bookmarked * Added "Library" tab to the search screen * Removed the "loop none" option * Imported playlists can now be synchronized * Added the ability to open channel urls * Opening a song url now automatically starts the playback ================================================ FILE: fastlane/metadata/android/en-US/changelogs/17.txt ================================================ * Added Android Auto support (must be enabled in the settings) * Improved the audio normalization algorithm * Fixed a bug which caused play time of a song to be incorrectly calculated * Fixed a bug which caused artists and albums to be incorrectly sorted * Fixed a crash that occurred when searching * Fixed "this video in not available in your country" problem that affected GB users ================================================ FILE: fastlane/metadata/android/en-US/changelogs/18.txt ================================================ * Added option to pause search history * Added option to clear trending song * Added description to artist screen * Fixed "Unknown" artist problem * Fixed "Quick picks" not loading * Fixed playback controls not working in Android < 26 ================================================ FILE: fastlane/metadata/android/en-US/changelogs/19.txt ================================================ * Android 13 is now officially supported * Added "Queue loop" toggle button * Added option to use the system font * Added option to automatically resume the playback when a wired or bluetooth device is connected * The screen will now not turn off when synchronized lyrics are displayed * Fixed a couple of crashes ================================================ FILE: fastlane/metadata/android/en-US/changelogs/20.txt ================================================ * Minor fixes and improvements ================================================ FILE: fastlane/metadata/android/en-US/changelogs/9.txt ================================================ * Initial release ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ Features: * Search and play any song or video from YouTube Music * Background playback w/ notification * Search for songs, albums, artists, videos and playlists * Bookmark artists and albums * Import playlists * Automatic cache system for offline playback and saving resources * Fetch and edit lyrics and synchronized lyrics * Open YouTube/YouTube Music links * Local playlists management * Queue management * Favorites and Offline built-in playlists * Sleep timer * Skip silence * Persistent queue * Loudness/audio normalization * Android Auto * Simple and minimal UI * Ridiculously lightweight APK ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ Seamlessly stream music from YouTube Music ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ ViMusic ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Wed Jul 06 23:33:16 CEST 2022 distributionBase=GRADLE_USER_HOME distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME ================================================ FILE: gradle.properties ================================================ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=false kotlin.code.style=official android.enableR8.fullMode=true ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: innertube/.gitignore ================================================ /build ================================================ FILE: innertube/build.gradle.kts ================================================ plugins { kotlin("jvm") @Suppress("DSL_SCOPE_VIOLATION") alias(libs.plugins.kotlin.serialization) } sourceSets.all { java.srcDir("src/$name/kotlin") } dependencies { implementation(projects.ktorClientBrotli) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.serialization) implementation(libs.ktor.serialization.json) testImplementation(testLibs.junit) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/Innertube.kt ================================================ package it.vfsfitvnm.innertube import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.BrowserUserAgent import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.compression.brotli import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.header import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.innertube.models.Runs import it.vfsfitvnm.innertube.models.Thumbnail import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json object Innertube { val client = HttpClient(OkHttp) { BrowserUserAgent() expectSuccess = true install(ContentNegotiation) { @OptIn(ExperimentalSerializationApi::class) json(Json { ignoreUnknownKeys = true explicitNulls = false encodeDefaults = true }) } install(ContentEncoding) { brotli() } defaultRequest { url(scheme = "https", host ="music.youtube.com") { headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) headers.append("X-Goog-Api-Key", "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8") parameters.append("prettyPrint", "false") } } } internal const val browse = "/youtubei/v1/browse" internal const val next = "/youtubei/v1/next" internal const val player = "/youtubei/v1/player" internal const val queue = "/youtubei/v1/music/get_queue" internal const val search = "/youtubei/v1/search" internal const val searchSuggestions = "/youtubei/v1/music/get_search_suggestions" internal const val musicResponsiveListItemRendererMask = "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)" internal const val musicTwoRowItemRendererMask = "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)" const val playlistPanelVideoRendererMask = "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)" internal fun HttpRequestBuilder.mask(value: String = "*") = header("X-Goog-FieldMask", value) data class Info( val name: String?, val endpoint: T? ) { @Suppress("UNCHECKED_CAST") constructor(run: Runs.Run) : this( name = run.text, endpoint = run.navigationEndpoint?.endpoint as T? ) } @JvmInline value class SearchFilter(val value: String) { companion object { val Song = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") val Video = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D") val Album = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") val Artist = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") val CommunityPlaylist = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF") val FeaturedPlaylist = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D") } } sealed class Item { abstract val thumbnail: Thumbnail? abstract val key: String } data class SongItem( val info: Info?, val authors: List>?, val album: Info?, val durationText: String?, override val thumbnail: Thumbnail? ) : Item() { override val key get() = info!!.endpoint!!.videoId!! companion object } data class VideoItem( val info: Info?, val authors: List>?, val viewsText: String?, val durationText: String?, override val thumbnail: Thumbnail? ) : Item() { override val key get() = info!!.endpoint!!.videoId!! val isOfficialMusicVideo: Boolean get() = info ?.endpoint ?.watchEndpointMusicSupportedConfigs ?.watchEndpointMusicConfig ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" val isUserGeneratedContent: Boolean get() = info ?.endpoint ?.watchEndpointMusicSupportedConfigs ?.watchEndpointMusicConfig ?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" companion object } data class AlbumItem( val info: Info?, val authors: List>?, val year: String?, override val thumbnail: Thumbnail? ) : Item() { override val key get() = info!!.endpoint!!.browseId!! companion object } data class ArtistItem( val info: Info?, val subscribersCountText: String?, override val thumbnail: Thumbnail? ) : Item() { override val key get() = info!!.endpoint!!.browseId!! companion object } data class PlaylistItem( val info: Info?, val channel: Info?, val songCount: Int?, override val thumbnail: Thumbnail? ) : Item() { override val key get() = info!!.endpoint!!.browseId!! companion object } data class ArtistPage( val name: String?, val description: String?, val thumbnail: Thumbnail?, val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?, val radioEndpoint: NavigationEndpoint.Endpoint.Watch?, val songs: List?, val songsEndpoint: NavigationEndpoint.Endpoint.Browse?, val albums: List?, val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?, val singles: List?, val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?, ) data class PlaylistOrAlbumPage( val title: String?, val authors: List>?, val year: String?, val thumbnail: Thumbnail?, val url: String?, val songsPage: ItemsPage?, val otherVersions: List? ) data class NextPage( val itemsPage: ItemsPage?, val playlistId: String?, val params: String? = null, val playlistSetVideoId: String? = null ) data class RelatedPage( val songs: List? = null, val playlists: List? = null, val albums: List? = null, val artists: List? = null, ) data class ItemsPage( val items: List?, val continuation: String? ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/BrowseResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @Serializable data class BrowseResponse( val contents: Contents?, val header: Header?, val microformat: Microformat? ) { @Serializable data class Contents( val singleColumnBrowseResultsRenderer: Tabs?, val sectionListRenderer: SectionListRenderer?, ) @Serializable data class Header @OptIn(ExperimentalSerializationApi::class) constructor( @JsonNames("musicVisualHeaderRenderer") val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?, val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?, ) { @Serializable data class MusicDetailHeaderRenderer( val title: Runs?, val subtitle: Runs?, val secondSubtitle: Runs?, val thumbnail: ThumbnailRenderer?, ) @Serializable data class MusicImmersiveHeaderRenderer( val description: Runs?, val playButton: PlayButton?, val startRadioButton: StartRadioButton?, val thumbnail: ThumbnailRenderer?, val foregroundThumbnail: ThumbnailRenderer?, val title: Runs? ) { @Serializable data class PlayButton( val buttonRenderer: ButtonRenderer? ) @Serializable data class StartRadioButton( val buttonRenderer: ButtonRenderer? ) } } @Serializable data class Microformat( val microformatDataRenderer: MicroformatDataRenderer? ) { @Serializable data class MicroformatDataRenderer( val urlCanonical: String? ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ButtonRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class ButtonRenderer( val navigationEndpoint: NavigationEndpoint? ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Context.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class Context( val client: Client, val thirdParty: ThirdParty? = null, ) { @Serializable data class Client( val clientName: String, val clientVersion: String, val platform: String, val hl: String = "en", val visitorData: String = "CgtEUlRINDFjdm1YayjX1pSaBg%3D%3D", val androidSdkVersion: Int? = null, val userAgent: String? = null ) @Serializable data class ThirdParty( val embedUrl: String, ) companion object { val DefaultWeb = Context( client = Client( clientName = "WEB_REMIX", clientVersion = "1.20220918", platform = "DESKTOP", ) ) val DefaultAndroid = Context( client = Client( clientName = "ANDROID_MUSIC", clientVersion = "5.28.1", platform = "MOBILE", androidSdkVersion = 30, userAgent = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip" ) ) val DefaultAgeRestrictionBypass = Context( client = Client( clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", clientVersion = "2.0", platform = "TV" ) ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Continuation.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class Continuation( @JsonNames("nextContinuationData", "nextRadioContinuationData") val nextContinuationData: Data? ) { @Serializable data class Data( val continuation: String? ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ContinuationResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class ContinuationResponse( val continuationContents: ContinuationContents?, ) { @Serializable data class ContinuationContents( @JsonNames("musicPlaylistShelfContinuation") val musicShelfContinuation: MusicShelfRenderer?, val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?, ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GetQueueResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class GetQueueResponse( val queueDatas: List?, ) { @Serializable data class QueueData( val content: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content? ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GridRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class GridRenderer( val items: List?, ) { @Serializable data class Item( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicCarouselShelfRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class MusicCarouselShelfRenderer( val header: Header?, val contents: List?, ) { @Serializable data class Content( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, ) @Serializable data class Header( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer? ) { @Serializable data class MusicCarouselShelfBasicHeaderRenderer( val moreContentButton: MoreContentButton?, val title: Runs?, val strapline: Runs?, ) { @Serializable data class MoreContentButton( val buttonRenderer: ButtonRenderer? ) } } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicResponsiveListItemRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class MusicResponsiveListItemRenderer( val fixedColumns: List?, val flexColumns: List, val thumbnail: ThumbnailRenderer?, val navigationEndpoint: NavigationEndpoint?, ) { @Serializable data class FlexColumn( @JsonNames("musicResponsiveListItemFixedColumnRenderer") val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer? ) { @Serializable data class MusicResponsiveListItemFlexColumnRenderer( val text: Runs? ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicShelfRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class MusicShelfRenderer( val bottomEndpoint: NavigationEndpoint?, val contents: List?, val continuations: List?, val title: Runs? ) { @Serializable data class Content( val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, ) { val runs: Pair, List>> get() = (musicResponsiveListItemRenderer ?.flexColumns ?.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs ?: emptyList()) to (musicResponsiveListItemRenderer ?.flexColumns ?.lastOrNull() ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.splitBySeparator() ?: emptyList() ) val thumbnail: Thumbnail? get() = musicResponsiveListItemRenderer ?.thumbnail ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.firstOrNull() } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicTwoRowItemRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class MusicTwoRowItemRenderer( val navigationEndpoint: NavigationEndpoint?, val thumbnailRenderer: ThumbnailRenderer?, val title: Runs?, val subtitle: Runs?, ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NavigationEndpoint.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable /** * watchPlaylistEndpoint: params, playlistId * watchEndpoint: params, playlistId, videoId, index * browseEndpoint: params, browseId * searchEndpoint: params, query */ //@Serializable //data class NavigationEndpoint( // @JsonNames("watchEndpoint", "watchPlaylistEndpoint", "navigationEndpoint", "browseEndpoint", "searchEndpoint") // val endpoint: Endpoint //) { // @Serializable // data class Endpoint( // val params: String?, // val playlistId: String?, // val videoId: String?, // val index: Int?, // val browseId: String?, // val query: String?, // val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs?, // val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?, // ) { // @Serializable // data class WatchEndpointMusicSupportedConfigs( // val watchEndpointMusicConfig: WatchEndpointMusicConfig // ) { // @Serializable // data class WatchEndpointMusicConfig( // val musicVideoType: String // ) // } // // @Serializable // data class BrowseEndpointContextSupportedConfigs( // val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig // ) { // @Serializable // data class BrowseEndpointContextMusicConfig( // val pageType: String // ) // } // } //} @Serializable data class NavigationEndpoint( val watchEndpoint: Endpoint.Watch?, val watchPlaylistEndpoint: Endpoint.WatchPlaylist?, val browseEndpoint: Endpoint.Browse?, val searchEndpoint: Endpoint.Search?, ) { val endpoint: Endpoint? get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint @Serializable sealed class Endpoint { @Serializable data class Watch( val params: String? = null, val playlistId: String? = null, val videoId: String? = null, val index: Int? = null, val playlistSetVideoId: String? = null, val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, ) : Endpoint() { val type: String? get() = watchEndpointMusicSupportedConfigs ?.watchEndpointMusicConfig ?.musicVideoType @Serializable data class WatchEndpointMusicSupportedConfigs( val watchEndpointMusicConfig: WatchEndpointMusicConfig? ) { @Serializable data class WatchEndpointMusicConfig( val musicVideoType: String? ) } } @Serializable data class WatchPlaylist( val params: String?, val playlistId: String?, ) : Endpoint() @Serializable data class Browse( val params: String? = null, val browseId: String? = null, val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null, ) : Endpoint() { val type: String? get() = browseEndpointContextSupportedConfigs ?.browseEndpointContextMusicConfig ?.pageType @Serializable data class BrowseEndpointContextSupportedConfigs( val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig ) { @Serializable data class BrowseEndpointContextMusicConfig( val pageType: String ) } } @Serializable data class Search( val params: String?, val query: String, ) : Endpoint() } } //@Serializable(with = NavigationEndpoint.Serializer::class) //sealed class NavigationEndpoint { // @Serializable // data class Watch( // val watchEndpoint: Data // ) : NavigationEndpoint() { // @Serializable // data class Data( // val params: String?, // val playlistId: String, // val videoId: String, //// val index: Int? // val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs, // ) // // @Serializable // data class WatchEndpointMusicSupportedConfigs( // val watchEndpointMusicConfig: WatchEndpointMusicConfig // ) { // @Serializable // data class WatchEndpointMusicConfig( // val musicVideoType: String // ) // } // } // // @Serializable // data class WatchPlaylist( // val watchPlaylistEndpoint: Data // ) : NavigationEndpoint() { // @Serializable // data class Data( // val params: String?, // val playlistId: String, // ) // } // // @Serializable // data class Browse( // val browseEndpoint: Data // ) : NavigationEndpoint() { // @Serializable // data class Data( // val params: String?, // val browseId: String, // val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs, // ) // // @Serializable // data class BrowseEndpointContextSupportedConfigs( // val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig // ) { // @Serializable // data class BrowseEndpointContextMusicConfig( // val pageType: String // ) // } // } // // @Serializable // data class Search( // val searchEndpoint: Data // ) : NavigationEndpoint() { // @Serializable // data class Data( // val params: String?, // val query: String, // ) // } // // object Serializer : JsonContentPolymorphicSerializer(NavigationEndpoint::class) { // override fun selectDeserializer(element: JsonElement) = when { // "watchEndpoint" in element.jsonObject -> Watch.serializer() // "watchPlaylistEndpoint" in element.jsonObject -> WatchPlaylist.serializer() // "browseEndpoint" in element.jsonObject -> Browse.serializer() // "searchEndpoint" in element.jsonObject -> Search.serializer() // else -> TODO() // } // } //} ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NextResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class NextResponse( val contents: Contents? ) { @Serializable data class MusicQueueRenderer( val content: Content? ) { @Serializable data class Content( @JsonNames("playlistPanelContinuation") val playlistPanelRenderer: PlaylistPanelRenderer? ) { @Serializable data class PlaylistPanelRenderer( val contents: List?, val continuations: List?, ) { @Serializable data class Content( val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?, val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?, ) { @Serializable data class AutomixPreviewVideoRenderer( val content: Content? ) { @Serializable data class Content( val automixPlaylistVideoRenderer: AutomixPlaylistVideoRenderer? ) { @Serializable data class AutomixPlaylistVideoRenderer( val navigationEndpoint: NavigationEndpoint? ) } } } } } } @Serializable data class Contents( val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer? ) { @Serializable data class SingleColumnMusicWatchNextResultsRenderer( val tabbedRenderer: TabbedRenderer? ) { @Serializable data class TabbedRenderer( val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer? ) { @Serializable data class WatchNextTabbedResultsRenderer( val tabs: List? ) { @Serializable data class Tab( val tabRenderer: TabRenderer? ) { @Serializable data class TabRenderer( val content: Content?, val endpoint: NavigationEndpoint?, val title: String? ) { @Serializable data class Content( val musicQueueRenderer: MusicQueueRenderer? ) } } } } } } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlayerResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class PlayerResponse( val playabilityStatus: PlayabilityStatus?, val playerConfig: PlayerConfig?, val streamingData: StreamingData?, val videoDetails: VideoDetails?, ) { @Serializable data class PlayabilityStatus( val status: String? ) @Serializable data class PlayerConfig( val audioConfig: AudioConfig? ) { @Serializable data class AudioConfig( private val loudnessDb: Double? ) { // For music clients only val normalizedLoudnessDb: Float? get() = loudnessDb?.plus(7)?.toFloat() } } @Serializable data class StreamingData( val adaptiveFormats: List? ) { val highestQualityFormat: AdaptiveFormat? get() = adaptiveFormats?.findLast { it.itag == 251 || it.itag == 140 } @Serializable data class AdaptiveFormat( val itag: Int, val mimeType: String, val bitrate: Long?, val averageBitrate: Long?, val contentLength: Long?, val audioQuality: String?, val approxDurationMs: Long?, val lastModified: Long?, val loudnessDb: Double?, val audioSampleRate: Int?, val url: String?, ) } @Serializable data class VideoDetails( val videoId: String? ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlaylistPanelVideoRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class PlaylistPanelVideoRenderer( val title: Runs?, val longBylineText: Runs?, val shortBylineText: Runs?, val lengthText: Runs?, val navigationEndpoint: NavigationEndpoint?, val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?, ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Runs.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class Runs( val runs: List = listOf() ) { val text: String get() = runs.joinToString("") { it.text ?: "" } fun splitBySeparator(): List> { return runs.flatMapIndexed { index, run -> when { index == 0 || index == runs.lastIndex -> listOf(index) run.text == " • " -> listOf(index - 1, index + 1) else -> emptyList() } }.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }.let { it.ifEmpty { listOf(runs) } } } @Serializable data class Run( val text: String?, val navigationEndpoint: NavigationEndpoint?, ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @Serializable data class SearchResponse( val contents: Contents?, ) { @Serializable data class Contents( val tabbedSearchResultsRenderer: Tabs? ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchSuggestionsResponse.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class SearchSuggestionsResponse( val contents: List? ) { @Serializable data class Content( val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer? ) { @Serializable data class SearchSuggestionsSectionRenderer( val contents: List? ) { @Serializable data class Content( val searchSuggestionRenderer: SearchSuggestionRenderer? ) { @Serializable data class SearchSuggestionRenderer( val navigationEndpoint: NavigationEndpoint?, ) } } } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SectionListRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class SectionListRenderer( val contents: List?, val continuations: List? ) { @Serializable data class Content( @JsonNames("musicImmersiveCarouselShelfRenderer") val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?, @JsonNames("musicPlaylistShelfRenderer") val musicShelfRenderer: MusicShelfRenderer?, val gridRenderer: GridRenderer?, val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, ) { @Serializable data class MusicDescriptionShelfRenderer( val description: Runs?, ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Tabs.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class Tabs( val tabs: List? ) { @Serializable data class Tab( val tabRenderer: TabRenderer? ) { @Serializable data class TabRenderer( val content: Content?, val title: String?, val tabIdentifier: String?, ) { @Serializable data class Content( val sectionListRenderer: SectionListRenderer?, ) } } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Thumbnail.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.Serializable @Serializable data class Thumbnail( val url: String, val height: Int?, val width: Int? ) { val isResizable: Boolean get() = !url.startsWith("https://i.ytimg.com") fun size(size: Int): String { return when { url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size" url.startsWith("https://yt3.ggpht.com") -> "$url-s$size" else -> url } } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ThumbnailRenderer.kt ================================================ package it.vfsfitvnm.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class ThumbnailRenderer( @JsonNames("croppedSquareThumbnailRenderer") val musicThumbnailRenderer: MusicThumbnailRenderer? ) { @Serializable data class MusicThumbnailRenderer( val thumbnail: Thumbnail? ) { @Serializable data class Thumbnail( val thumbnails: List? ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/BrowseBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class BrowseBody( val context: Context = Context.DefaultWeb, val browseId: String, val params: String? = null ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/ContinuationBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class ContinuationBody( val context: Context = Context.DefaultWeb, val continuation: String, ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/NextBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class NextBody( val context: Context = Context.DefaultWeb, val videoId: String?, val isAudioOnly: Boolean = true, val playlistId: String? = null, val tunerSettingValue: String = "AUTOMIX_SETTING_NORMAL", val index: Int? = null, val params: String? = null, val playlistSetVideoId: String? = null, val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs = WatchEndpointMusicSupportedConfigs( musicVideoType = "MUSIC_VIDEO_TYPE_ATV" ) ) { @Serializable data class WatchEndpointMusicSupportedConfigs( val musicVideoType: String ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/PlayerBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class PlayerBody( val context: Context = Context.DefaultAndroid, val videoId: String, val playlistId: String? = null ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/QueueBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class QueueBody( val context: Context = Context.DefaultWeb, val videoIds: List? = null, val playlistId: String? = null, ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class SearchBody( val context: Context = Context.DefaultWeb, val query: String, val params: String ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchSuggestionsBody.kt ================================================ package it.vfsfitvnm.innertube.models.bodies import it.vfsfitvnm.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class SearchSuggestionsBody( val context: Context = Context.DefaultWeb, val input: String ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/AlbumPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.http.Url import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.innertube.models.bodies.BrowseBody suspend fun Innertube.albumPage(body: BrowseBody): Result? { return playlistPage(body)?.map { album -> album.url?.let { Url(it).parameters["list"] }?.let { playlistId -> playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist -> album.copy(songsPage = playlist.songsPage) } } ?: album }?.map { album -> val albumInfo = Innertube.Info( name = album.title, endpoint = NavigationEndpoint.Endpoint.Browse( browseId = body.browseId, params = body.params ) ) album.copy( songsPage = album.songsPage?.copy( items = album.songsPage.items?.map { song -> song.copy( authors = song.authors ?: album.authors, album = albumInfo, thumbnail = album.thumbnail ) } ) ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ArtistPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.BrowseResponse import it.vfsfitvnm.innertube.models.MusicCarouselShelfRenderer import it.vfsfitvnm.innertube.models.MusicShelfRenderer import it.vfsfitvnm.innertube.models.SectionListRenderer import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.utils.findSectionByTitle import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.artistPage(body: BrowseBody): Result? = runCatchingNonCancellable { val response = client.post(browse) { setBody(body) mask("contents,header") }.body() fun findSectionByTitle(text: String): SectionListRenderer.Content? { return response .contents ?.singleColumnBrowseResultsRenderer ?.tabs ?.get(0) ?.tabRenderer ?.content ?.sectionListRenderer ?.findSectionByTitle(text) } val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer Innertube.ArtistPage( name = response .header ?.musicImmersiveHeaderRenderer ?.title ?.text, description = response .header ?.musicImmersiveHeaderRenderer ?.description ?.text, thumbnail = (response .header ?.musicImmersiveHeaderRenderer ?.foregroundThumbnail ?: response .header ?.musicImmersiveHeaderRenderer ?.thumbnail) ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.getOrNull(0), shuffleEndpoint = response .header ?.musicImmersiveHeaderRenderer ?.playButton ?.buttonRenderer ?.navigationEndpoint ?.watchEndpoint, radioEndpoint = response .header ?.musicImmersiveHeaderRenderer ?.startRadioButton ?.buttonRenderer ?.navigationEndpoint ?.watchEndpoint, songs = songsSection ?.contents ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull(Innertube.SongItem::from), songsEndpoint = songsSection ?.bottomEndpoint ?.browseEndpoint, albums = albumsSection ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) ?.mapNotNull(Innertube.AlbumItem::from), albumsEndpoint = albumsSection ?.header ?.musicCarouselShelfBasicHeaderRenderer ?.moreContentButton ?.buttonRenderer ?.navigationEndpoint ?.browseEndpoint, singles = singlesSection ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) ?.mapNotNull(Innertube.AlbumItem::from), singlesEndpoint = singlesSection ?.header ?.musicCarouselShelfBasicHeaderRenderer ?.moreContentButton ?.buttonRenderer ?.navigationEndpoint ?.browseEndpoint, ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ItemsPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.BrowseResponse import it.vfsfitvnm.innertube.models.ContinuationResponse import it.vfsfitvnm.innertube.models.GridRenderer import it.vfsfitvnm.innertube.models.MusicResponsiveListItemRenderer import it.vfsfitvnm.innertube.models.MusicShelfRenderer import it.vfsfitvnm.innertube.models.MusicTwoRowItemRenderer import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.itemsPage( body: BrowseBody, fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, ) = runCatchingNonCancellable { val response = client.post(browse) { setBody(body) // mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") }.body() val sectionListRendererContent = response .contents ?.singleColumnBrowseResultsRenderer ?.tabs ?.firstOrNull() ?.tabRenderer ?.content ?.sectionListRenderer ?.contents ?.firstOrNull() itemsPageFromMusicShelRendererOrGridRenderer( musicShelfRenderer = sectionListRendererContent ?.musicShelfRenderer, gridRenderer = sectionListRendererContent ?.gridRenderer, fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, ) } suspend fun Innertube.itemsPage( body: ContinuationBody, fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, ) = runCatchingNonCancellable { val response = client.post(browse) { setBody(body) // mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") }.body() itemsPageFromMusicShelRendererOrGridRenderer( musicShelfRenderer = response .continuationContents ?.musicShelfContinuation, gridRenderer = null, fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, ) } private fun itemsPageFromMusicShelRendererOrGridRenderer( musicShelfRenderer: MusicShelfRenderer?, gridRenderer: GridRenderer?, fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?, fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T?, ): Innertube.ItemsPage? { return if (musicShelfRenderer != null) { Innertube.ItemsPage( continuation = musicShelfRenderer .continuations ?.firstOrNull() ?.nextContinuationData ?.continuation, items = musicShelfRenderer .contents ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull(fromMusicResponsiveListItemRenderer) ) } else if (gridRenderer != null) { Innertube.ItemsPage( continuation = null, items = gridRenderer .items ?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) ?.mapNotNull(fromMusicTwoRowItemRenderer) ) } else { null } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Lyrics.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.BrowseResponse import it.vfsfitvnm.innertube.models.NextResponse import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.models.bodies.NextBody import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.lyrics(body: NextBody): Result? = runCatchingNonCancellable { val nextResponse = client.post(next) { setBody(body) mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") }.body() val browseId = nextResponse .contents ?.singleColumnMusicWatchNextResultsRenderer ?.tabbedRenderer ?.watchNextTabbedResultsRenderer ?.tabs ?.getOrNull(1) ?.tabRenderer ?.endpoint ?.browseEndpoint ?.browseId ?: return@runCatchingNonCancellable null val response = client.post(browse) { setBody(BrowseBody(browseId = browseId)) mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description") }.body() response.contents ?.sectionListRenderer ?.contents ?.firstOrNull() ?.musicDescriptionShelfRenderer ?.description ?.text } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/NextPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.ContinuationResponse import it.vfsfitvnm.innertube.models.NextResponse import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.models.bodies.NextBody import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.nextPage(body: NextBody): Result? = runCatchingNonCancellable { val response = client.post(next) { setBody(body) mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$playlistPanelVideoRendererMask))") }.body() val tabs = response .contents ?.singleColumnMusicWatchNextResultsRenderer ?.tabbedRenderer ?.watchNextTabbedResultsRenderer ?.tabs val playlistPanelRenderer = tabs ?.getOrNull(0) ?.tabRenderer ?.content ?.musicQueueRenderer ?.content ?.playlistPanelRenderer if (body.playlistId == null) { val endpoint = playlistPanelRenderer ?.contents ?.lastOrNull() ?.automixPreviewVideoRenderer ?.content ?.automixPlaylistVideoRenderer ?.navigationEndpoint ?.watchPlaylistEndpoint if (endpoint != null) { return nextPage( body.copy( playlistId = endpoint.playlistId, params = endpoint.params ) ) } } Innertube.NextPage( playlistId = body.playlistId, playlistSetVideoId = body.playlistSetVideoId, params = body.params, itemsPage = playlistPanelRenderer ?.toSongsPage() ) } suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingNonCancellable { val response = client.post(next) { setBody(body) mask("continuationContents.playlistPanelContinuation(continuations,contents.$playlistPanelVideoRendererMask)") }.body() response .continuationContents ?.playlistPanelContinuation ?.toSongsPage() } private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSongsPage() = Innertube.ItemsPage( items = this ?.contents ?.mapNotNull(NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content::playlistPanelVideoRenderer) ?.mapNotNull(Innertube.SongItem::from), continuation = this ?.continuations ?.firstOrNull() ?.nextContinuationData ?.continuation ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Player.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.Context import it.vfsfitvnm.innertube.models.PlayerResponse import it.vfsfitvnm.innertube.models.bodies.PlayerBody import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable import kotlinx.serialization.Serializable suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable { val response = client.post(player) { setBody(body) mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") }.body() if (response.playabilityStatus?.status == "OK") { response } else { @Serializable data class AudioStream( val url: String, val bitrate: Long ) @Serializable data class PipedResponse( val audioStreams: List ) val safePlayerResponse = client.post(player) { setBody( body.copy( context = Context.DefaultAgeRestrictionBypass.copy( thirdParty = Context.ThirdParty( embedUrl = "https://www.youtube.com/watch?v=${body.videoId}" ) ), ) ) mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") }.body() if (safePlayerResponse.playabilityStatus?.status != "OK") { return@runCatchingNonCancellable response } val audioStreams = client.get("https://watchapi.whatever.social/streams/${body.videoId}") { contentType(ContentType.Application.Json) }.body().audioStreams safePlayerResponse.copy( streamingData = safePlayerResponse.streamingData?.copy( adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat -> adaptiveFormat.copy( url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url ) } ) ) } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/PlaylistPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.BrowseResponse import it.vfsfitvnm.innertube.models.ContinuationResponse import it.vfsfitvnm.innertube.models.MusicCarouselShelfRenderer import it.vfsfitvnm.innertube.models.MusicShelfRenderer import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable { val response = client.post(browse) { setBody(body) mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),musicCarouselShelfRenderer.contents.$musicTwoRowItemRendererMask),header.musicDetailHeaderRenderer(title,subtitle,thumbnail),microformat") }.body() val musicDetailHeaderRenderer = response .header ?.musicDetailHeaderRenderer val sectionListRendererContents = response .contents ?.singleColumnBrowseResultsRenderer ?.tabs ?.firstOrNull() ?.tabRenderer ?.content ?.sectionListRenderer ?.contents val musicShelfRenderer = sectionListRendererContents ?.firstOrNull() ?.musicShelfRenderer val musicCarouselShelfRenderer = sectionListRendererContents ?.getOrNull(1) ?.musicCarouselShelfRenderer Innertube.PlaylistOrAlbumPage( title = musicDetailHeaderRenderer ?.title ?.text, thumbnail = musicDetailHeaderRenderer ?.thumbnail ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.firstOrNull(), authors = musicDetailHeaderRenderer ?.subtitle ?.splitBySeparator() ?.getOrNull(1) ?.map(Innertube::Info), year = musicDetailHeaderRenderer ?.subtitle ?.splitBySeparator() ?.getOrNull(2) ?.firstOrNull() ?.text, url = response .microformat ?.microformatDataRenderer ?.urlCanonical, songsPage = musicShelfRenderer ?.toSongsPage(), otherVersions = musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) ?.mapNotNull(Innertube.AlbumItem::from) ) } suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingNonCancellable { val response = client.post(browse) { setBody(body) mask("continuationContents.musicPlaylistShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") }.body() response .continuationContents ?.musicShelfContinuation ?.toSongsPage() } private fun MusicShelfRenderer?.toSongsPage() = Innertube.ItemsPage( items = this ?.contents ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull(Innertube.SongItem::from), continuation = this ?.continuations ?.firstOrNull() ?.nextContinuationData ?.continuation ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Queue.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.GetQueueResponse import it.vfsfitvnm.innertube.models.bodies.QueueBody import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.queue(body: QueueBody) = runCatchingNonCancellable { val response = client.post(queue) { setBody(body) mask("queueDatas.content.$playlistPanelVideoRendererMask") }.body() response .queueDatas ?.mapNotNull { queueData -> queueData .content ?.playlistPanelVideoRenderer ?.let(Innertube.SongItem::from) } } suspend fun Innertube.song(videoId: String): Result? = queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/RelatedPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.BrowseResponse import it.vfsfitvnm.innertube.models.MusicCarouselShelfRenderer import it.vfsfitvnm.innertube.models.NextResponse import it.vfsfitvnm.innertube.models.bodies.BrowseBody import it.vfsfitvnm.innertube.models.bodies.NextBody import it.vfsfitvnm.innertube.utils.findSectionByStrapline import it.vfsfitvnm.innertube.utils.findSectionByTitle import it.vfsfitvnm.innertube.utils.from import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { val nextResponse = client.post(next) { setBody(body) mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") }.body() val browseId = nextResponse .contents ?.singleColumnMusicWatchNextResultsRenderer ?.tabbedRenderer ?.watchNextTabbedResultsRenderer ?.tabs ?.getOrNull(2) ?.tabRenderer ?.endpoint ?.browseEndpoint ?.browseId ?: return@runCatchingNonCancellable null val response = client.post(browse) { setBody(BrowseBody(browseId = browseId)) mask("contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($musicResponsiveListItemRendererMask,$musicTwoRowItemRendererMask))") }.body() val sectionListRenderer = response .contents ?.sectionListRenderer Innertube.RelatedPage( songs = sectionListRenderer ?.findSectionByTitle("You might also like") ?.musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull(Innertube.SongItem::from), playlists = sectionListRenderer ?.findSectionByTitle("Recommended playlists") ?.musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) ?.mapNotNull(Innertube.PlaylistItem::from) ?.sortedByDescending { it.channel?.name == "YouTube Music" }, albums = sectionListRenderer ?.findSectionByStrapline("MORE FROM") ?.musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) ?.mapNotNull(Innertube.AlbumItem::from), artists = sectionListRenderer ?.findSectionByTitle("Similar artists") ?.musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) ?.mapNotNull(Innertube.ArtistItem::from), ) } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchPage.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.ContinuationResponse import it.vfsfitvnm.innertube.models.MusicShelfRenderer import it.vfsfitvnm.innertube.models.SearchResponse import it.vfsfitvnm.innertube.models.bodies.ContinuationBody import it.vfsfitvnm.innertube.models.bodies.SearchBody import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.searchPage( body: SearchBody, fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? ) = runCatchingNonCancellable { val response = client.post(search) { setBody(body) mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask)") }.body() response .contents ?.tabbedSearchResultsRenderer ?.tabs ?.firstOrNull() ?.tabRenderer ?.content ?.sectionListRenderer ?.contents ?.lastOrNull() ?.musicShelfRenderer ?.toItemsPage(fromMusicShelfRendererContent) } suspend fun Innertube.searchPage( body: ContinuationBody, fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? ) = runCatchingNonCancellable { val response = client.post(search) { setBody(body) mask("continuationContents.musicShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") }.body() response .continuationContents ?.musicShelfContinuation ?.toItemsPage(fromMusicShelfRendererContent) } private fun MusicShelfRenderer?.toItemsPage(mapper: (MusicShelfRenderer.Content) -> T?) = Innertube.ItemsPage( items = this ?.contents ?.mapNotNull(mapper), continuation = this ?.continuations ?.firstOrNull() ?.nextContinuationData ?.continuation ) ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchSuggestions.kt ================================================ package it.vfsfitvnm.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.SearchSuggestionsResponse import it.vfsfitvnm.innertube.models.bodies.SearchSuggestionsBody import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingNonCancellable { val response = client.post(searchSuggestions) { setBody(body) mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query") }.body() response .contents ?.firstOrNull() ?.searchSuggestionsSectionRenderer ?.contents ?.mapNotNull { content -> content .searchSuggestionRenderer ?.navigationEndpoint ?.searchEndpoint ?.query } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicResponsiveListItemRenderer.kt ================================================ package it.vfsfitvnm.innertube.utils import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.MusicResponsiveListItemRenderer import it.vfsfitvnm.innertube.models.NavigationEndpoint import it.vfsfitvnm.innertube.models.Runs fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer): Innertube.SongItem? { return Innertube.SongItem( info = renderer .flexColumns .getOrNull(0) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs ?.getOrNull(0) ?.let(Innertube::Info), authors = renderer .flexColumns .getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs ?.map>(Innertube::Info) ?.takeIf(List::isNotEmpty), durationText = renderer .fixedColumns ?.getOrNull(0) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs ?.getOrNull(0) ?.text, album = renderer .flexColumns .getOrNull(2) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs ?.firstOrNull() ?.let(Innertube::Info), thumbnail = renderer .thumbnail ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.firstOrNull() ).takeIf { it.info?.endpoint?.videoId != null } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicShelfRendererContent.kt ================================================ package it.vfsfitvnm.innertube.utils import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.MusicShelfRenderer import it.vfsfitvnm.innertube.models.NavigationEndpoint fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.SongItem? { val (mainRuns, otherRuns) = content.runs // Possible configurations: // "song" • author(s) • album • duration // "song" • author(s) • duration // author(s) • album • duration // author(s) • duration val album: Innertube.Info? = otherRuns .getOrNull(otherRuns.lastIndex - 1) ?.firstOrNull() ?.takeIf { run -> run .navigationEndpoint ?.browseEndpoint ?.type == "MUSIC_PAGE_TYPE_ALBUM" } ?.let(Innertube::Info) return Innertube.SongItem( info = mainRuns .firstOrNull() ?.let(Innertube::Info), authors = otherRuns .getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2) ?.map(Innertube::Info), album = album, durationText = otherRuns .lastOrNull() ?.firstOrNull()?.text, thumbnail = content .thumbnail ).takeIf { it.info?.endpoint?.videoId != null } } fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? { val (mainRuns, otherRuns) = content.runs return Innertube.VideoItem( info = mainRuns .firstOrNull() ?.let(Innertube::Info), authors = otherRuns .getOrNull(otherRuns.lastIndex - 2) ?.map(Innertube::Info), viewsText = otherRuns .getOrNull(otherRuns.lastIndex - 1) ?.firstOrNull() ?.text, durationText = otherRuns .getOrNull(otherRuns.lastIndex) ?.firstOrNull() ?.text, thumbnail = content .thumbnail ).takeIf { it.info?.endpoint?.videoId != null } } fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? { val (mainRuns, otherRuns) = content.runs return Innertube.AlbumItem( info = Innertube.Info( name = mainRuns .firstOrNull() ?.text, endpoint = content .musicResponsiveListItemRenderer ?.navigationEndpoint ?.browseEndpoint ), authors = otherRuns .getOrNull(otherRuns.lastIndex - 1) ?.map(Innertube::Info), year = otherRuns .getOrNull(otherRuns.lastIndex) ?.firstOrNull() ?.text, thumbnail = content .thumbnail ).takeIf { it.info?.endpoint?.browseId != null } } fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.ArtistItem? { val (mainRuns, otherRuns) = content.runs return Innertube.ArtistItem( info = Innertube.Info( name = mainRuns .firstOrNull() ?.text, endpoint = content .musicResponsiveListItemRenderer ?.navigationEndpoint ?.browseEndpoint ), subscribersCountText = otherRuns .lastOrNull() ?.last() ?.text, thumbnail = content .thumbnail ).takeIf { it.info?.endpoint?.browseId != null } } fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.PlaylistItem? { val (mainRuns, otherRuns) = content.runs return Innertube.PlaylistItem( info = Innertube.Info( name = mainRuns .firstOrNull() ?.text, endpoint = content .musicResponsiveListItemRenderer ?.navigationEndpoint ?.browseEndpoint ), channel = otherRuns .firstOrNull() ?.firstOrNull() ?.let(Innertube::Info), songCount = otherRuns .lastOrNull() ?.firstOrNull() ?.text ?.split(' ') ?.firstOrNull() ?.toIntOrNull(), thumbnail = content .thumbnail ).takeIf { it.info?.endpoint?.browseId != null } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicTwoRowItemRenderer.kt ================================================ package it.vfsfitvnm.innertube.utils import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.MusicTwoRowItemRenderer fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.AlbumItem? { return Innertube.AlbumItem( info = renderer .title ?.runs ?.firstOrNull() ?.let(Innertube::Info), authors = null, year = renderer .subtitle ?.runs ?.lastOrNull() ?.text, thumbnail = renderer .thumbnailRenderer ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.firstOrNull() ).takeIf { it.info?.endpoint?.browseId != null } } fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.ArtistItem? { return Innertube.ArtistItem( info = renderer .title ?.runs ?.firstOrNull() ?.let(Innertube::Info), subscribersCountText = renderer .subtitle ?.runs ?.firstOrNull() ?.text, thumbnail = renderer .thumbnailRenderer ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.firstOrNull() ).takeIf { it.info?.endpoint?.browseId != null } } fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.PlaylistItem? { return Innertube.PlaylistItem( info = renderer .title ?.runs ?.firstOrNull() ?.let(Innertube::Info), channel = renderer .subtitle ?.runs ?.getOrNull(2) ?.let(Innertube::Info), songCount = renderer .subtitle ?.runs ?.getOrNull(4) ?.text ?.split(' ') ?.firstOrNull() ?.toIntOrNull(), thumbnail = renderer .thumbnailRenderer ?.musicThumbnailRenderer ?.thumbnail ?.thumbnails ?.firstOrNull() ).takeIf { it.info?.endpoint?.browseId != null } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromPlaylistPanelVideoRenderer.kt ================================================ package it.vfsfitvnm.innertube.utils import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.PlaylistPanelVideoRenderer fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer): Innertube.SongItem? { return Innertube.SongItem( info = Innertube.Info( name = renderer .title ?.text, endpoint = renderer .navigationEndpoint ?.watchEndpoint ), authors = renderer .longBylineText ?.splitBySeparator() ?.getOrNull(0) ?.map(Innertube::Info), album = renderer .longBylineText ?.splitBySeparator() ?.getOrNull(1) ?.getOrNull(0) ?.let(Innertube::Info), thumbnail = renderer .thumbnail ?.thumbnails ?.getOrNull(0), durationText = renderer .lengthText ?.text ).takeIf { it.info?.endpoint?.videoId != null } } ================================================ FILE: innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/Utils.kt ================================================ package it.vfsfitvnm.innertube.utils import io.ktor.utils.io.CancellationException import it.vfsfitvnm.innertube.Innertube import it.vfsfitvnm.innertube.models.SectionListRenderer internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? { return contents?.find { content -> val title = content .musicCarouselShelfRenderer ?.header ?.musicCarouselShelfBasicHeaderRenderer ?.title ?: content .musicShelfRenderer ?.title title ?.runs ?.firstOrNull() ?.text == text } } internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionListRenderer.Content? { return contents?.find { content -> content .musicCarouselShelfRenderer ?.header ?.musicCarouselShelfBasicHeaderRenderer ?.strapline ?.runs ?.firstOrNull() ?.text == text } } internal inline fun runCatchingNonCancellable(block: () -> R): Result? { val result = runCatching(block) return when (result.exceptionOrNull()) { is CancellationException -> null else -> result } } infix operator fun Innertube.ItemsPage?.plus(other: Innertube.ItemsPage) = other.copy( items = (this?.items?.plus(other.items ?: emptyList()) ?: other.items)?.distinctBy(Innertube.Item::key) ) ================================================ FILE: innertube/src/test/kotlin/Test.kt ================================================ import kotlinx.coroutines.runBlocking import org.junit.Test class Test { @Test @Throws(Exception::class) fun test() = runBlocking { } } ================================================ FILE: ktor-client-brotli/.gitignore ================================================ /build ================================================ FILE: ktor-client-brotli/build.gradle.kts ================================================ plugins { kotlin("jvm") } sourceSets.all { java.srcDir("src/$name/kotlin") } dependencies { implementation(libs.ktor.client.encoding) implementation(libs.brotli) } ================================================ FILE: ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt ================================================ package io.ktor.client.plugins.compression import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.jvm.javaio.toByteReadChannel import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CoroutineScope import org.brotli.dec.BrotliInputStream internal object BrotliEncoder : ContentEncoder { override val name: String = "br" override fun CoroutineScope.encode(source: ByteReadChannel) = error("BrotliOutputStream not available (https://github.com/google/brotli/issues/715)") override fun CoroutineScope.decode(source: ByteReadChannel): ByteReadChannel = BrotliInputStream(source.toInputStream()).toByteReadChannel() } ================================================ FILE: ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt ================================================ package io.ktor.client.plugins.compression fun ContentEncoding.Config.brotli(quality: Float? = null) { customEncoder(BrotliEncoder, quality) } ================================================ FILE: kugou/.gitignore ================================================ /build ================================================ FILE: kugou/build.gradle.kts ================================================ plugins { kotlin("jvm") @Suppress("DSL_SCOPE_VIOLATION") alias(libs.plugins.kotlin.serialization) } sourceSets.all { java.srcDir("src/$name/kotlin") } dependencies { implementation(libs.kotlin.coroutines) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.serialization) implementation(libs.ktor.serialization.json) testImplementation(testLibs.junit) } ================================================ FILE: kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt ================================================ package it.vfsfitvnm.kugou import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.BrowserUserAgent import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.ContentType import io.ktor.http.encodeURLParameter import io.ktor.serialization.kotlinx.json.json import io.ktor.util.decodeBase64String import it.vfsfitvnm.kugou.models.DownloadLyricsResponse import it.vfsfitvnm.kugou.models.SearchLyricsResponse import it.vfsfitvnm.kugou.models.SearchSongResponse import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json object KuGou { @OptIn(ExperimentalSerializationApi::class) private val client by lazy { HttpClient(OkHttp) { BrowserUserAgent() expectSuccess = true install(ContentNegotiation) { val feature = Json { ignoreUnknownKeys = true explicitNulls = false encodeDefaults = true } json(feature) json(feature, ContentType.Text.Html) json(feature, ContentType.Text.Plain) } install(ContentEncoding) { gzip() deflate() } defaultRequest { url("https://krcs.kugou.com") } } } suspend fun lyrics(artist: String, title: String, duration: Long): Result? { return runCatching { val keyword = keyword(artist, title) val infoByKeyword = searchSong(keyword) if (infoByKeyword.isNotEmpty()) { var tolerance = 0 while (tolerance <= 5) { for (info in infoByKeyword) { if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) { searchLyricsByHash(info.hash).firstOrNull()?.let { candidate -> return@runCatching downloadLyrics(candidate.id, candidate.accessKey).normalize() } } } tolerance++ } } searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate -> return@runCatching downloadLyrics(candidate.id, candidate.accessKey).normalize() } null }.recoverIfCancelled() } private suspend fun downloadLyrics(id: Long, accessKey: String): Lyrics { return client.get("/download") { parameter("ver", 1) parameter("man", "yes") parameter("client", "pc") parameter("fmt", "lrc") parameter("id", id) parameter("accesskey", accessKey) }.body().content.decodeBase64String().let(::Lyrics) } private suspend fun searchLyricsByHash(hash: String): List { return client.get("/search") { parameter("ver", 1) parameter("man", "yes") parameter("client", "mobi") parameter("hash", hash) }.body().candidates } private suspend fun searchLyricsByKeyword(keyword: String): List { return client.get("/search") { parameter("ver", 1) parameter("man", "yes") parameter("client", "mobi") url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) }.body().candidates } private suspend fun searchSong(keyword: String): List { return client.get("https://mobileservice.kugou.com/api/v3/search/song") { parameter("version", 9108) parameter("plat", 0) parameter("pagesize", 8) parameter("showtype", 0) url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) }.body().data.info } private fun keyword(artist: String, title: String): String { val (newTitle, featuring) = title.extract(" (feat. ", ')') val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring") .replace(", ", "、") .replace(" & ", "、") .replace(".", "") return "$newArtist - $newTitle" } private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { val startIndex = indexOf(startDelimiter) if (startIndex == -1) return this to "" val endIndex = indexOf(endDelimiter, startIndex) if (endIndex == -1) return this to "" return removeRange( startIndex, endIndex + 1 ) to substring(startIndex + startDelimiter.length, endIndex) } @JvmInline value class Lyrics(val value: String) : CharSequence by value { val sentences: List> get() = mutableListOf(0L to "").apply { for (line in value.trim().lines()) { try { val position = line.take(10).run { get(8).digitToInt() * 10L + get(7).digitToInt() * 100 + get(5).digitToInt() * 1000 + get(4).digitToInt() * 10000 + get(2).digitToInt() * 60 * 1000 + get(1).digitToInt() * 600 * 1000 } add(position to line.substring(10)) } catch (_: Throwable) { } } } fun normalize(): Lyrics { var toDrop = 0 var maybeToDrop = 0 val text = value.replace("\r\n", "\n").trim() for (line in text.lineSequence()) { if (line.startsWith("[ti:") || line.startsWith("[ar:") || line.startsWith("[al:") || line.startsWith("[by:") || line.startsWith("[hash:") || line.startsWith("[sign:") || line.startsWith("[qq:") || line.startsWith("[total:") || line.startsWith("[offset:") || line.startsWith("[id:") || line.containsAt("]Written by:", 9) || line.containsAt("]Lyrics by:", 9) || line.containsAt("]Composed by:", 9) || line.containsAt("]Producer:", 9) || line.containsAt("]作曲 : ", 9) || line.containsAt("]作词 : ", 9) ) { toDrop += line.length + 1 + maybeToDrop maybeToDrop = 0 } else { if (maybeToDrop == 0) { maybeToDrop = line.length + 1 } else { maybeToDrop = 0 break } } } return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) } private fun String.containsAt(charSequence: CharSequence, startIndex: Int): Boolean = regionMatches(startIndex, charSequence, 0, charSequence.length) private fun String.removeHtmlEntities(): String = replace("'", "'") } } ================================================ FILE: kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt ================================================ package it.vfsfitvnm.kugou import kotlin.coroutines.cancellation.CancellationException internal fun Result.recoverIfCancelled(): Result? { return when (exceptionOrNull()) { is CancellationException -> null else -> this } } ================================================ FILE: kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt ================================================ package it.vfsfitvnm.kugou.models import kotlinx.serialization.Serializable @Serializable internal class DownloadLyricsResponse( val content: String ) ================================================ FILE: kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt ================================================ package it.vfsfitvnm.kugou.models import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable internal class SearchLyricsResponse( val candidates: List ) { @Serializable internal class Candidate( val id: Long, @SerialName("accesskey") val accessKey: String, val duration: Long ) } ================================================ FILE: kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt ================================================ package it.vfsfitvnm.kugou.models import kotlinx.serialization.Serializable @Serializable internal data class SearchSongResponse( val data: Data ) { @Serializable internal data class Data( val info: List ) { @Serializable internal data class Info( val duration: Long, val hash: String ) } } ================================================ FILE: kugou/src/test/kotlin/Test.kt ================================================ import kotlinx.coroutines.runBlocking import org.junit.Test class Test { @Test @Throws(Exception::class) fun test() { runBlocking { } } } ================================================ FILE: settings.gradle.kts ================================================ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { setUrl("https://jitpack.io") } } versionCatalogs { create("libs") { version("kotlin", "1.7.20") plugin("kotlin-serialization","org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") library("kotlin-coroutines","org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4") version("compose-compiler", "1.3.2") version("compose", "1.3.0-rc01") library("compose-foundation", "androidx.compose.foundation", "foundation").versionRef("compose") library("compose-ui", "androidx.compose.ui", "ui").versionRef("compose") library("compose-ui-util", "androidx.compose.ui", "ui-util").versionRef("compose") library("compose-ripple", "androidx.compose.material", "material-ripple").versionRef("compose") library("compose-shimmer", "com.valentinilk.shimmer", "compose-shimmer").version("1.0.3") library("compose-activity", "androidx.activity", "activity-compose").version("1.7.0-alpha01") library("compose-coil", "io.coil-kt", "coil-compose").version("2.2.2") version("room", "2.5.0-beta01") library("room", "androidx.room", "room-ktx").versionRef("room") library("room-compiler", "androidx.room", "room-compiler").versionRef("room") version("media3", "1.0.0-beta03") library("exoplayer", "androidx.media3", "media3-exoplayer").versionRef("media3") version("ktor", "2.1.2") library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor") library("ktor-client-cio", "io.ktor", "ktor-client-okhttp").versionRef("ktor") library("ktor-client-content-negotiation", "io.ktor", "ktor-client-content-negotiation").versionRef("ktor") library("ktor-client-encoding", "io.ktor", "ktor-client-encoding").versionRef("ktor") library("ktor-client-serialization", "io.ktor", "ktor-client-serialization").versionRef("ktor") library("ktor-serialization-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor") library("brotli", "org.brotli", "dec").version("0.1.2") library("palette", "androidx.palette", "palette").version("1.0.0") library("desugaring", "com.android.tools", "desugar_jdk_libs").version("1.1.5") } create("testLibs") { library("junit", "junit", "junit").version("4.13.2") } } } rootProject.name = "ViMusic" include(":app") include(":compose-routing") include(":compose-reordering") include(":compose-persist") include(":innertube") include(":ktor-client-brotli") include(":kugou")