diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..e00a326d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,49 @@ +name: "CodeQL" + +on: + push: + branches: + - main + - ng-changes + - v0.2.0 + pull_request: + branches: + - main + schedule: + - cron: "22 4 * * 1" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - java-kotlin + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: manual + + - name: Build app (degoogled debug) + run: ./gradlew :app:assembleDegoogledDebug --no-daemon + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/github_release.yml b/.github/workflows/github_release.yml deleted file mode 100644 index 0108abb7..00000000 --- a/.github/workflows/github_release.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: Github Release Workflow - -on: - push: - tags: - - '[0-9]+.[0-9]+.[0-9]+' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Setup JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: '17' - - - uses: actions/checkout@v3 - - - name: Cache Gradle and wrapper - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} - - - name: Make gradlew executable - run: chmod +x ./gradlew - - - name: Setup build tool version variable - shell: bash - run: | - BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) - echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV - echo Last build tool version is: $BUILD_TOOL_VERSION - - - name: Build All Release APKs - id: build - run: | - # Only build release variants (removed debug builds) - bash ./gradlew assembleTempusRelease - bash ./gradlew assembleDegoogledRelease - - - name: Create Artifact Staging Directory - run: mkdir -p release-artifacts - - - name: Sign Tempus Release APKs - id: sign_tempus_release - uses: r0adkll/sign-android-release@v1 - with: - releaseDirectory: app/build/outputs/apk/tempus/release - signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }} - alias: ${{ secrets.KEY_ALIAS_GITHUB }} - keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} - keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }} - env: - BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} - - - name: Prepare Signed Tempus APKs for Release - run: | - TEMPUS_PATH=app/build/outputs/apk/tempus/release - - echo "--- Tempus Files BEFORE Move ---" - ls -la $TEMPUS_PATH - echo "--------------------------------" - - # FIX: Use find/xargs for robust file matching and moving. - - # Renaming 64-bit APK and moving to safe staging directory - find $TEMPUS_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/ - mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-tempus-arm64-v8a-release.apk - - # Renaming 32-bit APK and moving to safe staging directory - find $TEMPUS_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/ - mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-tempus-armeabi-v7a-release.apk - - echo "Prepared Tempus APKs." - - - name: Sign Degoogled Release APKs - id: sign_degoogled_release - uses: r0adkll/sign-android-release@v1 - with: - releaseDirectory: app/build/outputs/apk/degoogled/release - signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }} - alias: ${{ secrets.KEY_ALIAS_GITHUB }} - keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} - keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }} - env: - BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} - - - name: Prepare Signed Degoogled APKs for Release - run: | - DEGOOGLED_PATH=app/build/outputs/apk/degoogled/release - - echo "--- Degoogled Files BEFORE Move ---" - ls -la $DEGOOGLED_PATH - echo "--------------------------------" - - # FIX: Use find/xargs for robust file matching and moving. - - # Renaming 64-bit APK and moving to safe staging directory - find $DEGOOGLED_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/ - mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-degoogled-arm64-v8a-release.apk - - # Renaming 32-bit APK and moving to safe staging directory - find $DEGOOGLED_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/ - mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-degoogled-armeabi-v7a-release.apk - - echo "Prepared Degoogled APKs." - ls -la ./release-artifacts/ - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} - body: '> Changelog coming soon' - draft: false - prerelease: false - files: ./release-artifacts/*.apk - - - name: Upload Release APKs as artifacts (For easy pipeline access) - uses: actions/upload-artifact@v4 - with: - name: release-apks - path: ./release-artifacts/*.apk - retention-days: 30 diff --git a/.gitignore b/.gitignore index 99beffd1..5a2f93e9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,12 @@ .vscode/settings.json # release / debug files tempus-release-key.jks +*.keystore +*.jks app/tempus/ app/degoogled/ +/release-apks/ +*.apk +*.aab + +/changes.md diff --git a/app/build.gradle b/app/build.gradle index 9c2baa3f..64ac53b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 24 - versionName '4.13.0' + versionCode 25 + versionName 'v0.2.1' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { @@ -68,12 +68,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildFeatures { @@ -88,6 +88,7 @@ dependencies { implementation files('../libs/lib-decoder-ffmpeg-release.aar') // AndroidX + implementation 'androidx.security:security-crypto:1.1.0-alpha06' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.1' @@ -97,7 +98,7 @@ dependencies { implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.core:core-splashscreen:1.0.1' implementation 'androidx.appcompat:appcompat:1.7.0' - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0' // Android Material implementation 'com.google.android.material:material:1.10.0' @@ -120,7 +121,7 @@ dependencies { // Retrofit implementation 'com.squareup.retrofit2:retrofit:2.11.0' - implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' implementation 'com.squareup.retrofit2:converter-gson:2.11.0' } java { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 48b1f068..1211baa3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,11 +18,24 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile +-renamesourcefileattribute SourceFile -keepattributes SourceFile, LineNumberTable -keep public class * extends java.lang.Exception -keep class retrofit2.** { *; } -keep class **.reflect.TypeToken { *; } --keep class * extends **.reflect.TypeToken \ No newline at end of file +-keep class * extends **.reflect.TypeToken + +-keep class com.google.crypto.tink.** { *; } +-keep class androidx.security.crypto.** { *; } + +-dontwarn com.google.api.client.http.GenericUrl +-dontwarn com.google.api.client.http.HttpHeaders +-dontwarn com.google.api.client.http.HttpRequest +-dontwarn com.google.api.client.http.HttpRequestFactory +-dontwarn com.google.api.client.http.HttpResponse +-dontwarn com.google.api.client.http.HttpTransport +-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder +-dontwarn com.google.api.client.http.javanet.NetHttpTransport +-dontwarn org.joda.time.Instant \ No newline at end of file diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json index 0e76b479..332782b5 100644 --- a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json +++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 14, - "identityHash": "42299b5bbc21c4c7c83eea7dc5ca5f66", + "identityHash": "5df6007016fbf8aac8b69173ee7f7c6e", "entities": [ { "tableName": "queue", @@ -242,7 +242,7 @@ }, { "tableName": "server", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, `client_cert` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "serverId", @@ -292,12 +292,6 @@ "affinity": "INTEGER", "notNull": true, "defaultValue": "false" - }, - { - "fieldPath": "clientCert", - "columnName": "client_cert", - "affinity": "TEXT", - "notNull": false } ], "primaryKey": { @@ -1068,7 +1062,7 @@ }, { "tableName": "playlist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `songCount` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -1088,6 +1082,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "coverArtId", "columnName": "coverArt", @@ -1158,7 +1158,7 @@ "views": [], "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, '42299b5bbc21c4c7c83eea7dc5ca5f66')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5df6007016fbf8aac8b69173ee7f7c6e')" ] } } \ No newline at end of file diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/15.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/15.json new file mode 100644 index 00000000..0720c2aa --- /dev/null +++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/15.json @@ -0,0 +1,1164 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "5df6007016fbf8aac8b69173ee7f7c6e", + "entities": [ + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackOrder", + "columnName": "track_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlay", + "columnName": "last_play", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playingChanged", + "columnName": "playing_changed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_order" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localAddress", + "columnName": "local_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLowSecurity", + "columnName": "low_security", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_search", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`search`))", + "fields": [ + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "search" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playlistName", + "columnName": "playlist_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "downloadUri", + "columnName": "download_uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chronology", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toStar", + "columnName": "toStar", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "session_media_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamUrl", + "columnName": "stream_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "index" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `songCount` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArtId", + "columnName": "coverArt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "structuredLyrics", + "columnName": "structured_lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '5df6007016fbf8aac8b69173ee7f7c6e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/16.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/16.json new file mode 100644 index 00000000..3f9a1700 --- /dev/null +++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/16.json @@ -0,0 +1,1214 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "47867264ef33da252bb39a7625c48d2b", + "entities": [ + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackOrder", + "columnName": "track_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlay", + "columnName": "last_play", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playingChanged", + "columnName": "playing_changed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_order" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, `client_cert` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localAddress", + "columnName": "local_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLowSecurity", + "columnName": "low_security", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "clientCert", + "columnName": "client_cert", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_search", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`search`))", + "fields": [ + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "search" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playlistName", + "columnName": "playlist_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "downloadUri", + "columnName": "download_uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chronology", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toStar", + "columnName": "toStar", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "session_media_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamUrl", + "columnName": "stream_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "index" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `songCount` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArtId", + "columnName": "coverArt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "structuredLyrics", + "columnName": "structured_lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "scrobble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`dbId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `submission` INTEGER NOT NULL, `server` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "dbId", + "columnName": "dbId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submission", + "columnName": "submission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "dbId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '47867264ef33da252bb39a7625c48d2b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.elzify.music.database.AppDatabase/16.json b/app/schemas/com.elzify.music.database.AppDatabase/16.json new file mode 100644 index 00000000..3f9a1700 --- /dev/null +++ b/app/schemas/com.elzify.music.database.AppDatabase/16.json @@ -0,0 +1,1214 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "47867264ef33da252bb39a7625c48d2b", + "entities": [ + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackOrder", + "columnName": "track_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlay", + "columnName": "last_play", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playingChanged", + "columnName": "playing_changed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_order" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, `client_cert` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localAddress", + "columnName": "local_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLowSecurity", + "columnName": "low_security", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "clientCert", + "columnName": "client_cert", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_search", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`search`))", + "fields": [ + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "search" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playlistName", + "columnName": "playlist_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "downloadUri", + "columnName": "download_uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chronology", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toStar", + "columnName": "toStar", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "session_media_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamUrl", + "columnName": "stream_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "index" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `songCount` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArtId", + "columnName": "coverArt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "structuredLyrics", + "columnName": "structured_lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "scrobble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`dbId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `submission` INTEGER NOT NULL, `server` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "dbId", + "columnName": "dbId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submission", + "columnName": "submission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "dbId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '47867264ef33da252bb39a7625c48d2b')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..8a76775f --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/degoogled/java/com/elzify/music/ui/fragment/ToolbarFragment.java b/app/src/degoogled/java/com/elzify/music/ui/fragment/ToolbarFragment.java index 5bead062..d5cee0ec 100644 --- a/app/src/degoogled/java/com/elzify/music/ui/fragment/ToolbarFragment.java +++ b/app/src/degoogled/java/com/elzify/music/ui/fragment/ToolbarFragment.java @@ -2,9 +2,6 @@ import android.os.Bundle; import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; @@ -13,7 +10,6 @@ import androidx.fragment.app.Fragment; import androidx.media3.common.util.UnstableApi; -import com.elzify.music.R; import com.elzify.music.databinding.FragmentToolbarBinding; import com.elzify.music.ui.activity.MainActivity; @@ -25,19 +21,6 @@ public class ToolbarFragment extends Fragment { private MainActivity activity; public ToolbarFragment() { - // Required empty public constructor - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.main_page_menu, menu); } @Override @@ -49,17 +32,4 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, return view; } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.action_search) { - activity.navController.navigate(R.id.searchFragment); - return true; - } else if (item.getItemId() == R.id.action_settings) { - activity.navController.navigate(R.id.settingsFragment); - return true; - } - - return false; - } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a6c915b..3e33f49b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,18 +18,13 @@ android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" - android:theme="@style/AppTheme.SplashScreen" - android:usesCleartextTraffic="true"> + android:theme="@style/AppTheme.SplashScreen"> - - @@ -37,6 +32,7 @@ diff --git a/app/src/main/java/com/elzify/music/App.java b/app/src/main/java/com/elzify/music/App.java index 64ebc031..87ec6aa0 100644 --- a/app/src/main/java/com/elzify/music/App.java +++ b/app/src/main/java/com/elzify/music/App.java @@ -3,9 +3,12 @@ import android.app.Application; import android.content.Context; import android.content.SharedPreferences; +import android.util.Log; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; import com.elzify.music.github.Github; import com.elzify.music.helper.ThemeHelper; @@ -14,41 +17,53 @@ import com.elzify.music.util.ClientCertManager; import com.elzify.music.util.Preferences; +import java.io.IOException; +import java.security.GeneralSecurityException; + public class App extends Application { private static App instance; private static Context context; private static Subsonic subsonic; private static Github github; private static SharedPreferences preferences; + private static SharedPreferences encryptedPreferences; @Override public void onCreate() { super.onCreate(); - - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - String themePref = sharedPreferences.getString(Preferences.THEME, ThemeHelper.DEFAULT_MODE); - ThemeHelper.applyTheme(themePref); - - instance = new App(); + instance = this; context = getApplicationContext(); + preferences = PreferenceManager.getDefaultSharedPreferences(context); + try { + MasterKey masterKey = new MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(); + + encryptedPreferences = EncryptedSharedPreferences.create( + context, + "encrypted_preferences", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ); + } catch (GeneralSecurityException | IOException e) { + Log.e("App", "Could not initialize EncryptedSharedPreferences", e); + encryptedPreferences = preferences; + } + + String themePref = preferences.getString(Preferences.THEME, ThemeHelper.DEFAULT_MODE); + ThemeHelper.applyTheme(themePref); + ClientCertManager.setupSslSocketFactory(context); } public static App getInstance() { - if (instance == null) { - instance = new App(); - } - return instance; } public static Context getContext() { - if (context == null) { - context = getInstance(); - } - return context; } @@ -116,6 +131,13 @@ public SharedPreferences getPreferences() { return preferences; } + public SharedPreferences getEncryptedPreferences() { + if (encryptedPreferences == null) { + return getPreferences(); + } + return encryptedPreferences; + } + public static void refreshSubsonicClient() { subsonic = getSubsonicClient(); } diff --git a/app/src/main/java/com/elzify/music/broadcast/receiver/ConnectivityStatusBroadcastReceiver.java b/app/src/main/java/com/elzify/music/broadcast/receiver/ConnectivityStatusBroadcastReceiver.java index 2f04f566..64e5c5f2 100644 --- a/app/src/main/java/com/elzify/music/broadcast/receiver/ConnectivityStatusBroadcastReceiver.java +++ b/app/src/main/java/com/elzify/music/broadcast/receiver/ConnectivityStatusBroadcastReceiver.java @@ -9,6 +9,7 @@ import androidx.annotation.OptIn; import androidx.media3.common.util.UnstableApi; +import com.elzify.music.service.MediaManager; import com.elzify.music.ui.activity.MainActivity; @OptIn(markerClass = UnstableApi.class) @@ -28,7 +29,8 @@ public void onReceive(Context context, Intent intent) { activity.bind.offlineModeTextView.setVisibility(View.VISIBLE); } else { activity.bind.offlineModeTextView.setVisibility(View.GONE); + MediaManager.submitPendingScrobbles(); } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/database/AppDatabase.java b/app/src/main/java/com/elzify/music/database/AppDatabase.java index e6f33a14..012573af 100644 --- a/app/src/main/java/com/elzify/music/database/AppDatabase.java +++ b/app/src/main/java/com/elzify/music/database/AppDatabase.java @@ -16,6 +16,7 @@ import com.elzify.music.database.dao.PlaylistDao; import com.elzify.music.database.dao.QueueDao; import com.elzify.music.database.dao.RecentSearchDao; +import com.elzify.music.database.dao.ScrobbleDao; import com.elzify.music.database.dao.ServerDao; import com.elzify.music.database.dao.SessionMediaItemDao; import com.elzify.music.model.Chronology; @@ -24,19 +25,16 @@ import com.elzify.music.model.LyricsCache; import com.elzify.music.model.Queue; import com.elzify.music.model.RecentSearch; +import com.elzify.music.model.Scrobble; import com.elzify.music.model.Server; import com.elzify.music.model.SessionMediaItem; import com.elzify.music.subsonic.models.Playlist; @UnstableApi @Database( - version = 14, - entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class}, - autoMigrations = { - @AutoMigration(from = 10, to = 11), - @AutoMigration(from = 11, to = 12), - @AutoMigration(from = 13, to = 14), - } + version = 16, + entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class, Scrobble.class}, + autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)} ) @TypeConverters({DateConverters.class}) public abstract class AppDatabase extends RoomDatabase { @@ -70,4 +68,6 @@ public static synchronized AppDatabase getInstance() { public abstract PlaylistDao playlistDao(); public abstract LyricsDao lyricsDao(); + + public abstract ScrobbleDao scrobbleDao(); } diff --git a/app/src/main/java/com/elzify/music/database/dao/ScrobbleDao.java b/app/src/main/java/com/elzify/music/database/dao/ScrobbleDao.java new file mode 100644 index 00000000..e995d83d --- /dev/null +++ b/app/src/main/java/com/elzify/music/database/dao/ScrobbleDao.java @@ -0,0 +1,25 @@ +package com.elzify.music.database.dao; + +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.Query; + +import com.elzify.music.model.Scrobble; + +import java.util.List; + +@Dao +public interface ScrobbleDao { + @Query("SELECT * FROM scrobble WHERE server = :server ORDER BY timestamp ASC") + List getPendingScrobbles(String server); + + @Insert + void insert(Scrobble scrobble); + + @Delete + void delete(Scrobble scrobble); + + @Query("DELETE FROM scrobble WHERE dbId = :dbId") + void deleteById(Long dbId); +} diff --git a/app/src/main/java/com/elzify/music/github/GithubRetrofitClient.kt b/app/src/main/java/com/elzify/music/github/GithubRetrofitClient.kt index be335e19..d1711a2f 100644 --- a/app/src/main/java/com/elzify/music/github/GithubRetrofitClient.kt +++ b/app/src/main/java/com/elzify/music/github/GithubRetrofitClient.kt @@ -1,5 +1,6 @@ package com.elzify.music.github +import com.elzify.music.BuildConfig import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -24,7 +25,10 @@ class GithubRetrofitClient(github: Github) { private fun getHttpLoggingInterceptor(): HttpLoggingInterceptor { val loggingInterceptor = HttpLoggingInterceptor() - loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) + loggingInterceptor.setLevel( + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC + else HttpLoggingInterceptor.Level.NONE + ) return loggingInterceptor } } \ No newline at end of file diff --git a/app/src/main/java/com/elzify/music/glide/IPv6StringLoader.java b/app/src/main/java/com/elzify/music/glide/IPv6StringLoader.java index 8bc01543..91a0c022 100644 --- a/app/src/main/java/com/elzify/music/glide/IPv6StringLoader.java +++ b/app/src/main/java/com/elzify/music/glide/IPv6StringLoader.java @@ -17,7 +17,7 @@ import java.net.URL; public class IPv6StringLoader implements ModelLoader { - private static final int DEFAULT_TIMEOUT_MS = 2500; + private static final int DEFAULT_TIMEOUT_MS = 8000; @Override public boolean handles(@NonNull String model) { @@ -107,4 +107,4 @@ public void teardown() { // No-op } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/interfaces/HomeRearrangementCallback.java b/app/src/main/java/com/elzify/music/interfaces/HomeRearrangementCallback.java new file mode 100644 index 00000000..7cb6d927 --- /dev/null +++ b/app/src/main/java/com/elzify/music/interfaces/HomeRearrangementCallback.java @@ -0,0 +1,5 @@ +package com.elzify.music.interfaces; + +public interface HomeRearrangementCallback { + void onChanged(); +} diff --git a/app/src/main/java/com/elzify/music/lastfm/LastFm.kt b/app/src/main/java/com/elzify/music/lastfm/LastFm.kt new file mode 100644 index 00000000..aeb8d6be --- /dev/null +++ b/app/src/main/java/com/elzify/music/lastfm/LastFm.kt @@ -0,0 +1,9 @@ +package com.elzify.music.lastfm + +import com.elzify.music.lastfm.api.TrackClient + +object LastFm { + const val BASE_URL = "https://ws.audioscrobbler.com/2.0/" + + val trackClient: TrackClient by lazy { TrackClient() } +} diff --git a/app/src/main/java/com/elzify/music/lastfm/LastFmRetrofitClient.kt b/app/src/main/java/com/elzify/music/lastfm/LastFmRetrofitClient.kt new file mode 100644 index 00000000..fa8207b4 --- /dev/null +++ b/app/src/main/java/com/elzify/music/lastfm/LastFmRetrofitClient.kt @@ -0,0 +1,30 @@ +package com.elzify.music.lastfm + +import com.elzify.music.BuildConfig +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +class LastFmRetrofitClient { + val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(LastFm.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .client(getOkHttpClient()) + .build() + + private fun getOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(getHttpLoggingInterceptor()) + .build() + } + + private fun getHttpLoggingInterceptor(): HttpLoggingInterceptor { + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.setLevel( + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC + else HttpLoggingInterceptor.Level.NONE + ) + return loggingInterceptor + } +} diff --git a/app/src/main/java/com/elzify/music/lastfm/api/TrackClient.kt b/app/src/main/java/com/elzify/music/lastfm/api/TrackClient.kt new file mode 100644 index 00000000..9081ac96 --- /dev/null +++ b/app/src/main/java/com/elzify/music/lastfm/api/TrackClient.kt @@ -0,0 +1,14 @@ +package com.elzify.music.lastfm.api + +import com.elzify.music.lastfm.LastFmRetrofitClient +import com.elzify.music.lastfm.models.LastFmTrackResponse +import retrofit2.Call + +class TrackClient { + private val trackService: TrackService = + LastFmRetrofitClient().retrofit.create(TrackService::class.java) + + fun getTrackInfo(artist: String, track: String, username: String, apiKey: String): Call { + return trackService.getTrackInfo(artist = artist, track = track, username = username, apiKey = apiKey) + } +} diff --git a/app/src/main/java/com/elzify/music/lastfm/api/TrackService.kt b/app/src/main/java/com/elzify/music/lastfm/api/TrackService.kt new file mode 100644 index 00000000..b2b4efbf --- /dev/null +++ b/app/src/main/java/com/elzify/music/lastfm/api/TrackService.kt @@ -0,0 +1,18 @@ +package com.elzify.music.lastfm.api + +import com.elzify.music.lastfm.models.LastFmTrackResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query + +interface TrackService { + @GET(".") + fun getTrackInfo( + @Query("method") method: String = "track.getInfo", + @Query("artist") artist: String, + @Query("track") track: String, + @Query("username") username: String, + @Query("api_key") apiKey: String, + @Query("format") format: String = "json" + ): Call +} diff --git a/app/src/main/java/com/elzify/music/lastfm/models/LastFmTrackResponse.kt b/app/src/main/java/com/elzify/music/lastfm/models/LastFmTrackResponse.kt new file mode 100644 index 00000000..1cc7b63f --- /dev/null +++ b/app/src/main/java/com/elzify/music/lastfm/models/LastFmTrackResponse.kt @@ -0,0 +1,18 @@ +package com.elzify.music.lastfm.models + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class LastFmTrackResponse( + @SerializedName("track") + val track: LastFmTrack? = null +) + +@Keep +data class LastFmTrack( + @SerializedName("name") + val name: String? = null, + @SerializedName("userplaycount") + val userPlayCount: String? = null +) diff --git a/app/src/main/java/com/elzify/music/model/Scrobble.kt b/app/src/main/java/com/elzify/music/model/Scrobble.kt new file mode 100644 index 00000000..0fefdcb4 --- /dev/null +++ b/app/src/main/java/com/elzify/music/model/Scrobble.kt @@ -0,0 +1,19 @@ +package com.elzify.music.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "scrobble") +data class Scrobble( + @PrimaryKey(autoGenerate = true) + val dbId: Long = 0, + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "timestamp") + val timestamp: Long, + @ColumnInfo(name = "submission") + val submission: Boolean, + @ColumnInfo(name = "server") + val server: String +) diff --git a/app/src/main/java/com/elzify/music/provider/AlbumArtContentProvider.java b/app/src/main/java/com/elzify/music/provider/AlbumArtContentProvider.java index 21ca4158..852d051e 100644 --- a/app/src/main/java/com/elzify/music/provider/AlbumArtContentProvider.java +++ b/app/src/main/java/com/elzify/music/provider/AlbumArtContentProvider.java @@ -8,7 +8,6 @@ import android.database.Cursor; import android.net.Uri; import android.os.ParcelFileDescriptor; -import android.util.Base64; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -52,18 +51,19 @@ public static Uri contentUri(String artworkId) { @Nullable @Override public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + if (uriMatcher.match(uri) != 1) { + throw new FileNotFoundException("Unknown URI: " + uri); + } + Context context = getContext(); String albumId = uri.getLastPathSegment(); - Uri artworkUri; - - if (albumId != null && albumId.startsWith("ir_")) { - String encodedUrl = albumId.substring("ir_".length()); - String decodedUrl = new String(Base64.decode(encodedUrl, Base64.URL_SAFE | Base64.NO_WRAP)); - artworkUri = Uri.parse(decodedUrl); - } else { - artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize())); + + if (albumId == null || albumId.isEmpty() || albumId.contains("..") || albumId.contains("/")) { + throw new FileNotFoundException("Invalid album ID"); } + Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize())); + try { // use pipe to communicate between background thread and caller of openFile() ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); diff --git a/app/src/main/java/com/elzify/music/repository/ArtistRepository.java b/app/src/main/java/com/elzify/music/repository/ArtistRepository.java index 89c45cb5..2465fb17 100644 --- a/app/src/main/java/com/elzify/music/repository/ArtistRepository.java +++ b/app/src/main/java/com/elzify/music/repository/ArtistRepository.java @@ -5,6 +5,7 @@ import android.util.Log; import com.elzify.music.App; +import com.elzify.music.subsonic.api.navidrome.NavidromeClient; import com.elzify.music.subsonic.base.ApiResponse; import com.elzify.music.subsonic.models.ArtistID3; import com.elzify.music.subsonic.models.AlbumID3; @@ -362,6 +363,36 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { return topSongs; } + public MutableLiveData> getRecentlyPlayedArtists(int count) { + MutableLiveData> result = new MutableLiveData<>(); + java.util.concurrent.Executors.newSingleThreadExecutor().execute(() -> { + try { + List artists = NavidromeClient.getInstance().getRecentlyPlayedArtists(count); + Log.d("ArtistRepository", "getRecentlyPlayedArtists: returning " + artists.size() + " artists"); + result.postValue(artists); + } catch (Exception e) { + Log.e("ArtistRepository", "getRecentlyPlayedArtists: exception", e); + result.postValue(null); + } + }); + return result; + } + + public MutableLiveData> getTopPlayedArtists(int count) { + MutableLiveData> result = new MutableLiveData<>(); + java.util.concurrent.Executors.newSingleThreadExecutor().execute(() -> { + try { + List artists = NavidromeClient.getInstance().getTopPlayedArtists(count); + Log.d("ArtistRepository", "getTopPlayedArtists: returning " + artists.size() + " artists"); + result.postValue(artists); + } catch (Exception e) { + Log.e("ArtistRepository", "getTopPlayedArtists: exception", e); + result.postValue(null); + } + }); + return result; + } + private void addToMutableLiveData(MutableLiveData> liveData, ArtistID3 artist) { List liveArtists = liveData.getValue(); if (liveArtists != null) liveArtists.add(artist); diff --git a/app/src/main/java/com/elzify/music/repository/ChronologyRepository.java b/app/src/main/java/com/elzify/music/repository/ChronologyRepository.java index 210f1efe..fd900f49 100644 --- a/app/src/main/java/com/elzify/music/repository/ChronologyRepository.java +++ b/app/src/main/java/com/elzify/music/repository/ChronologyRepository.java @@ -15,6 +15,10 @@ public LiveData> getChronology(String server, long start, long return chronologyDao.getAllFrom(start, end, server); } + public LiveData> getLastPlayed(String server, int count) { + return chronologyDao.getLastPlayed(server, count); + } + public void insert(Chronology item) { InsertThreadSafe insert = new InsertThreadSafe(chronologyDao, item); Thread thread = new Thread(insert); diff --git a/app/src/main/java/com/elzify/music/repository/LrcGetRepository.java b/app/src/main/java/com/elzify/music/repository/LrcGetRepository.java new file mode 100644 index 00000000..4b802b12 --- /dev/null +++ b/app/src/main/java/com/elzify/music/repository/LrcGetRepository.java @@ -0,0 +1,206 @@ +package com.elzify.music.repository; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.MutableLiveData; + +import com.elzify.music.subsonic.models.Child; +import com.elzify.music.subsonic.models.Line; +import com.elzify.music.subsonic.models.LyricsList; +import com.elzify.music.subsonic.models.StructuredLyrics; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class LrcGetRepository { + private static final String API_BASE_URL = "https://lrclib.net/api/get"; + private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,3}))?\\]"); + + private final OkHttpClient client = new OkHttpClient(); + private final Gson gson = new Gson(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public static class LrcGetLyricsResult { + private final String plainLyrics; + private final LyricsList syncedLyrics; + + public LrcGetLyricsResult(String plainLyrics, LyricsList syncedLyrics) { + this.plainLyrics = plainLyrics; + this.syncedLyrics = syncedLyrics; + } + + public String getPlainLyrics() { + return plainLyrics; + } + + public LyricsList getSyncedLyrics() { + return syncedLyrics; + } + } + + public MutableLiveData getLyrics(@NonNull Child media) { + MutableLiveData result = new MutableLiveData<>(null); + + if (TextUtils.isEmpty(media.getArtist()) || TextUtils.isEmpty(media.getTitle())) { + return result; + } + + executor.execute(() -> { + HttpUrl baseUrl = HttpUrl.parse(API_BASE_URL); + if (baseUrl == null) { + return; + } + + HttpUrl.Builder urlBuilder = baseUrl.newBuilder() + .addQueryParameter("artist_name", media.getArtist()) + .addQueryParameter("track_name", media.getTitle()); + + if (media.getDuration() != null && media.getDuration() > 0) { + urlBuilder.addQueryParameter("duration", String.valueOf(media.getDuration())); + } + + Request request = new Request.Builder() + .url(urlBuilder.build()) + .header("Accept", "application/json") + .header("User-Agent", "Rollynn") + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + return; + } + + String jsonBody = response.body().string(); + JsonObject jsonObject = gson.fromJson(jsonBody, JsonObject.class); + if (jsonObject == null || jsonObject.isJsonNull()) { + return; + } + + String plainLyrics = getString(jsonObject, "plainLyrics"); + String syncedLyricsText = getString(jsonObject, "syncedLyrics"); + String artist = getString(jsonObject, "artistName"); + String title = getString(jsonObject, "trackName"); + String lang = getString(jsonObject, "lang"); + + LyricsList syncedLyrics = parseSyncedLyrics(syncedLyricsText, artist, title, lang); + + if (!TextUtils.isEmpty(plainLyrics) || syncedLyrics != null) { + result.postValue(new LrcGetLyricsResult(plainLyrics, syncedLyrics)); + } + } catch (IOException ignored) { + } + }); + + return result; + } + + private LyricsList parseSyncedLyrics(String lrcText, String artist, String title, String lang) { + if (TextUtils.isEmpty(lrcText)) { + return null; + } + + List parsedLines = new ArrayList<>(); + String[] rawLines = lrcText.split("\\r?\\n"); + + for (String rawLine : rawLines) { + if (TextUtils.isEmpty(rawLine)) { + continue; + } + + Matcher matcher = TIMESTAMP_PATTERN.matcher(rawLine); + List starts = new ArrayList<>(); + int timestampEndIndex = -1; + + while (matcher.find()) { + int minutes = parsePart(matcher.group(1)); + int seconds = parsePart(matcher.group(2)); + int millis = parseMillis(matcher.group(3)); + starts.add((minutes * 60 * 1000) + (seconds * 1000) + millis); + timestampEndIndex = matcher.end(); + } + + if (starts.isEmpty()) { + continue; + } + + String value = rawLine.substring(Math.max(timestampEndIndex, 0)).trim(); + if (value.isEmpty()) { + continue; + } + + for (Integer start : starts) { + Line line = new Line(); + line.setStart(start); + line.setValue(value); + parsedLines.add(line); + } + } + + if (parsedLines.isEmpty()) { + return null; + } + + parsedLines.sort(Comparator.comparing(Line::getStart, Comparator.nullsLast(Integer::compareTo))); + + StructuredLyrics structuredLyrics = new StructuredLyrics(); + structuredLyrics.setDisplayArtist(artist); + structuredLyrics.setDisplayTitle(title); + structuredLyrics.setLang(lang); + structuredLyrics.setOffset(0); + structuredLyrics.setSynced(true); + structuredLyrics.setLine(parsedLines); + + LyricsList lyricsList = new LyricsList(); + lyricsList.setStructuredLyrics(Collections.singletonList(structuredLyrics)); + return lyricsList; + } + + private int parsePart(String value) { + try { + return Integer.parseInt(Objects.requireNonNullElse(value, "0")); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private int parseMillis(String fraction) { + if (TextUtils.isEmpty(fraction)) { + return 0; + } + + String normalized = fraction.trim(); + if (normalized.length() == 1) { + return parsePart(normalized) * 100; + } + if (normalized.length() == 2) { + return parsePart(normalized) * 10; + } + + return parsePart(normalized.substring(0, Math.min(3, normalized.length()))); + } + + private String getString(JsonObject jsonObject, String key) { + if (!jsonObject.has(key) || jsonObject.get(key).isJsonNull()) { + return null; + } + + String value = jsonObject.get(key).getAsString(); + return TextUtils.isEmpty(value) ? null : value; + } +} diff --git a/app/src/main/java/com/elzify/music/repository/PlaylistRepository.java b/app/src/main/java/com/elzify/music/repository/PlaylistRepository.java index e4b84b07..d9f7201d 100644 --- a/app/src/main/java/com/elzify/music/repository/PlaylistRepository.java +++ b/app/src/main/java/com/elzify/music/repository/PlaylistRepository.java @@ -1,11 +1,9 @@ package com.elzify.music.repository; import androidx.annotation.NonNull; -import androidx.annotation.OptIn; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; -import androidx.media3.common.util.UnstableApi; import com.elzify.music.App; import com.elzify.music.R; @@ -171,6 +169,10 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { } } + public void addSongToPlaylist(String playlistId, ArrayList songsId, Boolean playlistVisibilityIsPublic) { + addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null); + } + public void removeSongFromPlaylist(String playlistId, int index, AddToPlaylistCallback callback) { ArrayList indexes = new ArrayList<>(); indexes.add(index); @@ -194,10 +196,6 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { }); } - public void addSongToPlaylist(String playlistId, ArrayList songsId, Boolean playlistVisibilityIsPublic) { - addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null); - } - public void createPlaylist(String playlistId, String name, ArrayList songsId) { App.getSubsonicClientInstance(false) .getPlaylistClient() @@ -241,7 +239,6 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { }); } - @OptIn(markerClass = UnstableApi.class) private void updateLocalPinnedPlaylistName(String id, String newName) { new Thread(() -> { List pinned = playlistDao.getAllSync(); diff --git a/app/src/main/java/com/elzify/music/repository/SongRepository.java b/app/src/main/java/com/elzify/music/repository/SongRepository.java index bf120a7c..71ad2b11 100644 --- a/app/src/main/java/com/elzify/music/repository/SongRepository.java +++ b/app/src/main/java/com/elzify/music/repository/SongRepository.java @@ -6,16 +6,24 @@ import androidx.lifecycle.MutableLiveData; import com.elzify.music.App; +import com.elzify.music.database.AppDatabase; +import com.elzify.music.database.dao.ScrobbleDao; +import com.elzify.music.model.Scrobble; import com.elzify.music.subsonic.base.ApiResponse; import com.elzify.music.subsonic.models.Child; import com.elzify.music.subsonic.models.SubsonicResponse; import com.elzify.music.util.Constants.SeedType; +import com.elzify.music.util.Preferences; + +import com.elzify.music.subsonic.api.navidrome.NavidromeClient; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import retrofit2.Call; import retrofit2.Callback; @@ -24,13 +32,15 @@ public class SongRepository { private static final String TAG = "SongRepository"; + private final ScrobbleDao scrobbleDao = AppDatabase.getInstance().scrobbleDao(); + private final AtomicBoolean pendingScrobbleSyncInProgress = new AtomicBoolean(false); public interface MediaCallbackInternal { void onSongsAvailable(List songs); } public MutableLiveData> getStarredSongs(boolean random, int size) { - MutableLiveData> starredSongs = new MutableLiveData<>(Collections.emptyList()); + MutableLiveData> starredSongs = new MutableLiveData<>(null); App.getSubsonicClientInstance(false) .getAlbumSongListClient() @@ -294,6 +304,36 @@ public MutableLiveData> getRandomSample(int number, Integer fromYear return randomSongsSample; } + public MutableLiveData> getRecentlyPlayedSongs(int count) { + MutableLiveData> recentlyPlayedSongs = new MutableLiveData<>(); + Executors.newSingleThreadExecutor().execute(() -> { + try { + List songs = NavidromeClient.getInstance().getRecentlyPlayedSongs(count); + Log.d(TAG, "getRecentlyPlayedSongs: returning " + songs.size() + " songs"); + recentlyPlayedSongs.postValue(songs); + } catch (Exception e) { + Log.e(TAG, "getRecentlyPlayedSongs: exception", e); + recentlyPlayedSongs.postValue(null); + } + }); + return recentlyPlayedSongs; + } + + public MutableLiveData> getTopPlayedSongs(int count) { + MutableLiveData> topPlayedSongs = new MutableLiveData<>(); + Executors.newSingleThreadExecutor().execute(() -> { + try { + List songs = NavidromeClient.getInstance().getTopPlayedSongs(count); + Log.d(TAG, "getTopPlayedSongs: returning " + songs.size() + " songs"); + topPlayedSongs.postValue(songs); + } catch (Exception e) { + Log.e(TAG, "getTopPlayedSongs: exception", e); + topPlayedSongs.postValue(null); + } + }); + return topPlayedSongs; + } + public MutableLiveData> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) { MutableLiveData> randomSongsSample = new MutableLiveData<>(); @@ -314,12 +354,71 @@ public MutableLiveData> getRandomSampleWithGenre(int number, Integer } public void scrobble(String id, boolean submission) { - App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission).enqueue(new Callback() { - @Override public void onResponse(@NonNull Call call, @NonNull Response response) {} - @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + scrobble(id, submission, null); + } + + public void scrobble(String id, boolean submission, Long time) { + String server = Preferences.getServerId(); + long scrobbleTime = time != null ? time : System.currentTimeMillis(); + + App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission, time).enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) { + submitPendingScrobbles(); + } else { + saveScrobbleLocally(id, submission, scrobbleTime, server); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + saveScrobbleLocally(id, submission, scrobbleTime, server); + } }); } + private void saveScrobbleLocally(String id, boolean submission, long time, String server) { + if (server == null) return; + new Thread(() -> { + scrobbleDao.insert(new Scrobble(0, id, time, submission, server)); + }).start(); + } + + public void submitPendingScrobbles() { + String server = Preferences.getServerId(); + if (server == null) return; + if (!pendingScrobbleSyncInProgress.compareAndSet(false, true)) return; + + new Thread(() -> { + try { + List pending = scrobbleDao.getPendingScrobbles(server); + if (pending.isEmpty()) return; + + for (Scrobble scrobble : pending) { + try { + Response response = App.getSubsonicClientInstance(false) + .getMediaAnnotationClient() + .scrobble(scrobble.getId(), scrobble.getSubmission(), scrobble.getTimestamp()) + .execute(); + + if (response.isSuccessful()) { + scrobbleDao.delete(scrobble); + } else { + Log.w(TAG, "Pending scrobble sync failed with HTTP " + response.code()); + break; + } + } catch (Exception e) { + Log.w(TAG, "Pending scrobble sync failed", e); + break; + } + } + } finally { + pendingScrobbleSyncInProgress.set(false); + } + }).start(); + } + public void setRating(String id, int rating) { App.getSubsonicClientInstance(false).getMediaAnnotationClient().setRating(id, rating).enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) {} @@ -385,4 +484,4 @@ public MutableLiveData getSongLyrics(Child song) { }); return lyrics; } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/service/BaseMediaService.kt b/app/src/main/java/com/elzify/music/service/BaseMediaService.kt index 43852262..3f7540cb 100644 --- a/app/src/main/java/com/elzify/music/service/BaseMediaService.kt +++ b/app/src/main/java/com/elzify/music/service/BaseMediaService.kt @@ -73,11 +73,33 @@ open class BaseMediaService : MediaLibraryService() { widgetUpdateScheduled = false return } + + checkScrobbleThreshold(player) updateWidget(player) widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) } } + private fun checkScrobbleThreshold(player: Player) { + if (currentTrackScrobbled) return + if (player.mediaMetadata.extras?.getString("type") != Constants.MEDIA_TYPE_MUSIC) return + + val duration = player.duration + val position = player.currentPosition + + if (duration > 0 && position > 0) { + val threshold = Preferences.getScrobbleThreshold() + if (position * 100 / duration >= threshold) { + currentTrackScrobbled = true + MediaManager.scrobble(player.currentMediaItem, true, System.currentTimeMillis()) + MediaManager.saveChronology(player.currentMediaItem) + player.currentMediaItem?.mediaMetadata?.extras?.getString("id")?.let { + MediaManager.postScrobbleEvent(it) + } + } + } + } + private val radioHeaderCheckExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private var radioHeaderCheckScheduled = false private var radioHeaderCheckFuture: ScheduledFuture<*>? = null @@ -86,6 +108,7 @@ open class BaseMediaService : MediaLibraryService() { } private val binder = LocalBinder() + private var currentTrackScrobbled = false open fun playerInitHook() { initializeExoPlayer() @@ -142,10 +165,14 @@ open class BaseMediaService : MediaLibraryService() { player.addListener(object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { Log.d(TAG, "onMediaItemTransition" + player.currentMediaItemIndex) + currentTrackScrobbled = false if (mediaItem == null) return if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { MediaManager.setLastPlayedTimestamp(mediaItem) + if (mediaItem.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { + MediaManager.saveChronology(mediaItem) + } } // Restart header checks for radio streams when media item changes @@ -201,8 +228,8 @@ open class BaseMediaService : MediaLibraryService() { override fun onMetadata(metadata: Metadata) { // Handle streaming metadata (ICY, ID3) for radio / streaming content val currentItem = player.currentMediaItem ?: return - val extras = currentItem.mediaMetadata.extras - if (extras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return + val extras = currentItem.mediaMetadata.extras ?: return + if (extras.getString("type") != Constants.MEDIA_TYPE_RADIO) return var artist: String? = null var title: String? = null @@ -253,14 +280,14 @@ open class BaseMediaService : MediaLibraryService() { if (currentIndex == C.INDEX_UNSET) return val metadataBuilder = currentItem.mediaMetadata.buildUpon() - val newExtras = Bundle(extras ?: Bundle()) + val newExtras = Bundle(extras) // Store individual values in extras for UI artist?.let { newExtras.putString("radioArtist", it) } title?.let { newExtras.putString("radioTitle", it) } // Get station name (preserve if already set) - val stationName = extras?.getString("stationName") + val stationName = extras.getString("stationName") ?: currentItem.mediaMetadata.title?.toString() ?: "" if (stationName.isNotBlank()) { @@ -313,9 +340,11 @@ open class BaseMediaService : MediaLibraryService() { super.onPlaybackStateChanged(playbackState) if (!player.hasNextMediaItem() && playbackState == Player.STATE_ENDED && - player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC + player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC && + !currentTrackScrobbled ) { - MediaManager.scrobble(player.currentMediaItem, true) + currentTrackScrobbled = true + MediaManager.scrobble(player.currentMediaItem, true, System.currentTimeMillis()) MediaManager.saveChronology(player.currentMediaItem) } updateWidget(player) @@ -330,8 +359,9 @@ open class BaseMediaService : MediaLibraryService() { super.onPositionDiscontinuity(oldPosition, newPosition, reason) if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { - MediaManager.scrobble(oldPosition.mediaItem, true) + if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC && !currentTrackScrobbled) { + currentTrackScrobbled = true + MediaManager.scrobble(oldPosition.mediaItem, true, System.currentTimeMillis()) MediaManager.saveChronology(oldPosition.mediaItem) } @@ -384,10 +414,8 @@ open class BaseMediaService : MediaLibraryService() { override fun onTaskRemoved(rootIntent: Intent?) { val player = mediaLibrarySession.player - - if (!player.playWhenReady || player.mediaItemCount == 0) { - stopSelf() - } + player.pause() + stopSelf() } override fun onCreate() { @@ -556,18 +584,17 @@ open class BaseMediaService : MediaLibraryService() { private fun checkRadioHttpHeaders() { val player = mediaLibrarySession.player val currentItem = player.currentMediaItem ?: return - val extras = currentItem.mediaMetadata.extras - val mediaType = extras?.getString("type") - if (mediaType != Constants.MEDIA_TYPE_RADIO) return + val extras = currentItem.mediaMetadata.extras ?: return + if (extras.getString("type") != Constants.MEDIA_TYPE_RADIO) return // Skip if we already have embedded metadata (ICY/ID3) - HTTP headers are only fallback val hasEmbeddedMetadata = !currentItem.mediaMetadata.artist.isNullOrBlank() || !currentItem.mediaMetadata.title.isNullOrBlank() || - (extras != null && !extras.getString("radioArtist").isNullOrBlank()) || - (extras != null && !extras.getString("radioTitle").isNullOrBlank()) + !extras.getString("radioArtist").isNullOrBlank() || + !extras.getString("radioTitle").isNullOrBlank() if (hasEmbeddedMetadata) return - val streamUrl = extras?.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString() + val streamUrl = extras.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString() if (streamUrl.isNullOrBlank()) return try { @@ -577,6 +604,7 @@ open class BaseMediaService : MediaLibraryService() { // Only try HEAD request (lightweight) - skip GET fallback as it's unreliable connection.requestMethod = "HEAD" connection.setRequestProperty("Icy-MetaData", "1") + connection.setRequestProperty("User-Agent", "Rollynn/1.0") connection.setRequestProperty("User-Agent", "Elzify/1.0") connection.connectTimeout = 3000 // Reduced timeout connection.readTimeout = 3000 @@ -621,25 +649,25 @@ open class BaseMediaService : MediaLibraryService() { val currentIndex = player.currentMediaItemIndex if (currentIndex == C.INDEX_UNSET) return@post - val currentExtras = currentItemNow.mediaMetadata.extras - if (currentExtras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post + val currentExtras = currentItemNow.mediaMetadata.extras ?: return@post + if (currentExtras.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post // Double-check we still don't have embedded metadata (might have arrived since check) val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() || !currentItemNow.mediaMetadata.title.isNullOrBlank() || - (currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) || - (currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank()) + !currentExtras.getString("radioArtist").isNullOrBlank() || + !currentExtras.getString("radioTitle").isNullOrBlank() if (hasEmbeddedMetadata) return@post val metadataBuilder = currentItemNow.mediaMetadata.buildUpon() - val newExtras = Bundle(currentExtras ?: Bundle()) + val newExtras = Bundle(currentExtras) // Store individual values in extras for UI artist?.let { newExtras.putString("radioArtist", it) } title?.let { newExtras.putString("radioTitle", it) } // Get station name (preserve if already set) - val stationName = currentExtras?.getString("stationName") + val stationName = currentExtras.getString("stationName") ?: currentItemNow.mediaMetadata.title?.toString() ?: "" if (stationName.isNotBlank()) { @@ -831,19 +859,42 @@ open class BaseMediaService : MediaLibraryService() { private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { var wasWifi = false + var wasConnected = false init { val manager = getSystemService(ConnectivityManager::class.java) val network = manager.activeNetwork val capabilities = manager.getNetworkCapabilities(network) - if (capabilities != null) + if (capabilities != null) { wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + updateConnectivityState(capabilities) + } + + private fun isOnline(capabilities: NetworkCapabilities?): Boolean { + return capabilities != null + && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + private fun updateConnectivityState(capabilities: NetworkCapabilities?) { + val online = isOnline(capabilities) + if (online && !wasConnected) { + MediaManager.submitPendingScrobbles() + } + wasConnected = online + } + + override fun onAvailable(network: Network) { + val manager = getSystemService(ConnectivityManager::class.java) + updateConnectivityState(manager.getNetworkCapabilities(network)) } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { + updateConnectivityState(networkCapabilities) val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) if (isWifi != wasWifi) { wasWifi = isWifi @@ -852,6 +903,12 @@ open class BaseMediaService : MediaLibraryService() { } } } + + override fun onLost(network: Network) { + val manager = getSystemService(ConnectivityManager::class.java) + val activeCapabilities = manager.getNetworkCapabilities(manager.activeNetwork) + updateConnectivityState(activeCapabilities) + } } inner class LocalBinder : Binder() { @@ -863,4 +920,3 @@ open class BaseMediaService : MediaLibraryService() { private const val WIDGET_UPDATE_INTERVAL_MS = 1000L private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails - diff --git a/app/src/main/java/com/elzify/music/service/MediaManager.java b/app/src/main/java/com/elzify/music/service/MediaManager.java index 3b66f23b..b9274cb2 100644 --- a/app/src/main/java/com/elzify/music/service/MediaManager.java +++ b/app/src/main/java/com/elzify/music/service/MediaManager.java @@ -8,6 +8,7 @@ import androidx.annotation.Nullable; import androidx.annotation.OptIn; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.media3.common.MediaItem; import androidx.media3.common.Player; @@ -32,18 +33,65 @@ import com.google.common.util.concurrent.MoreExecutors; import java.lang.ref.WeakReference; +import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; public class MediaManager { private static final String TAG = "MediaManager"; private static WeakReference attachedBrowserRef = new WeakReference<>(null); public static AtomicBoolean justStarted = new AtomicBoolean(false); + private static int lastPlayNextInsertedIndex = -1; private static final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor(); + private static final MutableLiveData scrobbledSongId = new MutableLiveData<>(); + private static final AtomicLong scrobbleVersion = new AtomicLong(0); + private static final Map playCountIncrements = new ConcurrentHashMap<>(); + + public static LiveData getScrobbledSongId() { + return scrobbledSongId; + } + + public static long getScrobbleVersion() { + return scrobbleVersion.get(); + } + + public static int getPlayCountIncrement(String songId) { + if (songId == null) return 0; + return playCountIncrements.getOrDefault(songId, 0); + } + + public static void postScrobbleEvent(String songId) { + scrobbleVersion.incrementAndGet(); + playCountIncrements.merge(songId, 1, Integer::sum); + scrobbledSongId.postValue(songId); + } + + private static final MutableLiveData favoriteEvent = new MutableLiveData<>(); + + public static LiveData getFavoriteEvent() { + return favoriteEvent; + } + + public static void postFavoriteEvent(String songId, Date starred) { + favoriteEvent.postValue(new Object[]{songId, starred}); + } + + private static final MutableLiveData ratingEvent = new MutableLiveData<>(); + + public static LiveData getRatingEvent() { + return ratingEvent; + } + + public static void postRatingEvent(String songId, int rating) { + ratingEvent.postValue(new Object[]{songId, rating}); + } public static void registerPlaybackObserver( ListenableFuture browserFuture, @@ -59,6 +107,10 @@ public void onSuccess(MediaBrowser browser) { browser.addListener(new Player.Listener() { @Override public void onEvents(@NonNull Player player, @NonNull Player.Events events) { + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + lastPlayNextInsertedIndex = -1; + } + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) || events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { @@ -304,8 +356,20 @@ public static void enqueue(ListenableFuture mediaBrowserListenable Log.e(TAG, "enqueue"); MediaBrowser browser = mediaBrowserListenableFuture.get(); if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) { - enqueueDatabase(media, false, browser.getNextMediaItemIndex()); - browser.addMediaItems(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItems(media)); + int insertIndex; + if (Preferences.getPlayNextBehavior().equals(Preferences.PLAY_NEXT_BEHAVIOR_SEQUENTIAL)) { + if (lastPlayNextInsertedIndex == -1) { + insertIndex = browser.getNextMediaItemIndex(); + } else { + insertIndex = Math.min(lastPlayNextInsertedIndex, browser.getMediaItemCount()); + } + lastPlayNextInsertedIndex = insertIndex + media.size(); + } else { + insertIndex = browser.getNextMediaItemIndex(); + lastPlayNextInsertedIndex = insertIndex + media.size(); + } + enqueueDatabase(media, false, insertIndex); + browser.addMediaItems(insertIndex, MappingUtil.mapMediaItems(media)); } else { enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount()); mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media)); @@ -326,8 +390,20 @@ public static void enqueue(ListenableFuture mediaBrowserListenable Log.e(TAG, "enqueue"); MediaBrowser browser = mediaBrowserListenableFuture.get(); if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) { - enqueueDatabase(media, false, browser.getNextMediaItemIndex()); - browser.addMediaItem(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItem(media)); + int insertIndex; + if (Preferences.getPlayNextBehavior().equals(Preferences.PLAY_NEXT_BEHAVIOR_SEQUENTIAL)) { + if (lastPlayNextInsertedIndex == -1) { + insertIndex = browser.getNextMediaItemIndex(); + } else { + insertIndex = Math.min(lastPlayNextInsertedIndex, browser.getMediaItemCount()); + } + lastPlayNextInsertedIndex = insertIndex + 1; + } else { + insertIndex = browser.getNextMediaItemIndex(); + lastPlayNextInsertedIndex = insertIndex + 1; + } + enqueueDatabase(media, false, insertIndex); + browser.addMediaItem(insertIndex, MappingUtil.mapMediaItem(media)); } else { enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount()); mediaBrowserListenableFuture.get().addMediaItem(MappingUtil.mapMediaItem(media)); @@ -434,11 +510,19 @@ public static void setPlayingPausedTimestamp(MediaItem mediaItem, long ms) { } public static void scrobble(MediaItem mediaItem, boolean submission) { + scrobble(mediaItem, submission, null); + } + + public static void scrobble(MediaItem mediaItem, boolean submission, Long time) { if (mediaItem != null && Preferences.isScrobblingEnabled()) { - getSongRepository().scrobble(mediaItem.mediaMetadata.extras.getString("id"), submission); + getSongRepository().scrobble(mediaItem.mediaMetadata.extras.getString("id"), submission, time); } } + public static void submitPendingScrobbles() { + getSongRepository().submitPendingScrobbles(); + } + @OptIn(markerClass = UnstableApi.class) public static void continuousPlay(MediaItem mediaItem, ListenableFuture existingBrowserFuture) { diff --git a/app/src/main/java/com/elzify/music/subsonic/RetrofitClient.kt b/app/src/main/java/com/elzify/music/subsonic/RetrofitClient.kt index 279d2250..df43a016 100644 --- a/app/src/main/java/com/elzify/music/subsonic/RetrofitClient.kt +++ b/app/src/main/java/com/elzify/music/subsonic/RetrofitClient.kt @@ -3,7 +3,7 @@ package com.elzify.music.subsonic import com.elzify.music.App import com.elzify.music.subsonic.utils.CacheUtil import com.elzify.music.subsonic.utils.EmptyDateTypeAdapter -import com.elzify.music.util.ClientCertManager +import com.elzify.music.BuildConfig import com.google.gson.GsonBuilder import okhttp3.Cache import okhttp3.OkHttpClient @@ -14,7 +14,7 @@ import java.util.Date import java.util.concurrent.TimeUnit class RetrofitClient(subsonic: Subsonic) { - val retrofit: Retrofit + var retrofit: Retrofit init { val gson = GsonBuilder() @@ -51,13 +51,15 @@ class RetrofitClient(subsonic: Subsonic) { .addInterceptor(cacheUtil.offlineInterceptor) // .addNetworkInterceptor(cacheUtil.onlineInterceptor) .cache(getCache()) - .setupSsl() .build() } private fun getHttpLoggingInterceptor(): HttpLoggingInterceptor { val loggingInterceptor = HttpLoggingInterceptor() - loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) + loggingInterceptor.setLevel( + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC + else HttpLoggingInterceptor.Level.NONE + ) return loggingInterceptor } @@ -65,11 +67,4 @@ class RetrofitClient(subsonic: Subsonic) { val cacheSize = 10 * 1024 * 1024 return Cache(App.getContext().cacheDir, cacheSize.toLong()) } - - private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder { - ClientCertManager.sslSocketFactory?.let { sslSocketFactory -> - sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager) - } - return this - } } \ No newline at end of file diff --git a/app/src/main/java/com/elzify/music/subsonic/Subsonic.java b/app/src/main/java/com/elzify/music/subsonic/Subsonic.java index 65881403..e152b831 100644 --- a/app/src/main/java/com/elzify/music/subsonic/Subsonic.java +++ b/app/src/main/java/com/elzify/music/subsonic/Subsonic.java @@ -19,7 +19,7 @@ import java.util.Map; public class Subsonic { - private static final Version API_MAX_VERSION = Version.of("1.15.0"); + private static final Version API_MAX_VERSION = Version.of("1.16.1"); private final Version apiVersion = API_MAX_VERSION; private final SubsonicPreferences preferences; diff --git a/app/src/main/java/com/elzify/music/subsonic/SubsonicPreferences.java b/app/src/main/java/com/elzify/music/subsonic/SubsonicPreferences.java index 87936544..d2cb3cf5 100644 --- a/app/src/main/java/com/elzify/music/subsonic/SubsonicPreferences.java +++ b/app/src/main/java/com/elzify/music/subsonic/SubsonicPreferences.java @@ -7,7 +7,7 @@ public class SubsonicPreferences { private String serverUrl; private String username; - private String clientName = "Tempus"; + private String clientName = "Rollynn"; private SubsonicAuthentication authentication; public String getServerUrl() { diff --git a/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListClient.java b/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListClient.java index 0b44868b..4bf42471 100644 --- a/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListClient.java +++ b/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListClient.java @@ -44,6 +44,11 @@ public Call getSongsByGenre(String genre, int count, int offset) { return albumSongListService.getSongsByGenre(subsonic.getParams(), genre, count, offset); } + public Call getRecentlyPlayed(int count) { + Log.d(TAG, "getRecentlyPlayed()"); + return albumSongListService.getRecentlyPlayed(subsonic.getParams(), count); + } + public Call getNowPlaying() { Log.d(TAG, "getNowPlaying()"); return albumSongListService.getNowPlaying(subsonic.getParams()); diff --git a/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListService.java b/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListService.java index 04fe3433..b21f9f43 100644 --- a/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListService.java +++ b/app/src/main/java/com/elzify/music/subsonic/api/albumsonglist/AlbumSongListService.java @@ -25,6 +25,9 @@ public interface AlbumSongListService { @GET("getSongsByGenre") Call getSongsByGenre(@QueryMap Map params, @Query("genre") String genre, @Query("count") int count, @Query("offset") int offset); + @GET("getRecentlyPlayed.view") + Call getRecentlyPlayed(@QueryMap Map params, @Query("count") int count); + @GET("getNowPlaying") Call getNowPlaying(@QueryMap Map params); diff --git a/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationClient.java b/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationClient.java index fb288502..db41cdcb 100644 --- a/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationClient.java +++ b/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationClient.java @@ -35,7 +35,11 @@ public Call setRating(String id, int rating) { } public Call scrobble(String id, boolean submission) { + return scrobble(id, submission, null); + } + + public Call scrobble(String id, boolean submission, Long time) { Log.d(TAG, "scrobble()"); - return mediaAnnotationService.scrobble(subsonic.getParams(), id, submission); + return mediaAnnotationService.scrobble(subsonic.getParams(), id, submission, time); } } diff --git a/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationService.java b/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationService.java index 5acc247e..4ae40873 100644 --- a/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationService.java +++ b/app/src/main/java/com/elzify/music/subsonic/api/mediaannotation/MediaAnnotationService.java @@ -20,5 +20,5 @@ public interface MediaAnnotationService { Call setRating(@QueryMap Map params, @Query("id") String id, @Query("rating") int rating); @GET("scrobble") - Call scrobble(@QueryMap Map params, @Query("id") String id, @Query("submission") Boolean submission); + Call scrobble(@QueryMap Map params, @Query("id") String id, @Query("submission") Boolean submission, @Query("time") Long time); } diff --git a/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeClient.kt b/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeClient.kt new file mode 100644 index 00000000..38fcbdc8 --- /dev/null +++ b/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeClient.kt @@ -0,0 +1,212 @@ +package com.elzify.music.subsonic.api.navidrome + +import com.elzify.music.subsonic.models.ArtistID3 +import com.elzify.music.subsonic.models.Child +import com.elzify.music.util.Preferences +import com.elzify.music.BuildConfig +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.Date +import java.util.concurrent.TimeUnit + +class NavidromeClient { + + companion object { + @Volatile + private var instance: NavidromeClient? = null + + @JvmStatic + fun getInstance(): NavidromeClient { + return instance ?: synchronized(this) { + instance ?: NavidromeClient().also { instance = it } + } + } + + @JvmStatic + fun refresh() { + instance = null + } + } + + private val service: NavidromeService + @Volatile + private var jwtToken: String? = null + + init { + val baseUrl = getBaseUrl() + + val gson = GsonBuilder().setLenient().create() + + val client = OkHttpClient.Builder() + .callTimeout(2, TimeUnit.MINUTES) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(HttpLoggingInterceptor().setLevel( + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC + else HttpLoggingInterceptor.Level.NONE + )) + .build() + + service = Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(client) + .build() + .create(NavidromeService::class.java) + } + + private fun getBaseUrl(): String { + val server = Preferences.getServer() ?: "" + return if (server.endsWith("/")) server else "$server/" + } + + @Synchronized + private fun authenticate(forceRefresh: Boolean = false): String? { + if (!forceRefresh && !jwtToken.isNullOrBlank()) { + return jwtToken + } + + val username = Preferences.getUser() ?: return null + val password = Preferences.getPassword() ?: return null + + try { + val response = service.login(NavidromeCredentials(username, password)).execute() + if (response.isSuccessful && response.body()?.token != null) { + jwtToken = "Bearer ${response.body()!!.token}" + return jwtToken + } + } catch (_: Exception) { + } + + jwtToken = null + return null + } + + fun getRecentlyPlayedSongs(count: Int): List { + val songs = executeWithAuth { token -> + service.getSongs( + auth = token, + sort = "play_date", + order = "DESC", + start = 0, + end = count, + recentlyPlayed = true + ) + } ?: return emptyList() + + return songs.map { it.toChild() } + } + + fun getRecentlyPlayedArtists(count: Int): List { + val artists = executeWithAuth { token -> + service.getArtists( + auth = token, + sort = "play_date", + order = "DESC", + start = 0, + end = count, + recentlyPlayed = true + ) + } ?: return emptyList() + + return artists.map { it.toArtistID3() } + } + + fun getTopPlayedArtists(count: Int): List { + val artists = executeWithAuth { token -> + service.getArtistsSorted( + auth = token, + sort = "play_count", + order = "DESC", + start = 0, + end = count + ) + } ?: return emptyList() + + return artists.map { it.toArtistID3() } + } + + fun getTopPlayedSongs(count: Int): List { + val songs = executeWithAuth { token -> + service.getSongsSorted( + auth = token, + sort = "play_count", + order = "DESC", + start = 0, + end = count + ) + } ?: return emptyList() + + return songs.map { it.toChild() } + } + + private fun executeWithAuth(request: (String) -> Call): T? { + val firstToken = authenticate() ?: return null + val firstResponse = try { + request(firstToken).execute() + } catch (_: Exception) { + return null + } + + if (firstResponse.isSuccessful) { + return firstResponse.body() + } + + if (firstResponse.code() != 401 && firstResponse.code() != 403) { + return null + } + + val refreshedToken = authenticate(forceRefresh = true) ?: return null + val retryResponse = try { + request(refreshedToken).execute() + } catch (_: Exception) { + return null + } + + if (retryResponse.isSuccessful) { + return retryResponse.body() + } + + return null + } + + private fun NavidromeArtist.toArtistID3(): ArtistID3 { + return ArtistID3( + id = id, + name = name, + coverArtId = coverArtId ?: "ar-$id", + albumCount = albumCount ?: 0, + starred = if (starred == true) Date() else null + ) + } + + private fun NavidromeSong.toChild(): Child { + return Child( + id = id, + title = title, + album = album, + artist = artist, + albumId = albumId, + artistId = artistId, + duration = duration?.toInt(), + bitrate = bitRate, + size = size, + suffix = suffix, + contentType = contentType, + path = path, + genre = genre, + year = year, + track = trackNumber, + discNumber = discNumber, + playCount = playCount, + coverArtId = coverArtId ?: id, + userRating = rating, + starred = if (starred == true) Date() else null + ) + } +} diff --git a/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeModels.kt b/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeModels.kt new file mode 100644 index 00000000..6a4303a2 --- /dev/null +++ b/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeModels.kt @@ -0,0 +1,61 @@ +package com.elzify.music.subsonic.api.navidrome + +import androidx.annotation.Keep + +@Keep +data class NavidromeCredentials( + val username: String, + val password: String +) + +@Keep +data class NavidromeLoginResponse( + val id: String?, + val name: String?, + val token: String?, + val subsonicSalt: String?, + val subsonicToken: String? +) + +@Keep +data class NavidromeArtist( + val id: String, + val name: String?, + val albumCount: Int?, + val playCount: Long?, + val playDate: String?, + val songCount: Int?, + val size: Long?, + val coverArtId: String?, + val starred: Boolean?, + val starredAt: String?, + val orderArtistName: String? +) + +@Keep +data class NavidromeSong( + val id: String, + val title: String?, + val album: String?, + val artist: String?, + val albumId: String?, + val artistId: String?, + val albumArtist: String?, + val albumArtistId: String?, + val duration: Double?, + val bitRate: Int?, + val size: Long?, + val suffix: String?, + val contentType: String?, + val path: String?, + val genre: String?, + val year: Int?, + val trackNumber: Int?, + val discNumber: Int?, + val playCount: Long?, + val playDate: String?, + val coverArtId: String?, + val starred: Boolean?, + val starredAt: String?, + val rating: Int? +) diff --git a/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeService.kt b/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeService.kt new file mode 100644 index 00000000..f2691266 --- /dev/null +++ b/app/src/main/java/com/elzify/music/subsonic/api/navidrome/NavidromeService.kt @@ -0,0 +1,51 @@ +package com.elzify.music.subsonic.api.navidrome + +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query + +interface NavidromeService { + @POST("auth/login") + fun login(@Body credentials: NavidromeCredentials): Call + + @GET("api/song") + fun getSongs( + @Header("X-ND-Authorization") auth: String, + @Query("_sort") sort: String, + @Query("_order") order: String, + @Query("_start") start: Int, + @Query("_end") end: Int, + @Query("recently_played") recentlyPlayed: Boolean + ): Call> + + @GET("api/song") + fun getSongsSorted( + @Header("X-ND-Authorization") auth: String, + @Query("_sort") sort: String, + @Query("_order") order: String, + @Query("_start") start: Int, + @Query("_end") end: Int + ): Call> + + @GET("api/artist") + fun getArtists( + @Header("X-ND-Authorization") auth: String, + @Query("_sort") sort: String, + @Query("_order") order: String, + @Query("_start") start: Int, + @Query("_end") end: Int, + @Query("recently_played") recentlyPlayed: Boolean + ): Call> + + @GET("api/artist") + fun getArtistsSorted( + @Header("X-ND-Authorization") auth: String, + @Query("_sort") sort: String, + @Query("_order") order: String, + @Query("_start") start: Int, + @Query("_end") end: Int + ): Call> +} diff --git a/app/src/main/java/com/elzify/music/subsonic/api/open/OpenClient.java b/app/src/main/java/com/elzify/music/subsonic/api/open/OpenClient.java index 5d863604..710e4e8f 100644 --- a/app/src/main/java/com/elzify/music/subsonic/api/open/OpenClient.java +++ b/app/src/main/java/com/elzify/music/subsonic/api/open/OpenClient.java @@ -23,4 +23,9 @@ public Call getLyricsBySongId(String id) { Log.d(TAG, "getLyricsBySongId()"); return openService.getLyricsBySongId(subsonic.getParams(), id); } + + public Call getRecentlyPlayed(int count) { + Log.d(TAG, "getRecentlyPlayed()"); + return openService.getRecentlyPlayed(subsonic.getParams(), count); + } } diff --git a/app/src/main/java/com/elzify/music/subsonic/api/open/OpenService.java b/app/src/main/java/com/elzify/music/subsonic/api/open/OpenService.java index edd2218f..d0f63a9d 100644 --- a/app/src/main/java/com/elzify/music/subsonic/api/open/OpenService.java +++ b/app/src/main/java/com/elzify/music/subsonic/api/open/OpenService.java @@ -12,4 +12,7 @@ public interface OpenService { @GET("getLyricsBySongId") Call getLyricsBySongId(@QueryMap Map params, @Query("id") String id); + + @GET("getRecentlyPlayed") + Call getRecentlyPlayed(@QueryMap Map params, @Query("count") int count); } diff --git a/app/src/main/java/com/elzify/music/subsonic/models/ArtistWithAlbumsID3.kt b/app/src/main/java/com/elzify/music/subsonic/models/ArtistWithAlbumsID3.kt index 73f001c8..eddcb39e 100644 --- a/app/src/main/java/com/elzify/music/subsonic/models/ArtistWithAlbumsID3.kt +++ b/app/src/main/java/com/elzify/music/subsonic/models/ArtistWithAlbumsID3.kt @@ -10,4 +10,6 @@ import kotlinx.parcelize.Parcelize class ArtistWithAlbumsID3( @SerializedName("album") var albums: List? = null, + @SerializedName("appearsOn") + var appearsOn: List? = null, ) : ArtistID3(), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/elzify/music/subsonic/models/Playlist.kt b/app/src/main/java/com/elzify/music/subsonic/models/Playlist.kt index c43d836b..6a505048 100644 --- a/app/src/main/java/com/elzify/music/subsonic/models/Playlist.kt +++ b/app/src/main/java/com/elzify/music/subsonic/models/Playlist.kt @@ -22,10 +22,15 @@ open class Playlist( var name: String? = null, @ColumnInfo(name = "duration") var duration: Long = 0, + @ColumnInfo(name = "songCount") + var songCount: Int = 0, @SerializedName("coverArt") @ColumnInfo(name = "coverArt") var coverArtId: String? = null, ) : Parcelable { + @Ignore + @IgnoredOnParcel + var isPinned: Boolean = false @Ignore @IgnoredOnParcel var comment: String? = null @@ -38,9 +43,6 @@ open class Playlist( var isUniversal: Boolean? = null @Ignore @IgnoredOnParcel - var songCount: Int = 0 - @Ignore - @IgnoredOnParcel var created: Date? = null @Ignore @IgnoredOnParcel @@ -61,11 +63,10 @@ open class Playlist( changed: Date?, coverArtId: String?, allowedUsers: List?, - ) : this(id, name, duration, coverArtId) { + ) : this(id, name, duration, songCount, coverArtId) { this.comment = comment this.owner = owner this.isUniversal = isUniversal - this.songCount = songCount this.created = created this.changed = changed this.allowedUsers = allowedUsers diff --git a/app/src/main/java/com/elzify/music/subsonic/models/SubsonicResponse.kt b/app/src/main/java/com/elzify/music/subsonic/models/SubsonicResponse.kt index bb3b65d6..a7bb94bd 100644 --- a/app/src/main/java/com/elzify/music/subsonic/models/SubsonicResponse.kt +++ b/app/src/main/java/com/elzify/music/subsonic/models/SubsonicResponse.kt @@ -21,6 +21,7 @@ class SubsonicResponse { var newestPodcasts: NewestPodcasts? = null var podcasts: Podcasts? = null var lyrics: Lyrics? = null + var recentlyPlayed: Songs? = null var songsByGenre: Songs? = null var randomSongs: Songs? = null var albumList2: AlbumList2? = null diff --git a/app/src/main/java/com/elzify/music/ui/activity/MainActivity.java b/app/src/main/java/com/elzify/music/ui/activity/MainActivity.java index bc3344b5..2d227a4b 100644 --- a/app/src/main/java/com/elzify/music/ui/activity/MainActivity.java +++ b/app/src/main/java/com/elzify/music/ui/activity/MainActivity.java @@ -2,28 +2,34 @@ import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.res.Configuration; +import android.graphics.Rect; +import android.content.IntentFilter; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; import android.text.TextUtils; +import android.util.Log; import android.view.View; -import android.widget.FrameLayout; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.core.splashscreen.SplashScreen; -import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; -import androidx.media3.common.MimeTypes; import androidx.media3.common.Player; +import androidx.media3.common.MimeTypes; import androidx.media3.common.util.UnstableApi; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; +import androidx.navigation.ui.NavigationUI; import com.elzify.music.App; import com.elzify.music.BuildConfig; @@ -31,12 +37,8 @@ import com.elzify.music.broadcast.receiver.ConnectivityStatusBroadcastReceiver; import com.elzify.music.databinding.ActivityMainBinding; import com.elzify.music.github.utils.UpdateUtil; -import com.elzify.music.navigation.NavigationController; -import com.elzify.music.navigation.NavigationHelper; import com.elzify.music.service.MediaManager; import com.elzify.music.ui.activity.base.BaseActivity; -import com.elzify.music.ui.controller.BottomSheetController; -import com.elzify.music.ui.controller.BottomSheetHelper; import com.elzify.music.ui.dialog.ConnectionAlertDialog; import com.elzify.music.ui.dialog.GithubTempoUpdateDialog; import com.elzify.music.ui.dialog.ServerUnreachableDialog; @@ -45,13 +47,14 @@ import com.elzify.music.util.AssetLinkUtil; import com.elzify.music.util.Constants; import com.elzify.music.util.Preferences; +import com.elzify.music.util.UIUtil; import com.elzify.music.viewmodel.MainViewModel; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.color.DynamicColors; -import com.google.android.material.navigation.NavigationView; import com.google.common.util.concurrent.MoreExecutors; +import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; @@ -64,29 +67,29 @@ public class MainActivity extends BaseActivity { private FragmentManager fragmentManager; private NavHostFragment navHostFragment; - private BottomNavigationView bottomNavigationView; - private FrameLayout bottomNavigationViewFrame; - private DrawerLayout drawerLayout; - private NavigationView navigationView; public NavController navController; - private NavigationController navigationController; - private BottomSheetController bottomSheetController; - public BottomSheetBehavior bottomSheetBehavior; - public boolean isLandscape = false; + private BottomSheetBehavior bottomSheetBehavior; + private boolean isLandscape = false; private AssetLinkNavigator assetLinkNavigator; private AssetLinkUtil.AssetLink pendingAssetLink; + private ViewGroup dockContainer; ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver; private Intent pendingDownloadPlaybackIntent; - public ActivityMainBinding getBinding() { - return bind; - } - @Override protected void onCreate(Bundle savedInstanceState) { SplashScreen.installSplashScreen(this); - DynamicColors.applyToActivityIfAvailable(this); + + int accentColor = Preferences.getAccentColor(); + if (accentColor != -1) { + com.google.android.material.color.DynamicColors.applyToActivityIfAvailable(this, + new com.google.android.material.color.DynamicColorsOptions.Builder() + .setContentBasedSource(android.graphics.Bitmap.createBitmap(new int[]{accentColor}, 1, 1, android.graphics.Bitmap.Config.ARGB_8888)) + .build()); + } else { + DynamicColors.applyToActivityIfAvailable(this); + } super.onCreate(savedInstanceState); @@ -122,7 +125,6 @@ protected void onStart() { protected void onResume() { super.onResume(); pingServer(); - toggleNavigationDrawerLockOnOrientationChange(); } @Override @@ -149,6 +151,7 @@ public void onBackPressed() { } public void init() { + fragmentManager = getSupportFragmentManager(); initBottomSheet(); initNavigation(); @@ -158,79 +161,51 @@ public void init() { } else { goToLogin(); } - - toggleNavigationDrawerLockOnOrientationChange(); - - } - - private void initNavigation() { - // We link the nav_graph.xml with our navigationController - NavHostFragment navHostFragment = (NavHostFragment) this - .getSupportFragmentManager() - .findFragmentById(R.id.nav_host_fragment); - navController = Objects.requireNonNull(navHostFragment).getNavController(); - /* - navController is currently global since some legacy code still invokes it directly - the MainActivity methods that use it must be converted to NavigationHelper methods - */ - - // Helper - NavigationHelper navigationHelper = - new NavigationHelper( - findViewById(R.id.bottom_navigation), - findViewById(R.id.bottom_navigation_frame), - findViewById(R.id.drawer_layout), - findViewById(R.id.nav_view), - navHostFragment - ); - - // Controller - navigationController = new NavigationController(navigationHelper); - navigationController.syncWithBottomSheetBehavior(bottomSheetBehavior, navController); } + // BOTTOM SHEET/NAVIGATION private void initBottomSheet() { - FragmentManager fragmentManager = getSupportFragmentManager(); - View bottomSheetView = findViewById(R.id.player_bottom_sheet); - bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetView); - /* - bottomSheetBehavior is currently global since some legacy code still invokes it directly - the MainActivity methods that use it must be converted to BottomSheetHelper methods - */ - - // Helper - BottomSheetHelper bottomSheetHelper = - new BottomSheetHelper( - bottomSheetBehavior, - bottomSheetView, - fragmentManager - ); - - // Controller - bottomSheetController = new BottomSheetController(bottomSheetHelper); - bottomSheetController.addCallback(bottomSheetCallback); - bottomSheetController.replaceFragment(R.id.player_bottom_sheet); - bottomSheetController.checkAfterStateChanged(mainViewModel); + bottomSheetBehavior = BottomSheetBehavior.from(findViewById(R.id.player_bottom_sheet)); + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); + fragmentManager.beginTransaction().replace(R.id.player_bottom_sheet, new PlayerBottomSheetFragment(), "PlayerBottomSheet").commit(); + + checkBottomSheetAfterStateChanged(); } public void setBottomSheetInPeek(Boolean isVisible) { - bottomSheetController.setStateInPeek(isVisible); + if (isVisible) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } else { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } } public void setBottomSheetVisibility(boolean visibility) { - bottomSheetController.setVisibility(visibility); + if (visibility) { + findViewById(R.id.player_bottom_sheet).setVisibility(View.VISIBLE); + } else { + findViewById(R.id.player_bottom_sheet).setVisibility(View.GONE); + } + } + + private void checkBottomSheetAfterStateChanged() { + final Handler handler = new Handler(); + final Runnable runnable = () -> setBottomSheetInPeek(mainViewModel.isQueueLoaded()); + handler.postDelayed(runnable, 100); } public void collapseBottomSheetDelayed() { - bottomSheetController.collapseDelayed(); + final Handler handler = new Handler(); + final Runnable runnable = () -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + handler.postDelayed(runnable, 100); } public void expandBottomSheet() { - bottomSheetController.expand(); + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); } public void setBottomSheetDraggableState(Boolean isDraggable) { - bottomSheetController.setDraggable(isDraggable); + bottomSheetBehavior.setDraggable(isDraggable); } private final BottomSheetBehavior.BottomSheetCallback bottomSheetCallback = @@ -243,14 +218,23 @@ public void onStateChanged(@NonNull View view, int state) { switch (state) { case BottomSheetBehavior.STATE_HIDDEN: - resetMusicSession(); // I can't put the callback inside BottomSheetHelper because of this line + resetMusicSession(); + applyPlayerSystemBarColors(false); break; case BottomSheetBehavior.STATE_COLLAPSED: - if (playerBottomSheetFragment != null) + if (playerBottomSheetFragment != null) { playerBottomSheetFragment.goBackToFirstPage(); + playerBottomSheetFragment.setBodyVisibility(false); + } + applyPlayerSystemBarColors(false); break; - case BottomSheetBehavior.STATE_SETTLING: case BottomSheetBehavior.STATE_EXPANDED: + if (playerBottomSheetFragment != null) { + playerBottomSheetFragment.setBodyVisibility(true); + } + applyPlayerSystemBarColors(true); + break; + case BottomSheetBehavior.STATE_SETTLING: case BottomSheetBehavior.STATE_DRAGGING: case BottomSheetBehavior.STATE_HALF_EXPANDED: break; @@ -263,75 +247,191 @@ public void onSlide(@NonNull View view, float slideOffset) { if (!isLandscape) { animateBottomNavigation(slideOffset, navigationHeight); } - } - }; + } + }; + + private void applyPlayerSystemBarColors(boolean playerExpanded) { + if (playerExpanded) { + applySystemBarColors(UIUtil.getPlayerBackgroundColor(this)); + } else { + applySystemBarColors(); + } + } private void animateBottomSheet(float slideOffset) { - bottomSheetController.animate(slideOffset); + PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); + if (playerBottomSheetFragment != null) { + float condensedSlideOffset = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f; + playerBottomSheetFragment.getPlayerHeader().setAlpha(1 - condensedSlideOffset); + playerBottomSheetFragment.getPlayerHeader().setVisibility(condensedSlideOffset > 0.99 ? View.GONE : View.VISIBLE); + + // Show body during slide if expanding + if (slideOffset > 0.01) { + playerBottomSheetFragment.setBodyVisibility(true); + } else if (slideOffset <= 0) { + playerBottomSheetFragment.setBodyVisibility(false); + } + } } private void animateBottomNavigation(float slideOffset, int navigationHeight) { if (slideOffset < 0) return; if (navigationHeight == 0) { - navigationHeight = bind.bottomNavigation.getHeight(); + navigationHeight = bind.navigationDock.dockCard.getHeight() + 200; } - float slideY = navigationHeight - navigationHeight * (1 - slideOffset); + float slideY = navigationHeight * slideOffset; - bind.bottomNavigation.setTranslationY(slideY); + bind.navigationDock.dockCard.setTranslationY(slideY); } - public void setBottomNavigationBarVisibility(boolean visibility) { - navigationController.setNavbarVisibility(visibility); + private void initNavigation() { + dockContainer = bind.navigationDock.dockItemsContainer; + navHostFragment = (NavHostFragment) fragmentManager.findFragmentById(R.id.nav_host_fragment); + navController = Objects.requireNonNull(navHostFragment).getNavController(); + + navController.addOnDestinationChangedListener((controller, destination, arguments) -> { + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED && ( + destination.getId() == R.id.homeFragment || + destination.getId() == R.id.libraryFragment || + destination.getId() == R.id.downloadFragment || + destination.getId() == R.id.albumCatalogueFragment || + destination.getId() == R.id.playlistCatalogueFragment || + destination.getId() == R.id.searchFragment) + ) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } + updateDockActiveState(destination.getId()); + }); + + bind.navigationDock.dockCard.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + int width = right - left; + if (width > 0) { + PlayerBottomSheetFragment fragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); + if (fragment != null) { + if (isLandscape) { + int screenWidth = getResources().getDisplayMetrics().widthPixels; + int sideMargin = UIUtil.dpToPx(this, 24); + int minWidth = UIUtil.dpToPx(this, 420); + int availableWidth = screenWidth - width - (sideMargin * 2); + fragment.setMiniPlayerWidth(Math.max(minWidth, availableWidth)); + } else { + fragment.setMiniPlayerWidth(width + 100); + } + } + } + }); + + setupDock(); } - public void toggleBottomNavigationBarVisibilityOnOrientationChange() { - float displayDensity = getResources().getDisplayMetrics().density; - // Ignore orientation change, bottom navbar always hidden - if (Preferences.getHideBottomNavbarOnPortrait()) { - navigationController.setNavbarVisibility(false); - bottomSheetController.setPeekHeight(56, displayDensity); - navigationController.setSystemBarsVisibility(this, !isLandscape); - return; + private void setupDock() { + dockContainer.removeAllViews(); + if (dockContainer instanceof LinearLayout) { + ((LinearLayout) dockContainer).setOrientation(isLandscape ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); } - - if (!isLandscape) { - // Show app navbar + show system bars - bottomSheetController.setPeekHeight(136, displayDensity); - navigationController.setNavbarVisibility(true); - navigationController.setSystemBarsVisibility(this, true); - } else { - // Hide app navbar + hide system bars - bottomSheetController.setPeekHeight(56, displayDensity); - navigationController.setNavbarVisibility(false); - navigationController.setSystemBarsVisibility(this, false); + List items = new java.util.ArrayList<>(Preferences.getDockItems()); + + // Ensure mandatory items are present if they were somehow excluded in saved prefs + if (!items.contains(Constants.DOCK_ITEM_HOME)) items.add(Constants.DOCK_ITEM_HOME); + if (!items.contains(Constants.DOCK_ITEM_SEARCH)) items.add(Constants.DOCK_ITEM_SEARCH); + if (!items.contains(Constants.DOCK_ITEM_SETTINGS)) items.add(Constants.DOCK_ITEM_SETTINGS); + + for (String item : items) { + View dockItemView = getLayoutInflater().inflate(R.layout.item_dock_nav, dockContainer, false); + ImageView icon = dockItemView.findViewById(R.id.dock_item_icon); + TextView label = dockItemView.findViewById(R.id.dock_item_label); + + int fragmentId = getFragmentId(item); + icon.setImageResource(getDockIcon(item)); + label.setText(getDockLabel(item)); + + dockItemView.setOnClickListener(v -> { + if (navController.getCurrentDestination() == null || navController.getCurrentDestination().getId() != fragmentId) { + navController.navigate(fragmentId); + } + }); + dockItemView.setTag(fragmentId); + dockContainer.addView(dockItemView); + } + updateDockActiveState(navController.getCurrentDestination() != null ? navController.getCurrentDestination().getId() : -1); + } + + private String getDockLabel(String item) { + switch (item) { + case Constants.DOCK_ITEM_LIBRARY: return "Library"; + case Constants.DOCK_ITEM_DOWNLOADS: return "Downloads"; + case Constants.DOCK_ITEM_ALBUMS: return "Albums"; + case Constants.DOCK_ITEM_PLAYLISTS: return "Playlists"; + case Constants.DOCK_ITEM_SEARCH: return "Search"; + case Constants.DOCK_ITEM_SETTINGS: return "Settings"; + default: return "Home"; } } - public void setNavigationDrawerLock(boolean locked) { - navigationController.setDrawerLock(locked); + private int getFragmentId(String item) { + switch (item) { + case Constants.DOCK_ITEM_LIBRARY: return R.id.libraryFragment; + case Constants.DOCK_ITEM_DOWNLOADS: return R.id.downloadFragment; + case Constants.DOCK_ITEM_ALBUMS: return R.id.albumCatalogueFragment; + case Constants.DOCK_ITEM_PLAYLISTS: return R.id.playlistCatalogueFragment; + case Constants.DOCK_ITEM_SEARCH: return R.id.searchFragment; + case Constants.DOCK_ITEM_SETTINGS: return R.id.settingsFragment; + default: return R.id.homeFragment; + } } - public boolean isNavigationDrawerLocked() { - return navigationController.isNavigationDrawerLocked(); + private int getDockIcon(String item) { + switch (item) { + case Constants.DOCK_ITEM_LIBRARY: return R.drawable.ic_graphic_eq; + case Constants.DOCK_ITEM_DOWNLOADS: return R.drawable.ic_file_download; + case Constants.DOCK_ITEM_ALBUMS: return R.drawable.ic_placeholder_album; + case Constants.DOCK_ITEM_PLAYLISTS: return R.drawable.ic_playlist_add; + case Constants.DOCK_ITEM_SEARCH: return R.drawable.ic_search; + case Constants.DOCK_ITEM_SETTINGS: return R.drawable.ic_settings; + default: return R.drawable.ic_home; + } } - public void toggleNavigationDrawerLockOnOrientationChange() { - navigationController.toggleDrawerLockOnOrientation(this); + private void updateDockActiveState(int activeId) { + int colorOnPrimaryContainer = UIUtil.getThemeColor(this, com.google.android.material.R.attr.colorOnPrimaryContainer); + int colorOnSurfaceVariant = UIUtil.getThemeColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant); + int colorOnSurface = UIUtil.getThemeColor(this, com.google.android.material.R.attr.colorOnSurface); + + for (int i = 0; i < dockContainer.getChildCount(); i++) { + View child = dockContainer.getChildAt(i); + View root = child.findViewById(R.id.dock_item_root); + ImageView icon = child.findViewById(R.id.dock_item_icon); + TextView label = child.findViewById(R.id.dock_item_label); + + if (child.getTag() instanceof Integer && (Integer)child.getTag() == activeId) { + root.setBackgroundResource(R.drawable.bg_dock_item_selected); + icon.setAlpha(1.0f); + label.setAlpha(1.0f); + icon.setColorFilter(colorOnPrimaryContainer); + label.setTextColor(colorOnPrimaryContainer); + } else { + root.setBackground(null); + icon.setAlpha(0.6f); + label.setAlpha(0.6f); + icon.setColorFilter(colorOnSurfaceVariant); + label.setTextColor(colorOnSurface); + } + } } - public void setSystemBarsVisibility(boolean visibility) { - navigationController.setSystemBarsVisibility(this, visibility); + public void setBottomNavigationBarVisibility(boolean visibility) { + bind.navigationDock.dockCard.setVisibility(visibility ? View.VISIBLE : View.GONE); } - /* - There are only 4 init functions that must exist up to here - 1. init() - 2. initNavigation() - 3. initBottomSheet() - 4. bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { ... } - */ + public void toggleBottomNavigationBarVisibilityOnOrientationChange() { + if (!isLandscape && Preferences.getHideBottomNavbarOnPortrait()) { + setBottomNavigationBarVisibility(false); + } else { + setBottomNavigationBarVisibility(true); + } + } private void initService() { MediaManager.check(getMediaBrowserListenableFuture()); @@ -414,7 +514,6 @@ private void resetUserSession() { Preferences.setServer(null); Preferences.setLocalAddress(null); Preferences.setUser(null); - Preferences.setClientCert(null); // TODO Enter all settings to be reset Preferences.setOpenSubsonic(false); diff --git a/app/src/main/java/com/elzify/music/ui/activity/base/BaseActivity.java b/app/src/main/java/com/elzify/music/ui/activity/base/BaseActivity.java index 43cbff45..ac2763d1 100644 --- a/app/src/main/java/com/elzify/music/ui/activity/base/BaseActivity.java +++ b/app/src/main/java/com/elzify/music/ui/activity/base/BaseActivity.java @@ -6,12 +6,15 @@ import android.os.Build; import android.os.Bundle; import android.os.PowerManager; +import android.graphics.Color; import android.view.WindowManager; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.core.graphics.ColorUtils; +import androidx.core.view.WindowInsetsControllerCompat; import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.offline.DownloadService; import androidx.media3.session.MediaBrowser; @@ -22,6 +25,7 @@ import com.elzify.music.ui.dialog.BatteryOptimizationDialog; import com.elzify.music.util.Flavors; import com.elzify.music.util.Preferences; +import com.elzify.music.util.UIUtil; import com.google.android.material.elevation.SurfaceColors; import com.google.common.util.concurrent.ListenableFuture; @@ -44,10 +48,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override protected void onStart() { super.onStart(); - setNavigationBarColor(); + applySystemBarColors(); initializeBrowser(); } + @Override + protected void onResume() { + super.onResume(); + applySystemBarColors(); + } + @Override protected void onStop() { releaseBrowser(); @@ -80,6 +90,10 @@ private boolean detectBatteryOptimization() { return !powerManager.isIgnoringBatteryOptimizations(packageName); } + private void checkBatteryOptimizationDialog() { + // This was likely intended to be private or part of showBatteryOptimizationDialog + } + private void showBatteryOptimizationDialog() { BatteryOptimizationDialog dialog = new BatteryOptimizationDialog(); dialog.show(getSupportFragmentManager(), null); @@ -105,8 +119,30 @@ private void initializeDownloader() { } } - private void setNavigationBarColor() { - getWindow().setNavigationBarColor(SurfaceColors.getColorForElevation(this, 8)); - getWindow().setStatusBarColor(SurfaceColors.getColorForElevation(this, 0)); + protected void applySystemBarColors() { + applySystemBarColors(UIUtil.getSystemBarColor(this)); + } + + protected void applySystemBarColors(int rawColor) { + int systemBarColor = Color.rgb( + Color.red(rawColor), + Color.green(rawColor), + Color.blue(rawColor) + ); + + getWindow().setStatusBarColor(systemBarColor); + getWindow().setNavigationBarColor(systemBarColor); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + getWindow().setStatusBarContrastEnforced(false); + getWindow().setNavigationBarContrastEnforced(false); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + getWindow().setNavigationBarDividerColor(systemBarColor); + } + + WindowInsetsControllerCompat insetsController = new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView()); + boolean useDarkIcons = ColorUtils.calculateLuminance(systemBarColor) > 0.5; + insetsController.setAppearanceLightStatusBars(useDarkIcons); + insetsController.setAppearanceLightNavigationBars(useDarkIcons); } } diff --git a/app/src/main/java/com/elzify/music/ui/adapter/AlbumCarouselAdapter.java b/app/src/main/java/com/elzify/music/ui/adapter/AlbumCarouselAdapter.java new file mode 100644 index 00000000..a07944cc --- /dev/null +++ b/app/src/main/java/com/elzify/music/ui/adapter/AlbumCarouselAdapter.java @@ -0,0 +1,96 @@ +package com.elzify.music.ui.adapter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.elzify.music.databinding.ItemAlbumCarouselBinding; +import com.elzify.music.glide.CustomGlideRequest; +import com.elzify.music.interfaces.ClickCallback; +import com.elzify.music.subsonic.models.AlbumID3; +import com.elzify.music.util.Constants; + +import java.util.Collections; +import java.util.List; + +public class AlbumCarouselAdapter extends RecyclerView.Adapter { + private final ClickCallback click; + private final boolean isOffline; + + private List albums; + + public AlbumCarouselAdapter(ClickCallback click, boolean isOffline) { + this.click = click; + this.isOffline = isOffline; + this.albums = Collections.emptyList(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemAlbumCarouselBinding view = ItemAlbumCarouselBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + AlbumID3 album = albums.get(position); + + holder.item.albumNameLabel.setText(album.getName()); + holder.item.artistNameLabel.setText(album.getArtist()); + + CustomGlideRequest.Builder + .from(holder.itemView.getContext(), album.getCoverArtId(), CustomGlideRequest.ResourceType.Album) + .build() + .into(holder.item.albumCoverImageView); + } + + @Override + public int getItemCount() { + return albums.size(); + } + + public void setItems(List albums) { + this.albums = albums != null ? albums : Collections.emptyList(); + notifyDataSetChanged(); + } + + public AlbumID3 getItem(int position) { + return albums.get(position); + } + + public class ViewHolder extends RecyclerView.ViewHolder { + ItemAlbumCarouselBinding item; + + ViewHolder(ItemAlbumCarouselBinding item) { + super(item.getRoot()); + + this.item = item; + + item.albumNameLabel.setSelected(true); + item.artistNameLabel.setSelected(true); + + itemView.setOnClickListener(v -> onClick()); + itemView.setOnLongClickListener(v -> onLongClick()); + } + + private void onClick() { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ALBUM_OBJECT, albums.get(getBindingAdapterPosition())); + + click.onAlbumClick(bundle); + } + + private boolean onLongClick() { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ALBUM_OBJECT, albums.get(getBindingAdapterPosition())); + + click.onAlbumLongClick(bundle); + + return true; + } + } +} diff --git a/app/src/main/java/com/elzify/music/ui/adapter/PlayerSongQueueAdapter.java b/app/src/main/java/com/elzify/music/ui/adapter/PlayerSongQueueAdapter.java index fb323037..671fb403 100644 --- a/app/src/main/java/com/elzify/music/ui/adapter/PlayerSongQueueAdapter.java +++ b/app/src/main/java/com/elzify/music/ui/adapter/PlayerSongQueueAdapter.java @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; +import androidx.lifecycle.LifecycleOwner; import androidx.media3.session.MediaBrowser; import androidx.recyclerview.widget.RecyclerView; @@ -31,6 +32,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.Objects; @@ -115,23 +117,20 @@ public void onRecovery(int index) { holder.item.downloadIndicatorIcon.setVisibility(View.GONE); } - if (Preferences.showItemRating()) { - if (song.getStarred() == null && song.getUserRating() == null) { - holder.item.ratingIndicatorImageView.setVisibility(View.GONE); - } - + if (song.getStarred() == null && (song.getUserRating() == null || song.getUserRating() == 0)) { + holder.item.ratingIndicatorImageView.setVisibility(View.GONE); + } else { + holder.item.ratingIndicatorImageView.setVisibility(View.VISIBLE); holder.item.preferredIcon.setVisibility(song.getStarred() != null ? View.VISIBLE : View.GONE); - holder.item.ratingBarLayout.setVisibility(song.getUserRating() != null ? View.VISIBLE : View.GONE); + holder.item.ratingBarLayout.setVisibility(song.getUserRating() != null && song.getUserRating() > 0 ? View.VISIBLE : View.GONE); - if (song.getUserRating() != null) { + if (song.getUserRating() != null && song.getUserRating() > 0) { holder.item.oneStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 1 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); holder.item.twoStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 2 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); holder.item.threeStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 3 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); holder.item.fourStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 4 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); holder.item.fiveStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 5 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); } - } else { - holder.item.ratingIndicatorImageView.setVisibility(View.GONE); } holder.itemView.setOnClickListener(v -> { mediaBrowserListenableFuture.addListener(() -> { @@ -201,6 +200,32 @@ public void setMediaBrowserListenableFuture(ListenableFuture media this.mediaBrowserListenableFuture = mediaBrowserListenableFuture; } + public void observeMetadataEvents(LifecycleOwner owner) { + MediaManager.getFavoriteEvent().observe(owner, event -> { + if (event == null) return; + String songId = (String) event[0]; + Date starred = (Date) event[1]; + for (int i = 0; i < songs.size(); i++) { + if (songs.get(i).getId().equals(songId)) { + songs.get(i).setStarred(starred); + notifyItemChanged(i); + } + } + }); + + MediaManager.getRatingEvent().observe(owner, event -> { + if (event == null) return; + String songId = (String) event[0]; + int rating = (Integer) event[1]; + for (int i = 0; i < songs.size(); i++) { + if (songs.get(i).getId().equals(songId)) { + songs.get(i).setUserRating(rating); + notifyItemChanged(i); + } + } + }); + } + public void setPlaybackState(String mediaId, boolean playing) { String oldId = this.currentPlayingId; boolean oldPlaying = this.isPlaying; diff --git a/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogHorizontalAdapter.java b/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogHorizontalAdapter.java index 4224ce73..ddd0b68c 100644 --- a/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogHorizontalAdapter.java +++ b/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogHorizontalAdapter.java @@ -14,6 +14,7 @@ import com.elzify.music.util.Constants; import com.elzify.music.util.MusicUtil; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -21,10 +22,13 @@ public class PlaylistDialogHorizontalAdapter extends RecyclerView.Adapter playlists; + private List allPlaylists; + private final java.util.Set selectedIds = new java.util.HashSet<>(); public PlaylistDialogHorizontalAdapter(ClickCallback click) { this.click = click; this.playlists = Collections.emptyList(); + this.allPlaylists = Collections.emptyList(); } @NonNull @@ -40,6 +44,16 @@ public void onBindViewHolder(ViewHolder holder, int position) { holder.item.playlistDialogTitleTextView.setText(playlist.getName()); holder.item.playlistDialogCountTextView.setText(holder.itemView.getContext().getString(R.string.playlist_counted_tracks, playlist.getSongCount(), MusicUtil.getReadableDurationString(playlist.getDuration(), false))); + + holder.item.playlistDialogCheckbox.setOnCheckedChangeListener(null); + holder.item.playlistDialogCheckbox.setChecked(selectedIds.contains(playlist.getId())); + holder.item.playlistDialogCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + selectedIds.add(playlist.getId()); + } else { + selectedIds.remove(playlist.getId()); + } + }); } @Override @@ -47,11 +61,31 @@ public int getItemCount() { return playlists.size(); } + public List getSelectedIds() { + return new ArrayList<>(selectedIds); + } + public void setItems(List playlists) { + this.allPlaylists = playlists; this.playlists = playlists; notifyDataSetChanged(); } + public void filter(String query) { + if (query == null || query.isEmpty()) { + playlists = allPlaylists; + } else { + List filteredList = new ArrayList<>(); + for (Playlist playlist : allPlaylists) { + if (playlist.getName() != null && playlist.getName().toLowerCase().contains(query.toLowerCase())) { + filteredList.add(playlist); + } + } + playlists = filteredList; + } + notifyDataSetChanged(); + } + public Playlist getItem(int id) { return playlists.get(id); } @@ -66,14 +100,9 @@ public class ViewHolder extends RecyclerView.ViewHolder { item.playlistDialogTitleTextView.setSelected(true); - itemView.setOnClickListener(v -> onClick()); - } - - public void onClick() { - Bundle bundle = new Bundle(); - bundle.putParcelable(Constants.PLAYLIST_OBJECT, playlists.get(getBindingAdapterPosition())); - - click.onPlaylistClick(bundle); + itemView.setOnClickListener(v -> { + item.playlistDialogCheckbox.toggle(); + }); } } } diff --git a/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogSongHorizontalAdapter.java b/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogSongHorizontalAdapter.java index 008becdf..0de0c6cc 100644 --- a/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogSongHorizontalAdapter.java +++ b/app/src/main/java/com/elzify/music/ui/adapter/PlaylistDialogSongHorizontalAdapter.java @@ -36,6 +36,22 @@ public void onBindViewHolder(ViewHolder holder, int position) { holder.item.playlistDialogAlbumArtistTextView.setText(song.getArtist()); holder.item.playlistDialogSongDurationTextView.setText(MusicUtil.getReadableDurationString(song.getDuration(), false)); + if (song.getStarred() == null && (song.getUserRating() == null || song.getUserRating() == 0)) { + holder.item.ratingIndicatorImageView.setVisibility(android.view.View.GONE); + } else { + holder.item.ratingIndicatorImageView.setVisibility(android.view.View.VISIBLE); + holder.item.preferredIcon.setVisibility(song.getStarred() != null ? android.view.View.VISIBLE : android.view.View.GONE); + holder.item.ratingBarLayout.setVisibility(song.getUserRating() != null && song.getUserRating() > 0 ? android.view.View.VISIBLE : android.view.View.GONE); + + if (song.getUserRating() != null && song.getUserRating() > 0) { + holder.item.oneStarIcon.setImageDrawable(androidx.appcompat.content.res.AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 1 ? com.elzify.music.R.drawable.ic_star : com.elzify.music.R.drawable.ic_star_outlined)); + holder.item.twoStarIcon.setImageDrawable(androidx.appcompat.content.res.AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 2 ? com.elzify.music.R.drawable.ic_star : com.elzify.music.R.drawable.ic_star_outlined)); + holder.item.threeStarIcon.setImageDrawable(androidx.appcompat.content.res.AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 3 ? com.elzify.music.R.drawable.ic_star : com.elzify.music.R.drawable.ic_star_outlined)); + holder.item.fourStarIcon.setImageDrawable(androidx.appcompat.content.res.AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 4 ? com.elzify.music.R.drawable.ic_star : com.elzify.music.R.drawable.ic_star_outlined)); + holder.item.fiveStarIcon.setImageDrawable(androidx.appcompat.content.res.AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 5 ? com.elzify.music.R.drawable.ic_star : com.elzify.music.R.drawable.ic_star_outlined)); + } + } + CustomGlideRequest.Builder .from(holder.itemView.getContext(), song.getCoverArtId(), CustomGlideRequest.ResourceType.Song) .build() diff --git a/app/src/main/java/com/elzify/music/ui/adapter/PlaylistHorizontalAdapter.java b/app/src/main/java/com/elzify/music/ui/adapter/PlaylistHorizontalAdapter.java index aa43d1b7..52507001 100644 --- a/app/src/main/java/com/elzify/music/ui/adapter/PlaylistHorizontalAdapter.java +++ b/app/src/main/java/com/elzify/music/ui/adapter/PlaylistHorizontalAdapter.java @@ -34,13 +34,17 @@ protected FilterResults performFiltering(CharSequence constraint) { List filteredList = new ArrayList<>(); if (constraint == null || constraint.length() == 0) { - filteredList.addAll(playlistsFull); + synchronized (playlistsFull) { + filteredList.addAll(playlistsFull); + } } else { String filterPattern = constraint.toString().toLowerCase().trim(); - for (Playlist item : playlistsFull) { - if (item.getName().toLowerCase().contains(filterPattern)) { - filteredList.add(item); + synchronized (playlistsFull) { + for (Playlist item : playlistsFull) { + if (item.getName() != null && item.getName().toLowerCase().contains(filterPattern)) { + filteredList.add(item); + } } } } @@ -55,16 +59,15 @@ protected FilterResults performFiltering(CharSequence constraint) { @Override protected void publishResults(CharSequence constraint, FilterResults results) { playlists.clear(); - if (results.values != null) { - playlists.addAll((List) results.values); - } + if (results.count > 0) playlists.addAll((List) results.values); notifyDataSetChanged(); } }; public PlaylistHorizontalAdapter(ClickCallback click) { this.click = click; - this.playlists = Collections.emptyList(); + this.playlists = new ArrayList<>(); + this.playlistsFull = new ArrayList<>(); } @NonNull @@ -78,9 +81,12 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public void onBindViewHolder(ViewHolder holder, int position) { Playlist playlist = playlists.get(position); + holder.itemView.setTag(playlist.getId()); holder.item.playlistTitleTextView.setText(playlist.getName()); holder.item.playlistSubtitleTextView.setText(holder.itemView.getContext().getString(R.string.playlist_counted_tracks, playlist.getSongCount(), MusicUtil.getReadableDurationString(playlist.getDuration(), false))); + holder.item.playlistPinnedIcon.setVisibility(playlist.isPinned() ? android.view.View.VISIBLE : android.view.View.GONE); + CustomGlideRequest.Builder .from(holder.itemView.getContext(), playlist.getCoverArtId(), CustomGlideRequest.ResourceType.Playlist) .build() @@ -97,8 +103,27 @@ public Playlist getItem(int id) { } public void setItems(List playlists) { - this.playlists = playlists; - this.playlistsFull = new ArrayList<>(playlists); + if (playlists == null) return; + synchronized (playlistsFull) { + this.playlistsFull.clear(); + this.playlistsFull.addAll(playlists); + } + this.playlists.clear(); + this.playlists.addAll(this.playlistsFull); + sort(null); + notifyDataSetChanged(); + } + + public void setPinnedIds(List pinnedIds) { + if (pinnedIds == null) return; + synchronized (playlistsFull) { + for (Playlist playlist : playlistsFull) { + playlist.setPinned(pinnedIds.contains(playlist.getId())); + } + } + this.playlists.clear(); + this.playlists.addAll(this.playlistsFull); + sort(null); notifyDataSetChanged(); } @@ -140,15 +165,22 @@ public boolean onLongClick() { } public void sort(String order) { - switch (order) { - case Constants.PLAYLIST_ORDER_BY_NAME: - playlists.sort(Comparator.comparing(Playlist::getName)); - break; - case Constants.PLAYLIST_ORDER_BY_RANDOM: - Collections.shuffle(playlists); - break; + Comparator comparator = (p1, p2) -> Boolean.compare(p2.isPinned(), p1.isPinned()); + + if (order != null) { + switch (order) { + case Constants.PLAYLIST_ORDER_BY_NAME: + comparator = comparator.thenComparing(Playlist::getName); + break; + case Constants.PLAYLIST_ORDER_BY_RANDOM: + Collections.shuffle(playlists); + return; + } + } else { + comparator = comparator.thenComparing(Playlist::getName); } + playlists.sort(comparator); notifyDataSetChanged(); } } diff --git a/app/src/main/java/com/elzify/music/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/elzify/music/ui/adapter/SongHorizontalAdapter.java index c90e1392..e32fc942 100644 --- a/app/src/main/java/com/elzify/music/ui/adapter/SongHorizontalAdapter.java +++ b/app/src/main/java/com/elzify/music/ui/adapter/SongHorizontalAdapter.java @@ -1,7 +1,8 @@ package com.elzify.music.ui.adapter; +import android.app.Activity; +import android.graphics.drawable.Drawable; import android.os.Bundle; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -13,12 +14,15 @@ import androidx.lifecycle.LifecycleOwner; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import com.elzify.music.R; import com.elzify.music.databinding.ItemHorizontalTrackBinding; import com.elzify.music.glide.CustomGlideRequest; import com.elzify.music.interfaces.ClickCallback; +import com.elzify.music.service.DownloaderManager; +import com.elzify.music.service.MediaManager; import com.elzify.music.subsonic.models.AlbumID3; import com.elzify.music.subsonic.models.Child; import com.elzify.music.subsonic.models.DiscTitle; @@ -33,6 +37,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -54,6 +59,10 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter currentPlayingPositions = Collections.emptyList(); private ListenableFuture mediaBrowserListenableFuture; + private Drawable starDrawable; + private Drawable starOutlinedDrawable; + private DownloaderManager downloadTracker; + private final Filter filtering = new Filter() { @Override protected FilterResults performFiltering(CharSequence constraint) { @@ -80,8 +89,10 @@ protected FilterResults performFiltering(CharSequence constraint) { @Override protected void publishResults(CharSequence constraint, FilterResults results) { - songs = (List) results.values; - notifyDataSetChanged(); + List newSongs = (List) results.values; + DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new SongDiffCallback(songs, newSongs)); + songs = newSongs; + diffResult.dispatchUpdatesTo(SongHorizontalAdapter.this); for (int pos : currentPlayingPositions) { if (pos >= 0 && pos < songs.size()) { @@ -103,12 +114,59 @@ public SongHorizontalAdapter(LifecycleOwner lifecycleOwner, ClickCallback click, if (lifecycleOwner != null) { MappingUtil.observeExternalAudioRefresh(lifecycleOwner, this::handleExternalAudioRefresh); + + MediaManager.getFavoriteEvent().observe(lifecycleOwner, event -> { + if (event == null) return; + String songId = (String) event[0]; + Date starred = (Date) event[1]; + updateFavoriteInList(songId, starred); + }); + + MediaManager.getRatingEvent().observe(lifecycleOwner, event -> { + if (event == null) return; + String songId = (String) event[0]; + int rating = (Integer) event[1]; + updateRatingInList(songId, rating); + }); + } + } + + private void updateFavoriteInList(String songId, Date starred) { + for (int i = 0; i < songs.size(); i++) { + if (songs.get(i).getId().equals(songId)) { + songs.get(i).setStarred(starred); + notifyItemChanged(i); + } + } + for (Child c : songsFull) { + if (c.getId().equals(songId)) { + c.setStarred(starred); + } + } + } + + private void updateRatingInList(String songId, int rating) { + for (int i = 0; i < songs.size(); i++) { + if (songs.get(i).getId().equals(songId)) { + songs.get(i).setUserRating(rating); + notifyItemChanged(i); + } + } + for (Child c : songsFull) { + if (c.getId().equals(songId)) { + c.setUserRating(rating); + } } } @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (starDrawable == null) { + starDrawable = AppCompatResources.getDrawable(parent.getContext(), R.drawable.ic_star); + starOutlinedDrawable = AppCompatResources.getDrawable(parent.getContext(), R.drawable.ic_star_outlined); + downloadTracker = DownloadUtil.getDownloadTracker(parent.getContext()); + } ItemHorizontalTrackBinding view = ItemHorizontalTrackBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); return new ViewHolder(view); } @@ -142,7 +200,7 @@ public void onBindViewHolder(@NonNull ViewHolder holder, int position) { holder.item.trackNumberTextView.setText(MusicUtil.getReadableTrackNumber(holder.itemView.getContext(), song.getTrack())); if (Preferences.getDownloadDirectoryUri() == null) { - if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) { + if (downloadTracker != null && downloadTracker.isDownloaded(song.getId())) { holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE); } else { holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE); @@ -189,23 +247,21 @@ public void onBindViewHolder(@NonNull ViewHolder holder, int position) { } } - if (Preferences.showItemRating()) { - if (song.getStarred() == null && song.getUserRating() == null) { - holder.item.ratingIndicatorImageView.setVisibility(View.GONE); - } - + if (song.getStarred() == null && (song.getUserRating() == null || song.getUserRating() == 0)) { + holder.item.ratingIndicatorImageView.setVisibility(View.GONE); + } else { + holder.item.ratingIndicatorImageView.setVisibility(View.VISIBLE); holder.item.preferredIcon.setVisibility(song.getStarred() != null ? View.VISIBLE : View.GONE); - holder.item.ratingBarLayout.setVisibility(song.getUserRating() != null ? View.VISIBLE : View.GONE); - - if (song.getUserRating() != null) { - holder.item.oneStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 1 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); - holder.item.twoStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 2 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); - holder.item.threeStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 3 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); - holder.item.fourStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 4 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); - holder.item.fiveStarIcon.setImageDrawable(AppCompatResources.getDrawable(holder.itemView.getContext(), song.getUserRating() >= 5 ? R.drawable.ic_star : R.drawable.ic_star_outlined)); + holder.item.ratingBarLayout.setVisibility(song.getUserRating() != null && song.getUserRating() > 0 ? View.VISIBLE : View.GONE); + + if (song.getUserRating() != null && song.getUserRating() > 0) { + int rating = song.getUserRating(); + holder.item.oneStarIcon.setImageDrawable(rating >= 1 ? starDrawable : starOutlinedDrawable); + holder.item.twoStarIcon.setImageDrawable(rating >= 2 ? starDrawable : starOutlinedDrawable); + holder.item.threeStarIcon.setImageDrawable(rating >= 3 ? starDrawable : starOutlinedDrawable); + holder.item.fourStarIcon.setImageDrawable(rating >= 4 ? starDrawable : starOutlinedDrawable); + holder.item.fiveStarIcon.setImageDrawable(rating >= 5 ? starDrawable : starOutlinedDrawable); } - } else { - holder.item.ratingIndicatorImageView.setVisibility(View.GONE); } bindPlaybackState(holder, song); @@ -250,12 +306,6 @@ public int getItemCount() { public void setItems(List songs) { this.songsFull = songs != null ? songs : Collections.emptyList(); filtering.filter(currentFilter); - notifyDataSetChanged(); - } - - @Override - public int getItemViewType(int position) { - return position; } @Override @@ -338,17 +388,15 @@ public void onClick() { bundle.putInt(Constants.ITEM_POSITION, MusicUtil.getPlayableMediaPosition(songs, getBindingAdapterPosition())); if (tappedSong.getId().equals(currentPlayingId)) { - Log.i("SongHorizontalAdapter", "Tapping on currently playing song, toggling playback"); - try{ + try { MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); - Log.i("SongHorizontalAdapter", "MediaBrowser retrieved, isPlaying: " + isPlaying); if (isPlaying) { mediaBrowser.pause(); } else { mediaBrowser.play(); } } catch (ExecutionException | InterruptedException e) { - Log.e("SongHorizontalAdapter", "Error getting MediaBrowser", e); + Thread.currentThread().interrupt(); } } else { click.onMediaClick(bundle); @@ -367,22 +415,66 @@ private boolean onLongClick() { } public void sort(String order) { + List sorted = new ArrayList<>(songs); switch (order) { case Constants.MEDIA_BY_TITLE: - songs.sort(Comparator.comparing(Child::getTitle)); + sorted.sort(Comparator.comparing(Child::getTitle, String.CASE_INSENSITIVE_ORDER)); + break; + case Constants.MEDIA_BY_ARTIST: + sorted.sort(Comparator.comparing( + song -> song.getArtist() != null ? song.getArtist().split("[,/;&\u2022]")[0].trim() : "", + String.CASE_INSENSITIVE_ORDER + )); + break; + case Constants.MEDIA_DEFAULT_ORDER: + sorted = new ArrayList<>(songsFull); break; case Constants.MEDIA_MOST_RECENTLY_STARRED: - songs.sort(Comparator.comparing(Child::getStarred, Comparator.nullsLast(Comparator.reverseOrder()))); + sorted.sort(Comparator.comparing(Child::getStarred, Comparator.nullsLast(Comparator.reverseOrder()))); break; case Constants.MEDIA_LEAST_RECENTLY_STARRED: - songs.sort(Comparator.comparing(Child::getStarred, Comparator.nullsLast(Comparator.naturalOrder()))); + sorted.sort(Comparator.comparing(Child::getStarred, Comparator.nullsLast(Comparator.naturalOrder()))); break; } - notifyDataSetChanged(); + DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new SongDiffCallback(songs, sorted)); + songs = sorted; + diffResult.dispatchUpdatesTo(this); } public void setMediaBrowserListenableFuture(ListenableFuture mediaBrowserListenableFuture) { this.mediaBrowserListenableFuture = mediaBrowserListenableFuture; } + + private static class SongDiffCallback extends DiffUtil.Callback { + private final List oldList; + private final List newList; + + SongDiffCallback(List oldList, List newList) { + this.oldList = oldList; + this.newList = newList; + } + + @Override + public int getOldListSize() { return oldList.size(); } + + @Override + public int getNewListSize() { return newList.size(); } + + @Override + public boolean areItemsTheSame(int oldPos, int newPos) { + return oldList.get(oldPos).getId().equals(newList.get(newPos).getId()); + } + + @Override + public boolean areContentsTheSame(int oldPos, int newPos) { + Child o = oldList.get(oldPos); + Child n = newList.get(newPos); + return Objects.equals(o.getStarred(), n.getStarred()) + && Objects.equals(o.getUserRating(), n.getUserRating()) + && Objects.equals(o.getTitle(), n.getTitle()) + && Objects.equals(o.getArtist(), n.getArtist()) + && Objects.equals(o.getAlbum(), n.getAlbum()); + } + } } diff --git a/app/src/main/java/com/elzify/music/ui/dialog/HomeRearrangementDialog.java b/app/src/main/java/com/elzify/music/ui/dialog/HomeRearrangementDialog.java index 1333c429..3748cf3d 100644 --- a/app/src/main/java/com/elzify/music/ui/dialog/HomeRearrangementDialog.java +++ b/app/src/main/java/com/elzify/music/ui/dialog/HomeRearrangementDialog.java @@ -2,6 +2,7 @@ import android.app.Dialog; import android.os.Bundle; + import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; import androidx.lifecycle.ViewModelProvider; @@ -11,6 +12,7 @@ import com.elzify.music.R; import com.elzify.music.databinding.DialogHomeRearrangementBinding; +import com.elzify.music.interfaces.HomeRearrangementCallback; import com.elzify.music.ui.adapter.HomeSectorHorizontalAdapter; import com.elzify.music.viewmodel.HomeRearrangementViewModel; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -22,6 +24,14 @@ public class HomeRearrangementDialog extends DialogFragment { private DialogHomeRearrangementBinding bind; private HomeRearrangementViewModel homeRearrangementViewModel; private HomeSectorHorizontalAdapter homeSectorHorizontalAdapter; + private HomeRearrangementCallback callback; + + public HomeRearrangementDialog() { + } + + public HomeRearrangementDialog(HomeRearrangementCallback callback) { + this.callback = callback; + } @NonNull @Override @@ -59,11 +69,13 @@ private void setButtonAction() { alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { homeRearrangementViewModel.saveHomeSectorList(homeSectorHorizontalAdapter.getItems()); + if (callback != null) callback.onChanged(); dismiss(); }); alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { homeRearrangementViewModel.resetHomeSectorList(); + if (callback != null) callback.onChanged(); dismiss(); }); } diff --git a/app/src/main/java/com/elzify/music/ui/dialog/PlaylistChooserDialog.java b/app/src/main/java/com/elzify/music/ui/dialog/PlaylistChooserDialog.java index 8d64e77c..ad18c053 100644 --- a/app/src/main/java/com/elzify/music/ui/dialog/PlaylistChooserDialog.java +++ b/app/src/main/java/com/elzify/music/ui/dialog/PlaylistChooserDialog.java @@ -2,6 +2,8 @@ import android.app.Dialog; import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; import android.view.View; import android.widget.Toast; @@ -19,7 +21,7 @@ import com.elzify.music.viewmodel.PlaylistChooserViewModel; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -public class PlaylistChooserDialog extends DialogFragment implements ClickCallback { +public class PlaylistChooserDialog extends DialogFragment { private DialogPlaylistChooserBinding bind; private PlaylistChooserViewModel playlistChooserViewModel; private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter; @@ -27,7 +29,6 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { - DialogPlaylistChooserBinding.inflate(getLayoutInflater()); bind = DialogPlaylistChooserBinding.inflate(getLayoutInflater()); playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class); @@ -38,11 +39,23 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { ); bind.playlistChooserDialogCreateButton.setOnClickListener(v -> launchPlaylistEditor()); bind.playlistChooserDialogCancelButton.setOnClickListener(v -> dismiss()); + bind.playlistChooserDialogConfirmButton.setOnClickListener(v -> { + if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) { + java.util.List selectedIds = playlistDialogHorizontalAdapter.getSelectedIds(); + if (!selectedIds.isEmpty()) { + playlistChooserViewModel.addSongsToPlaylists(requireActivity(), getDialog(), selectedIds); + } else { + Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show(); + } + } else { + Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show(); + } + }); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()) + return new MaterialAlertDialogBuilder(requireContext()) .setView(bind.getRoot()) - .setTitle(R.string.playlist_chooser_dialog_title); - return builder.create(); + .setTitle(R.string.playlist_chooser_dialog_title) + .create(); } @Override @@ -83,7 +96,7 @@ private void initPlaylistView() { bind.playlistDialogRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.playlistDialogRecyclerView.setHasFixedSize(true); - playlistDialogHorizontalAdapter = new PlaylistDialogHorizontalAdapter(this); + playlistDialogHorizontalAdapter = new PlaylistDialogHorizontalAdapter(null); bind.playlistDialogRecyclerView.setAdapter(playlistDialogHorizontalAdapter); playlistChooserViewModel.getPlaylistList(requireActivity()).observe(requireActivity(), playlists -> { @@ -98,15 +111,20 @@ private void initPlaylistView() { } } }); - } - @Override - public void onPlaylistClick(Bundle bundle) { - if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) { - Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT); - playlistChooserViewModel.addSongsToPlaylist(this, getDialog(), playlist.getId()); - } else { - Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show(); - } + bind.playlistSearchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (playlistDialogHorizontalAdapter != null) { + playlistDialogHorizontalAdapter.filter(s.toString()); + } + } + + @Override + public void afterTextChanged(Editable s) {} + }); } } diff --git a/app/src/main/java/com/elzify/music/ui/dialog/TrackInfoDialog.java b/app/src/main/java/com/elzify/music/ui/dialog/TrackInfoDialog.java index 80de4ea7..8e70f262 100644 --- a/app/src/main/java/com/elzify/music/ui/dialog/TrackInfoDialog.java +++ b/app/src/main/java/com/elzify/music/ui/dialog/TrackInfoDialog.java @@ -11,6 +11,7 @@ import com.elzify.music.R; import com.elzify.music.databinding.DialogTrackInfoBinding; import com.elzify.music.glide.CustomGlideRequest; +import com.elzify.music.service.MediaManager; import com.elzify.music.util.AssetLinkUtil; import com.elzify.music.util.Constants; import com.elzify.music.util.MusicUtil; @@ -171,6 +172,9 @@ private void setTrackInfo() { bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder)); bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder))); bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder)); + String songId = mediaMetadata.extras.getString("id"); + long playCount = mediaMetadata.extras.getLong("playCount", 0) + MediaManager.getPlayCountIncrement(songId); + bind.playCountValueSector.setText(playCount != 0 ? String.valueOf(playCount) : getString(R.string.label_placeholder)); bindAssetLink(bind.titleValueSector, songLink); bindAssetLink(bind.albumValueSector, albumLink); diff --git a/app/src/main/java/com/elzify/music/ui/fragment/AlbumListPageFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/AlbumListPageFragment.java index 311579ea..8df6e551 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/AlbumListPageFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/AlbumListPageFragment.java @@ -93,6 +93,10 @@ private void init() { albumListPageViewModel.artist = requireArguments().getParcelable(Constants.ARTIST_OBJECT); albumListPageViewModel.title = Constants.ALBUM_FROM_ARTIST; bind.pageTitleLabel.setText(albumListPageViewModel.artist.getName()); + } else if (requireArguments().getParcelableArrayList(Constants.ALBUMS_OBJECT) != null) { + albumListPageViewModel.albums = requireArguments().getParcelableArrayList(Constants.ALBUMS_OBJECT); + albumListPageViewModel.title = requireArguments().getString(Constants.ALBUM_LIST_TITLE, ""); + bind.pageTitleLabel.setText(albumListPageViewModel.title); } } diff --git a/app/src/main/java/com/elzify/music/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/AlbumPageFragment.java index e4801a8a..b32c993d 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/AlbumPageFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/AlbumPageFragment.java @@ -336,8 +336,13 @@ private void initBackCover() { private void initSongsView() { albumPageViewModel.getAlbum().observe(getViewLifecycleOwner(), album -> { if (bind != null && album != null) { - bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); - bind.songRecyclerView.setHasFixedSize(true); + bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()) { + @Override + public boolean canScrollVertically() { + return false; + } + }); + bind.songRecyclerView.setHasFixedSize(false); songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album); bind.songRecyclerView.setAdapter(songHorizontalAdapter); diff --git a/app/src/main/java/com/elzify/music/ui/fragment/ArtistListPageFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/ArtistListPageFragment.java index 65cb1f09..c8b9ceb6 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/ArtistListPageFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/ArtistListPageFragment.java @@ -77,6 +77,12 @@ private void init() { } else if (requireArguments().getString(Constants.ARTIST_DOWNLOADED) != null) { artistListPageViewModel.title = Constants.ARTIST_DOWNLOADED; bind.pageTitleLabel.setText(R.string.artist_list_page_downloaded); + } else if (requireArguments().getString(Constants.ARTIST_RECENTLY_PLAYED) != null) { + artistListPageViewModel.title = Constants.ARTIST_RECENTLY_PLAYED; + bind.pageTitleLabel.setText(R.string.artist_list_page_recently_played); + } else if (requireArguments().getString(Constants.ARTIST_TOP_PLAYED) != null) { + artistListPageViewModel.title = Constants.ARTIST_TOP_PLAYED; + bind.pageTitleLabel.setText(R.string.artist_list_page_top_played); } } @@ -179,6 +185,8 @@ private void setArtistListPageSubtitle(List artists) { switch (artistListPageViewModel.title) { case Constants.ARTIST_STARRED: case Constants.ARTIST_DOWNLOADED: + case Constants.ARTIST_RECENTLY_PLAYED: + case Constants.ARTIST_TOP_PLAYED: bind.pageSubtitleLabel.setText(getString(R.string.generic_list_page_count, artists.size())); break; } @@ -190,6 +198,10 @@ private void setArtistListPageSorter() { case Constants.ARTIST_DOWNLOADED: bind.artistListSortImageView.setVisibility(View.VISIBLE); break; + case Constants.ARTIST_RECENTLY_PLAYED: + case Constants.ARTIST_TOP_PLAYED: + bind.artistListSortImageView.setVisibility(View.GONE); + break; } } diff --git a/app/src/main/java/com/elzify/music/ui/fragment/DockConfigurationFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/DockConfigurationFragment.java new file mode 100644 index 00000000..51ecb636 --- /dev/null +++ b/app/src/main/java/com/elzify/music/ui/fragment/DockConfigurationFragment.java @@ -0,0 +1,197 @@ +package com.elzify.music.ui.fragment; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.elzify.music.R; +import com.elzify.music.ui.activity.MainActivity; +import com.elzify.music.util.Constants; +import com.elzify.music.util.Preferences; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class DockConfigurationFragment extends Fragment { + + private RecyclerView recyclerView; + private DockAdapter adapter; + private MainActivity activity; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + activity = (MainActivity) getActivity(); + View view = inflater.inflate(R.layout.fragment_dock_configuration, container, false); + + androidx.appcompat.widget.Toolbar toolbar = view.findViewById(R.id.toolbar); + activity.setSupportActionBar(toolbar); + if (activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + toolbar.setNavigationOnClickListener(v -> activity.navController.navigateUp()); + + recyclerView = view.findViewById(R.id.dock_items_recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + + setupAdapter(); + + return view; + } + + private void setupAdapter() { + List currentItems = Preferences.getDockItems(); + List allItems = new ArrayList<>(Arrays.asList( + Constants.DOCK_ITEM_HOME, + Constants.DOCK_ITEM_SEARCH, + Constants.DOCK_ITEM_SETTINGS, + Constants.DOCK_ITEM_LIBRARY, + Constants.DOCK_ITEM_DOWNLOADS, + Constants.DOCK_ITEM_PLAYLISTS + )); + + // Reorder allItems to have current items first in their saved order + List orderedAllItems = new ArrayList<>(currentItems); + for (String item : allItems) { + if (!orderedAllItems.contains(item)) { + orderedAllItems.add(item); + } + } + + adapter = new DockAdapter(orderedAllItems, currentItems); + recyclerView.setAdapter(adapter); + + ItemTouchHelper touchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + int fromPos = viewHolder.getBindingAdapterPosition(); + int toPos = target.getBindingAdapterPosition(); + adapter.moveItem(fromPos, toPos); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + }); + touchHelper.attachToRecyclerView(recyclerView); + } + + @Override + public void onPause() { + super.onPause(); + Preferences.setDockItems(adapter.getSelectedItems()); + // Refresh dock in MainActivity + if (activity != null) { + activity.init(); // Re-init navigation logic + } + } + + private class DockAdapter extends RecyclerView.Adapter { + private final List items; + private final List selectedItems; + + public DockAdapter(List items, List selectedItems) { + this.items = items; + this.selectedItems = new ArrayList<>(selectedItems); + } + + public void moveItem(int fromPos, int toPos) { + Collections.swap(items, fromPos, toPos); + notifyItemMoved(fromPos, toPos); + } + + public List getSelectedItems() { + List result = new ArrayList<>(); + for (String item : items) { + if (selectedItems.contains(item)) { + result.add(item); + } + } + return result; + } + + @NonNull + @Override + public DockViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_dock_config, parent, false); + return new DockViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull DockViewHolder holder, int position) { + String item = items.get(position); + holder.name.setText(getItemDisplayName(item)); + holder.icon.setImageResource(getDockIcon(item)); + + boolean isMandatory = item.equals(Constants.DOCK_ITEM_HOME) || + item.equals(Constants.DOCK_ITEM_SEARCH) || + item.equals(Constants.DOCK_ITEM_SETTINGS); + + holder.checkBox.setEnabled(!isMandatory); + holder.checkBox.setChecked(selectedItems.contains(item) || isMandatory); + + holder.checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + if (!selectedItems.contains(item)) selectedItems.add(item); + } else { + selectedItems.remove(item); + } + }); + } + + @Override + public int getItemCount() { + return items.size(); + } + + private String getItemDisplayName(String item) { + switch (item) { + case Constants.DOCK_ITEM_LIBRARY: return "Library"; + case Constants.DOCK_ITEM_DOWNLOADS: return "Downloads"; + case Constants.DOCK_ITEM_PLAYLISTS: return "Playlists"; + case Constants.DOCK_ITEM_SEARCH: return "Search"; + case Constants.DOCK_ITEM_SETTINGS: return "Settings"; + default: return "Home"; + } + } + + private int getDockIcon(String item) { + switch (item) { + case Constants.DOCK_ITEM_LIBRARY: return R.drawable.ic_graphic_eq; + case Constants.DOCK_ITEM_DOWNLOADS: return R.drawable.ic_file_download; + case Constants.DOCK_ITEM_PLAYLISTS: return R.drawable.ic_placeholder_playlist; + case Constants.DOCK_ITEM_SEARCH: return R.drawable.ic_search; + case Constants.DOCK_ITEM_SETTINGS: return R.drawable.ic_settings; + default: return R.drawable.ic_home; + } + } + } + + private static class DockViewHolder extends RecyclerView.ViewHolder { + ImageView dragHandle, icon; + TextView name; + CheckBox checkBox; + + public DockViewHolder(@NonNull View itemView) { + super(itemView); + dragHandle = itemView.findViewById(R.id.item_drag_handle); + icon = itemView.findViewById(R.id.item_icon); + name = itemView.findViewById(R.id.item_name); + checkBox = itemView.findViewById(R.id.item_checkbox); + } + } +} diff --git a/app/src/main/java/com/elzify/music/ui/fragment/DownloadFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/DownloadFragment.java index 488ac744..20f6b4f6 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/DownloadFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/DownloadFragment.java @@ -103,7 +103,8 @@ private void initAppBar() { materialToolbar = bind.getRoot().findViewById(R.id.toolbar); activity.setSupportActionBar(materialToolbar); - Objects.requireNonNull(materialToolbar.getOverflowIcon()).setTint(requireContext().getResources().getColor(R.color.titleTextColor, null)); + materialToolbar.post(() -> materialToolbar.getMenu().clear()); + materialToolbar.setOverflowIcon(null); } private void initDownloadedView() { diff --git a/app/src/main/java/com/elzify/music/ui/fragment/HomeFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/HomeFragment.java index ddc87f17..372d63c5 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/HomeFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/HomeFragment.java @@ -64,17 +64,12 @@ public void onDestroyView() { } private void initAppBar() { - appBarLayout = bind.getRoot().findViewById(R.id.toolbar_fragment); materialToolbar = bind.getRoot().findViewById(R.id.toolbar); activity.setSupportActionBar(materialToolbar); - Objects.requireNonNull(materialToolbar.getOverflowIcon()).setTint(requireContext().getResources().getColor(R.color.titleTextColor, null)); + // Objects.requireNonNull(materialToolbar.getOverflowIcon()).setTint(requireContext().getResources().getColor(R.color.titleTextColor, null)); - tabLayout = new TabLayout(requireContext()); - tabLayout.setTabGravity(TabLayout.GRAVITY_FILL); - tabLayout.setTabMode(TabLayout.MODE_FIXED); - - appBarLayout.addView(tabLayout); + tabLayout = bind.homeTabLayout; } private void initHomePager() { diff --git a/app/src/main/java/com/elzify/music/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/HomeTabMusicFragment.java index 3a1b6409..8e9683ad 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/HomeTabMusicFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/HomeTabMusicFragment.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,6 +17,7 @@ import androidx.annotation.Nullable; import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; +import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; @@ -45,6 +47,7 @@ import com.elzify.music.subsonic.models.Share; import com.elzify.music.ui.activity.MainActivity; import com.elzify.music.ui.adapter.AlbumAdapter; +import com.elzify.music.ui.adapter.AlbumCarouselAdapter; import com.elzify.music.ui.adapter.AlbumHorizontalAdapter; import com.elzify.music.ui.adapter.ArtistAdapter; import com.elzify.music.ui.adapter.ArtistHorizontalAdapter; @@ -88,17 +91,31 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { private ArtistAdapter bestOfArtistAdapter; private SongHorizontalAdapter starredSongAdapter; private SongHorizontalAdapter topSongAdapter; + private SongHorizontalAdapter historyAdapter; private AlbumHorizontalAdapter starredAlbumAdapter; private ArtistHorizontalAdapter starredArtistAdapter; private AlbumAdapter recentlyAddedAlbumAdapter; private AlbumAdapter recentlyPlayedAlbumAdapter; private AlbumAdapter mostPlayedAlbumAdapter; - private AlbumHorizontalAdapter newReleasesAlbumAdapter; + private AlbumCarouselAdapter newReleasesAlbumAdapter; private YearAdapter yearAdapter; private PlaylistHorizontalAdapter playlistHorizontalAdapter; + private ArtistAdapter recentlyPlayedArtistAdapter; + private ArtistAdapter topPlayedArtistAdapter; + private SongHorizontalAdapter topPlayedSongAdapter; private ShareHorizontalAdapter shareHorizontalAdapter; private ListenableFuture mediaBrowserListenableFuture; + private final Handler scrobbleRefreshHandler = new Handler(Looper.getMainLooper()); + private final Runnable refreshRecentlyPlayedAlbumsRunnable = () -> { + if (homeViewModel == null || homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_LAST_PLAYED)) { + return; + } + LifecycleOwner owner = getViewLifecycleOwnerLiveData().getValue(); + if (owner != null) { + homeViewModel.refreshRecentlyPlayedAlbumList(owner); + } + }; @Nullable @Override @@ -119,27 +136,137 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + homeViewModel.refreshPinnedPlaylists(); initSyncStarredView(); initSyncStarredAlbumsView(); initSyncStarredArtistsView(); - initDiscoverSongSlideView(); - initSimilarSongView(); - initArtistRadio(); - initArtistBestOf(); - initStarredTracksView(); - initStarredAlbumsView(); - initStarredArtistsView(); - initMostPlayedAlbumView(); - initRecentPlayedAlbumView(); - initNewReleasesView(); - initYearSongView(); - initRecentAddedAlbumView(); - initTopSongsView(); - initPinnedPlaylistsView(); - initSharesView(); initHomeReorganizer(); reorder(); + observeScrobbleEvents(); + } + + private void initHistoryView() { + if (historyAdapter != null) return; + boolean isHidden = homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_HISTORY); + Log.d(TAG, "initHistoryView: isHidden = " + isHidden); + if (isHidden) return; + + bind.historyRecyclerView.setHasFixedSize(false); + + historyAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null); + bind.historyRecyclerView.setAdapter(historyAdapter); + setHistoryMediaBrowserListenableFuture(); + reapplyHistoryPlayback(); + homeViewModel.getHistory(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), historySongs -> { + Log.d(TAG, "initHistoryView: historySongs size = " + (historySongs != null ? historySongs.size() : "null")); + if (historySongs == null || historySongs.isEmpty()) { + if (bind != null) bind.historySector.setVisibility(View.GONE); + } else { + if (bind != null) bind.historySector.setVisibility(View.VISIBLE); + if (bind != null) + bind.historyRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(historySongs.size(), 5), GridLayoutManager.HORIZONTAL, false)); + + historyAdapter.setItems(historySongs); + reapplyHistoryPlayback(); + } + }); + + SnapHelper historySnapHelper = new PagerSnapHelper(); + historySnapHelper.attachToRecyclerView(bind.historyRecyclerView); + + bind.historyRecyclerView.addItemDecoration( + new DotsIndicatorDecoration( + getResources().getDimensionPixelSize(R.dimen.radius), + getResources().getDimensionPixelSize(R.dimen.radius) * 4, + getResources().getDimensionPixelSize(R.dimen.dots_height), + requireContext().getResources().getColor(R.color.titleTextColor, null), + requireContext().getResources().getColor(R.color.titleTextColor, null)) + ); + } + + private void initRecentlyPlayedArtistsView() { + if (recentlyPlayedArtistAdapter != null) return; + if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_RECENTLY_PLAYED_ARTISTS)) return; + + bind.recentlyPlayedArtistsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + bind.recentlyPlayedArtistsRecyclerView.setHasFixedSize(true); + + recentlyPlayedArtistAdapter = new ArtistAdapter(this, false, false); + bind.recentlyPlayedArtistsRecyclerView.setAdapter(recentlyPlayedArtistAdapter); + homeViewModel.getRecentlyPlayedArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), artists -> { + if (artists == null) { + if (bind != null) bind.recentlyPlayedArtistsSector.setVisibility(View.GONE); + } else { + if (bind != null) + bind.recentlyPlayedArtistsSector.setVisibility(!artists.isEmpty() ? View.VISIBLE : View.GONE); + + recentlyPlayedArtistAdapter.setItems(artists); + } + }); + + CustomLinearSnapHelper snapHelper = new CustomLinearSnapHelper(); + snapHelper.attachToRecyclerView(bind.recentlyPlayedArtistsRecyclerView); + } + + private void initTopPlayedArtistsView() { + if (topPlayedArtistAdapter != null) return; + if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_TOP_PLAYED_ARTISTS)) return; + + bind.topPlayedArtistsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + bind.topPlayedArtistsRecyclerView.setHasFixedSize(true); + + topPlayedArtistAdapter = new ArtistAdapter(this, false, false); + bind.topPlayedArtistsRecyclerView.setAdapter(topPlayedArtistAdapter); + homeViewModel.getTopPlayedArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), artists -> { + if (artists == null) { + if (bind != null) bind.topPlayedArtistsSector.setVisibility(View.GONE); + } else { + if (bind != null) + bind.topPlayedArtistsSector.setVisibility(!artists.isEmpty() ? View.VISIBLE : View.GONE); + + topPlayedArtistAdapter.setItems(artists); + } + }); + + CustomLinearSnapHelper snapHelper = new CustomLinearSnapHelper(); + snapHelper.attachToRecyclerView(bind.topPlayedArtistsRecyclerView); + } + + private void initTopPlayedSongsView() { + if (topPlayedSongAdapter != null) return; + if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_TOP_PLAYED_SONGS)) return; + + bind.topPlayedSongsRecyclerView.setHasFixedSize(false); + + topPlayedSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null); + bind.topPlayedSongsRecyclerView.setAdapter(topPlayedSongAdapter); + setTopPlayedSongsMediaBrowserListenableFuture(); + reapplyTopPlayedSongsPlayback(); + homeViewModel.getTopPlayedSongs(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> { + if (songs == null || songs.isEmpty()) { + if (bind != null) bind.topPlayedSongsSector.setVisibility(View.GONE); + } else { + if (bind != null) bind.topPlayedSongsSector.setVisibility(View.VISIBLE); + if (bind != null) + bind.topPlayedSongsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false)); + + topPlayedSongAdapter.setItems(songs); + reapplyTopPlayedSongsPlayback(); + } + }); + + SnapHelper snapHelper = new PagerSnapHelper(); + snapHelper.attachToRecyclerView(bind.topPlayedSongsRecyclerView); + + bind.topPlayedSongsRecyclerView.addItemDecoration( + new DotsIndicatorDecoration( + getResources().getDimensionPixelSize(R.dimen.radius), + getResources().getDimensionPixelSize(R.dimen.radius) * 4, + getResources().getDimensionPixelSize(R.dimen.dots_height), + requireContext().getResources().getColor(R.color.titleTextColor, null), + requireContext().getResources().getColor(R.color.titleTextColor, null)) + ); } @Override @@ -151,6 +278,8 @@ public void onStart() { MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); observeStarredSongsPlayback(); observeTopSongsPlayback(); + observeHistoryPlayback(); + observeTopPlayedSongsPlayback(); } @Override @@ -158,7 +287,16 @@ public void onResume() { super.onResume(); refreshSharesView(); if (topSongAdapter != null) setTopSongsMediaBrowserListenableFuture(); - if (starredSongAdapter != null) setStarredSongsMediaBrowserListenableFuture(); + if (starredSongAdapter != null) { + setStarredSongsMediaBrowserListenableFuture(); + if (starredSongAdapter.getItemCount() == 0) { + homeViewModel.refreshStarredTracks(getViewLifecycleOwner()); + } + } + if (historyAdapter != null) setHistoryMediaBrowserListenableFuture(); + if (topPlayedSongAdapter != null) setTopPlayedSongsMediaBrowserListenableFuture(); + scrobbleRefreshHandler.removeCallbacks(refreshRecentlyPlayedAlbumsRunnable); + scrobbleRefreshHandler.postDelayed(refreshRecentlyPlayedAlbumsRunnable, 100); } @Override @@ -170,7 +308,38 @@ public void onStop() { @Override public void onDestroyView() { super.onDestroyView(); + scrobbleRefreshHandler.removeCallbacks(refreshRecentlyPlayedAlbumsRunnable); bind = null; + + discoverSongAdapter = null; + similarMusicAdapter = null; + radioArtistAdapter = null; + bestOfArtistAdapter = null; + starredSongAdapter = null; + topSongAdapter = null; + historyAdapter = null; + starredAlbumAdapter = null; + starredArtistAdapter = null; + recentlyAddedAlbumAdapter = null; + recentlyPlayedAlbumAdapter = null; + mostPlayedAlbumAdapter = null; + newReleasesAlbumAdapter = null; + yearAdapter = null; + playlistHorizontalAdapter = null; + shareHorizontalAdapter = null; + recentlyPlayedArtistAdapter = null; + topPlayedArtistAdapter = null; + topPlayedSongAdapter = null; + } + + private void observeScrobbleEvents() { + MediaManager.getScrobbledSongId().observe(getViewLifecycleOwner(), scrobbledId -> { + if (scrobbledId == null || scrobbledId.isEmpty()) { + return; + } + scrobbleRefreshHandler.removeCallbacks(refreshRecentlyPlayedAlbumsRunnable); + scrobbleRefreshHandler.postDelayed(refreshRecentlyPlayedAlbumsRunnable, 400); + }); } private void init() { @@ -277,11 +446,55 @@ private void init() { return true; }); + bind.historyTextViewRefreshable.setOnLongClickListener(v -> { + homeViewModel.refreshHistory(getViewLifecycleOwner()); + return true; + }); + + bind.historyTextViewClickable.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putString(Constants.MEDIA_RECENTLY_PLAYED, Constants.MEDIA_RECENTLY_PLAYED); + activity.navController.navigate(R.id.action_homeFragment_to_songListPageFragment, bundle); + }); + bind.sharesTextViewRefreshable.setOnLongClickListener(v -> { homeViewModel.refreshShares(getViewLifecycleOwner()); return true; }); + bind.recentlyPlayedArtistsTextViewRefreshable.setOnLongClickListener(v -> { + homeViewModel.refreshRecentlyPlayedArtists(getViewLifecycleOwner()); + return true; + }); + + bind.recentlyPlayedArtistsTextViewClickable.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putString(Constants.ARTIST_RECENTLY_PLAYED, Constants.ARTIST_RECENTLY_PLAYED); + activity.navController.navigate(R.id.action_homeFragment_to_artistListPageFragment, bundle); + }); + + bind.topPlayedArtistsTextViewRefreshable.setOnLongClickListener(v -> { + homeViewModel.refreshTopPlayedArtists(getViewLifecycleOwner()); + return true; + }); + + bind.topPlayedArtistsTextViewClickable.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putString(Constants.ARTIST_TOP_PLAYED, Constants.ARTIST_TOP_PLAYED); + activity.navController.navigate(R.id.action_homeFragment_to_artistListPageFragment, bundle); + }); + + bind.topPlayedSongsTextViewRefreshable.setOnLongClickListener(v -> { + homeViewModel.refreshTopPlayedSongs(getViewLifecycleOwner()); + return true; + }); + + bind.topPlayedSongsTextViewClickable.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putString(Constants.MEDIA_TOP_PLAYED, Constants.MEDIA_TOP_PLAYED); + activity.navController.navigate(R.id.action_homeFragment_to_songListPageFragment, bundle); + }); + bind.gridTracksPreTextView.setOnClickListener(view -> showPopupMenu(view, R.menu.filter_top_songs_popup_menu)); } @@ -679,6 +892,7 @@ public void onChanged(List allSongs) { } private void initDiscoverSongSlideView() { + if (discoverSongAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return; bind.discoverSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -702,6 +916,7 @@ private void initDiscoverSongSlideView() { } private void initSimilarSongView() { + if (similarMusicAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_MADE_FOR_YOU)) return; bind.similarTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -727,6 +942,7 @@ private void initSimilarSongView() { } private void initArtistBestOf() { + if (bestOfArtistAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_BEST_OF)) return; bind.bestOfArtistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -750,6 +966,7 @@ private void initArtistBestOf() { } private void initArtistRadio() { + if (radioArtistAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_RADIO_STATION)) return; bind.radioArtistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -775,9 +992,10 @@ private void initArtistRadio() { } private void initTopSongsView() { + if (topSongAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_TOP_SONGS)) return; - bind.topSongsRecyclerView.setHasFixedSize(true); + bind.topSongsRecyclerView.setHasFixedSize(false); topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null); bind.topSongsRecyclerView.setAdapter(topSongAdapter); @@ -816,9 +1034,11 @@ private void initTopSongsView() { } private void initStarredTracksView() { + if (starredSongAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_STARRED_TRACKS)) return; - bind.starredTracksRecyclerView.setHasFixedSize(true); + bind.starredTracksRecyclerView.setHasFixedSize(false); + bind.starredTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null); bind.starredTracksRecyclerView.setAdapter(starredSongAdapter); @@ -830,28 +1050,15 @@ private void initStarredTracksView() { } else { if (bind != null) bind.starredTracksSector.setVisibility(!songs.isEmpty() ? View.VISIBLE : View.GONE); - if (bind != null) - bind.starredTracksRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false)); starredSongAdapter.setItems(songs); reapplyStarredSongsPlayback(); } }); - - SnapHelper starredTrackSnapHelper = new PagerSnapHelper(); - starredTrackSnapHelper.attachToRecyclerView(bind.starredTracksRecyclerView); - - bind.starredTracksRecyclerView.addItemDecoration( - new DotsIndicatorDecoration( - getResources().getDimensionPixelSize(R.dimen.radius), - getResources().getDimensionPixelSize(R.dimen.radius) * 4, - getResources().getDimensionPixelSize(R.dimen.dots_height), - requireContext().getResources().getColor(R.color.titleTextColor, null), - requireContext().getResources().getColor(R.color.titleTextColor, null)) - ); } private void initStarredAlbumsView() { + if (starredAlbumAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_STARRED_ALBUMS)) return; bind.starredAlbumsRecyclerView.setHasFixedSize(true); @@ -885,6 +1092,7 @@ private void initStarredAlbumsView() { } private void initStarredArtistsView() { + if (starredArtistAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_STARRED_ARTISTS)) return; bind.starredArtistsRecyclerView.setHasFixedSize(true); @@ -920,11 +1128,13 @@ private void initStarredArtistsView() { } private void initNewReleasesView() { + if (newReleasesAlbumAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_NEW_RELEASES)) return; bind.newReleasesRecyclerView.setHasFixedSize(true); + bind.newReleasesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); - newReleasesAlbumAdapter = new AlbumHorizontalAdapter(this, false); + newReleasesAlbumAdapter = new AlbumCarouselAdapter(this, true); bind.newReleasesRecyclerView.setAdapter(newReleasesAlbumAdapter); homeViewModel.getRecentlyReleasedAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), albums -> { if (albums == null) { @@ -932,27 +1142,14 @@ private void initNewReleasesView() { } else { if (bind != null) bind.homeNewReleasesSector.setVisibility(!albums.isEmpty() ? View.VISIBLE : View.GONE); - if (bind != null) - bind.newReleasesRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(albums.size(), 5), GridLayoutManager.HORIZONTAL, false)); newReleasesAlbumAdapter.setItems(albums); } }); - - SnapHelper newReleasesSnapHelper = new PagerSnapHelper(); - newReleasesSnapHelper.attachToRecyclerView(bind.newReleasesRecyclerView); - - bind.newReleasesRecyclerView.addItemDecoration( - new DotsIndicatorDecoration( - getResources().getDimensionPixelSize(R.dimen.radius), - getResources().getDimensionPixelSize(R.dimen.radius) * 4, - getResources().getDimensionPixelSize(R.dimen.dots_height), - requireContext().getResources().getColor(R.color.titleTextColor, null), - requireContext().getResources().getColor(R.color.titleTextColor, null)) - ); } private void initYearSongView() { + if (yearAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_FLASHBACK)) return; bind.yearsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -976,6 +1173,7 @@ private void initYearSongView() { } private void initMostPlayedAlbumView() { + if (mostPlayedAlbumAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_MOST_PLAYED)) return; bind.mostPlayedAlbumsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -999,6 +1197,7 @@ private void initMostPlayedAlbumView() { } private void initRecentPlayedAlbumView() { + if (recentlyPlayedAlbumAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_LAST_PLAYED)) return; bind.recentlyPlayedAlbumsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -1022,6 +1221,7 @@ private void initRecentPlayedAlbumView() { } private void initRecentAddedAlbumView() { + if (recentlyAddedAlbumAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_RECENTLY_ADDED)) return; bind.recentlyAddedAlbumsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); @@ -1045,6 +1245,7 @@ private void initRecentAddedAlbumView() { } private void initPinnedPlaylistsView() { + if (playlistHorizontalAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_PINNED_PLAYLISTS)) return; bind.pinnedPlaylistsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); @@ -1060,11 +1261,14 @@ private void initPinnedPlaylistsView() { bind.pinnedPlaylistsSector.setVisibility(!playlists.isEmpty() ? View.VISIBLE : View.GONE); playlistHorizontalAdapter.setItems(playlists); + java.util.List ids = playlists.stream().map(com.elzify.music.subsonic.models.Playlist::getId).collect(java.util.stream.Collectors.toList()); + playlistHorizontalAdapter.setPinnedIds(ids); } }); } private void initSharesView() { + if (shareHorizontalAdapter != null) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_SHARED)) return; bind.sharesRecyclerView.setHasFixedSize(true); @@ -1100,14 +1304,13 @@ private void initSharesView() { } private void initHomeReorganizer() { - final Handler handler = new Handler(); - final Runnable runnable = () -> { - if (bind != null) bind.homeSectorRearrangementButton.setVisibility(View.VISIBLE); - }; - handler.postDelayed(runnable, 5000); + if (bind != null) bind.homeSectorRearrangementButton.setVisibility(View.VISIBLE); bind.homeSectorRearrangementButton.setOnClickListener(v -> { - HomeRearrangementDialog dialog = new HomeRearrangementDialog(); + HomeRearrangementDialog dialog = new HomeRearrangementDialog(() -> { + homeViewModel.refreshHomeSectorList(); + reorder(); + }); dialog.show(requireActivity().getSupportFragmentManager(), null); }); } @@ -1158,50 +1361,100 @@ public void reorder() { switch (sector.getId()) { case Constants.HOME_SECTOR_DISCOVERY: + initDiscoverSongSlideView(); + bind.homeDiscoverSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeDiscoverSector); break; case Constants.HOME_SECTOR_MADE_FOR_YOU: + initSimilarSongView(); + bind.homeSimilarTracksSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeSimilarTracksSector); break; case Constants.HOME_SECTOR_BEST_OF: + initArtistBestOf(); + bind.homeBestOfArtistSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeBestOfArtistSector); break; case Constants.HOME_SECTOR_RADIO_STATION: + initArtistRadio(); + bind.homeRadioArtistSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeRadioArtistSector); break; case Constants.HOME_SECTOR_TOP_SONGS: + initTopSongsView(); + bind.homeGridTracksSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeGridTracksSector); break; case Constants.HOME_SECTOR_STARRED_TRACKS: + initStarredTracksView(); + bind.starredTracksSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.starredTracksSector); break; case Constants.HOME_SECTOR_STARRED_ALBUMS: + initStarredAlbumsView(); + bind.starredAlbumsSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.starredAlbumsSector); break; case Constants.HOME_SECTOR_STARRED_ARTISTS: + initStarredArtistsView(); + bind.starredArtistsSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.starredArtistsSector); break; case Constants.HOME_SECTOR_NEW_RELEASES: + initNewReleasesView(); + bind.homeNewReleasesSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeNewReleasesSector); break; case Constants.HOME_SECTOR_FLASHBACK: + initYearSongView(); + bind.homeFlashbackSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeFlashbackSector); break; case Constants.HOME_SECTOR_MOST_PLAYED: + initMostPlayedAlbumView(); + bind.homeMostPlayedAlbumsSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeMostPlayedAlbumsSector); break; case Constants.HOME_SECTOR_LAST_PLAYED: + initRecentPlayedAlbumView(); + bind.homeRecentlyPlayedAlbumsSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeRecentlyPlayedAlbumsSector); break; case Constants.HOME_SECTOR_RECENTLY_ADDED: + initRecentAddedAlbumView(); + bind.homeRecentlyAddedAlbumsSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.homeRecentlyAddedAlbumsSector); break; case Constants.HOME_SECTOR_PINNED_PLAYLISTS: + initPinnedPlaylistsView(); + bind.pinnedPlaylistsSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.pinnedPlaylistsSector); break; case Constants.HOME_SECTOR_SHARED: + initSharesView(); + bind.sharesSector.setVisibility(View.VISIBLE); bind.homeLinearLayoutContainer.addView(bind.sharesSector); break; + case Constants.HOME_SECTOR_HISTORY: + initHistoryView(); + bind.historySector.setVisibility(View.VISIBLE); + bind.homeLinearLayoutContainer.addView(bind.historySector); + break; + case Constants.HOME_SECTOR_RECENTLY_PLAYED_ARTISTS: + initRecentlyPlayedArtistsView(); + bind.recentlyPlayedArtistsSector.setVisibility(View.VISIBLE); + bind.homeLinearLayoutContainer.addView(bind.recentlyPlayedArtistsSector); + break; + case Constants.HOME_SECTOR_TOP_PLAYED_ARTISTS: + initTopPlayedArtistsView(); + bind.topPlayedArtistsSector.setVisibility(View.VISIBLE); + bind.homeLinearLayoutContainer.addView(bind.topPlayedArtistsSector); + break; + case Constants.HOME_SECTOR_TOP_PLAYED_SONGS: + initTopPlayedSongsView(); + bind.topPlayedSongsSector.setVisibility(View.VISIBLE); + bind.homeLinearLayoutContainer.addView(bind.topPlayedSongsSector); + break; } } @@ -1281,8 +1534,10 @@ public void onMediaClick(Bundle bundle) { MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION)); activity.setBottomSheetInPeek(true); } - topSongAdapter.notifyDataSetChanged(); - starredSongAdapter.notifyDataSetChanged(); + if (topSongAdapter != null) topSongAdapter.notifyDataSetChanged(); + if (starredSongAdapter != null) starredSongAdapter.notifyDataSetChanged(); + if (historyAdapter != null) historyAdapter.notifyDataSetChanged(); + if (topPlayedSongAdapter != null) topPlayedSongAdapter.notifyDataSetChanged(); } @Override @@ -1403,6 +1658,21 @@ private void observeTopSongsPlayback() { }); } + private void observeHistoryPlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (historyAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + historyAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (historyAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + historyAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + private void reapplyStarredSongsPlayback() { if (starredSongAdapter != null) { String id = playbackViewModel.getCurrentSongId().getValue(); @@ -1419,6 +1689,14 @@ private void reapplyTopSongsPlayback() { } } + private void reapplyHistoryPlayback() { + if (historyAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + historyAdapter.setPlaybackState(id, playing != null && playing); + } + } + private void setTopSongsMediaBrowserListenableFuture() { topSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } @@ -1426,4 +1704,39 @@ private void setTopSongsMediaBrowserListenableFuture() { private void setStarredSongsMediaBrowserListenableFuture() { starredSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } + + private void setHistoryMediaBrowserListenableFuture() { + if (historyAdapter != null) { + historyAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } + } + + private void observeTopPlayedSongsPlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (topPlayedSongAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + topPlayedSongAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (topPlayedSongAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + topPlayedSongAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyTopPlayedSongsPlayback() { + if (topPlayedSongAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + topPlayedSongAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setTopPlayedSongsMediaBrowserListenableFuture() { + if (topPlayedSongAdapter != null) { + topPlayedSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } + } } diff --git a/app/src/main/java/com/elzify/music/ui/fragment/LibraryFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/LibraryFragment.java index c50ddb14..dad9a638 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/LibraryFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/LibraryFragment.java @@ -158,7 +158,8 @@ private void initAppBar() { materialToolbar = bind.getRoot().findViewById(R.id.toolbar); activity.setSupportActionBar(materialToolbar); - Objects.requireNonNull(materialToolbar.getOverflowIcon()).setTint(requireContext().getResources().getColor(R.color.titleTextColor, null)); + materialToolbar.post(() -> materialToolbar.getMenu().clear()); + materialToolbar.setOverflowIcon(null); } private void initMusicFolderView() { diff --git a/app/src/main/java/com/elzify/music/ui/fragment/MetadataConfigurationFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/MetadataConfigurationFragment.java new file mode 100644 index 00000000..9e880ca0 --- /dev/null +++ b/app/src/main/java/com/elzify/music/ui/fragment/MetadataConfigurationFragment.java @@ -0,0 +1,196 @@ +package com.elzify.music.ui.fragment; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.elzify.music.R; +import com.elzify.music.ui.activity.MainActivity; +import com.elzify.music.util.Constants; +import com.elzify.music.util.Preferences; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class MetadataConfigurationFragment extends Fragment { + + private RecyclerView recyclerView; + private MetadataAdapter adapter; + private MainActivity activity; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + activity = (MainActivity) getActivity(); + View view = inflater.inflate(R.layout.fragment_metadata_configuration, container, false); + + androidx.appcompat.widget.Toolbar toolbar = view.findViewById(R.id.toolbar); + activity.setSupportActionBar(toolbar); + if (activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + toolbar.setNavigationOnClickListener(v -> activity.navController.navigateUp()); + + recyclerView = view.findViewById(R.id.metadata_items_recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + + setupAdapter(); + + return view; + } + + private void setupAdapter() { + List currentItems = Preferences.getNowPlayingMetadata(); + List allItems = new ArrayList<>(Arrays.asList( + Constants.METADATA_TITLE, + Constants.METADATA_ARTIST, + Constants.METADATA_ALBUM, + Constants.METADATA_YEAR, + Constants.METADATA_GENRE, + Constants.METADATA_BITRATE, + Constants.METADATA_PLAY_COUNT, + Constants.METADATA_SCROBBLES + )); + + // Reorder allItems to have current items first in their saved order + List orderedAllItems = new ArrayList<>(currentItems); + for (String item : allItems) { + if (!orderedAllItems.contains(item)) { + orderedAllItems.add(item); + } + } + + adapter = new MetadataAdapter(orderedAllItems, currentItems); + recyclerView.setAdapter(adapter); + + ItemTouchHelper touchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + int fromPos = viewHolder.getBindingAdapterPosition(); + int toPos = target.getBindingAdapterPosition(); + adapter.moveItem(fromPos, toPos); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + }); + touchHelper.attachToRecyclerView(recyclerView); + } + + @Override + public void onPause() { + super.onPause(); + Preferences.setNowPlayingMetadata(adapter.getSelectedItems()); + } + + private class MetadataAdapter extends RecyclerView.Adapter { + private final List items; + private final List selectedItems; + + public MetadataAdapter(List items, List selectedItems) { + this.items = items; + this.selectedItems = new ArrayList<>(selectedItems); + } + + public void moveItem(int fromPos, int toPos) { + Collections.swap(items, fromPos, toPos); + notifyItemMoved(fromPos, toPos); + } + + public List getSelectedItems() { + List result = new ArrayList<>(); + for (String item : items) { + if (selectedItems.contains(item)) { + result.add(item); + } + } + return result; + } + + @NonNull + @Override + public MetadataViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_dock_config, parent, false); + return new MetadataViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull MetadataViewHolder holder, int position) { + String item = items.get(position); + holder.name.setText(getItemDisplayName(item)); + holder.icon.setImageResource(getMetadataIcon(item)); + + holder.checkBox.setChecked(selectedItems.contains(item)); + + holder.checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + if (!selectedItems.contains(item)) selectedItems.add(item); + } else { + selectedItems.remove(item); + } + }); + } + + @Override + public int getItemCount() { + return items.size(); + } + + private String getItemDisplayName(String item) { + switch (item) { + case Constants.METADATA_TITLE: return "Title"; + case Constants.METADATA_ARTIST: return "Artist"; + case Constants.METADATA_ALBUM: return "Album"; + case Constants.METADATA_YEAR: return "Year"; + case Constants.METADATA_GENRE: return "Genre"; + case Constants.METADATA_BITRATE: return "Bitrate"; + case Constants.METADATA_PLAY_COUNT: return "Play Count"; + case Constants.METADATA_SCROBBLES: return "Scrobbles"; + default: return item; + } + } + + private int getMetadataIcon(String item) { + switch (item) { + case Constants.METADATA_TITLE: return R.drawable.ic_check_circle; + case Constants.METADATA_ARTIST: return R.drawable.ic_placeholder_artist; + case Constants.METADATA_ALBUM: return R.drawable.ic_placeholder_album; + case Constants.METADATA_YEAR: return R.drawable.ic_history; + case Constants.METADATA_GENRE: return R.drawable.ic_eq; + case Constants.METADATA_BITRATE: return R.drawable.ic_graphic_eq; + case Constants.METADATA_PLAY_COUNT: return R.drawable.ic_repeat; + case Constants.METADATA_SCROBBLES: return R.drawable.ic_refresh; + default: return R.drawable.ic_info_stream; + } + } + } + + private static class MetadataViewHolder extends RecyclerView.ViewHolder { + ImageView dragHandle, icon; + TextView name; + CheckBox checkBox; + + public MetadataViewHolder(@NonNull View itemView) { + super(itemView); + dragHandle = itemView.findViewById(R.id.item_drag_handle); + icon = itemView.findViewById(R.id.item_icon); + name = itemView.findViewById(R.id.item_name); + checkBox = itemView.findViewById(R.id.item_checkbox); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlayerBottomSheetFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlayerBottomSheetFragment.java index 4d1d57ea..5659ac1d 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlayerBottomSheetFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlayerBottomSheetFragment.java @@ -1,6 +1,8 @@ package com.elzify.music.ui.fragment; import android.content.ComponentName; +import android.content.res.Configuration; +import android.graphics.Color; import android.os.Bundle; import android.os.Handler; import android.text.TextUtils; @@ -24,16 +26,20 @@ import androidx.viewpager2.widget.ViewPager2; import com.elzify.music.R; -import com.elzify.music.provider.AlbumArtContentProvider; import com.elzify.music.databinding.FragmentPlayerBottomSheetBinding; import com.elzify.music.glide.CustomGlideRequest; import com.elzify.music.service.MediaManager; import com.elzify.music.service.MediaService; +import com.elzify.music.subsonic.models.Child; import com.elzify.music.subsonic.models.PlayQueue; + +import java.util.Date; import com.elzify.music.ui.activity.MainActivity; import com.elzify.music.ui.fragment.pager.PlayerControllerVerticalPager; import com.elzify.music.util.Constants; +import com.elzify.music.util.MusicUtil; import com.elzify.music.util.Preferences; +import com.elzify.music.util.UIUtil; import com.elzify.music.viewmodel.PlayerBottomSheetViewModel; import com.google.android.material.elevation.SurfaceColors; import com.google.common.util.concurrent.ListenableFuture; @@ -66,10 +72,32 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c customizeBottomSheetAction(); initViewPager(); setHeaderBookmarksButton(); + initFavoriteButton(); + applyPlayerBackgroundColor(); + applyPlayerTextColor(); return view; } + private void initFavoriteButton() { + playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), media -> { + if (media != null && bind != null) { + bind.playerHeaderLayout.buttonFavoriteMini.setChecked(media.getStarred() != null); + bind.playerHeaderLayout.buttonFavoriteMini.setOnClickListener(v -> playerBottomSheetViewModel.setFavorite(requireContext(), media)); + } + }); + + MediaManager.getFavoriteEvent().observe(getViewLifecycleOwner(), event -> { + if (event == null || bind == null) return; + String songId = (String) event[0]; + Date starred = (Date) event[1]; + Child media = playerBottomSheetViewModel.getLiveMedia().getValue(); + if (media != null && media.getId().equals(songId)) { + bind.playerHeaderLayout.buttonFavoriteMini.setChecked(starred != null); + } + }); + } + @Override public void onStart() { super.onStart(); @@ -103,6 +131,27 @@ private void initViewPager() { bind.playerBodyLayout.playerBodyBottomSheetViewPager.setAdapter(new PlayerControllerVerticalPager(this)); } + private void applyPlayerBackgroundColor() { + if (bind == null) return; + + int playerBackgroundColor = UIUtil.getPlayerBackgroundColor(requireContext()); + bind.getRoot().setBackgroundColor(Color.TRANSPARENT); + bind.playerBodyLayout.playerBodyBottomSheetViewPager.setBackgroundColor(playerBackgroundColor); + } + + private void applyPlayerTextColor() { + if (bind == null) return; + + int textColor = getPlayerTextColor(); + bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setTextColor(textColor); + bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setTextColor(textColor); + } + + private int getPlayerTextColor() { + int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return mode == Configuration.UI_MODE_NIGHT_YES ? Color.WHITE : Color.BLACK; + } + private void initializeMediaBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } @@ -133,7 +182,7 @@ private void setMediaControllerListener(MediaBrowser mediaBrowser) { setMetadata(mediaBrowser.getMediaMetadata()); setContentDuration(mediaBrowser.getContentDuration()); setPlayingState(mediaBrowser.isPlaying()); - setHeaderMediaController(); + setHeaderMediaController(mediaBrowser); setHeaderNextButtonState(mediaBrowser.hasNextMediaItem()); mediaBrowser.addListener(new Player.Listener() { @@ -235,10 +284,15 @@ private void setMetadata(MediaMetadata mediaMetadata) { .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); } else { // Fallback to the content provider so we can still render when `homepageUrl` isn't present. - Uri artworkUri = coverArtId != null ? AlbumArtContentProvider.contentUri(coverArtId) : null; - Glide.with(requireContext()) - .load(artworkUri) - .apply(CustomGlideRequest.createRequestOptions(requireContext(), coverArtId, CustomGlideRequest.ResourceType.Radio)) + // Uri artworkUri = coverArtId != null ? AlbumArtContentProvider.contentUri(coverArtId) : null; + // Glide.with(requireContext()) + // .load(artworkUri) + // .apply(CustomGlideRequest.createRequestOptions(requireContext(), coverArtId, CustomGlideRequest.ResourceType.Radio)) + // .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); + + CustomGlideRequest.Builder + .from(requireContext(), coverArtId, CustomGlideRequest.ResourceType.Radio) + .build() .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); } } @@ -264,12 +318,12 @@ private void setMediaControllerUI(MediaBrowser mediaBrowser) { } private void setContentDuration(long duration) { - bind.playerHeaderLayout.playerHeaderSeekBar.setMax((int) (duration / 1000)); + bind.playerHeaderLayout.playerHeaderProgressBar.setMax((int) (duration / 1000)); } private void setProgress(MediaBrowser mediaBrowser) { if (bind != null) - bind.playerHeaderLayout.playerHeaderSeekBar.setProgress((int) (mediaBrowser.getCurrentPosition() / 1000), true); + bind.playerHeaderLayout.playerHeaderProgressBar.setProgress((int) (mediaBrowser.getCurrentPosition() / 1000), true); } private void setPlayingState(boolean isPlaying) { @@ -277,11 +331,17 @@ private void setPlayingState(boolean isPlaying) { runProgressBarHandler(isPlaying); } - private void setHeaderMediaController() { - bind.playerHeaderLayout.playerHeaderButton.setOnClickListener(view -> bind.getRoot().findViewById(R.id.exo_play_pause).performClick()); - bind.playerHeaderLayout.playerHeaderNextMediaButton.setOnClickListener(view -> bind.getRoot().findViewById(R.id.exo_next).performClick()); - bind.playerHeaderLayout.playerHeaderRewindMediaButton.setOnClickListener(view -> bind.getRoot().findViewById(R.id.exo_rew).performClick()); - bind.playerHeaderLayout.playerHeaderFastForwardMediaButton.setOnClickListener(view -> bind.getRoot().findViewById(R.id.exo_ffwd).performClick()); + private void setHeaderMediaController(MediaBrowser mediaBrowser) { + bind.playerHeaderLayout.playerHeaderButton.setOnClickListener(view -> { + if (mediaBrowser.isPlaying()) { + mediaBrowser.pause(); + } else { + mediaBrowser.play(); + } + }); + bind.playerHeaderLayout.playerHeaderNextMediaButton.setOnClickListener(view -> mediaBrowser.seekToNext()); + bind.playerHeaderLayout.playerHeaderRewindMediaButton.setOnClickListener(view -> mediaBrowser.seekBack()); + bind.playerHeaderLayout.playerHeaderFastForwardMediaButton.setOnClickListener(view -> mediaBrowser.seekForward()); } private void setHeaderNextButtonState(boolean isEnabled) { @@ -327,6 +387,20 @@ public void setPlayerControllerVerticalPagerDraggableState(Boolean isDraggable) playerControllerVerticalPager.setUserInputEnabled(isDraggable); } + public void setBodyVisibility(boolean visible) { + if (bind != null) { + bind.playerBodyLayout.getRoot().setVisibility(visible ? View.VISIBLE : View.GONE); + } + } + + public void setMiniPlayerWidth(int width) { + if (bind != null) { + ViewGroup.LayoutParams params = bind.playerHeaderLayout.getRoot().getLayoutParams(); + params.width = width; + bind.playerHeaderLayout.getRoot().setLayoutParams(params); + } + } + private void defineProgressBarHandler(MediaBrowser mediaBrowser) { progressBarHandler = new Handler(); progressBarRunnable = () -> { diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlayerControllerFragment.java index 43466d9c..14c5aaca 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlayerControllerFragment.java @@ -4,6 +4,8 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.res.Configuration; +import android.graphics.Color; import android.os.Bundle; import android.os.IBinder; import android.text.TextUtils; @@ -16,13 +18,13 @@ import android.widget.Button; import android.widget.ImageButton; import android.widget.LinearLayout; +import android.widget.PopupMenu; import android.widget.RatingBar; import android.widget.TextView; import android.widget.ToggleButton; import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.MediaMetadata; @@ -41,6 +43,10 @@ import androidx.transition.TransitionSet; import androidx.viewpager2.widget.ViewPager2; +import com.elzify.music.service.MediaManager; +import com.elzify.music.subsonic.models.Child; +import com.elzify.music.ui.dialog.PlaylistChooserDialog; + import com.elzify.music.R; import com.elzify.music.databinding.InnerFragmentPlayerControllerBinding; import com.elzify.music.service.EqualizerManager; @@ -54,15 +60,18 @@ import com.elzify.music.util.Constants; import com.elzify.music.util.MusicUtil; import com.elzify.music.util.Preferences; +import com.elzify.music.util.UIUtil; import com.elzify.music.viewmodel.PlayerBottomSheetViewModel; import com.elzify.music.viewmodel.RatingViewModel; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; -import com.google.android.material.elevation.SurfaceColors; + import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Objects; @@ -75,17 +84,19 @@ public class PlayerControllerFragment extends Fragment { private ToggleButton buttonFavorite; private RatingViewModel ratingViewModel; private RatingBar songRatingBar; - private TextView playerMediaTitleLabel; - private TextView playerArtistNameLabel; + private LinearLayout playerMetadataContainer; private Button playbackSpeedButton; private ToggleButton skipSilenceToggleButton; private Chip playerMediaExtension; private TextView playerMediaBitrate; - private ConstraintLayout playerQuickActionView; + private View playerQuickActionView; private ImageButton playerOpenQueueButton; private ImageButton playerTrackInfo; - private LinearLayout ratingContainer; + private View ratingContainer; private ImageButton equalizerButton; + private ImageButton addToPlaylistButton; + private ImageButton overflowMenuButton; + private ImageButton lyricsButton; private ChipGroup assetLinkChipGroup; private Chip playerSongLinkChip; private Chip playerAlbumLinkChip; @@ -97,6 +108,20 @@ public class PlayerControllerFragment extends Fragment { private MediaService.LocalBinder mediaServiceBinder; private boolean isServiceBound = false; + private boolean isFirstBatch = true; + + private final android.content.SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = (sharedPreferences, key) -> { + if ("now_playing_metadata".equals(key)) { + if (bind != null && mediaBrowserListenableFuture != null && mediaBrowserListenableFuture.isDone()) { + try { + MediaBrowser browser = mediaBrowserListenableFuture.get(); + setMetadata(browser.getMediaMetadata()); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + }; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -112,9 +137,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, initQuickActionView(); initCoverLyricsSlideView(); initMediaListenable(); - initMediaLabelButton(); - initArtistLabelButton(); initEqualizerButton(); + initOverflowMenu(); return view; } @@ -122,12 +146,16 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, @Override public void onStart() { super.onStart(); + androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + .registerOnSharedPreferenceChangeListener(preferenceChangeListener); initializeBrowser(); bindMediaController(); } @Override public void onStop() { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); releaseBrowser(); super.onStop(); } @@ -141,8 +169,7 @@ public void onDestroyView() { private void init() { playerMediaCoverViewPager = bind.getRoot().findViewById(R.id.player_media_cover_view_pager); buttonFavorite = bind.getRoot().findViewById(R.id.button_favorite); - playerMediaTitleLabel = bind.getRoot().findViewById(R.id.player_media_title_label); - playerArtistNameLabel = bind.getRoot().findViewById(R.id.player_artist_name_label); + playerMetadataContainer = bind.getRoot().findViewById(R.id.player_metadata_container); playbackSpeedButton = bind.getRoot().findViewById(R.id.player_playback_speed_button); skipSilenceToggleButton = bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button); playerMediaExtension = bind.getRoot().findViewById(R.id.player_media_extension); @@ -153,6 +180,12 @@ private void init() { songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); ratingContainer = bind.getRoot().findViewById(R.id.rating_container); equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); + addToPlaylistButton = bind.getRoot().findViewById(R.id.button_add_to_playlist); + if (addToPlaylistButton != null) { + addToPlaylistButton.setOnClickListener(v -> launchPlaylistChooser()); + } + overflowMenuButton = bind.getRoot().findViewById(R.id.button_overflow_menu); + lyricsButton = bind.getRoot().findViewById(R.id.player_open_lyrics_button); assetLinkChipGroup = bind.getRoot().findViewById(R.id.asset_link_chip_group); playerSongLinkChip = bind.getRoot().findViewById(R.id.asset_link_song_chip); playerAlbumLinkChip = bind.getRoot().findViewById(R.id.asset_link_album_chip); @@ -161,14 +194,22 @@ private void init() { } private void initQuickActionView() { - playerQuickActionView.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8)); - playerOpenQueueButton.setOnClickListener(view -> { PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) requireActivity().getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); if (playerBottomSheetFragment != null) { playerBottomSheetFragment.goToQueuePage(); } }); + + if (lyricsButton != null) { + lyricsButton.setOnClickListener(view -> { + if (playerMediaCoverViewPager.getCurrentItem() == 1) { + goToControllerPage(); + } else { + goToLyricsPage(); + } + }); + } } private void initializeBrowser() { @@ -181,6 +222,7 @@ private void releaseBrowser() { private void bindMediaController() { mediaBrowserListenableFuture.addListener(() -> { + if (bind == null) return; try { MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); @@ -220,66 +262,221 @@ public void onRepeatModeChanged(int repeatMode) { } private void setMetadata(MediaMetadata mediaMetadata) { + setDynamicMetadata(mediaMetadata); + updateAssetLinkChips(mediaMetadata); + } + + private void setDynamicMetadata(MediaMetadata mediaMetadata) { + if (playerMetadataContainer == null) return; + playerMetadataContainer.removeAllViews(); + + List enabledFields = Preferences.getNowPlayingMetadata(); String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null; if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) { - // For radio: always read from extras first (radioArtist, radioTitle, stationName) - // MediaMetadata.title/artist are formatted for notification - String stationName = mediaMetadata.extras != null - ? mediaMetadata.extras.getString("stationName", - mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "") - : mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""; - - String artist = mediaMetadata.extras != null - ? mediaMetadata.extras.getString("radioArtist", "") - : ""; - - String title = mediaMetadata.extras != null - ? mediaMetadata.extras.getString("radioTitle", "") - : ""; - - // Format: "Artist - Song" or fallback to title or station name - String mainTitle; - if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) { - mainTitle = artist + " - " + title; - } else if (!TextUtils.isEmpty(title)) { - mainTitle = title; - } else if (!TextUtils.isEmpty(artist)) { - mainTitle = artist; - } else { - mainTitle = stationName; - } - - playerMediaTitleLabel.setText(mainTitle); - playerArtistNameLabel.setText(stationName); + renderRadioMetadata(mediaMetadata, enabledFields); + return; + } - playerMediaTitleLabel.setSelected(true); - playerArtistNameLabel.setSelected(true); + for (String field : enabledFields) { + switch (field) { + case Constants.METADATA_TITLE: + if (mediaMetadata.title != null) { + TextView titleView = createMetadataView(String.valueOf(mediaMetadata.title), R.style.PlayerMetadataTitle); + playerMetadataContainer.addView(titleView); + bindAlbumLink(titleView); + } + break; + case Constants.METADATA_ARTIST: + if (mediaMetadata.artist != null) { + TextView artistView = createMetadataView(String.valueOf(mediaMetadata.artist), R.style.PlayerMetadataArtist); + artistView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(artistView); + bindArtistLink(artistView); + } + break; + case Constants.METADATA_ALBUM: + if (mediaMetadata.albumTitle != null) { + TextView albumView = createMetadataView(String.valueOf(mediaMetadata.albumTitle), R.style.PlayerMetadataAlbum); + albumView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(albumView); + bindAlbumLink(albumView); + } + break; + case Constants.METADATA_YEAR: + if (mediaMetadata.releaseYear != null) { + TextView yearView = createMetadataView(String.valueOf(mediaMetadata.releaseYear), R.style.PlayerMetadataSecondary); + yearView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(yearView); + } else if (mediaMetadata.extras != null && mediaMetadata.extras.containsKey("year")) { + TextView yearView = createMetadataView(String.valueOf(mediaMetadata.extras.getInt("year")), R.style.PlayerMetadataSecondary); + yearView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(yearView); + } + break; + case Constants.METADATA_GENRE: + if (mediaMetadata.genre != null) { + TextView genreView = createMetadataView(String.valueOf(mediaMetadata.genre), R.style.PlayerMetadataSecondary); + genreView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(genreView); + } + break; + case Constants.METADATA_BITRATE: + if (mediaMetadata.extras != null) { + int rawBitrate = mediaMetadata.extras.getInt("bitrate", 0); + String suffix = mediaMetadata.extras.getString("suffix"); + StringBuilder bitrateText = new StringBuilder(); + if (!TextUtils.isEmpty(suffix)) { + bitrateText.append(suffix.toUpperCase()); + } + if (rawBitrate != 0) { + if (bitrateText.length() > 0) bitrateText.append(" • "); + bitrateText.append(rawBitrate).append(" kbps"); + } - playerMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE); - playerArtistNameLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE); + if (bitrateText.length() > 0) { + TextView bitrateView = createMetadataView(bitrateText.toString(), R.style.PlayerMetadataSecondary); + bitrateView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(bitrateView); + } + } + break; + case Constants.METADATA_PLAY_COUNT: + if (mediaMetadata.extras != null) { + String currentSongId = mediaMetadata.extras.getString("id"); + long basePlayCount = mediaMetadata.extras.getLong("playCount", 0); + long effectivePlayCount = basePlayCount + MediaManager.getPlayCountIncrement(currentSongId); + if (effectivePlayCount != 0) { + TextView playCountView = createMetadataView(effectivePlayCount + " plays", R.style.PlayerMetadataSecondary); + playCountView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(playCountView); + + final long[] lastSeenVersion = {MediaManager.getScrobbleVersion()}; + MediaManager.getScrobbledSongId().observe(getViewLifecycleOwner(), scrobbledId -> { + long currentVersion = MediaManager.getScrobbleVersion(); + if (currentVersion <= lastSeenVersion[0]) return; + lastSeenVersion[0] = currentVersion; + if (currentSongId != null && currentSongId.equals(scrobbledId)) { + long updated = basePlayCount + MediaManager.getPlayCountIncrement(currentSongId); + playCountView.setText(updated + " plays"); + } + }); + } else { + TextView playCountView = createMetadataView("", R.style.PlayerMetadataSecondary); + playCountView.setTextColor(getPlayerTextColor()); + playCountView.setVisibility(View.GONE); + playerMetadataContainer.addView(playCountView); + + final long[] lastSeenVersion = {MediaManager.getScrobbleVersion()}; + MediaManager.getScrobbledSongId().observe(getViewLifecycleOwner(), scrobbledId -> { + long currentVersion = MediaManager.getScrobbleVersion(); + if (currentVersion <= lastSeenVersion[0]) return; + lastSeenVersion[0] = currentVersion; + if (currentSongId != null && currentSongId.equals(scrobbledId)) { + long updated = basePlayCount + MediaManager.getPlayCountIncrement(currentSongId); + playCountView.setText(updated + " plays"); + playCountView.setVisibility(View.VISIBLE); + } + }); + } + } + break; + case Constants.METADATA_SCROBBLES: + TextView scrobbleView = createMetadataView("", R.style.PlayerMetadataSecondary); + scrobbleView.setTextColor(getPlayerTextColor()); + scrobbleView.setVisibility(View.GONE); + playerMetadataContainer.addView(scrobbleView); + + String artist = mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : null; + String title = mediaMetadata.title != null ? String.valueOf(mediaMetadata.title) : null; + playerBottomSheetViewModel.fetchLastFmScrobbleCount(artist, title); + playerBottomSheetViewModel.getLastFmScrobbleCount().observe(getViewLifecycleOwner(), count -> { + if (count != null && count > 0) { + scrobbleView.setText(count + " scrobbles"); + scrobbleView.setVisibility(View.VISIBLE); + } else { + scrobbleView.setVisibility(View.GONE); + } + }); + break; + } + } + } - updateAssetLinkChips(mediaMetadata); - return; + private void renderRadioMetadata(MediaMetadata mediaMetadata, List enabledFields) { + String stationName = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("stationName", + mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "") + : mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""; + + String artist = mediaMetadata.extras != null ? mediaMetadata.extras.getString("radioArtist", "") : ""; + String title = mediaMetadata.extras != null ? mediaMetadata.extras.getString("radioTitle", "") : ""; + + String mainTitle; + if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) { + mainTitle = artist + " - " + title; + } else if (!TextUtils.isEmpty(title)) { + mainTitle = title; + } else if (!TextUtils.isEmpty(artist)) { + mainTitle = artist; + } else { + mainTitle = stationName; } - playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title)); - playerArtistNameLabel.setText( - mediaMetadata.artist != null - ? String.valueOf(mediaMetadata.artist) - : ""); + TextView titleView = createMetadataView(mainTitle, R.style.HeadlineLarge); + playerMetadataContainer.addView(titleView); - playerMediaTitleLabel.setSelected(true); - playerArtistNameLabel.setSelected(true); + TextView stationView = createMetadataView(stationName, R.style.TitleMedium); + stationView.setTextColor(getPlayerTextColor()); + playerMetadataContainer.addView(stationView); + } - playerMediaTitleLabel.setVisibility(mediaMetadata.title != null && !Objects.equals(mediaMetadata.title, "") ? View.VISIBLE : View.GONE); - playerArtistNameLabel.setVisibility( - (mediaMetadata.artist != null && !Objects.equals(mediaMetadata.artist, "")) - || mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null - ? View.VISIBLE - : View.GONE); + private TextView createMetadataView(String text, int styleRes) { + TextView textView = new TextView(requireContext()); + textView.setText(text); + textView.setTextAppearance(styleRes); + textView.setTextColor(getPlayerTextColor()); + textView.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + textView.setGravity(android.view.Gravity.CENTER); + textView.setSingleLine(true); + textView.setEllipsize(TextUtils.TruncateAt.MARQUEE); + textView.setSelected(true); + textView.setPadding(0, UIUtil.dpToPx(requireContext(), 2), 0, UIUtil.dpToPx(requireContext(), 2)); + return textView; + } - updateAssetLinkChips(mediaMetadata); + private int getPlayerTextColor() { + int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return mode == Configuration.UI_MODE_NIGHT_YES ? Color.WHITE : Color.BLACK; + } + + private void bindAlbumLink(View view) { + playerBottomSheetViewModel.getLiveAlbum().observe(getViewLifecycleOwner(), album -> { + if (album != null) { + view.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ALBUM_OBJECT, album); + NavHostFragment.findNavController(this).navigate(R.id.albumPageFragment, bundle); + activity.collapseBottomSheetDelayed(); + }); + } + }); + } + + private void bindArtistLink(View view) { + playerBottomSheetViewModel.getLiveArtist().observe(getViewLifecycleOwner(), artist -> { + if (artist != null) { + view.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ARTIST_OBJECT, artist); + NavHostFragment.findNavController(this).navigate(R.id.artistPageFragment, bundle); + activity.collapseBottomSheetDelayed(); + }); + } + }); } private void setMediaInfo(MediaMetadata mediaMetadata) { @@ -377,8 +574,6 @@ private void updateAssetLinkChips(MediaMetadata mediaMetadata) { AssetLinkUtil.AssetLink songLink = bindAssetLinkChip(playerSongLinkChip, AssetLinkUtil.TYPE_SONG, songId); AssetLinkUtil.AssetLink albumLink = bindAssetLinkChip(playerAlbumLinkChip, AssetLinkUtil.TYPE_ALBUM, albumId); AssetLinkUtil.AssetLink artistLink = bindAssetLinkChip(playerArtistLinkChip, AssetLinkUtil.TYPE_ARTIST, artistId); - bindAssetLinkView(playerMediaTitleLabel, songLink); - bindAssetLinkView(playerArtistNameLabel, artistLink != null ? artistLink : songLink); bindAssetLinkView(playerMediaCoverViewPager, songLink); syncAssetLinkGroupVisibility(); } @@ -475,9 +670,9 @@ private void setMediaControllerUI(MediaBrowser mediaBrowser) { bind.getRoot().setShowNextButton(false); bind.getRoot().setShowFastForwardButton(true); bind.getRoot().setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE); - bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.VISIBLE); - bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button).setVisibility(View.VISIBLE); - bind.getRoot().findViewById(R.id.button_favorite).setVisibility(View.GONE); + setViewVisibilityIfPresent(R.id.button_favorite, View.GONE); + setViewVisibilityIfPresent(R.id.button_add_to_playlist, View.GONE); + setViewVisibilityIfPresent(R.id.button_overflow_menu, View.GONE); setPlaybackParameters(mediaBrowser); break; case Constants.MEDIA_TYPE_RADIO: @@ -487,9 +682,9 @@ private void setMediaControllerUI(MediaBrowser mediaBrowser) { bind.getRoot().setShowNextButton(false); bind.getRoot().setShowFastForwardButton(false); bind.getRoot().setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE); - bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.GONE); - bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button).setVisibility(View.GONE); - bind.getRoot().findViewById(R.id.button_favorite).setVisibility(View.GONE); + setViewVisibilityIfPresent(R.id.button_favorite, View.GONE); + setViewVisibilityIfPresent(R.id.button_add_to_playlist, View.GONE); + setViewVisibilityIfPresent(R.id.button_overflow_menu, View.GONE); setPlaybackParameters(mediaBrowser); break; case Constants.MEDIA_TYPE_MUSIC: @@ -500,9 +695,9 @@ private void setMediaControllerUI(MediaBrowser mediaBrowser) { bind.getRoot().setShowNextButton(true); bind.getRoot().setShowFastForwardButton(false); bind.getRoot().setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL | RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE); - bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.VISIBLE); - bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button).setVisibility(View.GONE); - bind.getRoot().findViewById(R.id.button_favorite).setVisibility(View.VISIBLE); + setViewVisibilityIfPresent(R.id.button_favorite, View.VISIBLE); + setViewVisibilityIfPresent(R.id.button_add_to_playlist, View.VISIBLE); + setViewVisibilityIfPresent(R.id.button_overflow_menu, View.VISIBLE); setPlaybackParameters(mediaBrowser); break; } @@ -512,11 +707,13 @@ private void setMediaControllerUI(MediaBrowser mediaBrowser) { private void initCoverLyricsSlideView() { playerMediaCoverViewPager.setOrientation(ViewPager2.ORIENTATION_HORIZONTAL); playerMediaCoverViewPager.setAdapter(new PlayerControllerHorizontalPager(this)); + updateTrackInfoVisibility(playerMediaCoverViewPager.getCurrentItem()); playerMediaCoverViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(int position) { super.onPageSelected(position); + updateTrackInfoVisibility(position); PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) requireActivity().getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); @@ -537,6 +734,11 @@ public void onPageSelected(int position) { }); } + private void updateTrackInfoVisibility(int position) { + if (playerTrackInfo == null) return; + playerTrackInfo.setVisibility(position == 0 ? View.VISIBLE : View.GONE); + } + private void initMediaListenable() { playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), media -> { if (media != null) { @@ -569,6 +771,7 @@ public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromUser) if (fromUser) { ratingViewModel.rate((int) rating); media.setUserRating((int) rating); + MediaManager.postRatingEvent(media.getId(), (int) rating); } } }); @@ -579,29 +782,31 @@ public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromUser) } } }); - } - private void initMediaLabelButton() { - playerBottomSheetViewModel.getLiveAlbum().observe(getViewLifecycleOwner(), album -> { - if (album != null) { - playerMediaTitleLabel.setOnClickListener(view -> { - Bundle bundle = new Bundle(); - bundle.putParcelable(Constants.ALBUM_OBJECT, album); - NavHostFragment.findNavController(this).navigate(R.id.albumPageFragment, bundle); - activity.collapseBottomSheetDelayed(); - }); + MediaManager.getFavoriteEvent().observe(getViewLifecycleOwner(), event -> { + if (event == null) return; + String songId = (String) event[0]; + Date starred = (Date) event[1]; + Child media = playerBottomSheetViewModel.getLiveMedia().getValue(); + if (media != null && media.getId().equals(songId)) { + buttonFavorite.setChecked(starred != null); } }); - } - private void initArtistLabelButton() { - playerBottomSheetViewModel.getLiveArtist().observe(getViewLifecycleOwner(), artist -> { - if (artist != null) { - playerArtistNameLabel.setOnClickListener(view -> { - Bundle bundle = new Bundle(); - bundle.putParcelable(Constants.ARTIST_OBJECT, artist); - NavHostFragment.findNavController(this).navigate(R.id.artistPageFragment, bundle); - activity.collapseBottomSheetDelayed(); + MediaManager.getRatingEvent().observe(getViewLifecycleOwner(), event -> { + if (event == null) return; + String songId = (String) event[0]; + int rating = (Integer) event[1]; + Child media = playerBottomSheetViewModel.getLiveMedia().getValue(); + if (media != null && media.getId().equals(songId)) { + songRatingBar.setOnRatingBarChangeListener(null); + songRatingBar.setRating(rating); + songRatingBar.setOnRatingBarChangeListener((ratingBar, r, fromUser) -> { + if (fromUser) { + ratingViewModel.rate((int) r); + media.setUserRating((int) r); + MediaManager.postRatingEvent(media.getId(), (int) r); + } }); } }); @@ -623,6 +828,7 @@ private void initPlaybackSpeedButton(MediaBrowser mediaBrowser) { } private void initEqualizerButton() { + if (equalizerButton == null) return; equalizerButton.setOnClickListener(v -> { NavController navController = NavHostFragment.findNavController(this); NavOptions navOptions = new NavOptions.Builder() @@ -634,6 +840,79 @@ private void initEqualizerButton() { }); } + private void initOverflowMenu() { + if (overflowMenuButton == null) return; + overflowMenuButton.setOnClickListener(v -> { + PopupMenu popup = new PopupMenu(requireContext(), v); + popup.getMenuInflater().inflate(R.menu.menu_now_playing_overflow, popup.getMenu()); + popup.setOnMenuItemClickListener(item -> { + int id = item.getItemId(); + if (id == R.id.menu_add_to_playlist) { + launchPlaylistChooser(); + return true; + } else if (id == R.id.menu_go_to_album) { + playerBottomSheetViewModel.getLiveAlbum().observe(getViewLifecycleOwner(), album -> { + if (album != null) { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ALBUM_OBJECT, album); + NavHostFragment.findNavController(this).navigate(R.id.albumPageFragment, bundle); + activity.collapseBottomSheetDelayed(); + } + }); + return true; + } else if (id == R.id.menu_go_to_artist) { + playerBottomSheetViewModel.getLiveArtist().observe(getViewLifecycleOwner(), artist -> { + if (artist != null) { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ARTIST_OBJECT, artist); + NavHostFragment.findNavController(this).navigate(R.id.artistPageFragment, bundle); + activity.collapseBottomSheetDelayed(); + } + }); + return true; + } else if (id == R.id.menu_instant_mix) { + Child media = playerBottomSheetViewModel.getLiveMedia().getValue(); + if (media != null) { + ListenableFuture activityBrowserFuture = activity.getMediaBrowserListenableFuture(); + if (activityBrowserFuture == null) return true; + + isFirstBatch = true; + Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); + + playerBottomSheetViewModel.getMediaInstantMix(activity, media).observe(activity, mixMedia -> { + if (mixMedia == null || mixMedia.isEmpty()) return; + if (getActivity() == null) return; + + MusicUtil.ratingFilter(mixMedia); + + if (isFirstBatch) { + isFirstBatch = false; + MediaManager.startQueue(activityBrowserFuture, mixMedia, 0); + activity.setBottomSheetInPeek(true); + } else { + MediaManager.enqueue(activityBrowserFuture, mixMedia, true); + } + }); + } + return true; + } + return false; + }); + popup.show(); + }); + } + + private void launchPlaylistChooser() { + Child media = playerBottomSheetViewModel.getLiveMedia().getValue(); + if (media != null) { + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(Collections.singletonList(media))); + PlaylistChooserDialog dialog = new PlaylistChooserDialog(); + dialog.setArguments(bundle); + dialog.show(requireActivity().getSupportFragmentManager(), null); + } + } + public void goToControllerPage() { playerMediaCoverViewPager.setCurrentItem(0, false); } @@ -643,13 +922,17 @@ public void goToLyricsPage() { } private void checkAndSetRatingContainerVisibility() { - if (ratingContainer == null) return; + if (ratingContainer == null) return; - if (Preferences.showItemStarRating()) { - ratingContainer.setVisibility(View.VISIBLE); - } - else { - ratingContainer.setVisibility(View.GONE); + if (Preferences.showItemStarRating()) { + songRatingBar.setVisibility(View.VISIBLE); + } else { + songRatingBar.setVisibility(View.GONE); + } + + TextView ratingText = bind.getRoot().findViewById(R.id.rating_text); + if (ratingText != null) { + ratingText.setVisibility(Preferences.showItemStarRating() ? View.VISIBLE : View.GONE); } } @@ -659,10 +942,21 @@ private void setPlaybackParameters(MediaBrowser mediaBrowser) { boolean skipSilence = Preferences.isSkipSilenceMode(); mediaBrowser.setPlaybackParameters(new PlaybackParameters(currentSpeed)); - playbackSpeedButton.setText(getString(R.string.player_playback_speed, currentSpeed)); + if (playbackSpeedButton != null) { + playbackSpeedButton.setText(getString(R.string.player_playback_speed, currentSpeed)); + } // TODO Skippare il silenzio - skipSilenceToggleButton.setChecked(skipSilence); + if (skipSilenceToggleButton != null) { + skipSilenceToggleButton.setChecked(skipSilence); + } + } + + private void setViewVisibilityIfPresent(int viewId, int visibility) { + View view = bind.getRoot().findViewById(viewId); + if (view != null) { + view.setVisibility(visibility); + } } private void resetPlaybackParameters(MediaBrowser mediaBrowser) { @@ -698,21 +992,7 @@ private void checkEqualizerBands() { short numBands = eqManager.getNumberOfBands(); if (equalizerButton != null) { - if (numBands == 0) { - equalizerButton.setVisibility(View.GONE); - - ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams(); - params.startToEnd = ConstraintLayout.LayoutParams.UNSET; - params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; - playerOpenQueueButton.setLayoutParams(params); - } else { - equalizerButton.setVisibility(View.VISIBLE); - - ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams(); - params.startToStart = ConstraintLayout.LayoutParams.UNSET; - params.startToEnd = R.id.player_open_equalizer_button; - playerOpenQueueButton.setLayoutParams(params); - } + equalizerButton.setVisibility(numBands == 0 ? View.GONE : View.VISIBLE); } } } diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlayerCoverFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlayerCoverFragment.java index 1fb5d57c..3730662f 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlayerCoverFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlayerCoverFragment.java @@ -2,14 +2,9 @@ import android.content.ComponentName; import android.os.Bundle; -import android.os.Handler; -import android.transition.Fade; -import android.transition.Transition; -import android.transition.TransitionManager; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import java.util.ArrayList; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; @@ -20,35 +15,19 @@ import androidx.media3.session.MediaBrowser; import androidx.media3.session.SessionToken; -import android.util.Log; - -import com.elzify.music.R; import com.elzify.music.databinding.InnerFragmentPlayerCoverBinding; import com.elzify.music.glide.CustomGlideRequest; -import com.elzify.music.model.Download; -import com.elzify.music.service.MediaManager; import com.elzify.music.service.MediaService; -import com.elzify.music.ui.dialog.PlaylistChooserDialog; -import com.elzify.music.util.Constants; -import com.elzify.music.util.DownloadUtil; -import com.elzify.music.util.MappingUtil; -import com.elzify.music.util.Preferences; -import com.elzify.music.util.ExternalAudioWriter; import com.elzify.music.viewmodel.PlayerBottomSheetViewModel; -import com.elzify.music.subsonic.models.Child; -import com.google.android.material.snackbar.Snackbar; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @UnstableApi public class PlayerCoverFragment extends Fragment { - private static final String TAG = "PlayerCoverFragment"; private PlayerBottomSheetViewModel playerBottomSheetViewModel; private InnerFragmentPlayerCoverBinding bind; private ListenableFuture mediaBrowserListenableFuture; - private final Handler handler = new Handler(); - @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { bind = InnerFragmentPlayerCoverBinding.inflate(inflater, container, false); @@ -56,9 +35,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class); - initOverlay(); - initInnerButton(); - return view; } @@ -67,7 +43,6 @@ public void onStart() { super.onStart(); initializeBrowser(); bindMediaController(); - toggleOverlayVisibility(false); } @Override @@ -82,90 +57,6 @@ public void onDestroyView() { bind = null; } - private void initTapButtonHideTransition() { - bind.nowPlayingTapButton.setVisibility(View.VISIBLE); - - handler.removeCallbacksAndMessages(null); - - final Runnable runnable = () -> { - if (bind != null) bind.nowPlayingTapButton.setVisibility(View.GONE); - }; - - handler.postDelayed(runnable, 10000); - } - - private void initOverlay() { - bind.nowPlayingSongCoverImageView.setOnClickListener(view -> toggleOverlayVisibility(true)); - bind.nowPlayingSongCoverButtonGroup.setOnClickListener(view -> toggleOverlayVisibility(false)); - bind.nowPlayingTapButton.setOnClickListener(view -> toggleOverlayVisibility(true)); - } - - private void toggleOverlayVisibility(boolean isVisible) { - Transition transition = new Fade(); - transition.setDuration(200); - transition.addTarget(bind.nowPlayingSongCoverButtonGroup); - - TransitionManager.beginDelayedTransition(bind.getRoot(), transition); - bind.nowPlayingSongCoverButtonGroup.setVisibility(isVisible ? View.VISIBLE : View.GONE); - bind.nowPlayingTapButton.setVisibility(isVisible ? View.GONE : View.VISIBLE); - - bind.innerButtonBottomRight.setVisibility(Preferences.isSyncronizationEnabled() ? View.VISIBLE : View.GONE); - bind.innerButtonBottomRightAlternative.setVisibility(Preferences.isSyncronizationEnabled() ? View.GONE : View.VISIBLE); - - if (!isVisible) initTapButtonHideTransition(); - } - - private void initInnerButton() { - playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> { - if (song != null && bind != null) { - bind.innerButtonTopLeft.setOnClickListener(view -> { - if (Preferences.getDownloadDirectoryUri() == null) { - DownloadUtil.getDownloadTracker(requireContext()).download( - MappingUtil.mapDownload(song), - new Download(song) - ); - } else { - ExternalAudioWriter.downloadToUserDirectory(requireContext(), song); - } - }); - - bind.innerButtonTopRight.setOnClickListener(view -> { - ArrayList tracks = new ArrayList<>(); - tracks.add(song); - Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, tracks); - - PlaylistChooserDialog dialog = new PlaylistChooserDialog(); - dialog.setArguments(bundle); - dialog.show(requireActivity().getSupportFragmentManager(), null); - } - ); - - bind.innerButtonBottomLeft.setOnClickListener(view -> - playerBottomSheetViewModel.getMediaInstantMix(getViewLifecycleOwner(), song) - .observe(getViewLifecycleOwner(), media -> - MediaManager.enqueue(mediaBrowserListenableFuture, media, true) - ) - ); - - bind.innerButtonBottomRight.setOnClickListener(view -> { - if (playerBottomSheetViewModel.savePlayQueue()) { - Snackbar.make(requireView(), R.string.player_queue_save_queue_success, Snackbar.LENGTH_LONG).show(); - } - }); - - bind.innerButtonBottomRightAlternative.setOnClickListener(view -> { - if (getActivity() != null) { - PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) requireActivity().getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); - if (playerBottomSheetFragment != null) { - playerBottomSheetFragment.goToLyricsPage(); - } - } - }); - } - }); - } - private void initializeBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } @@ -178,11 +69,9 @@ private void bindMediaController() { mediaBrowserListenableFuture.addListener(() -> { try { MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); - if (mediaBrowser != null) { - setMediaBrowserListener(mediaBrowser); - } + setMediaBrowserListener(mediaBrowser); } catch (Exception exception) { - Log.e(TAG, "Failed to bind media controller", exception); + exception.printStackTrace(); } }, MoreExecutors.directExecutor()); } @@ -194,41 +83,14 @@ private void setMediaBrowserListener(MediaBrowser mediaBrowser) { @Override public void onMediaMetadataChanged(@NonNull MediaMetadata mediaMetadata) { setCover(mediaMetadata); - toggleOverlayVisibility(false); } }); } private void setCover(MediaMetadata mediaMetadata) { - Bundle extras = mediaMetadata.extras; - if (extras == null) { - CustomGlideRequest.Builder - .from(requireContext(), null, CustomGlideRequest.ResourceType.Song) - .build() - .into(bind.nowPlayingSongCoverImageView); - return; - } - String type = extras.getString("type"); - - if (Constants.MEDIA_TYPE_RADIO.equals(type)) { - String homepageUrl = extras.getString("homepageUrl"); - - if (homepageUrl != null) { - homepageUrl = homepageUrl.trim(); - } - if (homepageUrl != null && !homepageUrl.isEmpty() - && (homepageUrl.startsWith("http://") || homepageUrl.startsWith("https://"))) { - CustomGlideRequest.Builder - .from(requireContext(), homepageUrl, CustomGlideRequest.ResourceType.Radio) - .build() - .into(bind.nowPlayingSongCoverImageView); - return; - } - } - CustomGlideRequest.Builder - .from(requireContext(), extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song) + .from(requireContext(), mediaMetadata.extras != null ? mediaMetadata.extras.getString("coverArtId") : null, CustomGlideRequest.ResourceType.Song) .build() .into(bind.nowPlayingSongCoverImageView); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlayerLyricsFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlayerLyricsFragment.java index 1b5289c4..5499b7a3 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlayerLyricsFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlayerLyricsFragment.java @@ -54,6 +54,7 @@ public class PlayerLyricsFragment extends Fragment { private LyricsList currentLyricsList; private Integer lastLineIdx; private String currentDescription; + private boolean lyricsSourceSwitchAvailable; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -73,6 +74,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat initPanelContent(); observeDownloadState(); + observeLyricsSourceState(); } @Override @@ -112,12 +114,13 @@ public void onDestroyView() { currentLyricsList = null; currentDescription = null; lastLineIdx = null; + lyricsSourceSwitchAvailable = false; } private void initOverlay() { - bind.syncLyricsTapButton.setOnClickListener(view -> { - playerBottomSheetViewModel.changeSyncLyricsState(); - }); + float overlayLift = getResources().getDisplayMetrics().density * 64f; + bind.lyricsSourceToggleButton.setTranslationY(-overlayLift); + bind.downloadLyricsButton.setTranslationY(-overlayLift); bind.downloadLyricsButton.setOnClickListener(view -> { boolean saved = playerBottomSheetViewModel.downloadCurrentLyrics(); @@ -129,6 +132,20 @@ private void initOverlay() { ).show(); } }); + + bind.lyricsSourceToggleButton.setOnClickListener(view -> { + Integer sourceBefore = playerBottomSheetViewModel.getLyricsSource().getValue(); + boolean switched = playerBottomSheetViewModel.switchLyricsSource(); + if (!switched) { + Toast.makeText(requireContext(), R.string.player_lyrics_source_switch_unavailable, Toast.LENGTH_SHORT).show(); + return; + } + + int labelRes = sourceBefore != null && sourceBefore == PlayerBottomSheetViewModel.LYRICS_SOURCE_SERVER + ? R.string.player_lyrics_source_lrclib_label + : R.string.player_lyrics_source_server_label; + Toast.makeText(requireContext(), getString(R.string.player_lyrics_source_active, getString(labelRes)), Toast.LENGTH_SHORT).show(); + }); } private void initializeBrowser() { @@ -190,6 +207,27 @@ private void observeDownloadState() { }); } + private void observeLyricsSourceState() { + playerBottomSheetViewModel.getLyricsSourceSwitchAvailable().observe(getViewLifecycleOwner(), canSwitch -> { + lyricsSourceSwitchAvailable = canSwitch != null && canSwitch; + updateLyricsSourceButtonVisibility(hasStructuredLyrics(currentLyricsList) || hasText(currentLyrics)); + }); + + playerBottomSheetViewModel.getLyricsSource().observe(getViewLifecycleOwner(), source -> { + if (bind == null) { + return; + } + + if (source != null && source == PlayerBottomSheetViewModel.LYRICS_SOURCE_LRCLIB) { + bind.lyricsSourceToggleButton.setText(getString(R.string.player_lyrics_source_lrclib_label)); + bind.lyricsSourceToggleButton.setContentDescription(getString(R.string.player_lyrics_source_lrclib_content_description)); + } else { + bind.lyricsSourceToggleButton.setText(getString(R.string.player_lyrics_source_server_label)); + bind.lyricsSourceToggleButton.setContentDescription(getString(R.string.player_lyrics_source_server_content_description)); + } + }); + } + private void updatePanelContent() { if (bind == null) { return; @@ -202,7 +240,6 @@ private void updatePanelContent() { bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); bind.emptyDescriptionImageView.setVisibility(View.GONE); bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); - bind.syncLyricsTapButton.setVisibility(View.VISIBLE); bind.downloadLyricsButton.setVisibility(View.VISIBLE); bind.downloadLyricsButton.setEnabled(true); } else if (hasText(currentLyrics)) { @@ -210,7 +247,6 @@ private void updatePanelContent() { bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); bind.emptyDescriptionImageView.setVisibility(View.GONE); bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); - bind.syncLyricsTapButton.setVisibility(View.GONE); bind.downloadLyricsButton.setVisibility(View.VISIBLE); bind.downloadLyricsButton.setEnabled(true); } else if (hasText(currentDescription)) { @@ -218,17 +254,27 @@ private void updatePanelContent() { bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); bind.emptyDescriptionImageView.setVisibility(View.GONE); bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); - bind.syncLyricsTapButton.setVisibility(View.GONE); bind.downloadLyricsButton.setVisibility(View.GONE); bind.downloadLyricsButton.setEnabled(false); } else { bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE); bind.emptyDescriptionImageView.setVisibility(View.VISIBLE); bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE); - bind.syncLyricsTapButton.setVisibility(View.GONE); bind.downloadLyricsButton.setVisibility(View.GONE); bind.downloadLyricsButton.setEnabled(false); } + + updateLyricsSourceButtonVisibility(hasStructuredLyrics(currentLyricsList) || hasText(currentLyrics)); + } + + private void updateLyricsSourceButtonVisibility(boolean hasLyrics) { + if (bind == null) { + return; + } + + bind.lyricsSourceToggleButton.setVisibility(hasLyrics ? View.VISIBLE : View.GONE); + bind.lyricsSourceToggleButton.setEnabled(hasLyrics); + bind.lyricsSourceToggleButton.setAlpha(lyricsSourceSwitchAvailable ? 0.85f : 0.65f); } private boolean hasText(String value) { @@ -374,4 +420,4 @@ private int getScroll(int startIndex) { return Math.max(scroll, 0); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlayerQueueFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlayerQueueFragment.java index 8c287e28..4b587663 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlayerQueueFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlayerQueueFragment.java @@ -160,6 +160,7 @@ private void initQueueRecyclerView() { playerSongQueueAdapter = new PlayerSongQueueAdapter(this); bind.playerQueueRecyclerView.setAdapter(playerSongQueueAdapter); + playerSongQueueAdapter.observeMetadataEvents(getViewLifecycleOwner()); reapplyPlayback(); playerBottomSheetViewModel.getQueueSong().observe(getViewLifecycleOwner(), queue -> { diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlaylistCatalogueFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlaylistCatalogueFragment.java index 3b461d4d..cfd48444 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlaylistCatalogueFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlaylistCatalogueFragment.java @@ -26,11 +26,17 @@ import com.elzify.music.R; import com.elzify.music.databinding.FragmentPlaylistCatalogueBinding; import com.elzify.music.interfaces.ClickCallback; +import com.elzify.music.repository.PlaylistRepository; +import com.elzify.music.subsonic.models.Playlist; import com.elzify.music.ui.activity.MainActivity; import com.elzify.music.ui.adapter.PlaylistHorizontalAdapter; import com.elzify.music.ui.dialog.PlaylistEditorDialog; import com.elzify.music.util.Constants; import com.elzify.music.viewmodel.PlaylistCatalogueViewModel; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.List; +import java.util.stream.Collectors; @UnstableApi public class PlaylistCatalogueFragment extends Fragment implements ClickCallback { @@ -68,10 +74,17 @@ public void onDestroyView() { } private void init() { - if (requireArguments().getString(Constants.PLAYLIST_ALL) != null) { + Bundle args = getArguments(); + if (args != null) { + if (args.getString(Constants.PLAYLIST_ALL) != null) { + playlistCatalogueViewModel.setType(Constants.PLAYLIST_ALL); + } else if (args.getString(Constants.PLAYLIST_DOWNLOADED) != null) { + playlistCatalogueViewModel.setType(Constants.PLAYLIST_DOWNLOADED); + } else { + playlistCatalogueViewModel.setType(Constants.PLAYLIST_ALL); + } + } else { playlistCatalogueViewModel.setType(Constants.PLAYLIST_ALL); - } else if (requireArguments().getString(Constants.PLAYLIST_DOWNLOADED) != null) { - playlistCatalogueViewModel.setType(Constants.PLAYLIST_DOWNLOADED); } } @@ -98,6 +111,8 @@ private void initAppBar() { }); } + private java.util.List lastPinnedIds = new java.util.ArrayList<>(); + @SuppressLint("ClickableViewAccessibility") private void initPlaylistCatalogueView() { bind.playlistCatalogueRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); @@ -107,8 +122,20 @@ private void initPlaylistCatalogueView() { bind.playlistCatalogueRecyclerView.setAdapter(playlistHorizontalAdapter); if (getActivity() != null) { + playlistCatalogueViewModel.getPinnedPlaylists().observe(getViewLifecycleOwner(), pinned -> { + if (pinned != null) { + lastPinnedIds = pinned.stream().map(Playlist::getId).collect(Collectors.toList()); + playlistHorizontalAdapter.setPinnedIds(lastPinnedIds); + } + }); + playlistCatalogueViewModel.getPlaylistList(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), playlists -> { - if (playlists != null) playlistHorizontalAdapter.setItems(playlists); + if (playlists != null) { + playlistHorizontalAdapter.setItems(playlists); + if (!lastPinnedIds.isEmpty()) { + playlistHorizontalAdapter.setPinnedIds(lastPinnedIds); + } + } }); } @@ -178,9 +205,46 @@ public void onPlaylistClick(Bundle bundle) { @Override public void onPlaylistLongClick(Bundle bundle) { - PlaylistEditorDialog dialog = new PlaylistEditorDialog(null); - dialog.setArguments(bundle); - dialog.show(activity.getSupportFragmentManager(), null); + Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT); + if (playlist == null) return; + + View anchor = bind.playlistCatalogueRecyclerView.findViewWithTag(playlist.getId()); + if (anchor == null) anchor = bind.getRoot(); + + PopupMenu popup = new PopupMenu(requireContext(), anchor); + popup.getMenuInflater().inflate(R.menu.playlist_actions_menu, popup.getMenu()); + + MenuItem pinItem = popup.getMenu().findItem(R.id.menu_playlist_pin); + pinItem.setTitle(playlist.isPinned() ? R.string.playlist_unpin : R.string.playlist_pin); + + popup.setOnMenuItemClickListener(menuItem -> { + if (menuItem.getItemId() == R.id.menu_playlist_pin) { + if (playlist.isPinned()) { + playlistCatalogueViewModel.unpinPlaylist(playlist); + } else { + playlistCatalogueViewModel.pinPlaylist(playlist); + } + return true; + } else if (menuItem.getItemId() == R.id.menu_playlist_edit) { + PlaylistEditorDialog dialog = new PlaylistEditorDialog(null); + dialog.setArguments(bundle); + dialog.show(activity.getSupportFragmentManager(), null); + return true; + } else if (menuItem.getItemId() == R.id.menu_playlist_delete) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); + builder.setTitle(R.string.menu_delete); + builder.setMessage(R.string.playlist_editor_dialog_action_delete_toast); + builder.setPositiveButton(R.string.playlist_editor_dialog_neutral_button, (dialog, which) -> { + new PlaylistRepository().deletePlaylist(playlist.getId()); + }); + builder.setNegativeButton(R.string.playlist_editor_dialog_negative_button, null); + builder.show(); + return true; + } + return false; + }); + + popup.show(); hideKeyboard(requireView()); } } \ No newline at end of file diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PlaylistPageFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PlaylistPageFragment.java index 1014a625..2d0670d2 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PlaylistPageFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PlaylistPageFragment.java @@ -43,6 +43,9 @@ import com.elzify.music.viewmodel.PlaylistPageViewModel; import com.google.common.util.concurrent.ListenableFuture; +import android.widget.PopupMenu; + +import java.util.Collections; import java.util.Objects; import java.util.stream.Collectors; @@ -138,7 +141,12 @@ public void onDestroyView() { @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.action_download_playlist) { + if (item.getItemId() == R.id.action_sort_playlist) { + View anchor = activity.findViewById(R.id.action_sort_playlist); + if (anchor == null) anchor = bind.animToolbar; + showSortPopupMenu(anchor); + return true; + } else if (item.getItemId() == R.id.action_download_playlist) { playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> { if (isVisible() && getActivity() != null) { if (Preferences.getDownloadDirectoryUri() == null) { @@ -324,6 +332,27 @@ private void reapplyPlayback() { } } + private void showSortPopupMenu(View anchor) { + PopupMenu popup = new PopupMenu(requireContext(), anchor); + popup.getMenuInflater().inflate(R.menu.sort_playlist_song_popup_menu, popup.getMenu()); + + popup.setOnMenuItemClickListener(menuItem -> { + if (menuItem.getItemId() == R.id.menu_playlist_song_sort_default) { + songHorizontalAdapter.sort(Constants.MEDIA_DEFAULT_ORDER); + return true; + } else if (menuItem.getItemId() == R.id.menu_playlist_song_sort_artist) { + songHorizontalAdapter.sort(Constants.MEDIA_BY_ARTIST); + return true; + } else if (menuItem.getItemId() == R.id.menu_playlist_song_sort_name) { + songHorizontalAdapter.sort(Constants.MEDIA_BY_TITLE); + return true; + } + return false; + }); + + popup.show(); + } + private void setMediaBrowserListenableFuture() { songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } diff --git a/app/src/main/java/com/elzify/music/ui/fragment/PodcastChannelPageFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/PodcastChannelPageFragment.java index 75b4f4b4..0aa8b29c 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/PodcastChannelPageFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/PodcastChannelPageFragment.java @@ -177,7 +177,7 @@ public void onPodcastEpisodeAltClick(Bundle bundle) { podcastChannelPageViewModel.requestPodcastEpisodeDownload(episode); Snackbar.make(requireView(), R.string.podcast_episode_download_request_snackbar, Snackbar.LENGTH_SHORT) - .setAnchorView(activity.bind.bottomNavigation) + .setAnchorView(activity.bind.navigationDock.dockCard) .show(); } } \ No newline at end of file diff --git a/app/src/main/java/com/elzify/music/ui/fragment/SettingsFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/SettingsFragment.java index 8eddb623..c3e90559 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/SettingsFragment.java @@ -1,87 +1,852 @@ package com.elzify.music.ui.fragment; -import static com.google.android.material.internal.ViewUtils.hideKeyboard; - +import android.app.Activity; +import android.content.Context; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.audiofx.AudioEffect; +import android.net.Uri; import android.os.Bundle; +import android.os.IBinder; +import android.text.InputFilter; +import android.text.InputType; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; +import androidx.annotation.OptIn; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.os.LocaleListCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.util.UnstableApi; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.fragment.NavHostFragment; +import androidx.preference.EditTextPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreference; +import com.elzify.music.BuildConfig; import com.elzify.music.R; -import com.elzify.music.databinding.FragmentSettingsBinding; +import com.elzify.music.helper.ThemeHelper; +import com.elzify.music.interfaces.DialogClickCallback; +import com.elzify.music.interfaces.ScanCallback; +import com.elzify.music.service.EqualizerManager; +import com.elzify.music.service.MediaService; import com.elzify.music.ui.activity.MainActivity; +import com.elzify.music.ui.dialog.DeleteDownloadStorageDialog; +import com.elzify.music.ui.dialog.DownloadStorageDialog; +import com.elzify.music.ui.dialog.StarredSyncDialog; +import com.elzify.music.ui.dialog.StarredAlbumSyncDialog; +import com.elzify.music.ui.dialog.StarredArtistSyncDialog; +import com.elzify.music.ui.dialog.StreamingCacheStorageDialog; +import com.elzify.music.util.DownloadUtil; import com.elzify.music.util.Preferences; +import com.elzify.music.util.UIUtil; +import com.elzify.music.util.ExternalAudioReader; +import com.elzify.music.viewmodel.SettingViewModel; +import com.elzify.music.ui.view.SettingsItemDecoration; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; + +import java.util.Locale; +import java.util.Map; + +@OptIn(markerClass = UnstableApi.class) +public class SettingsFragment extends PreferenceFragmentCompat implements PreferenceFragmentCompat.OnPreferenceStartScreenCallback { + private static final String TAG = "SettingsFragment"; -public class SettingsFragment extends Fragment { + private static final String DEFAULT_CATEGORY = "screen_appearance"; + + private static final String[][] TAB_DEFINITIONS = { + {"screen_appearance", "settings_tab_appearance"}, + {"screen_library", "settings_tab_library"}, + {"screen_playback", "settings_tab_playback"}, + {"screen_general", "settings_tab_general"}, + }; private MainActivity activity; - private FragmentSettingsBinding bind; + private SettingViewModel settingViewModel; + + private ActivityResultLauncher equalizerResultLauncher; + private ActivityResultLauncher directoryPickerLauncher; + + private MediaService.LocalBinder mediaServiceBinder; + private boolean isServiceBound = false; + + private String selectedCategory = DEFAULT_CATEGORY; + private ChipGroup chipGroup; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - activity = (MainActivity) getActivity(); + equalizerResultLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> {} + ); + + if (!BuildConfig.FLAVOR.equals("tempus")) { + PreferenceCategory githubUpdateCategory = findPreference("settings_github_update_category_key"); + if (githubUpdateCategory != null) { + getPreferenceScreen().removePreference(githubUpdateCategory); + } + } + + directoryPickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + requireContext().getContentResolver().takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ); + Preferences.setDownloadDirectoryUri(uri.toString()); + ExternalAudioReader.refreshCache(); + Toast.makeText(requireContext(), R.string.settings_download_folder_set, Toast.LENGTH_SHORT).show(); + checkDownloadDirectory(); + } + } + } + }); + } + + private boolean isRoot() { + return getArguments() == null || getArguments().getString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT) == null; } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - bind = FragmentSettingsBinding.inflate(inflater,container,false); - View view = bind.getRoot(); + activity = (MainActivity) getActivity(); + settingViewModel = new ViewModelProvider(requireActivity()).get(SettingViewModel.class); + + View prefView = super.onCreateView(inflater, container, savedInstanceState); + + if (!isRoot()) return prefView; + + LinearLayout root = new LinearLayout(requireContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + root.setBackgroundColor(UIUtil.getThemeColor(requireContext(), android.R.attr.colorBackground)); + + TextView title = new TextView(requireContext()); + title.setText("Settings"); + title.setTextSize(40); + title.setTypeface(null, android.graphics.Typeface.BOLD); + title.setPadding(UIUtil.dpToPx(requireContext(), 24), + UIUtil.dpToPx(requireContext(), 32), + UIUtil.dpToPx(requireContext(), 24), + UIUtil.dpToPx(requireContext(), 8)); + title.setTextColor(UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorOnSurface)); + root.addView(title); + + HorizontalScrollView scrollView = new HorizontalScrollView(requireContext()); + scrollView.setHorizontalScrollBarEnabled(false); + scrollView.setClipToPadding(false); + scrollView.setPadding(UIUtil.dpToPx(requireContext(), 16), 0, UIUtil.dpToPx(requireContext(), 16), UIUtil.dpToPx(requireContext(), 8)); + + chipGroup = new ChipGroup(requireContext()); + chipGroup.setSingleSelection(true); + chipGroup.setSelectionRequired(true); + chipGroup.setChipSpacingHorizontal(UIUtil.dpToPx(requireContext(), 8)); + + for (String[] tab : TAB_DEFINITIONS) { + Chip chip = new Chip(requireContext()); + chip.setTag(tab[0]); + + int stringResId = getResources().getIdentifier(tab[1], "string", requireContext().getPackageName()); + chip.setText(getString(stringResId)); - initAppBar(); + chip.setCheckable(true); + chip.setCheckedIconVisible(false); - return view; + chip.setChipBackgroundColor(android.content.res.ColorStateList.valueOf( + UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorSurfaceVariant))); + chip.setTextColor(UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorOnSurfaceVariant)); + chip.setOnClickListener(v -> { + String key = (String) v.getTag(); + if (!key.equals(selectedCategory)) { + switchCategory(key); + } + }); + + chipGroup.addView(chip); + } + + scrollView.addView(chipGroup); + root.addView(scrollView); + + if (prefView != null) { + root.addView(prefView); + } + + selectChip(selectedCategory); + + return root; + } + + private void selectChip(String categoryKey) { + if (chipGroup == null) return; + int primaryColor = UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorPrimary); + int onPrimaryColor = UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorOnPrimary); + int surfaceVariantColor = UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorSurfaceVariant); + int onSurfaceVariantColor = UIUtil.getThemeColor(requireContext(), com.google.android.material.R.attr.colorOnSurfaceVariant); + for (int i = 0; i < chipGroup.getChildCount(); i++) { + Chip chip = (Chip) chipGroup.getChildAt(i); + boolean selected = categoryKey.equals(chip.getTag()); + chip.setChecked(selected); + if (selected) { + chip.setChipBackgroundColor(android.content.res.ColorStateList.valueOf(primaryColor)); + chip.setTextColor(onPrimaryColor); + } else { + chip.setChipBackgroundColor(android.content.res.ColorStateList.valueOf(surfaceVariantColor)); + chip.setTextColor(onSurfaceVariantColor); + } + } + } + + private void switchCategory(String rootKey) { + selectedCategory = rootKey; + setPreferencesFromResource(R.xml.global_preferences, rootKey); + applyCustomLayouts(getPreferenceScreen()); + reinitializePreferences(); + selectChip(rootKey); } @Override - public void onViewCreated(@NonNull View view, - @Nullable Bundle savedInstanceState) { + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - // Add the PreferenceFragment only the first time - if (savedInstanceState == null) { - SettingsContainerFragment prefFragment = new SettingsContainerFragment(); + setDivider(null); + setDividerHeight(0); - // Use the child fragment manager so the PreferenceFragment is scoped to this fragment - getChildFragmentManager() - .beginTransaction() - .replace(R.id.settings_container, prefFragment) - .setReorderingAllowed(true) // optional but recommended - .commit(); + if (getListView() != null) { + getListView().setPadding(0, 0, 0, (int) getResources().getDimension(R.dimen.global_padding_bottom)); + getListView().setClipToPadding(false); + getListView().addItemDecoration(new SettingsItemDecoration(requireContext())); + } + + initAppBar(view); + } + + private void initAppBar(View view) { + androidx.appcompat.widget.Toolbar toolbar = activity.findViewById(R.id.toolbar); + + if (toolbar != null) { + activity.setSupportActionBar(toolbar); + if (activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setDisplayHomeAsUpEnabled(!isRoot()); + activity.getSupportActionBar().setDisplayShowHomeEnabled(!isRoot()); + activity.getSupportActionBar().setTitle(isRoot() ? "" : getPreferenceScreen().getTitle()); + } + + toolbar.setNavigationOnClickListener(v -> { + if (!activity.navController.popBackStack()) { + activity.navController.navigateUp(); + } + }); } } @Override public void onStart() { super.onStart(); - activity.setBottomNavigationBarVisibility(false); + activity.setBottomNavigationBarVisibility(true); activity.setBottomSheetVisibility(false); - activity.setNavigationDrawerLock(true); - activity.setSystemBarsVisibility(!activity.isLandscape); + } + + @Override + public void onResume() { + super.onResume(); + reinitializePreferences(); + bindMediaService(); + actionAppEqualizer(); + } + + private void reinitializePreferences() { + checkSystemEqualizer(); + checkCacheStorage(); + checkStorage(); + checkDownloadDirectory(); + + setStreamingCacheSize(); + setAppLanguage(); + setVersion(); + setNetorkPingTimeoutBase(); + + actionLogout(); + actionScan(); + actionSyncStarredAlbums(); + actionSyncStarredTracks(); + actionSyncStarredArtists(); + actionChangeStreamingCacheStorage(); + actionChangeDownloadStorage(); + actionSetDownloadDirectory(); + actionDeleteDownloadStorage(); + actionKeepScreenOn(); + actionAutoDownloadLyrics(); + actionMiniPlayerHeart(); + actionConfigureDock(); + actionConfigureMetadata(); + + ListPreference themePreference = findPreference(Preferences.THEME); + if (themePreference != null) { + themePreference.setOnPreferenceChangeListener( + (preference, newValue) -> { + String themeOption = (String) newValue; + ThemeHelper.applyTheme(themeOption); + return true; + }); + } + } + + private void actionConfigureDock() { + Preference pref = findPreference("configure_dock"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + NavController navController = NavHostFragment.findNavController(this); + navController.navigate(R.id.dockConfigurationFragment); + return true; + }); + } + } + + private void actionConfigureMetadata() { + Preference pref = findPreference("configure_metadata"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + NavController navController = NavHostFragment.findNavController(this); + navController.navigate(R.id.metadataConfigurationFragment); + return true; + }); + } } @Override public void onStop() { super.onStop(); activity.setBottomSheetVisibility(true); + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + if (rootKey == null) { + setPreferencesFromResource(R.xml.global_preferences, selectedCategory); + } else { + setPreferencesFromResource(R.xml.global_preferences, rootKey); + } + + applyCustomLayouts(getPreferenceScreen()); + } - if (activity.isLandscape) { - activity.setNavigationDrawerLock(false); - } else if (Preferences.getEnableDrawerOnPortrait()) { - activity.setNavigationDrawerLock(false); + private void applyCustomLayouts(androidx.preference.PreferenceGroup group) { + for (int i = 0; i < group.getPreferenceCount(); i++) { + Preference pref = group.getPreference(i); + if (pref instanceof PreferenceCategory) { + pref.setLayoutResource(R.layout.preference_category_card); + } + if (pref instanceof androidx.preference.PreferenceGroup) { + applyCustomLayouts((androidx.preference.PreferenceGroup) pref); + } } } - private void initAppBar() { - bind.settingsToolbar.setNavigationOnClickListener(v -> { - activity.navController.navigateUp(); + private void checkSystemEqualizer() { + Preference equalizer = findPreference("system_equalizer"); + + if (equalizer == null) return; + + Intent intent = new Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL); + + if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) { + equalizer.setOnPreferenceClickListener(preference -> { + equalizerResultLauncher.launch(intent); + return true; + }); + } else { + equalizer.setVisible(false); + } + } + + private void checkCacheStorage() { + Preference storage = findPreference("streaming_cache_storage"); + + if (storage == null) return; + + try { + if (requireContext().getExternalFilesDirs(null)[1] == null) { + storage.setVisible(false); + } else { + storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button); + } + } catch (Exception exception) { + storage.setVisible(false); + } + } + + private void checkStorage() { + Preference storage = findPreference("download_storage"); + + if (storage == null) return; + + try { + if (requireContext().getExternalFilesDirs(null)[1] == null) { + storage.setVisible(false); + } else { + int pref = Preferences.getDownloadStoragePreference(); + if (pref == 0) { + storage.setSummary(R.string.download_storage_internal_dialog_negative_button); + } else if (pref == 1) { + storage.setSummary(R.string.download_storage_external_dialog_positive_button); + } else { + storage.setSummary(R.string.download_storage_directory_dialog_neutral_button); + } + } + } catch (Exception exception) { + storage.setVisible(false); + } + } + + private void checkDownloadDirectory() { + Preference storage = findPreference("download_storage"); + Preference directory = findPreference("set_download_directory"); + + if (directory == null) return; + + String current = Preferences.getDownloadDirectoryUri(); + if (current != null) { + if (storage != null) storage.setVisible(false); + directory.setVisible(true); + directory.setIcon(R.drawable.ic_close); + directory.setTitle(R.string.settings_clear_download_folder); + directory.setSummary(current); + } else { + if (storage != null) storage.setVisible(true); + if (Preferences.getDownloadStoragePreference() == 2) { + directory.setVisible(true); + directory.setIcon(R.drawable.ic_folder); + directory.setTitle(R.string.settings_set_download_folder); + directory.setSummary(R.string.settings_choose_download_folder); + } else { + directory.setVisible(false); + } + } + } + + private void setNetorkPingTimeoutBase() { + EditTextPreference networkPingTimeoutBase = findPreference("network_ping_timeout_base"); + + if (networkPingTimeoutBase != null) { + networkPingTimeoutBase.setSummaryProvider(EditTextPreference.SimpleSummaryProvider.getInstance()); + networkPingTimeoutBase.setOnBindEditTextListener(editText -> { + editText.setInputType(InputType.TYPE_CLASS_NUMBER); + editText.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) -> { + for (int i = start; i < end; i++) { + if (!Character.isDigit(source.charAt(i))) { + return ""; + } + } + return null; + }}); + }); + + networkPingTimeoutBase.setOnPreferenceChangeListener((preference, newValue) -> { + String input = (String) newValue; + return input != null && !input.isEmpty(); }); + } + } + + private void setStreamingCacheSize() { + ListPreference streamingCachePreference = findPreference("streaming_cache_size"); + + if (streamingCachePreference != null) { + streamingCachePreference.setSummaryProvider(new Preference.SummaryProvider() { + @Nullable + @Override + public CharSequence provideSummary(@NonNull ListPreference preference) { + CharSequence entry = preference.getEntry(); + + if (entry == null) return null; + + long currentSizeMb = DownloadUtil.getStreamingCacheSize(requireActivity()) / (1024 * 1024); + + return getString(R.string.settings_summary_streaming_cache_size, entry, String.valueOf(currentSizeMb)); + } + }); + } + } + + private void setAppLanguage() { + ListPreference localePref = (ListPreference) findPreference("language"); + if (localePref == null) return; + + Map locales = UIUtil.getLangPreferenceDropdownEntries(requireContext()); + + CharSequence[] entries = locales.keySet().toArray(new CharSequence[locales.size()]); + CharSequence[] entryValues = locales.values().toArray(new CharSequence[locales.size()]); + + localePref.setEntries(entries); + localePref.setEntryValues(entryValues); + + String value = localePref.getValue(); + if ("default".equals(value)) { + localePref.setSummary(requireContext().getString(R.string.settings_system_language)); + } else { + localePref.setSummary(Locale.forLanguageTag(value).getDisplayName()); + } + + localePref.setOnPreferenceChangeListener((preference, newValue) -> { + if ("default".equals(newValue)) { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()); + preference.setSummary(requireContext().getString(R.string.settings_system_language)); + } else { + LocaleListCompat appLocale = LocaleListCompat.forLanguageTags((String) newValue); + AppCompatDelegate.setApplicationLocales(appLocale); + preference.setSummary(Locale.forLanguageTag((String) newValue).getDisplayName()); + } + return true; + }); + } + + private void setVersion() { + Preference pref = findPreference("version"); + if (pref != null) { + pref.setSummary(BuildConfig.VERSION_NAME); + } + } + + private void actionLogout() { + Preference pref = findPreference("logout"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + activity.quit(); + return true; + }); + } + } + + private void actionScan() { + Preference pref = findPreference("scan_library"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + settingViewModel.launchScan(new ScanCallback() { + @Override + public void onError(Exception exception) { + Preference p = findPreference("scan_library"); + if (p != null) p.setSummary(exception.getMessage()); + } + + @Override + public void onSuccess(boolean isScanning, long count) { + Preference p = findPreference("scan_library"); + if (p != null) p.setSummary(getString(R.string.settings_scan_result, count)); + if (isScanning) getScanStatus(); + } + }); + + return true; + }); + } + } + + private void actionSyncStarredTracks() { + Preference pref = findPreference("sync_starred_tracks_for_offline_use"); + if (pref != null) { + pref.setOnPreferenceChangeListener((preference, newValue) -> { + if (newValue instanceof Boolean) { + if ((Boolean) newValue) { + StarredSyncDialog dialog = new StarredSyncDialog(() -> { + ((SwitchPreference)preference).setChecked(false); + }); + dialog.show(activity.getSupportFragmentManager(), null); + } + } + return true; + }); + } + } + + private void actionSyncStarredAlbums() { + Preference pref = findPreference("sync_starred_albums_for_offline_use"); + if (pref != null) { + pref.setOnPreferenceChangeListener((preference, newValue) -> { + if (newValue instanceof Boolean) { + if ((Boolean) newValue) { + StarredAlbumSyncDialog dialog = new StarredAlbumSyncDialog(() -> { + ((SwitchPreference)preference).setChecked(false); + }); + dialog.show(activity.getSupportFragmentManager(), null); + } + } + return true; + }); + } + } + + private void actionSyncStarredArtists() { + Preference pref = findPreference("sync_starred_artists_for_offline_use"); + if (pref != null) { + pref.setOnPreferenceChangeListener((preference, newValue) -> { + if (newValue instanceof Boolean) { + if ((Boolean) newValue) { + StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> { + ((SwitchPreference)preference).setChecked(false); + }); + dialog.show(activity.getSupportFragmentManager(), null); + } + } + return true; + }); + } + } + + private void actionChangeStreamingCacheStorage() { + Preference pref = findPreference("streaming_cache_storage"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() { + @Override + public void onPositiveClick() { + Preference p = findPreference("streaming_cache_storage"); + if (p != null) p.setSummary(R.string.streaming_cache_storage_external_dialog_positive_button); + } + + @Override + public void onNegativeClick() { + Preference p = findPreference("streaming_cache_storage"); + if (p != null) p.setSummary(R.string.streaming_cache_storage_internal_dialog_negative_button); + } + }); + dialog.show(activity.getSupportFragmentManager(), null); + return true; + }); + } + } + + private void actionChangeDownloadStorage() { + Preference pref = findPreference("download_storage"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + DownloadStorageDialog dialog = new DownloadStorageDialog(new DialogClickCallback() { + @Override + public void onPositiveClick() { + Preference p = findPreference("download_storage"); + if (p != null) p.setSummary(R.string.download_storage_external_dialog_positive_button); + checkDownloadDirectory(); + } + + @Override + public void onNegativeClick() { + Preference p = findPreference("download_storage"); + if (p != null) p.setSummary(R.string.download_storage_internal_dialog_negative_button); + checkDownloadDirectory(); + } + + @Override + public void onNeutralClick() { + Preference p = findPreference("download_storage"); + if (p != null) p.setSummary(R.string.download_storage_directory_dialog_neutral_button); + checkDownloadDirectory(); + } + }); + dialog.show(activity.getSupportFragmentManager(), null); + return true; + }); + } + } + + private void actionSetDownloadDirectory() { + Preference pref = findPreference("set_download_directory"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + String current = Preferences.getDownloadDirectoryUri(); + + if (current != null) { + Preferences.setDownloadDirectoryUri(null); + Preferences.setDownloadStoragePreference(0); + ExternalAudioReader.refreshCache(); + Toast.makeText(requireContext(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show(); + checkStorage(); + checkDownloadDirectory(); + } else { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + directoryPickerLauncher.launch(intent); + } + return true; + }); + } + } + + private void actionDeleteDownloadStorage() { + Preference pref = findPreference("delete_download_storage"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog(); + dialog.show(activity.getSupportFragmentManager(), null); + return true; + }); + } + } + + private void actionMiniPlayerHeart() { + SwitchPreference preference = findPreference("mini_shuffle_button_visibility"); + if (preference != null) { + preference.setChecked(Preferences.showShuffleInsteadOfHeart()); + preference.setOnPreferenceChangeListener((pref, newValue) -> { + if (newValue instanceof Boolean) { + Preferences.setShuffleInsteadOfHeart((Boolean) newValue); + } + return true; + }); + } + } + + private void actionAutoDownloadLyrics() { + SwitchPreference preference = findPreference("auto_download_lyrics"); + if (preference != null) { + preference.setChecked(Preferences.isAutoDownloadLyricsEnabled()); + preference.setOnPreferenceChangeListener((pref, newValue) -> { + if (newValue instanceof Boolean) { + Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue); + } + return true; + }); + } + } + + private void getScanStatus() { + Preference pref = findPreference("scan_library"); + if (pref != null) { + settingViewModel.getScanStatus(new ScanCallback() { + @Override + public void onError(Exception exception) { + Preference p = findPreference("scan_library"); + if (p != null) p.setSummary(exception.getMessage()); + } + + @Override + public void onSuccess(boolean isScanning, long count) { + Preference p = findPreference("scan_library"); + if (p != null) p.setSummary(getString(R.string.settings_scan_result, count)); + if (isScanning) getScanStatus(); + } + }); + } + } + + private void actionKeepScreenOn() { + Preference pref = findPreference("always_on_display"); + if (pref != null) { + pref.setOnPreferenceChangeListener((preference, newValue) -> { + if (newValue instanceof Boolean) { + if ((Boolean) newValue) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + return true; + }); + } + } + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mediaServiceBinder = (MediaService.LocalBinder) service; + isServiceBound = true; + checkEqualizerBands(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mediaServiceBinder = null; + isServiceBound = false; + } + }; + + private void bindMediaService() { + Intent intent = new Intent(requireActivity(), MediaService.class); + intent.setAction(MediaService.ACTION_BIND_EQUALIZER); + requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + isServiceBound = true; + } + + private void checkEqualizerBands() { + if (mediaServiceBinder != null) { + EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager(); + short numBands = eqManager.getNumberOfBands(); + Preference appEqualizer = findPreference("app_equalizer"); + if (appEqualizer != null) { + appEqualizer.setVisible(numBands > 0); + } + } + } + + private void actionAppEqualizer() { + Preference appEqualizer = findPreference("app_equalizer"); + if (appEqualizer != null) { + appEqualizer.setOnPreferenceClickListener(preference -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + activity.setBottomNavigationBarVisibility(true); + activity.setBottomSheetVisibility(true); + navController.navigate(R.id.equalizerFragment, null, navOptions); + return true; + }); + } + } + + @Override + public boolean onPreferenceStartScreen(PreferenceFragmentCompat caller, PreferenceScreen pref) { + String key = pref.getKey(); + for (String[] tab : TAB_DEFINITIONS) { + if (tab[0].equals(key)) { + return false; + } + } + + Bundle args = new Bundle(); + args.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, key); + + NavController navController = NavHostFragment.findNavController(this); + navController.navigate(R.id.settingsFragment, args); + return true; + } + + @Override + public void onPause() { + super.onPause(); + if (isServiceBound) { + requireActivity().unbindService(serviceConnection); + isServiceBound = false; + } } } diff --git a/app/src/main/java/com/elzify/music/ui/fragment/SongListPageFragment.java b/app/src/main/java/com/elzify/music/ui/fragment/SongListPageFragment.java index 1ebbb414..da9fb777 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/SongListPageFragment.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/SongListPageFragment.java @@ -142,6 +142,10 @@ private void init() { songListPageViewModel.year = requireArguments().getInt("year_object"); songListPageViewModel.toolbarTitle = getString(R.string.song_list_page_year, songListPageViewModel.year); bind.pageTitleLabel.setText(getString(R.string.song_list_page_year, songListPageViewModel.year)); + } else if (requireArguments().getString(Constants.MEDIA_TOP_PLAYED) != null) { + songListPageViewModel.title = Constants.MEDIA_TOP_PLAYED; + songListPageViewModel.toolbarTitle = getString(R.string.song_list_page_top_played); + bind.pageTitleLabel.setText(R.string.song_list_page_top_played); } else if (requireArguments().getString(Constants.MEDIA_STARRED) != null) { songListPageViewModel.title = Constants.MEDIA_STARRED; songListPageViewModel.toolbarTitle = getString(R.string.song_list_page_starred); @@ -184,6 +188,8 @@ private void initAppBar() { private void initButtons() { songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> { + if (songs == null) return; + if (bind != null) { setSongListPageSorter(); @@ -206,6 +212,8 @@ private void initSongListView() { setMediaBrowserListenableFuture(); reapplyPlayback(); songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> { + if (songs == null) return; + isLoading = false; songHorizontalAdapter.setItems(songs); reapplyPlayback(); @@ -302,6 +310,8 @@ private void setSongListPageSubtitle(List children) { case Constants.MEDIA_BY_ARTIST: case Constants.MEDIA_BY_GENRES: case Constants.MEDIA_STARRED: + case Constants.MEDIA_TOP_PLAYED: + case Constants.MEDIA_RECENTLY_PLAYED: bind.pageSubtitleLabel.setText(getString(R.string.generic_list_page_count, children.size())); break; } @@ -311,6 +321,8 @@ private void setSongListPageSorter() { switch (songListPageViewModel.title) { case Constants.MEDIA_BY_GENRE: case Constants.MEDIA_BY_YEAR: + case Constants.MEDIA_TOP_PLAYED: + case Constants.MEDIA_RECENTLY_PLAYED: bind.songListSortImageView.setVisibility(View.GONE); break; case Constants.MEDIA_BY_ARTIST: @@ -367,4 +379,4 @@ private void reapplyPlayback() { private void setMediaBrowserListenableFuture() { songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/elzify/music/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java index bdead260..4f53af91 100644 --- a/app/src/main/java/com/elzify/music/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java +++ b/app/src/main/java/com/elzify/music/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java @@ -31,6 +31,7 @@ import com.elzify.music.ui.activity.MainActivity; import com.elzify.music.ui.dialog.PlaylistChooserDialog; import com.elzify.music.ui.dialog.RatingDialog; +import com.elzify.music.ui.dialog.TrackInfoDialog; import com.elzify.music.util.AssetLinkUtil; import com.elzify.music.util.Constants; import com.elzify.music.util.DownloadUtil; @@ -313,6 +314,13 @@ public void onAllSkipped() { })); share.setVisibility(Preferences.isSharingEnabled() ? View.VISIBLE : View.GONE); + + TextView trackInfo = view.findViewById(R.id.track_info_text_view); + trackInfo.setOnClickListener(v -> { + TrackInfoDialog dialog = new TrackInfoDialog(MappingUtil.mapMediaItem(song).mediaMetadata); + dialog.show(requireActivity().getSupportFragmentManager(), null); + dismissBottomSheet(); + }); } @Override diff --git a/app/src/main/java/com/elzify/music/ui/view/AccentColorPreference.java b/app/src/main/java/com/elzify/music/ui/view/AccentColorPreference.java new file mode 100644 index 00000000..19cda2c1 --- /dev/null +++ b/app/src/main/java/com/elzify/music/ui/view/AccentColorPreference.java @@ -0,0 +1,87 @@ +package com.elzify.music.ui.view; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.elzify.music.R; +import com.elzify.music.util.Preferences; + +public class AccentColorPreference extends Preference { + + private static final int[] COLORS = { + Color.parseColor("#6750A4"), // Default Purple + Color.parseColor("#4CAF50"), // Green + Color.parseColor("#2196F3"), // Blue + Color.parseColor("#E91E63"), // Pink + Color.parseColor("#FF9800"), // Orange + Color.parseColor("#00BCD4"), // Cyan + Color.parseColor("#9C27B0") // Purple + }; + + public AccentColorPreference(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + setLayoutResource(R.layout.preference_accent_color); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + LinearLayout container = (LinearLayout) holder.findViewById(R.id.color_options_container); + container.removeAllViews(); + + int selectedColor = Preferences.getAccentColor(); + + for (int color : COLORS) { + View colorView = createColorView(color, color == selectedColor); + container.addView(colorView); + } + } + + private View createColorView(int color, boolean isSelected) { + int size = (int) (40 * getContext().getResources().getDisplayMetrics().density); + int margin = (int) (8 * getContext().getResources().getDisplayMetrics().density); + + FrameLayout frame = new FrameLayout(getContext()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(size, size); + params.setMargins(0, 0, margin, 0); + frame.setLayoutParams(params); + + View circle = new View(getContext()); + GradientDrawable shape = new GradientDrawable(); + shape.setShape(GradientDrawable.OVAL); + shape.setColor(color); + circle.setBackground(shape); + frame.addView(circle); + + if (isSelected) { + ImageView check = new ImageView(getContext()); + check.setImageResource(R.drawable.ic_check_circle); + check.setColorFilter(Color.WHITE); + FrameLayout.LayoutParams checkParams = new FrameLayout.LayoutParams(size / 2, size / 2); + checkParams.gravity = android.view.Gravity.CENTER; + check.setLayoutParams(checkParams); + frame.addView(check); + } + + frame.setOnClickListener(v -> { + Preferences.setAccentColor(color); + notifyChanged(); + if (getContext() instanceof android.app.Activity) { + ((android.app.Activity) getContext()).recreate(); + } + }); + + return frame; + } +} diff --git a/app/src/main/java/com/elzify/music/ui/view/SettingsItemDecoration.java b/app/src/main/java/com/elzify/music/ui/view/SettingsItemDecoration.java new file mode 100644 index 00000000..9e18437f --- /dev/null +++ b/app/src/main/java/com/elzify/music/ui/view/SettingsItemDecoration.java @@ -0,0 +1,107 @@ +package com.elzify.music.ui.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.elzify.music.R; +import com.elzify.music.util.UIUtil; + +public class SettingsItemDecoration extends RecyclerView.ItemDecoration { + + private final float cornerRadius; + private final int margin; + private final int contentInset; + private final Paint paint; + + public SettingsItemDecoration(Context context) { + float density = context.getResources().getDisplayMetrics().density; + cornerRadius = 24 * density; + margin = (int) (16 * density); + contentInset = (int) (8 * density); + paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(UIUtil.getThemeColor(context, com.google.android.material.R.attr.colorSurfaceContainerLow)); + } + + @Override + public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + super.onDraw(c, parent, state); + + int childCount = parent.getChildCount(); + if (childCount == 0) return; + + for (int i = 0; i < childCount; i++) { + View child = parent.getChildAt(i); + int position = parent.getChildAdapterPosition(child); + if (position == RecyclerView.NO_POSITION) continue; + + if (isCategoryItem(child)) continue; + + boolean isFirst = isFirstInCategory(parent, position); + boolean isLast = isLastInCategory(parent, position, state.getItemCount()); + + float left = margin; + float right = parent.getWidth() - margin; + float top = child.getTop(); + float bottom = child.getBottom(); + + // Add slight vertical margin between items if they are not part of the same card + // But here we want items in same card to be tight. + + Path path = new Path(); + float[] radii = new float[8]; + + if (isFirst && isLast) { + for (int j = 0; j < 8; j++) radii[j] = cornerRadius; + } else if (isFirst) { + radii[0] = cornerRadius; radii[1] = cornerRadius; + radii[2] = cornerRadius; radii[3] = cornerRadius; + } else if (isLast) { + radii[4] = cornerRadius; radii[5] = cornerRadius; + radii[6] = cornerRadius; radii[7] = cornerRadius; + } + + path.addRoundRect(new RectF(left, top, right, bottom), radii, Path.Direction.CW); + c.drawPath(path, paint); + } + } + + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + if (!isCategoryItem(view)) { + outRect.left = contentInset; + outRect.right = contentInset; + } + } + + private boolean isCategoryItem(View view) { + // Our custom category layout has a title with android.R.id.title + // and no icon. Regular preferences have icons now. + return view.findViewById(android.R.id.title) != null && view.findViewById(android.R.id.icon) == null; + } + + private boolean isFirstInCategory(RecyclerView parent, int position) { + if (position == 0) return true; + View prevView = parent.getLayoutManager().findViewByPosition(position - 1); + if (prevView != null) { + return isCategoryItem(prevView); + } + return false; + } + + private boolean isLastInCategory(RecyclerView parent, int position, int itemCount) { + if (position == itemCount - 1) return true; + View nextView = parent.getLayoutManager().findViewByPosition(position + 1); + if (nextView != null) { + return isCategoryItem(nextView); + } + return false; + } +} diff --git a/app/src/main/java/com/elzify/music/util/Constants.kt b/app/src/main/java/com/elzify/music/util/Constants.kt index b9638db2..3ee0df3f 100644 --- a/app/src/main/java/com/elzify/music/util/Constants.kt +++ b/app/src/main/java/com/elzify/music/util/Constants.kt @@ -19,6 +19,8 @@ object Constants { const val MUSIC_DIRECTORY_OBJECT = "MUSIC_DIRECTORY_OBJECT" const val MUSIC_INDEX_OBJECT = "MUSIC_DIRECTORY_OBJECT" const val MUSIC_DIRECTORY_ID = "MUSIC_DIRECTORY_ID" + const val ALBUMS_OBJECT = "ALBUMS_OBJECT" + const val ALBUM_LIST_TITLE = "ALBUM_LIST_TITLE" const val ALBUM_RECENTLY_PLAYED = "ALBUM_RECENTLY_PLAYED" const val ALBUM_MOST_PLAYED = "ALBUM_MOST_PLAYED" @@ -37,6 +39,8 @@ object Constants { const val ALBUM_ORDER_BY_MOST_RECENTLY_STARRED = "ALBUM_ORDER_BY_MOST_RECENTLY_STARRED" const val ALBUM_ORDER_BY_LEAST_RECENTLY_STARRED = "ALBUM_ORDER_BY_LEAST_RECENTLY_STARRED" + const val ARTIST_RECENTLY_PLAYED = "ARTIST_RECENTLY_PLAYED" + const val ARTIST_TOP_PLAYED = "ARTIST_TOP_PLAYED" const val ARTIST_DOWNLOADED = "ARTIST_DOWNLOADED" const val ARTIST_STARRED = "ARTIST_STARRED" const val ARTIST_ORDER_BY_NAME = "ARTIST_ORDER_BY_NAME" @@ -62,6 +66,7 @@ object Constants { const val MEDIA_TYPE_VIDEO = "video" const val MEDIA_TYPE_RADIO = "radio" + const val MEDIA_TOP_PLAYED = "MEDIA_TOP_PLAYED" const val MEDIA_RECENTLY_PLAYED = "MEDIA_RECENTLY_PLAYED" const val MEDIA_MOST_PLAYED = "MEDIA_MOST_PLAYED" const val MEDIA_RECENTLY_ADDED = "MEDIA_RECENTLY_ADDED" @@ -76,6 +81,7 @@ object Constants { const val MEDIA_CHRONOLOGY = "MEDIA_CHRONOLOGY" const val MEDIA_BEST_OF = "MEDIA_BEST_OF" const val MEDIA_BY_TITLE = "MEDIA_BY_TITLE" + const val MEDIA_DEFAULT_ORDER = "MEDIA_DEFAULT_ORDER" const val MEDIA_MOST_RECENTLY_STARRED = "MEDIA_MOST_RECENTLY_STARRED" const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED" @@ -118,6 +124,31 @@ object Constants { const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED" const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS" const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED" + const val HOME_SECTOR_HISTORY = "HOME_SECTOR_HISTORY" + const val HOME_SECTOR_RECENTLY_PLAYED_ARTISTS = "HOME_SECTOR_RECENTLY_PLAYED_ARTISTS" + const val HOME_SECTOR_TOP_PLAYED_ARTISTS = "HOME_SECTOR_TOP_PLAYED_ARTISTS" + const val HOME_SECTOR_TOP_PLAYED_SONGS = "HOME_SECTOR_TOP_PLAYED_SONGS" + + const val DOCK_ITEMS = "DOCK_ITEMS" + const val DOCK_ITEM_HOME = "homeFragment" + const val DOCK_ITEM_LIBRARY = "libraryFragment" + const val DOCK_ITEM_DOWNLOADS = "downloadFragment" + const val DOCK_ITEM_ALBUMS = "albumCatalogueFragment" + const val DOCK_ITEM_PLAYLISTS = "playlistCatalogueFragment" + const val DOCK_ITEM_SEARCH = "searchFragment" + const val DOCK_ITEM_SETTINGS = "settingsFragment" + + const val METADATA_TITLE = "METADATA_TITLE" + const val METADATA_ARTIST = "METADATA_ARTIST" + const val METADATA_ALBUM = "METADATA_ALBUM" + const val METADATA_YEAR = "METADATA_YEAR" + const val METADATA_GENRE = "METADATA_GENRE" + const val METADATA_BITRATE = "METADATA_BITRATE" + const val METADATA_PLAY_COUNT = "METADATA_PLAY_COUNT" + const val METADATA_SCROBBLES = "METADATA_SCROBBLES" + + const val LAST_FM_USER = "last_fm_user" + const val LAST_FM_API_KEY = "last_fm_api_key" const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON" const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF" diff --git a/app/src/main/java/com/elzify/music/util/DownloadUtil.java b/app/src/main/java/com/elzify/music/util/DownloadUtil.java index 0a73bc20..5bb0d3d4 100644 --- a/app/src/main/java/com/elzify/music/util/DownloadUtil.java +++ b/app/src/main/java/com/elzify/music/util/DownloadUtil.java @@ -88,7 +88,7 @@ public static synchronized DataSource.Factory getHttpDataSourceFactoryForRadio() // Create a factory with ICY metadata support for radio streams Map defaultRequestProperties = new HashMap<>(); defaultRequestProperties.put("Icy-MetaData", "1"); - defaultRequestProperties.put("User-Agent", "Tempus/1.0"); + defaultRequestProperties.put("User-Agent", "Rollynn/1.0"); return new DefaultHttpDataSource .Factory() diff --git a/app/src/main/java/com/elzify/music/util/Preferences.kt b/app/src/main/java/com/elzify/music/util/Preferences.kt index 19b0ff88..27c64467 100644 --- a/app/src/main/java/com/elzify/music/util/Preferences.kt +++ b/app/src/main/java/com/elzify/music/util/Preferences.kt @@ -1,6 +1,5 @@ package com.elzify.music.util -import android.util.Log import androidx.media3.common.Player import com.elzify.music.App import com.elzify.music.model.HomeSector @@ -16,7 +15,6 @@ object Preferences { private const val TOKEN = "token" private const val SALT = "salt" private const val LOW_SECURITY = "low_security" - private const val CLIENT_CERT = "client_cert" private const val BATTERY_OPTIMIZATION = "battery_optimization" private const val SERVER_ID = "server_id" private const val OPEN_SUBSONIC = "open_subsonic" @@ -25,15 +23,12 @@ object Preferences { private const val IN_USE_SERVER_ADDRESS = "in_use_server_address" private const val NEXT_SERVER_SWITCH = "next_server_switch" private const val PLAYBACK_SPEED = "playback_speed" - private const val BITRATE_VISIBLE = "bitrate_visible" private const val SKIP_SILENCE = "skip_silence" private const val SHUFFLE_MODE = "shuffle_mode" private const val REPEAT_MODE = "repeat_mode" private const val IMAGE_CACHE_SIZE = "image_cache_size" private const val STREAMING_CACHE_SIZE = "streaming_cache_size" private const val LANDSCAPE_ITEMS_PER_ROW = "landscape_items_per_row" - private const val ENABLE_DRAWER_ON_PORTRAIT = "enable_drawer_on_portrait" - private const val HIDE_BOTTOM_NAVBAR_ON_PORTRAIT = "hide_bottom_navbar_on_portrait" private const val IMAGE_SIZE = "image_size" private const val MAX_BITRATE_WIFI = "max_bitrate_wifi" private const val MAX_BITRATE_MOBILE = "max_bitrate_mobile" @@ -65,6 +60,7 @@ object Preferences { private const val AUDIO_TRANSCODE_FORMAT_DOWNLOAD = "audio_transcode_format_download" private const val SHARE = "share" private const val SCROBBLING = "scrobbling" + private const val SCROBBLE_THRESHOLD = "scrobble_threshold" private const val ESTIMATE_CONTENT_LENGTH = "estimate_content_length" private const val BUFFERING_STRATEGY = "buffering_strategy" private const val SKIP_MIN_STAR_RATING = "skip_min_star_rating" @@ -91,20 +87,20 @@ object Preferences { private const val SORT_SEARCH_CHRONOLOGICALLY= "sort_search_chronologically" private const val ARTIST_DISPLAY_BIOGRAPHY= "artist_display_biography" private const val NETWORK_PING_TIMEOUT = "network_ping_timeout_base" - + private const val DOCK_ITEMS = "dock_items" + private const val ACCENT_COLOR = "accent_color" + private const val NOW_PLAYING_METADATA = "now_playing_metadata" + private const val PLAY_NEXT_BEHAVIOR = "play_next_behavior" + private const val CLIENT_CERT = "client_cert" + private const val HIDE_BOTTOM_NAVBAR_ON_PORTRAIT = "hide_bottom_navbar_on_portrait" + private const val ENABLE_DRAWER_ON_PORTRAIT = "enable_drawer_on_portrait" + private const val BITRATE_VISIBLE = "bitrate_visible" private const val TILE_SIZE = "tile_size" - private const val AA_ALBUM_VIEW = "androidauto_album_view" - private const val AA_HOME_VIEW = "androidauto_home_view" - private const val AA_PLAYLIST_VIEW = "androidauto_playlist_view" - private const val AA_PODCAST_VIEW = "androidauto_podcast_view" - private const val AA_RADIO_VIEW = "androidauto_radio_view" - private const val AA_FIRST_TAB = "androidauto_first_tab" - private const val AA_SECOND_TAB = "androidauto_second_tab" - private const val AA_THIRD_TAB = "androidauto_third_tab" - private const val AA_FOURTH_TAB = "androidauto_fourth_tab" - private const val AA_SHUFFLE_GENRE_SONGS = "androidauto_shuffle_genre_songs" + const val PLAY_NEXT_BEHAVIOR_TOP = "top" + const val PLAY_NEXT_BEHAVIOR_SEQUENTIAL = "sequential" - @JvmStatic + + @JvmStatic fun getServer(): String? { return App.getInstance().preferences.getString(SERVER, null) } @@ -139,32 +135,32 @@ object Preferences { @JvmStatic fun getPassword(): String? { - return App.getInstance().preferences.getString(PASSWORD, null) + return App.getInstance().encryptedPreferences.getString(PASSWORD, null) } @JvmStatic fun setPassword(password: String?) { - App.getInstance().preferences.edit().putString(PASSWORD, password).apply() + App.getInstance().encryptedPreferences.edit().putString(PASSWORD, password).apply() } @JvmStatic fun getToken(): String? { - return App.getInstance().preferences.getString(TOKEN, null) + return App.getInstance().encryptedPreferences.getString(TOKEN, null) } @JvmStatic fun setToken(token: String?) { - App.getInstance().preferences.edit().putString(TOKEN, token).apply() + App.getInstance().encryptedPreferences.edit().putString(TOKEN, token).apply() } @JvmStatic fun getSalt(): String? { - return App.getInstance().preferences.getString(SALT, null) + return App.getInstance().encryptedPreferences.getString(SALT, null) } @JvmStatic fun setSalt(salt: String?) { - App.getInstance().preferences.edit().putString(SALT, salt).apply() + App.getInstance().encryptedPreferences.edit().putString(SALT, salt).apply() } @JvmStatic @@ -177,16 +173,6 @@ object Preferences { App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply() } - @JvmStatic - fun getClientCert(): String? { - return App.getInstance().preferences.getString(CLIENT_CERT, null) - } - - @JvmStatic - fun setClientCert(clientCert: String?) { - App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply() - } - @JvmStatic fun getServerId(): String? { return App.getInstance().preferences.getString(SERVER_ID, null) @@ -295,16 +281,6 @@ object Preferences { App.getInstance().preferences.edit().putFloat(PLAYBACK_SPEED, playbackSpeed).apply() } - @JvmStatic - fun getBitrateVisible(): Boolean { - return App.getInstance().preferences.getBoolean(BITRATE_VISIBLE, true) - } - - @JvmStatic - fun setBitrateVisible(bitrateVisible: Boolean) { - App.getInstance().preferences.edit().putBoolean(BITRATE_VISIBLE, bitrateVisible).apply() - } - @JvmStatic fun isSkipSilenceMode(): Boolean { return App.getInstance().preferences.getBoolean(SKIP_SILENCE, false) @@ -345,16 +321,6 @@ object Preferences { return App.getInstance().preferences.getString(LANDSCAPE_ITEMS_PER_ROW, "4")!!.toInt() } - @JvmStatic - fun getEnableDrawerOnPortrait(): Boolean { - return App.getInstance().preferences.getBoolean(ENABLE_DRAWER_ON_PORTRAIT, false) - } - - @JvmStatic - fun getHideBottomNavbarOnPortrait(): Boolean { - return App.getInstance().preferences.getBoolean(HIDE_BOTTOM_NAVBAR_ON_PORTRAIT, false) - } - @JvmStatic fun getImageSize(): Int { return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt() @@ -547,10 +513,6 @@ object Preferences { @JvmStatic fun setDownloadDirectoryUri(uri: String?) { - val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null) - if (current != uri) { - ExternalDownloadMetadataStore.clear() - } App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply() } @@ -600,6 +562,16 @@ object Preferences { return App.getInstance().preferences.getBoolean(SCROBBLING, true) } + @JvmStatic + fun getScrobbleThreshold(): Int { + return App.getInstance().preferences.getInt(SCROBBLE_THRESHOLD, 90) + } + + @JvmStatic + fun setScrobbleThreshold(threshold: Int) { + App.getInstance().preferences.edit().putInt(SCROBBLE_THRESHOLD, threshold).apply() + } + @JvmStatic fun askForEstimateContentLength(): Boolean { return App.getInstance().preferences.getBoolean(ESTIMATE_CONTENT_LENGTH, false) @@ -747,12 +719,9 @@ object Preferences { @JvmStatic fun getArtistSortOrder(): String { - val sort_by_album_count = App.getInstance().preferences.getBoolean(ARTIST_SORT_BY_ALBUM_COUNT, false) - Log.d("Preferences", "getSortOrder") - if (sort_by_album_count) - return Constants.ARTIST_ORDER_BY_ALBUM_COUNT - else - return Constants.ARTIST_ORDER_BY_NAME + val sortByAlbumCount = App.getInstance().preferences.getBoolean(ARTIST_SORT_BY_ALBUM_COUNT, false) + return if (sortByAlbumCount) Constants.ARTIST_ORDER_BY_ALBUM_COUNT + else Constants.ARTIST_ORDER_BY_NAME } @JvmStatic @@ -771,62 +740,162 @@ object Preferences { } @JvmStatic - fun getTileSize(): Int { - val parsed = App.getInstance().preferences.getString(TILE_SIZE, "2")?.toIntOrNull() - return parsed?.takeIf { it in 2..6 } ?: 2 + fun getDockItems(): List { + val json = App.getInstance().preferences.getString(DOCK_ITEMS, null) + if (json == null) { + return listOf( + Constants.DOCK_ITEM_HOME, + Constants.DOCK_ITEM_SEARCH, + Constants.DOCK_ITEM_LIBRARY, + Constants.DOCK_ITEM_DOWNLOADS, + Constants.DOCK_ITEM_SETTINGS + ) + } + return Gson().fromJson(json, object : com.google.gson.reflect.TypeToken>() {}.type) } - fun isAndroidAutoAlbumViewEnabled(): Boolean { - return App.getInstance().preferences.getBoolean(AA_ALBUM_VIEW, true) + + @JvmStatic + fun setDockItems(items: List) { + App.getInstance().preferences.edit().putString(DOCK_ITEMS, Gson().toJson(items)).apply() } @JvmStatic - fun isAndroidAutoHomeViewEnabled(): Boolean { - return App.getInstance().preferences.getBoolean(AA_HOME_VIEW, false) + fun getAccentColor(): Int { + return App.getInstance().preferences.getInt(ACCENT_COLOR, -1) } @JvmStatic - fun isAndroidAutoPlaylistViewEnabled(): Boolean { - return App.getInstance().preferences.getBoolean(AA_PLAYLIST_VIEW, false) + fun setAccentColor(color: Int) { + App.getInstance().preferences.edit().putInt(ACCENT_COLOR, color).apply() } @JvmStatic - fun isAndroidAutoPodcastViewEnabled(): Boolean { - return App.getInstance().preferences.getBoolean(AA_PODCAST_VIEW, false) + fun getNowPlayingMetadata(): List { + val json = App.getInstance().preferences.getString(NOW_PLAYING_METADATA, null) + if (json == null) { + return listOf( + Constants.METADATA_TITLE, + Constants.METADATA_ARTIST, + Constants.METADATA_ALBUM + ) + } + return Gson().fromJson(json, object : com.google.gson.reflect.TypeToken>() {}.type) } @JvmStatic - fun isAndroidAutoRadioViewEnabled(): Boolean { - return App.getInstance().preferences.getBoolean(AA_RADIO_VIEW, false) + fun setNowPlayingMetadata(items: List) { + App.getInstance().preferences.edit().putString(NOW_PLAYING_METADATA, Gson().toJson(items)).apply() } @JvmStatic - fun getAndroidAutoFirstTab(): Int { - return App.getInstance().preferences.getString(AA_FIRST_TAB, "0")!!.toInt() + fun getPlayNextBehavior(): String { + return App.getInstance().preferences.getString(PLAY_NEXT_BEHAVIOR, PLAY_NEXT_BEHAVIOR_TOP) ?: PLAY_NEXT_BEHAVIOR_TOP } @JvmStatic - fun getAndroidAutoSecondTab(): Int { - return App.getInstance().preferences.getString(AA_SECOND_TAB, "1")!!.toInt() + fun setPlayNextBehavior(behavior: String) { + App.getInstance().preferences.edit().putString(PLAY_NEXT_BEHAVIOR, behavior).apply() } @JvmStatic - fun getAndroidAutoThirdTab(): Int { - return App.getInstance().preferences.getString(AA_THIRD_TAB, "2")!!.toInt() + fun getLastFmUser(): String? { + return App.getInstance().preferences.getString(Constants.LAST_FM_USER, null) } - + @JvmStatic - fun getAndroidAutoFourthTab(): Int { - return App.getInstance().preferences.getString(AA_FOURTH_TAB, "3")!!.toInt() + fun setLastFmUser(user: String?) { + App.getInstance().preferences.edit().putString(Constants.LAST_FM_USER, user).apply() } - + + @JvmStatic + fun getLastFmApiKey(): String? { + val app = App.getInstance() + val encryptedPrefs = app.encryptedPreferences + val plainPrefs = app.preferences + + val encryptedValue = encryptedPrefs.getString(Constants.LAST_FM_API_KEY, null) + if (!encryptedValue.isNullOrEmpty()) { + return encryptedValue + } + + val plainValue = plainPrefs.getString(Constants.LAST_FM_API_KEY, null) + if (!plainValue.isNullOrEmpty() && encryptedPrefs !== plainPrefs) { + encryptedPrefs.edit().putString(Constants.LAST_FM_API_KEY, plainValue).apply() + plainPrefs.edit().remove(Constants.LAST_FM_API_KEY).apply() + } + + return plainValue + } + + @JvmStatic + fun setLastFmApiKey(apiKey: String?) { + val app = App.getInstance() + val encryptedPrefs = app.encryptedPreferences + val plainPrefs = app.preferences + + encryptedPrefs.edit().putString(Constants.LAST_FM_API_KEY, apiKey).apply() + + if (encryptedPrefs !== plainPrefs) { + plainPrefs.edit().remove(Constants.LAST_FM_API_KEY).apply() + } + } + + @JvmStatic + fun getClientCert(): String? { + return App.getInstance().preferences.getString(CLIENT_CERT, null) + } + + @JvmStatic + fun setClientCert(clientCert: String?) { + App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply() + } + @JvmStatic - fun isAndroidAutoShuffleGenreSongsEnabled(): Boolean { - return App.getInstance().preferences.getBoolean(AA_SHUFFLE_GENRE_SONGS, false) + fun getHideBottomNavbarOnPortrait(): Boolean { + return App.getInstance().preferences.getBoolean(HIDE_BOTTOM_NAVBAR_ON_PORTRAIT, false) + } + + @JvmStatic + fun setHideBottomNavbarOnPortrait(hide: Boolean) { + App.getInstance().preferences.edit().putBoolean(HIDE_BOTTOM_NAVBAR_ON_PORTRAIT, hide).apply() + } + + @JvmStatic + fun getEnableDrawerOnPortrait(): Boolean { + return App.getInstance().preferences.getBoolean(ENABLE_DRAWER_ON_PORTRAIT, false) + } + + @JvmStatic + fun setEnableDrawerOnPortrait(enable: Boolean) { + App.getInstance().preferences.edit().putBoolean(ENABLE_DRAWER_ON_PORTRAIT, enable).apply() + } + + @JvmStatic + fun getBitrateVisible(): Boolean { + return App.getInstance().preferences.getBoolean(BITRATE_VISIBLE, false) + } + + @JvmStatic + fun setBitrateVisible(visible: Boolean) { + App.getInstance().preferences.edit().putBoolean(BITRATE_VISIBLE, visible).apply() + } + + @JvmStatic + fun getTileSize(): Int { + val prefs = App.getInstance().preferences + return try { + prefs.getInt(TILE_SIZE, 3) + } catch (e: Exception) { + // If it's stored as a String (e.g. from a ListPreference), parse it and fix the preference type + val value = try { prefs.getString(TILE_SIZE, "3") } catch (e2: Exception) { "3" } + val intValue = value?.toIntOrNull() ?: 3 + prefs.edit().putInt(TILE_SIZE, intValue).apply() + intValue + } } @JvmStatic - fun setAndroidAutoShuffleGenreSongsEnabled(enabled: Boolean) { - App.getInstance().preferences.edit().putBoolean(AA_SHUFFLE_GENRE_SONGS, enabled).apply() + fun setTileSize(size: Int) { + App.getInstance().preferences.edit().putInt(TILE_SIZE, size).apply() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/elzify/music/util/UIUtil.java b/app/src/main/java/com/elzify/music/util/UIUtil.java index a453f91c..d2f573ff 100644 --- a/app/src/main/java/com/elzify/music/util/UIUtil.java +++ b/app/src/main/java/com/elzify/music/util/UIUtil.java @@ -2,9 +2,14 @@ import android.content.Context; import android.content.res.TypedArray; +import android.graphics.Color; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; +import android.util.TypedValue; +import androidx.annotation.AttrRes; +import androidx.annotation.ColorInt; +import androidx.core.graphics.ColorUtils; import androidx.core.os.LocaleListCompat; import androidx.recyclerview.widget.DividerItemDecoration; @@ -112,4 +117,33 @@ public static String getReadableDate(Date date) { return formatter.format(date); } + @ColorInt + public static int getThemeColor(Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + context.getTheme().resolveAttribute(attr, typedValue, true); + return typedValue.data; + } + + @ColorInt + public static int getPlayerBackgroundColor(Context context) { + int surface = getThemeColor(context, com.google.android.material.R.attr.colorSurface); + int accent = Preferences.getAccentColor(); + + if (accent == -1) { + return getThemeColor(context, com.google.android.material.R.attr.colorSurfaceContainerHigh); + } + + float blendRatio = ColorUtils.calculateLuminance(surface) > 0.5 ? 0.24f : 0.32f; + return ColorUtils.blendARGB(surface, accent, blendRatio); + } + + @ColorInt + public static int getSystemBarColor(Context context) { + return getThemeColor(context, com.google.android.material.R.attr.colorSurface); + } + + public static int dpToPx(Context context, int dp) { + return (int) (dp * context.getResources().getDisplayMetrics().density); + } + } diff --git a/app/src/main/java/com/elzify/music/viewmodel/AlbumListPageViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/AlbumListPageViewModel.java index 17c24c4f..4a8c23ce 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/AlbumListPageViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/AlbumListPageViewModel.java @@ -23,6 +23,7 @@ public class AlbumListPageViewModel extends AndroidViewModel { public String title; public ArtistID3 artist; + public List albums; private MutableLiveData> albumList; @@ -34,6 +35,9 @@ public AlbumListPageViewModel(@NonNull Application application) { } public LiveData> getAlbumList(LifecycleOwner owner) { + if (albums != null) { + return new MutableLiveData<>(albums); + } albumList = new MutableLiveData<>(new ArrayList<>()); switch (title) { diff --git a/app/src/main/java/com/elzify/music/viewmodel/ArtistListPageViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/ArtistListPageViewModel.java index a1d7c00f..2faa1887 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/ArtistListPageViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/ArtistListPageViewModel.java @@ -42,6 +42,12 @@ public LiveData> getArtistList(LifecycleOwner owner) { case Constants.ARTIST_STARRED: artistList = artistRepository.getStarredArtists(false, -1); break; + case Constants.ARTIST_RECENTLY_PLAYED: + artistList = artistRepository.getRecentlyPlayedArtists(500); + break; + case Constants.ARTIST_TOP_PLAYED: + artistList = artistRepository.getTopPlayedArtists(500); + break; case Constants.ARTIST_DOWNLOADED: downloadRepository.getLiveDownload().observe(owner, downloads -> { List unique = downloads diff --git a/app/src/main/java/com/elzify/music/viewmodel/ArtistPageViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/ArtistPageViewModel.java index 2c592384..779d186d 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/ArtistPageViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/ArtistPageViewModel.java @@ -8,6 +8,7 @@ import androidx.annotation.OptIn; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.media3.common.util.UnstableApi; import com.elzify.music.model.Download; @@ -24,6 +25,7 @@ import com.elzify.music.util.NetworkUtil; import com.elzify.music.util.Preferences; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.stream.Collectors; @@ -35,6 +37,11 @@ public class ArtistPageViewModel extends AndroidViewModel { private ArtistID3 artist; + private final MutableLiveData> singles = new MutableLiveData<>(); + private final MutableLiveData> eps = new MutableLiveData<>(); + private final MutableLiveData> mainAlbums = new MutableLiveData<>(); + private final MutableLiveData> appearsOn = new MutableLiveData<>(); + public ArtistPageViewModel(@NonNull Application application) { super(application); @@ -43,6 +50,62 @@ public ArtistPageViewModel(@NonNull Application application) { favoriteRepository = new FavoriteRepository(); } + public void fetchCategorizedAlbums(androidx.lifecycle.LifecycleOwner owner) { + artistRepository.getArtist(artist.getId()).observe(owner, artistWithAlbums -> { + if (artistWithAlbums != null && artistWithAlbums instanceof com.elzify.music.subsonic.models.ArtistWithAlbumsID3) { + com.elzify.music.subsonic.models.ArtistWithAlbumsID3 fullArtist = (com.elzify.music.subsonic.models.ArtistWithAlbumsID3) artistWithAlbums; + + List allAlbums = fullArtist.getAlbums(); + if (allAlbums != null) { + allAlbums.sort(Comparator.comparing(AlbumID3::getYear).reversed()); + + mainAlbums.setValue(allAlbums.stream() + .filter(a -> isType(a, "album")) + .collect(Collectors.toList())); + + singles.setValue(allAlbums.stream() + .filter(a -> isType(a, "single")) + .collect(Collectors.toList())); + + eps.setValue(allAlbums.stream() + .filter(a -> isType(a, "ep")) + .collect(Collectors.toList())); + } + + List appearsOnList = fullArtist.getAppearsOn(); + if (appearsOnList != null) { + appearsOnList.sort(Comparator.comparing(AlbumID3::getYear).reversed()); + appearsOn.setValue(appearsOnList); + } else { + appearsOn.setValue(new java.util.ArrayList<>()); + } + } + }); + } + + private boolean isType(AlbumID3 album, String targetType) { + if (album.getReleaseTypes() != null && !album.getReleaseTypes().isEmpty()) { + return album.getReleaseTypes().contains(targetType); + } + // Fallback to song count if releaseTypes is not available + int songCount = album.getSongCount() != null ? album.getSongCount() : 0; + switch (targetType) { + case "single": + return songCount >= 1 && songCount <= 2; + case "ep": + return songCount >= 3 && songCount <= 7; + case "album": + return songCount >= 8; + default: + return false; + } + } + + public LiveData> getSingles() { return singles; } + public LiveData> getEPs() { return eps; } + public LiveData> getMainAlbums() { return mainAlbums; } + public LiveData> getAppearsOn() { return appearsOn; } + public LiveData> getAlbumList() { return albumRepository.getArtistAlbums(artist.getId()); } diff --git a/app/src/main/java/com/elzify/music/viewmodel/HomeRearrangementViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/HomeRearrangementViewModel.java index 8a73ea1b..49aa4de4 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/HomeRearrangementViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/HomeRearrangementViewModel.java @@ -31,6 +31,19 @@ public List getHomeSectorList() { new TypeToken>() { }.getType() ); + + // Add missing sectors if any (e.g. after an app update) + List standardSectors = fillStandardHomeSectorList(); + boolean changed = false; + for (HomeSector standardSector : standardSectors) { + if (sectors.stream().noneMatch(s -> s.getId().equals(standardSector.getId()))) { + sectors.add(standardSector); + changed = true; + } + } + if (changed) { + Preferences.setHomeSectorList(sectors); + } } else { sectors = fillStandardHomeSectorList(); } @@ -72,6 +85,10 @@ private List fillStandardHomeSectorList() { sectors.add(new HomeSector(Constants.HOME_SECTOR_RECENTLY_ADDED, getApplication().getString(R.string.home_title_recently_added), true, 13)); sectors.add(new HomeSector(Constants.HOME_SECTOR_PINNED_PLAYLISTS, getApplication().getString(R.string.home_title_pinned_playlists), true, 14)); sectors.add(new HomeSector(Constants.HOME_SECTOR_SHARED, getApplication().getString(R.string.home_title_shares), true, 15)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_HISTORY, getApplication().getString(R.string.home_title_history), true, 16)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_RECENTLY_PLAYED_ARTISTS, getApplication().getString(R.string.home_title_recently_played_artists), true, 17)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_TOP_PLAYED_ARTISTS, getApplication().getString(R.string.home_title_top_played_artists), true, 18)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_TOP_PLAYED_SONGS, getApplication().getString(R.string.home_title_top_played_songs), true, 19)); return sectors; } diff --git a/app/src/main/java/com/elzify/music/viewmodel/HomeViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/HomeViewModel.java index 05d367cb..42465059 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/HomeViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/HomeViewModel.java @@ -8,6 +8,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import com.elzify.music.R; import com.elzify.music.interfaces.StarCallback; import com.elzify.music.model.Chronology; import com.elzify.music.model.Favorite; @@ -19,6 +20,7 @@ import com.elzify.music.repository.PlaylistRepository; import com.elzify.music.repository.SharingRepository; import com.elzify.music.repository.SongRepository; +import com.elzify.music.service.MediaManager; import com.elzify.music.subsonic.models.AlbumID3; import com.elzify.music.subsonic.models.ArtistID3; import com.elzify.music.subsonic.models.Child; @@ -65,9 +67,13 @@ public class HomeViewModel extends AndroidViewModel { private final MutableLiveData> recentlyAddedAlbumSample = new MutableLiveData<>(null); private final MutableLiveData> thisGridTopSong = new MutableLiveData<>(null); + private final MutableLiveData> history = new MutableLiveData<>(null); private final MutableLiveData> mediaInstantMix = new MutableLiveData<>(null); private final MutableLiveData> artistInstantMix = new MutableLiveData<>(null); private final MutableLiveData> artistBestOf = new MutableLiveData<>(null); + private final MutableLiveData> recentlyPlayedArtists = new MutableLiveData<>(null); + private final MutableLiveData> topPlayedArtists = new MutableLiveData<>(null); + private final MutableLiveData> topPlayedSongs = new MutableLiveData<>(null); private final MutableLiveData> pinnedPlaylists = new MutableLiveData<>(null); private final MutableLiveData> shares = new MutableLiveData<>(null); @@ -90,6 +96,12 @@ public HomeViewModel(@NonNull Application application) { artistSyncViewModel = new StarredArtistsSyncViewModel(application); setOfflineFavorite(); + + playlistRepository.getPlaylistUpdateTrigger().observeForever(needsRefresh -> { + if (needsRefresh != null && needsRefresh) { + playlistRepository.updatePinnedPlaylists(); + } + }); } public LiveData> getDiscoverSongSample(LifecycleOwner owner) { @@ -118,6 +130,32 @@ public LiveData> getChronologySample(LifecycleOwner owner) { return thisGridTopSong; } + public LiveData> getHistory(LifecycleOwner owner) { + songRepository.getRecentlyPlayedSongs(20).observe(owner, history::postValue); + return history; + } + + public LiveData> getRecentlyPlayedArtists(LifecycleOwner owner) { + if (recentlyPlayedArtists.getValue() == null) { + artistRepository.getRecentlyPlayedArtists(20).observe(owner, recentlyPlayedArtists::postValue); + } + return recentlyPlayedArtists; + } + + public LiveData> getTopPlayedArtists(LifecycleOwner owner) { + if (topPlayedArtists.getValue() == null) { + artistRepository.getTopPlayedArtists(20).observe(owner, topPlayedArtists::postValue); + } + return topPlayedArtists; + } + + public LiveData> getTopPlayedSongs(LifecycleOwner owner) { + if (topPlayedSongs.getValue() == null) { + songRepository.getTopPlayedSongs(20).observe(owner, topPlayedSongs::postValue); + } + return topPlayedSongs; + } + public LiveData> getRecentlyReleasedAlbums(LifecycleOwner owner) { if (newReleasedAlbum.getValue() == null) { int currentYear = Calendar.getInstance().get(Calendar.YEAR); @@ -159,7 +197,16 @@ public LiveData> getBestOfArtists(LifecycleOwner owner) { public LiveData> getStarredTracks(LifecycleOwner owner) { if (starredTracks.getValue() == null) { - songRepository.getStarredSongs(true, 20).observe(owner, starredTracks::postValue); + songRepository.getStarredSongs(false, -1).observe(owner, songs -> { + if (songs == null) { + return; + } + + List sampled = sampleRandomItems(songs, 20); + if (!sampled.isEmpty() || starredTracks.getValue() == null || starredTracks.getValue().isEmpty()) { + starredTracks.postValue(sampled); + } + }); } return starredTracks; @@ -246,28 +293,7 @@ public LiveData> getArtistBestOf(LifecycleOwner owner, ArtistID3 art } public LiveData> getPinnedPlaylists(LifecycleOwner owner) { - pinnedPlaylists.setValue(Collections.emptyList()); - - playlistRepository.getPlaylists(false, -1).observe(owner, remotes -> { - if (remotes != null && !remotes.isEmpty()) { - List playlists = new ArrayList<>(remotes); - String result = Preferences.getHomeSortPlaylists(); - if (Preferences.getHomeSortPlaylists().equals(Constants.PLAYLIST_ORDER_BY_RANDOM)) - { - Collections.shuffle(playlists); - } - else { - playlists.sort(Comparator.comparing(Playlist::getName)); - } - List subsetPlaylists = playlists.size() > 5 - ? playlists.subList(0, 5) - : playlists; - - pinnedPlaylists.setValue(subsetPlaylists); - } - }); - - return pinnedPlaylists; + return playlistRepository.getPinnedPlaylists(); } public LiveData> getShares(LifecycleOwner owner) { @@ -324,7 +350,27 @@ public void refreshBestOfArtist(LifecycleOwner owner) { } public void refreshStarredTracks(LifecycleOwner owner) { - songRepository.getStarredSongs(true, 20).observe(owner, starredTracks::postValue); + songRepository.getStarredSongs(false, -1).observe(owner, songs -> { + if (songs == null) { + return; + } + + List sampled = sampleRandomItems(songs, 20); + if (!sampled.isEmpty() || starredTracks.getValue() == null || starredTracks.getValue().isEmpty()) { + starredTracks.postValue(sampled); + } + }); + } + + private List sampleRandomItems(List source, int size) { + if (source == null || source.isEmpty() || size <= 0) { + return Collections.emptyList(); + } + + List shuffled = new ArrayList<>(source); + Collections.shuffle(shuffled); + + return new ArrayList<>(shuffled.subList(0, Math.min(size, shuffled.size()))); } public void refreshStarredAlbums(LifecycleOwner owner) { @@ -347,10 +393,34 @@ public void refreshRecentlyPlayedAlbumList(LifecycleOwner owner) { albumRepository.getAlbums("recent", 20, null, null).observe(owner, recentlyPlayedAlbumSample::postValue); } + public void refreshHistory(LifecycleOwner owner) { + songRepository.getRecentlyPlayedSongs(20).observe(owner, history::postValue); + } + + public void refreshRecentlyPlayedArtists(LifecycleOwner owner) { + artistRepository.getRecentlyPlayedArtists(20).observe(owner, recentlyPlayedArtists::postValue); + } + + public void refreshTopPlayedArtists(LifecycleOwner owner) { + artistRepository.getTopPlayedArtists(20).observe(owner, topPlayedArtists::postValue); + } + + public void refreshTopPlayedSongs(LifecycleOwner owner) { + songRepository.getTopPlayedSongs(20).observe(owner, topPlayedSongs::postValue); + } + + public void refreshPinnedPlaylists() { + playlistRepository.updatePinnedPlaylists(); + } + public void refreshShares(LifecycleOwner owner) { sharingRepository.getShares().observe(owner, this.shares::postValue); } + public void refreshHomeSectorList() { + setHomeSectorList(); + } + private void setHomeSectorList() { if (Preferences.getHomeSectorList() != null && !Preferences.getHomeSectorList().equals("null")) { sectors = new Gson().fromJson( @@ -358,20 +428,65 @@ private void setHomeSectorList() { new TypeToken>() { }.getType() ); + + // Add missing sectors if any (e.g. after an app update) + List standardSectors = fillStandardHomeSectorList(); + boolean changed = false; + for (HomeSector standardSector : standardSectors) { + if (sectors.stream().noneMatch(s -> s.getId().equals(standardSector.getId()))) { + sectors.add(standardSector); + changed = true; + } + } + if (changed) { + Preferences.setHomeSectorList(sectors); + } + } else { + sectors = fillStandardHomeSectorList(); } } + private List fillStandardHomeSectorList() { + List sectors = new ArrayList<>(); + + sectors.add(new HomeSector(Constants.HOME_SECTOR_DISCOVERY, getApplication().getString(R.string.home_title_discovery), true, 1)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_MADE_FOR_YOU, getApplication().getString(R.string.home_title_made_for_you), true, 2)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_BEST_OF, getApplication().getString(R.string.home_title_best_of), true, 3)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_RADIO_STATION, getApplication().getString(R.string.home_title_radio_station), true, 4)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_TOP_SONGS, getApplication().getString(R.string.home_title_top_songs), true, 5)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_STARRED_TRACKS, getApplication().getString(R.string.home_title_starred_tracks), true, 6)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_STARRED_ALBUMS, getApplication().getString(R.string.home_title_starred_albums), true, 7)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_STARRED_ARTISTS, getApplication().getString(R.string.home_title_starred_artists), true, 8)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_NEW_RELEASES, getApplication().getString(R.string.home_title_new_releases), true, 9)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_FLASHBACK, getApplication().getString(R.string.home_title_flashback), true, 10)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_MOST_PLAYED, getApplication().getString(R.string.home_title_most_played), true, 11)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_LAST_PLAYED, getApplication().getString(R.string.home_title_last_played), true, 12)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_RECENTLY_ADDED, getApplication().getString(R.string.home_title_recently_added), true, 13)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_PINNED_PLAYLISTS, getApplication().getString(R.string.home_title_pinned_playlists), true, 14)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_SHARED, getApplication().getString(R.string.home_title_shares), true, 15)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_HISTORY, getApplication().getString(R.string.home_title_history), true, 16)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_RECENTLY_PLAYED_ARTISTS, getApplication().getString(R.string.home_title_recently_played_artists), true, 17)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_TOP_PLAYED_ARTISTS, getApplication().getString(R.string.home_title_top_played_artists), true, 18)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_TOP_PLAYED_SONGS, getApplication().getString(R.string.home_title_top_played_songs), true, 19)); + + return sectors; + } + public List getHomeSectorList() { return sectors; } public boolean checkHomeSectorVisibility(String sectorId) { - return sectors != null && sectors.stream().filter(sector -> sector.getId().equals(sectorId)) + if (sectors == null) return true; + HomeSector sector = sectors.stream() + .filter(s -> s.getId().equals(sectorId)) .findAny() - .orElse(null) == null; + .orElse(null); + return sector == null || !sector.isVisible(); } public void setOfflineFavorite() { + MediaManager.submitPendingScrobbles(); ArrayList favorites = getFavorites(); ArrayList favoritesToSave = getFavoritesToSave(favorites); ArrayList favoritesToDelete = getFavoritesToDelete(favorites, favoritesToSave); diff --git a/app/src/main/java/com/elzify/music/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/PlayerBottomSheetViewModel.java index ce435364..59b22f23 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/PlayerBottomSheetViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/PlayerBottomSheetViewModel.java @@ -21,6 +21,7 @@ import com.elzify.music.repository.AlbumRepository; import com.elzify.music.repository.ArtistRepository; import com.elzify.music.repository.FavoriteRepository; +import com.elzify.music.repository.LrcGetRepository; import com.elzify.music.repository.LyricsRepository; import com.elzify.music.repository.OpenRepository; import com.elzify.music.repository.QueueRepository; @@ -30,6 +31,9 @@ import com.elzify.music.subsonic.models.Child; import com.elzify.music.subsonic.models.LyricsList; import com.elzify.music.subsonic.models.PlayQueue; +import com.elzify.music.lastfm.LastFm; +import com.elzify.music.lastfm.models.LastFmTrackResponse; +import com.elzify.music.service.MediaManager; import com.elzify.music.util.Constants; import com.elzify.music.util.DownloadUtil; import com.elzify.music.util.MappingUtil; @@ -38,6 +42,10 @@ import com.elzify.music.util.Preferences; import com.google.gson.Gson; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + import java.util.Collections; import java.util.Date; import java.util.List; @@ -46,6 +54,8 @@ @OptIn(markerClass = UnstableApi.class) public class PlayerBottomSheetViewModel extends AndroidViewModel { private static final String TAG = "PlayerBottomSheetViewModel"; + public static final int LYRICS_SOURCE_SERVER = 0; + public static final int LYRICS_SOURCE_LRCLIB = 1; private final SongRepository songRepository; private final AlbumRepository albumRepository; @@ -53,20 +63,29 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { private final QueueRepository queueRepository; private final FavoriteRepository favoriteRepository; private final OpenRepository openRepository; + private final LrcGetRepository lrcGetRepository; private final LyricsRepository lyricsRepository; private final MutableLiveData lyricsLiveData = new MutableLiveData<>(null); private final MutableLiveData lyricsListLiveData = new MutableLiveData<>(null); private final MutableLiveData lyricsCachedLiveData = new MutableLiveData<>(false); + private final MutableLiveData lyricsSourceLiveData = new MutableLiveData<>(LYRICS_SOURCE_SERVER); + private final MutableLiveData lyricsSourceSwitchAvailableLiveData = new MutableLiveData<>(false); private final MutableLiveData descriptionLiveData = new MutableLiveData<>(null); private final MutableLiveData liveMedia = new MutableLiveData<>(null); private final MutableLiveData liveAlbum = new MutableLiveData<>(null); private final MutableLiveData liveArtist = new MutableLiveData<>(null); private final MutableLiveData> instantMix = new MutableLiveData<>(null); + private final MutableLiveData lastFmScrobbleCount = new MutableLiveData<>(null); private final Gson gson = new Gson(); private boolean lyricsSyncState = true; private LiveData cachedLyricsSource; private String currentSongId; private final Observer cachedLyricsObserver = this::onCachedLyricsChanged; + private int preferredLyricsSource = LYRICS_SOURCE_SERVER; + private String serverLyrics; + private LyricsList serverLyricsList; + private String lrcGetLyrics; + private LyricsList lrcGetLyricsList; public PlayerBottomSheetViewModel(@NonNull Application application) { @@ -78,6 +97,7 @@ public PlayerBottomSheetViewModel(@NonNull Application application) { queueRepository = new QueueRepository(); favoriteRepository = new FavoriteRepository(); openRepository = new OpenRepository(); + lrcGetRepository = new LrcGetRepository(); lyricsRepository = new LyricsRepository(); } @@ -106,6 +126,8 @@ public void setFavorite(Context context, Child media) { private void removeFavoriteOffline(Child media) { favoriteRepository.starLater(media.getId(), null, null, false); media.setStarred(null); + liveMedia.postValue(media); + MediaManager.postFavoriteEvent(media.getId(), null); } private void removeFavoriteOnline(Child media) { @@ -117,11 +139,15 @@ public void onError() { } }); media.setStarred(null); + liveMedia.postValue(media); + MediaManager.postFavoriteEvent(media.getId(), null); } private void setFavoriteOffline(Child media) { favoriteRepository.starLater(media.getId(), null, null, true); media.setStarred(new Date()); + liveMedia.postValue(media); + MediaManager.postFavoriteEvent(media.getId(), media.getStarred()); } private void setFavoriteOnline(Context context, Child media) { @@ -134,6 +160,8 @@ public void onError() { }); media.setStarred(new Date()); + liveMedia.postValue(media); + MediaManager.postFavoriteEvent(media.getId(), media.getStarred()); if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(context).download( @@ -151,9 +179,29 @@ public LiveData getLiveLyricsList() { return lyricsListLiveData; } + public LiveData getLyricsSource() { + return lyricsSourceLiveData; + } + + public LiveData getLyricsSourceSwitchAvailable() { + return lyricsSourceSwitchAvailableLiveData; + } + + public boolean switchLyricsSource() { + if (!canSwitchLyricsSource()) { + return false; + } + + preferredLyricsSource = preferredLyricsSource == LYRICS_SOURCE_SERVER + ? LYRICS_SOURCE_LRCLIB + : LYRICS_SOURCE_SERVER; + + publishLyricsByPreferredSource(); + return true; + } + public void refreshMediaInfo(LifecycleOwner owner, Child media) { - lyricsLiveData.postValue(null); - lyricsListLiveData.postValue(null); + resetLyricsSources(); lyricsCachedLiveData.postValue(false); clearCachedLyricsObserver(); @@ -179,23 +227,39 @@ public void refreshMediaInfo(LifecycleOwner owner, Child media) { if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) { openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsList -> { - lyricsListLiveData.postValue(lyricsList); - lyricsLiveData.postValue(null); + serverLyricsList = hasStructuredLyrics(lyricsList) ? lyricsList : null; + serverLyrics = null; + publishLyricsByPreferredSource(); - if (shouldAutoDownloadLyrics() && hasStructuredLyrics(lyricsList)) { + if (shouldAutoDownloadLyrics() && hasStructuredLyrics(serverLyricsList)) { saveLyricsToCache(media, null, lyricsList); } }); } else { songRepository.getSongLyrics(media).observe(owner, lyrics -> { - lyricsLiveData.postValue(lyrics); - lyricsListLiveData.postValue(null); + serverLyrics = lyrics; + serverLyricsList = null; + publishLyricsByPreferredSource(); if (shouldAutoDownloadLyrics() && !TextUtils.isEmpty(lyrics)) { saveLyricsToCache(media, lyrics, null); } }); } + + lrcGetRepository.getLyrics(media).observe(owner, result -> { + if (result == null) { + return; + } + + lrcGetLyrics = result.getPlainLyrics(); + lrcGetLyricsList = hasStructuredLyrics(result.getSyncedLyrics()) ? result.getSyncedLyrics() : null; + publishLyricsByPreferredSource(); + + if (shouldAutoDownloadLyrics() && !hasAnyServerLyrics()) { + saveLyricsToCache(media, lrcGetLyrics, lrcGetLyricsList); + } + }); } public LiveData getLiveMedia() { @@ -209,8 +273,7 @@ public void setLiveMedia(LifecycleOwner owner, String mediaType, String mediaId) refreshMediaInfo(owner, null); } else { clearCachedLyricsObserver(); - lyricsLiveData.postValue(null); - lyricsListLiveData.postValue(null); + resetLyricsSources(); lyricsCachedLiveData.postValue(false); } @@ -326,16 +389,84 @@ private void onCachedLyricsChanged(LyricsCache lyricsCache) { if (!TextUtils.isEmpty(lyricsCache.getStructuredLyrics())) { try { LyricsList cachedList = gson.fromJson(lyricsCache.getStructuredLyrics(), LyricsList.class); - lyricsListLiveData.postValue(cachedList); - lyricsLiveData.postValue(null); + serverLyricsList = cachedList; + serverLyrics = null; } catch (Exception exception) { - lyricsListLiveData.postValue(null); - lyricsLiveData.postValue(lyricsCache.getLyrics()); + serverLyricsList = null; + serverLyrics = lyricsCache.getLyrics(); } } else { - lyricsListLiveData.postValue(null); - lyricsLiveData.postValue(lyricsCache.getLyrics()); + serverLyricsList = null; + serverLyrics = lyricsCache.getLyrics(); } + + publishLyricsByPreferredSource(); + } + + private void resetLyricsSources() { + preferredLyricsSource = LYRICS_SOURCE_SERVER; + serverLyrics = null; + serverLyricsList = null; + lrcGetLyrics = null; + lrcGetLyricsList = null; + + lyricsSourceLiveData.postValue(LYRICS_SOURCE_SERVER); + lyricsSourceSwitchAvailableLiveData.postValue(false); + lyricsLiveData.postValue(null); + lyricsListLiveData.postValue(null); + } + + private void publishLyricsByPreferredSource() { + boolean hasServer = hasAnyServerLyrics(); + boolean hasLrcGet = hasAnyLrcGetLyrics(); + + lyricsSourceSwitchAvailableLiveData.postValue(hasServer && hasLrcGet); + + int effectiveSource = preferredLyricsSource; + if (effectiveSource == LYRICS_SOURCE_SERVER && !hasServer && hasLrcGet) { + effectiveSource = LYRICS_SOURCE_LRCLIB; + } else if (effectiveSource == LYRICS_SOURCE_LRCLIB && !hasLrcGet && hasServer) { + effectiveSource = LYRICS_SOURCE_SERVER; + } + + lyricsSourceLiveData.postValue(effectiveSource); + + if (effectiveSource == LYRICS_SOURCE_LRCLIB && hasLrcGet) { + lyricsListLiveData.postValue(lrcGetLyricsList); + lyricsLiveData.postValue(lrcGetLyrics); + return; + } + + if (hasServer) { + lyricsListLiveData.postValue(serverLyricsList); + lyricsLiveData.postValue(serverLyrics); + return; + } + + if (hasLrcGet) { + lyricsListLiveData.postValue(lrcGetLyricsList); + lyricsLiveData.postValue(lrcGetLyrics); + return; + } + + lyricsListLiveData.postValue(null); + lyricsLiveData.postValue(null); + } + + private boolean canSwitchLyricsSource() { + return hasAnyServerLyrics() && hasAnyLrcGetLyrics(); + } + + private boolean hasAnyServerLyrics() { + return hasStructuredLyrics(serverLyricsList) || hasText(serverLyrics); + } + + private boolean hasAnyLrcGetLyrics() { + return hasStructuredLyrics(lrcGetLyricsList) || hasText(lrcGetLyrics); + } + + private boolean hasText(String value) { + return !TextUtils.isEmpty(value) && !value.trim().isEmpty(); } private void saveLyricsToCache(Child media, String lyrics, LyricsList lyricsList) { @@ -405,4 +536,44 @@ public void changeSyncLyricsState() { public boolean getSyncLyricsState() { return lyricsSyncState; } + + public LiveData getLastFmScrobbleCount() { + return lastFmScrobbleCount; + } + + public void fetchLastFmScrobbleCount(String artist, String track) { + lastFmScrobbleCount.postValue(null); + + String username = Preferences.getLastFmUser(); + String apiKey = Preferences.getLastFmApiKey(); + + if (TextUtils.isEmpty(username) || TextUtils.isEmpty(apiKey) || TextUtils.isEmpty(artist) || TextUtils.isEmpty(track)) { + return; + } + + String firstArtist = artist.split("\\s*[,/;&\u2022]\\s*|\\s+feat\\.?\\s+|\\s+ft\\.?\\s+")[0].trim(); + if (firstArtist.isEmpty()) { + return; + } + + LastFm.INSTANCE.getTrackClient().getTrackInfo(firstArtist, track, username, apiKey).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful() && response.body() != null && response.body().getTrack() != null) { + String countStr = response.body().getTrack().getUserPlayCount(); + if (countStr != null) { + try { + lastFmScrobbleCount.postValue(Long.parseLong(countStr)); + } catch (NumberFormatException ignored) { + } + } + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Log.e(TAG, "Last.fm API error", t); + } + }); + } } diff --git a/app/src/main/java/com/elzify/music/viewmodel/PlaylistCatalogueViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/PlaylistCatalogueViewModel.java index a5e887ad..8489915f 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/PlaylistCatalogueViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/PlaylistCatalogueViewModel.java @@ -19,19 +19,33 @@ public class PlaylistCatalogueViewModel extends AndroidViewModel { private String type; private final MutableLiveData> playlistList = new MutableLiveData<>(null); + private LiveData> pinnedPlaylists; public PlaylistCatalogueViewModel(@NonNull Application application) { super(application); playlistRepository = new PlaylistRepository(); + pinnedPlaylists = playlistRepository.getPinnedPlaylists(); } public LiveData> getPlaylistList(LifecycleOwner owner) { - if (playlistList.getValue() == null) { - playlistRepository.getPlaylists(false, -1).observe(owner, playlistList::postValue); - } + return playlistRepository.getAllPlaylists(owner); + } + + public void refreshPlaylistList(LifecycleOwner owner) { + playlistRepository.refreshAllPlaylists(); + } + + public LiveData> getPinnedPlaylists() { + return pinnedPlaylists; + } + + public void pinPlaylist(Playlist playlist) { + playlistRepository.insert(playlist); + } - return playlistList; + public void unpinPlaylist(Playlist playlist) { + playlistRepository.delete(playlist); } public void setType(String type) { diff --git a/app/src/main/java/com/elzify/music/viewmodel/PlaylistChooserViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/PlaylistChooserViewModel.java index f8d237db..b1378e94 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/PlaylistChooserViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/PlaylistChooserViewModel.java @@ -9,6 +9,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import com.elzify.music.R; import com.elzify.music.repository.PlaylistRepository; import com.elzify.music.subsonic.models.Child; import com.elzify.music.subsonic.models.Playlist; @@ -19,6 +20,7 @@ import java.util.List; public class PlaylistChooserViewModel extends AndroidViewModel { + private static final String TAG = "PlaylistChooserVM"; private final PlaylistRepository playlistRepository; private final MutableLiveData> playlists = new MutableLiveData<>(null); private final MutableLiveData playlistIsPublic = new MutableLiveData<>(false); @@ -45,19 +47,99 @@ public LiveData> getPlaylistList(LifecycleOwner owner) { } public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) { - List songIds = Lists.transform(toAdd, Child::getId); - if (Preferences.allowPlaylistDuplicates()) { - playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds), getIsPlaylistPublic()); + List playlistIds = new ArrayList<>(); + playlistIds.add(playlistId); + addSongsToPlaylists(owner, dialog, playlistIds); + } + + public void addSongsToPlaylists(LifecycleOwner owner, Dialog dialog, List playlistIds) { + android.util.Log.d(TAG, "addSongsToPlaylists: playlists=" + playlistIds + ", songs=" + toAdd.size()); + if (playlistIds == null || playlistIds.isEmpty()) { + if (dialog != null) dialog.dismiss(); + return; + } + + final int totalRequests = playlistIds.size(); + final int[] completedRequests = {0}; + final List addedToNames = new ArrayList<>(); + final List skippedFromNames = new ArrayList<>(); + final List failedForNames = new ArrayList<>(); + + List songIdsToAdd = new ArrayList<>(Lists.transform(toAdd, Child::getId)); + + for (String playlistId : playlistIds) { + String playlistName = getPlaylistNameById(playlistId); + if (Preferences.allowPlaylistDuplicates()) { + playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIdsToAdd), getIsPlaylistPublic(), new com.elzify.music.repository.PlaylistRepository.AddToPlaylistCallback() { + @Override public void onSuccess() { + addedToNames.add(playlistName); + checkCompletion(completedRequests, totalRequests, dialog, addedToNames, skippedFromNames, failedForNames); + } + @Override public void onFailure() { + failedForNames.add(playlistName); + checkCompletion(completedRequests, totalRequests, dialog, addedToNames, skippedFromNames, failedForNames); + } + @Override public void onAllSkipped() { + skippedFromNames.add(playlistName); + checkCompletion(completedRequests, totalRequests, dialog, addedToNames, skippedFromNames, failedForNames); + } + }); + } else { + playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> { + List specificSongIdsToAdd = new ArrayList<>(songIdsToAdd); + if (playlistSongs != null) { + List playlistSongIds = Lists.transform(playlistSongs, Child::getId); + specificSongIdsToAdd.removeAll(playlistSongIds); + } + playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(specificSongIdsToAdd), getIsPlaylistPublic(), new com.elzify.music.repository.PlaylistRepository.AddToPlaylistCallback() { + @Override public void onSuccess() { + addedToNames.add(playlistName); + checkCompletion(completedRequests, totalRequests, dialog, addedToNames, skippedFromNames, failedForNames); + } + @Override public void onFailure() { + failedForNames.add(playlistName); + checkCompletion(completedRequests, totalRequests, dialog, addedToNames, skippedFromNames, failedForNames); + } + @Override public void onAllSkipped() { + skippedFromNames.add(playlistName); + checkCompletion(completedRequests, totalRequests, dialog, addedToNames, skippedFromNames, failedForNames); + } + }); + }); + } + } + } + + private String getPlaylistNameById(String id) { + if (playlists.getValue() != null) { + for (Playlist p : playlists.getValue()) { + if (p.getId().equals(id)) return p.getName(); + } + } + return id; + } + + private void checkCompletion(int[] completedRequests, int totalRequests, Dialog dialog, List addedTo, List skippedFrom, List failedFor) { + completedRequests[0]++; + if (completedRequests[0] >= totalRequests) { + StringBuilder message = new StringBuilder(); + if (!addedTo.isEmpty()) { + message.append(getApplication().getString(R.string.playlist_chooser_dialog_toast_added_to, String.join(", ", addedTo))); + } + if (!skippedFrom.isEmpty()) { + if (message.length() > 0) message.append("\n"); + message.append(getApplication().getString(R.string.playlist_chooser_dialog_toast_skipped_from, String.join(", ", skippedFrom))); + } + if (!failedFor.isEmpty()) { + if (message.length() > 0) message.append("\n"); + message.append(getApplication().getString(R.string.playlist_chooser_dialog_toast_failed_for, String.join(", ", failedFor))); + } + + if (message.length() > 0) { + android.widget.Toast.makeText(getApplication(), message.toString(), android.widget.Toast.LENGTH_LONG).show(); + } + playlistRepository.updatePinnedPlaylists(); dialog.dismiss(); - } else { - playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> { - if (playlistSongs != null) { - List playlistSongIds = Lists.transform(playlistSongs, Child::getId); - songIds.removeAll(playlistSongIds); - } - playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds), getIsPlaylistPublic()); - dialog.dismiss(); - }); } } diff --git a/app/src/main/java/com/elzify/music/viewmodel/RatingViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/RatingViewModel.java index 1e0e73a9..13d4d4dd 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/RatingViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/RatingViewModel.java @@ -9,6 +9,7 @@ import com.elzify.music.repository.AlbumRepository; import com.elzify.music.repository.ArtistRepository; import com.elzify.music.repository.SongRepository; +import com.elzify.music.service.MediaManager; import com.elzify.music.subsonic.models.AlbumID3; import com.elzify.music.subsonic.models.ArtistID3; import com.elzify.music.subsonic.models.Child; @@ -75,6 +76,8 @@ public void setArtist(ArtistID3 artist) { public void rate(int star) { if (song != null) { songRepository.setRating(song.getId(), star); + song.setUserRating(star); + MediaManager.postRatingEvent(song.getId(), star); } else if (album != null) { albumRepository.setRating(album.getId(), star); } else if (artist != null) { diff --git a/app/src/main/java/com/elzify/music/viewmodel/SongBottomSheetViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/SongBottomSheetViewModel.java index d9cc7669..6dd73f0f 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/SongBottomSheetViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/SongBottomSheetViewModel.java @@ -12,6 +12,7 @@ import com.elzify.music.interfaces.MediaCallback; import com.elzify.music.interfaces.StarCallback; +import com.elzify.music.service.MediaManager; import com.elzify.music.model.Download; import com.elzify.music.repository.AlbumRepository; import com.elzify.music.repository.ArtistRepository; @@ -88,6 +89,7 @@ public void setFavorite(Context context) { private void removeFavoriteOffline(Child media) { favoriteRepository.starLater(media.getId(), null, null, false); media.setStarred(null); + MediaManager.postFavoriteEvent(media.getId(), null); } private void removeFavoriteOnline(Child media) { @@ -100,11 +102,13 @@ public void onError() { }); media.setStarred(null); + MediaManager.postFavoriteEvent(media.getId(), null); } private void setFavoriteOffline(Child media) { favoriteRepository.starLater(media.getId(), null, null, true); media.setStarred(new Date()); + MediaManager.postFavoriteEvent(media.getId(), media.getStarred()); } private void setFavoriteOnline(Context context, Child media) { @@ -117,6 +121,7 @@ public void onError() { }); media.setStarred(new Date()); + MediaManager.postFavoriteEvent(media.getId(), media.getStarred()); if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(context).download( diff --git a/app/src/main/java/com/elzify/music/viewmodel/SongListPageViewModel.java b/app/src/main/java/com/elzify/music/viewmodel/SongListPageViewModel.java index 0cee45bf..d98b20fa 100644 --- a/app/src/main/java/com/elzify/music/viewmodel/SongListPageViewModel.java +++ b/app/src/main/java/com/elzify/music/viewmodel/SongListPageViewModel.java @@ -9,6 +9,8 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import com.elzify.music.repository.AlbumRepository; +import com.elzify.music.repository.ChronologyRepository; import com.elzify.music.repository.ArtistRepository; import com.elzify.music.repository.SongRepository; import com.elzify.music.subsonic.models.AlbumID3; @@ -16,13 +18,17 @@ import com.elzify.music.subsonic.models.Child; import com.elzify.music.subsonic.models.Genre; import com.elzify.music.util.Constants; +import com.elzify.music.util.Preferences; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; public class SongListPageViewModel extends AndroidViewModel { private final SongRepository songRepository; private final ArtistRepository artistRepository; + private final AlbumRepository albumRepository; + private final ChronologyRepository chronologyRepository; public String title; public String toolbarTitle; @@ -44,6 +50,8 @@ public SongListPageViewModel(@NonNull Application application) { songRepository = new SongRepository(); artistRepository = new ArtistRepository(); + albumRepository = new AlbumRepository(); + chronologyRepository = new ChronologyRepository(); } public LiveData> getSongList() { @@ -65,6 +73,12 @@ public LiveData> getSongList() { case Constants.MEDIA_STARRED: songList = songRepository.getStarredSongs(false, -1); break; + case Constants.MEDIA_RECENTLY_PLAYED: + songList = songRepository.getRecentlyPlayedSongs(500); + break; + case Constants.MEDIA_TOP_PLAYED: + songList = songRepository.getTopPlayedSongs(500); + break; } return songList; @@ -90,6 +104,8 @@ public void getSongsByPage(LifecycleOwner owner) { case Constants.MEDIA_BY_GENRES: case Constants.MEDIA_BY_YEAR: case Constants.MEDIA_STARRED: + case Constants.MEDIA_RECENTLY_PLAYED: + case Constants.MEDIA_TOP_PLAYED: break; } } diff --git a/app/src/main/res/drawable/bg_dock_item_selected.xml b/app/src/main/res/drawable/bg_dock_item_selected.xml new file mode 100644 index 00000000..fb68171e --- /dev/null +++ b/app/src/main/res/drawable/bg_dock_item_selected.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_mini_player_pill.xml b/app/src/main/res/drawable/bg_mini_player_pill.xml new file mode 100644 index 00000000..bfb142c8 --- /dev/null +++ b/app/src/main/res/drawable/bg_mini_player_pill.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_mini_player_progress.xml b/app/src/main/res/drawable/bg_mini_player_progress.xml new file mode 100644 index 00000000..15131547 --- /dev/null +++ b/app/src/main/res/drawable/bg_mini_player_progress.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_pill_tabs.xml b/app/src/main/res/drawable/bg_pill_tabs.xml new file mode 100644 index 00000000..7b9e2f2e --- /dev/null +++ b/app/src/main/res/drawable/bg_pill_tabs.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_settings_card.xml b/app/src/main/res/drawable/bg_settings_card.xml new file mode 100644 index 00000000..46846c59 --- /dev/null +++ b/app/src/main/res/drawable/bg_settings_card.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_tab_indicator.xml b/app/src/main/res/drawable/bg_tab_indicator.xml new file mode 100644 index 00000000..5a59687e --- /dev/null +++ b/app/src/main/res/drawable/bg_tab_indicator.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_tab_unselected.xml b/app/src/main/res/drawable/bg_tab_unselected.xml new file mode 100644 index 00000000..7b9e2f2e --- /dev/null +++ b/app/src/main/res/drawable/bg_tab_unselected.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lyrics.xml b/app/src/main/res/drawable/ic_lyrics.xml index cac983a6..3957df52 100644 --- a/app/src/main/res/drawable/ic_lyrics.xml +++ b/app/src/main/res/drawable/ic_lyrics.xml @@ -6,5 +6,5 @@ android:autoMirrored="true"> + android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4s4,-1.79 4,-4V7h4V3H12z"/> diff --git a/app/src/main/res/drawable/ic_swap_horiz.xml b/app/src/main/res/drawable/ic_swap_horiz.xml new file mode 100644 index 00000000..10f38138 --- /dev/null +++ b/app/src/main/res/drawable/ic_swap_horiz.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/sel_tab_pill.xml b/app/src/main/res/drawable/sel_tab_pill.xml new file mode 100644 index 00000000..ec5f3a62 --- /dev/null +++ b/app/src/main/res/drawable/sel_tab_pill.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 80a66ec3..d49ae1e6 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -1,44 +1,24 @@ - + android:background="?attr/colorSurface" + android:orientation="vertical"> + android:layout_height="0dp" + android:layout_weight="1"> - - - - - - - - - + app:defaultNavHost="true" + app:navGraph="@navigation/nav_graph" /> - + android:layout_gravity="start|center_vertical" + android:layout_marginStart="16dp" /> - + - + \ No newline at end of file diff --git a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml index a49c60f8..cabb7995 100644 --- a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml +++ b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml @@ -24,42 +24,32 @@ app:layout_constraintStart_toEndOf="@+id/vertical_guideline" app:layout_constraintTop_toTopOf="parent"> - - - - - - - + app:layout_constraintHorizontal_chainStyle="packed" + android:textColor="?attr/colorOnSurface"/> + + + app:tint="?attr/colorOnSurface" />