Compare commits

..

No commits in common. "develop" and "fdroid-dummy-4" have entirely different histories.

151 changed files with 2951 additions and 4914 deletions

4
.gitignore vendored
View File

@ -1,9 +1,9 @@
*.iml
**/.gradle
.gradle
/local.properties
/.idea
.DS_Store
**/build
/build
/captures
.externalNativeBuild
*.keystore

View File

@ -1,5 +1,4 @@
# This image lives in https://dev.funkwhale.audio/funkwhale/ci
image: $CI_REGISTRY/funkwhale/ci/android:latest
image: jangrewe/gitlab-ci-android
variables:
COBERTURA_REPORT: '$CI_PROJECT_DIR/app/build/reports/cobertura.xml'
@ -7,20 +6,11 @@ variables:
JACOCO_XML_LOCATION: '$CI_PROJECT_DIR/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml'
stages:
- build_ci_env
- test
- visualize
- build
- test-after-build
- deploy
cache: &global_cache
key: ${CI_PIPELINE_ID}
paths:
- .gradle/wrapper
- .gradle/caches
policy: pull
.gradle-default:
before_script:
- export GRADLE_USER_HOME=$(pwd)/.gradle
@ -29,6 +19,11 @@ cache: &global_cache
script:
- echo "Overwrite me"
cache:
key: ${CI_PROJECT_ID}
paths:
- .gradle/
.build:
stage: build
variables:
@ -39,7 +34,7 @@ cache: &global_cache
before_script:
- git fetch --unshallow --tags
after_script:
- export versionCode=`$ANDROID_HOME/build-tools/30.0.3/aapt dump badging $apk_file | grep versionCode | awk '{print $3}' | sed s/versionCode=//g | sed s/\'//g`
- export versionCode=`$ANDROID_HOME/build-tools/30.0.2/aapt dump badging $apk_file | grep versionCode | awk '{print $3}' | sed s/versionCode=//g | sed s/\'//g`
- apt update && apt install gettext-base
- cat $metadata_template | envsubst > $metadata_file
extends: .gradle-default
@ -48,9 +43,6 @@ cache: &global_cache
- $apk_file
- $metadata_file
- $output_metadata
cache:
# inherit all global cache settings
<<: *global_cache
test:
extends: .gradle-default
@ -58,30 +50,17 @@ test:
except:
- tags
script:
- ./gradlew --no-daemon --stacktrace test jacocoTestReport
- ./gradlew test jacocoTestReport
- awk -F"," '{ instructions += $4 + $5; covered += $5 } END { print covered, "/", instructions, " instructions covered"; print 100*covered/instructions, "% covered" }' $JACOCO_CSV_LOCATION
artifacts:
reports:
junit: app/build/test-results/test**/TEST-*.xml
paths:
- $JACOCO_XML_LOCATION
cache:
# inherit all global cache settings
<<: *global_cache
# override the policy
policy: pull-push
test_nonfree_code:
stage: test-after-build
image: registry.funkwhale.audio/funkwhale/ci/android-fdroidserver
script:
- fdroid scanner -v app/build/outputs/apk/*/app-*.apk |& tee output.txt
- cat output.txt
- (! grep "CRITICAL" output.txt)
coverage:
stage: visualize
image: haynes/jacoco2cobertura:1.0.9
image: gjrtimmer/jacoco2cobertura:1.0.8
script:
# convert report from jacoco to cobertura, use relative project path
- 'python /opt/cover2cover.py $JACOCO_XML_LOCATION $CI_PROJECT_DIR/app/src/main/java > app/build/reports/cobertura.xml'
@ -92,15 +71,13 @@ coverage:
- tags
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: $COBERTURA_REPORT
cobertura: $COBERTURA_REPORT
build-develop:
extends: .build
script:
- echo -n $PREVIEW_SIGNING_KEY_STORE | base64 -d > app/android.keystore
- ./gradlew --stacktrace --no-daemon assembleDebug -x check -Psigning.store=android.keystore -Psigning.store_passphrase=$PREVIEW_SIGNING_KEY_PASS -Psigning.key_passphrase=$PREVIEW_SIGNING_KEY_PASS
- ./gradlew assembleDebug -Psigning.store=android.keystore -Psigning.store_passphrase=$PREVIEW_SIGNING_KEY_PASS -Psigning.key_passphrase=$PREVIEW_SIGNING_KEY_PASS
only:
- develop
@ -113,51 +90,45 @@ build-release:
extends: .build
script:
- echo -n $SIGNING_KEY_STORE | base64 -d > app/android.keystore
- ./gradlew --stacktrace --no-daemon assembleRelease -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
- ./gradlew assembleRelease -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
only:
- tags
build-bleeding-edge:
extends: .build
script:
- ./gradlew --stacktrace --no-daemon -x check assembleDebug
- ./gradlew assembleDebug
except:
- develop
- tags
.deploy:
image: curlimages/curl:latest
script:
- 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file $FILE "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/$PACKAGE/$CI_COMMIT_SHORT_SHA/$PACKAGE-$CI_COMMIT_SHORT_SHA.apk"'
image: debian
before_script:
- apt update && apt -y install openssh-server
deploy-develop:
extends: .deploy
stage: deploy
only:
- develop
variables:
FILE: app/build/outputs/apk/debug/app-debug.apk
PACKAGE: audio.funkwhale.ffa.dev
script:
- eval `ssh-agent -s`
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/debug/app-debug.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa.dev-$CI_COMMIT_SHORT_SHA.apk
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/debug/output-metadata.json fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/output-metadata.json
- scp -o StrictHostKeyChecking=no metadata/audio.funkwhale.android.dev.yml fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/metadata/audio.funkwhale.ffa.dev.yml
- ssh -o StrictHostKeyChecking=no fdroid@apps.funkwhale.audio 'docker run --rm -u $(id -u):$(id -g) -v /srv/fdroid/fdroid/develop:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:master update'
deploy-release:
extends: .deploy
stage: deploy
only:
- tags
variables:
FILE: app/build/outputs/apk/release/app-release.apk
PACKAGE: audio.funkwhale.ffa
trigger-fdroid-update-develop:
stage: .post
only:
- develop
image: curlimages/curl:7.88.1
script: curl "https://fdroid.funkwhale.audio/hooks/update-index?name=audio.funkwhale.ffa.dev&version=$CI_COMMIT_SHORT_SHA"
trigger-fdroid-update-release:
stage: .post
only:
- tags
image: curlimages/curl:7.88.1
script: curl "https://fdroid.funkwhale.audio/hooks/update-index?name=audio.funkwhale.ffa&version=$CI_COMMIT_SHORT_SHA"
script:
- eval `ssh-agent -s`
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/release/app-release.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa-$CI_COMMIT_TAG.apk
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/release/output-metadata.json fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/output-metadata.json
- scp -o StrictHostKeyChecking=no metadata/audio.funkwhale.android.yml fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/metadata/audio.funkwhale.ffa.yml
- ssh -o StrictHostKeyChecking=no fdroid@apps.funkwhale.audio 'docker run --rm -u $(id -u):$(id -g) -v /srv/fdroid/fdroid/develop:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:master update'

View File

@ -1,3 +1,3 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=11.0.13-tem
java=11.0.11.hs-adpt

View File

@ -1 +0,0 @@
java temurin-11.0.16+101

View File

@ -1,54 +1,3 @@
0.3.0 (2023-12-12)
Features:
- Add option to limit bandwidth usage by streaming transcoded music
- Improve player bottom sheet, in particular fling support
Enhancements:
- Refactor CoverArt.withContext().
Bugfixes:
- Fix buffering progress bar display
- Fix landscape view induced MainActivity leak.
- Fix Too Many Receivers exception
0.2.1 (2023-04-18)
Bugfixes:
- Removed navigation-dynamic-features-fragment, which has proprietary dependencies and isn't needed
0.2.0 (2023-04-05)
Features:
- Add filtering functionality to favorites view (thanks @PhieF)
- Allow backward skip after pause by configurable number of seconds (contributed by hdasch)
- Use the track cover in an album track list if one is available
Bugfixes:
- Make the mini player overlay stay on top (contributed by @christophehenry)
- Use Picasso stableKey for better caching against pre-signed URLs (thanks @rickosborne)
0.1.5 (2022-07-04)
Bugfixes:
- Fix App crashes when interacting with playlist (@Mouath)
- Fix leaked database cursor resource
- Fix playback order to respect preference setting on albums fragment
- Fix the removal of existing downloads
- Fix unresponsive bluetooth buttons with Oreo and later (thanks @hdasch)
- Fix warnings in log output due to leaked BufferedReader resource (thanks @hdasch)
- Fixes problem where users are logged out sporadically (thanks to @hdasch)
0.1.4 (2021-09-18)
Bugfixes:

View File

@ -7,8 +7,9 @@ You can get help and discuss Funkwhale on Matrix on [#funkwhale-android:matrix.o
## Installation
We have an official version available on F-Droid and the Google Play-Store, but you can also install a preview version of Funkwhale for Android™ through our selfhosted [F-Droid repository](https://fdroid.funkwhale.audio/develop/).
You'll have to add this repository to your F-Droid client, please visit the link above for further instructions. Once you added the repository, you can use F-Droid as usual and search for "Funkwhale".
Currently you can install a preview version of Funkwhale for Android™ through a selfhosted [F-Droid repository](https://fdroid.funkwhale.audio/develop/).
You'll have to add this repository to your F-Droid client, please visit the link above for further instructions. Once you added the repository, you can
use F-Droid as usual and search for "Funkwhale".
## State
@ -30,9 +31,13 @@ Funkwhale for Android™ will try to behave as you would expect a mobile music p
## Screenshots
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="33%" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="33%" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="33%" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="200" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="200" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="200" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png" width="200" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png" width="200" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/6.png" width="200" />
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/7.png" width="200" />
## Translation

View File

@ -5,16 +5,12 @@ import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("androidx.navigation.safeargs.kotlin")
id("kotlin-parcelize")
id("kotlin-kapt")
id("org.jlleitschuh.gradle.ktlint") version "11.2.0"
id("org.jlleitschuh.gradle.ktlint") version "10.1.0"
id("com.gladed.androidgitversion") version "0.4.14"
id("com.github.triplet.play") version "3.8.1"
id("com.github.triplet.play") version "3.6.0"
id("de.mobilej.unmock")
id("com.github.ben-manes.versions")
id("org.jetbrains.kotlin.android")
jacoco
}
@ -36,35 +32,27 @@ androidGitVersion {
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
namespace = "audio.funkwhale.ffa"
testCoverage {
version = "0.8.7"
version = Versions.jacoco
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildFeatures {
viewBinding = true
dataBinding = true
}
packagingOptions {
resources.excludes.add("META-INF/LICENSE.md")
resources.excludes.add("META-INF/LICENSE-notice.md")
}
lint {
disable += listOf("MissingTranslation", "ExtraTranslation")
}
compileSdk = 33
compileSdk = 30
defaultConfig {
@ -74,7 +62,7 @@ android {
versionName = androidGitVersion.name()
minSdk = 24
targetSdk = 33
targetSdk = 30
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -163,65 +151,54 @@ play {
}
dependencies {
val navVersion: String by rootProject.extra
val lifecycleVersion: String by rootProject.extra
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha03")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
implementation("androidx.preference:preference-ktx:1.1.1")
implementation("androidx.recyclerview:recyclerview:1.2.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("com.google.android.material:material:1.9.0") {
exclude("androidx.constraintlayout")
}
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("com.google.android.material:material:1.4.0")
implementation("com.android.support.constraint:constraint-layout:2.0.4")
implementation("com.google.android.exoplayer:exoplayer-core:2.18.1")
implementation("com.google.android.exoplayer:exoplayer-ui:2.18.1")
implementation("com.google.android.exoplayer:extension-mediasession:2.18.1")
implementation("com.google.android.exoplayer:exoplayer-core:${Versions.exoPlayer}")
implementation("com.google.android.exoplayer:exoplayer-ui:${Versions.exoPlayer}")
implementation("com.google.android.exoplayer:extension-mediasession:${Versions.exoPlayer}")
implementation("io.insert-koin:koin-core:3.5.3")
implementation("io.insert-koin:koin-android:3.5.3")
testImplementation("io.insert-koin:koin-test:3.5.3")
implementation("io.insert-koin:koin-core:${Versions.koin}")
implementation("io.insert-koin:koin-android:${Versions.koin}")
testImplementation("io.insert-koin:koin-test:${Versions.koin}")
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:789a4f83169cff5c7a91655bb828fde2cfde671a") {
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:${Versions.exoPlayerExtensions}") {
isTransitive = false
}
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:789a4f83169cff5c7a91655bb828fde2cfde671a") {
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:${Versions.exoPlayerExtensions}") {
isTransitive = false
}
implementation("com.github.AliAsadi:PowerPreference:2.1.1")
implementation("com.github.kittinunf.fuel:fuel:2.3.1")
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.3.1")
implementation("com.github.kittinunf.fuel:fuel-android:2.3.1")
implementation("com.github.kittinunf.fuel:fuel-gson:2.3.1")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.aliassadi:power-preference-lib:${Versions.powerPreference}")
implementation("com.github.kittinunf.fuel:fuel:${Versions.fuel}")
implementation("com.github.kittinunf.fuel:fuel-coroutines:${Versions.fuel}")
implementation("com.github.kittinunf.fuel:fuel-android:${Versions.fuel}")
implementation("com.github.kittinunf.fuel:fuel-gson:${Versions.fuel}")
implementation("com.google.code.gson:gson:${Versions.gson}")
implementation("com.squareup.picasso:picasso:2.71828")
implementation("jp.wasabeef:picasso-transformations:2.4.0")
implementation("net.openid:appauth:0.11.1")
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
implementation("net.openid:appauth:${Versions.openIdAppAuth}")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.4")
testImplementation("androidx.test:core:1.5.0")
testImplementation("io.strikt:strikt-core:0.34.1")
testImplementation("org.robolectric:robolectric:4.9.2")
debugImplementation("io.sentry:sentry-android:6.17.0")
testImplementation("io.mockk:mockk:1.12.0")
testImplementation("androidx.test:core:1.4.0")
testImplementation("io.strikt:strikt-core:${Versions.strikt}")
testImplementation("org.robolectric:robolectric:${Versions.robolectric}")
androidTestImplementation("io.mockk:mockk-android:1.13.4")
androidTestImplementation("androidx.navigation:navigation-testing:$navVersion")
androidTestImplementation("io.mockk:mockk-android:${Versions.mockk}")
}
project.afterEvaluate {

View File

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
package="audio.funkwhale.ffa">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
@ -23,7 +22,7 @@
android:name=".activities.SplashActivity"
android:launchMode="singleInstance"
android:noHistory="true"
android:exported="true">
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -41,7 +40,12 @@
android:screenOrientation="portrait" />
<activity
android:name=".activities.MainActivity" />
android:name=".activities.MainActivity"
android:screenOrientation="portrait" />
<activity
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
<activity
android:name=".activities.DownloadsActivity"
@ -55,15 +59,9 @@
android:name=".activities.LicencesActivity"
android:screenOrientation="portrait" />
<activity
android:name="net.openid.appauth.AuthorizationManagementActivity"
android:launchMode="@integer/launch_mode_for_app_auth"
tools:replace="android:launchMode" />
<service
android:name=".playback.PlayerService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
@ -82,14 +80,12 @@
</service>
<receiver android:name="androidx.media.session.MediaButtonReceiver"
android:exported="false">
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<meta-data android:name="io.sentry.dsn" android:value="https://4e377f47d01242baae2d9d8bd689c3ef@am.funkwhale.audio/4" />
</application>
</manifest>

View File

@ -6,8 +6,13 @@ import androidx.appcompat.app.AppCompatDelegate
import audio.funkwhale.ffa.koin.authModule
import audio.funkwhale.ffa.koin.exoplayerModule
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.FFACache
import audio.funkwhale.ffa.utils.Request
import com.preference.PowerPreference
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import org.koin.core.context.startKoin
import java.text.SimpleDateFormat
import java.util.Date
@ -23,6 +28,11 @@ class FFA : Application() {
var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
val eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
val commandBus: BroadcastChannel<Command> = BroadcastChannel(10)
val requestBus: BroadcastChannel<Request> = BroadcastChannel(10)
val progressBus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
override fun onCreate() {
super.onCreate()
@ -73,7 +83,7 @@ class FFA : Application() {
builder.appendLine(e.toString())
FFACache.set(this@FFA, "crashdump", builder.toString())
FFACache.set(this@FFA, "crashdump", builder.toString().toByteArray())
}
}

View File

@ -14,6 +14,7 @@ import com.google.android.exoplayer2.offline.DownloadManager
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
@ -64,19 +65,20 @@ class DownloadsActivity : AppCompatActivity() {
private fun refresh() {
lifecycleScope.launch(Main) {
adapter.downloads.clear()
exoDownloadManager.downloadIndex.getDownloads()
.use { cursor ->
while (cursor.moveToNext()) {
val download = cursor.download
val cursor = exoDownloadManager.downloadIndex.getDownloads()
download.getMetadata()?.let { info ->
adapter.downloads.add(
info.apply { this.download = download }
)
}
}
adapter.downloads.clear()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let { info ->
adapter.downloads.add(
info.apply { this.download = download }
)
}
}
adapter.notifyDataSetChanged()
}
}
@ -99,29 +101,26 @@ class DownloadsActivity : AppCompatActivity() {
}
private suspend fun refreshProgress() {
exoDownloadManager.downloadIndex.getDownloads()
.use { cursor ->
while (cursor.moveToNext()) {
val download = cursor.download
val cursor = exoDownloadManager.downloadIndex.getDownloads()
download.getMetadata()?.let { info ->
adapter.downloads.withIndex().associate { it.value to it.index }
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
if (download.state == Download.STATE_DOWNLOADING &&
download.percentDownloaded != (info.download?.percentDownloaded ?: 0)
) {
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
while (cursor.moveToNext()) {
val download = cursor.download
adapter.notifyItemChanged(match.second)
}
download.getMetadata()?.let { info ->
adapter.downloads.withIndex().associate { it.value to it.index }
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
if (download.state == Download.STATE_DOWNLOADING && download.percentDownloaded != info.download?.percentDownloaded ?: 0) {
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
adapter.notifyItemChanged(match.second)
}
}
}
}
}
}
}
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {

View File

@ -1,13 +1,10 @@
package audio.funkwhale.ffa.activities
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.text.Editable
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnLayout
import androidx.lifecycle.lifecycleScope
@ -43,36 +40,34 @@ class LoginActivity : AppCompatActivity() {
limitContainerWidth()
}
private var resultLauncher =
registerForActivityResult(StartActivityForResult()) { result ->
result.data?.let {
oAuth.exchange(this, it) {
PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.setBoolean("anonymous", false)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
lifecycleScope.launch(Main) {
Userinfo.get(this@LoginActivity, oAuth)?.let {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
data?.let {
when (requestCode) {
0 -> {
oAuth.exchange(this, data) {
PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.setBoolean("anonymous", false)
return@launch finish()
lifecycleScope.launch(Main) {
Userinfo.get(this@LoginActivity, oAuth)?.let {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
return@launch finish()
}
throw Exception(getString(R.string.login_error_userinfo))
}
throw Exception(getString(R.string.login_error_userinfo))
}
}
}
}
}
override fun onResume() {
super.onResume()
with(binding) {
val preferences = getPreferences(Context.MODE_PRIVATE)
val hn = preferences?.getString("hostname", "")
if (hn != null && !hn.isEmpty()) {
hostname.text = Editable.Factory.getInstance().newEditable(hn)
}
cleartext.setChecked(preferences?.getBoolean("cleartext", false) ?: false)
anonymous.setChecked(preferences?.getBoolean("anonymous", false) ?: false)
login.setOnClickListener {
var hostname = hostname.text.toString().trim().trim('/')
@ -105,12 +100,6 @@ class LoginActivity : AppCompatActivity() {
hostnameField.error = message
}
if (hostnameField.error == null) {
val preferences = getPreferences(Context.MODE_PRIVATE)
preferences?.edit()?.putString("hostname", hostname)?.commit()
preferences?.edit()?.putBoolean("cleartext", cleartext.isChecked)?.commit()
preferences?.edit()?.putBoolean("anonymous", anonymous.isChecked)?.commit()
}
}
}
}
@ -145,7 +134,7 @@ class LoginActivity : AppCompatActivity() {
oAuth.init(hostname)
return oAuth.register {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
resultLauncher.launch(oAuth.authorizeIntent(this))
oAuth.authorize(this)
}
}

View File

@ -1,211 +1,236 @@
package audio.funkwhale.ffa.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Fragment
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.ActivityMainBinding
import audio.funkwhale.ffa.fragments.AddToPlaylistDialog
import audio.funkwhale.ffa.fragments.BrowseFragmentDirections
import audio.funkwhale.ffa.fragments.NowPlayingFragment
import audio.funkwhale.ffa.fragments.AlbumsFragment
import audio.funkwhale.ffa.fragments.ArtistsFragment
import audio.funkwhale.ffa.fragments.BrowseFragment
import audio.funkwhale.ffa.fragments.LandscapeQueueFragment
import audio.funkwhale.ffa.fragments.QueueFragment
import audio.funkwhale.ffa.fragments.TrackInfoDetailsFragment
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.playback.MediaControlsManager
import audio.funkwhale.ffa.playback.PinService
import audio.funkwhale.ffa.playback.PlayerService
import audio.funkwhale.ffa.repositories.FavoritedRepository
import audio.funkwhale.ffa.repositories.FavoritesRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.FFACache
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Request
import audio.funkwhale.ffa.utils.RequestBus
import audio.funkwhale.ffa.utils.Response
import audio.funkwhale.ffa.utils.ProgressBus
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.Userinfo
import audio.funkwhale.ffa.utils.authorize
import audio.funkwhale.ffa.utils.log
import audio.funkwhale.ffa.utils.logError
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import audio.funkwhale.ffa.utils.onApi
import audio.funkwhale.ffa.utils.toast
import audio.funkwhale.ffa.utils.wait
import audio.funkwhale.ffa.utils.untilNetwork
import audio.funkwhale.ffa.views.DisableableFrameLayout
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitStringResponse
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.gson.Gson
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
class MainActivity : AppCompatActivity() {
enum class ResultCode(val code: Int) {
LOGOUT(1001)
}
private val favoritedRepository by lazy {
FavoritedRepository(applicationContext)
}
private val favoriteRepository = FavoritesRepository(this)
private val favoritedRepository = FavoritedRepository(this)
private var menu: Menu? = null
private lateinit var binding: ActivityMainBinding
private val oAuth: OAuth by inject(OAuth::class.java)
private val navigation: NavController by lazy {
val navHost = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navHost.navController
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppContext.init(applicationContext)
AppContext.init(this)
binding = ActivityMainBinding.inflate(layoutInflater)
(supportFragmentManager.findFragmentById(R.id.now_playing) as NowPlayingFragment).apply {
onDetailsMenuItemClicked { binding.nowPlayingBottomSheet.close() }
binding.nowPlayingBottomSheet.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// Add padding to the main fragment so that player control don't overlap
// artists and albums
addSiblingFragmentPadding()
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
// Animate the cover and other elements of the bottom sheet
onBottomSheetDrag(slideOffset)
}
}
)
}
addSiblingFragmentPadding()
setContentView(binding.root)
setSupportActionBar(binding.appbar)
supportActionBar?.setDisplayShowTitleEnabled(false)
onBackPressedDispatcher.addCallback(this) {
if (binding.nowPlayingBottomSheet.isOpen) {
binding.nowPlayingBottomSheet.close()
} else {
navigation.navigateUp()
}
}
when (intent.action) {
MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment())
}
lifecycleScope.launch {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let {
if (it.queue.isNotEmpty() && binding.nowPlayingBottomSheet.isHidden) {
binding.nowPlayingBottomSheet.show()
} else if (it.queue.isEmpty()) {
binding.nowPlayingBottomSheet.hide()
}
}
// Watch the event bus only after to prevent concurrency in displaying the bottom sheet
watchEventBus()
}
supportFragmentManager
.beginTransaction()
.replace(R.id.container, BrowseFragment())
.commit()
watchEventBus()
}
override fun onResume() {
super.onResume()
binding.nowPlaying.getFragment<NowPlayingFragment>().apply {
favoritedRepository.update(applicationContext, lifecycleScope)
(binding.container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
if (binding.nowPlaying.isOpened()) {
binding.nowPlaying.close()
startService(Intent(applicationContext, PlayerService::class.java))
DownloadService.start(applicationContext, PinService::class.java)
return@setShouldRegisterTouch false
}
CommandBus.send(Command.RefreshService)
true
}
lifecycleScope.launch(IO) {
Userinfo.get(applicationContext, oAuth)
favoritedRepository.update(this, lifecycleScope)
startService(Intent(this, PlayerService::class.java))
DownloadService.start(this, PinService::class.java)
CommandBus.send(Command.RefreshService)
lifecycleScope.launch(IO) {
Userinfo.get(this@MainActivity, oAuth)
}
with(binding) {
nowPlayingContainer?.nowPlayingToggle?.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
nowPlayingContainer?.nowPlayingNext?.setOnClickListener {
CommandBus.send(Command.NextTrack)
}
nowPlayingContainer?.nowPlayingDetailsPrevious?.setOnClickListener {
CommandBus.send(Command.PreviousTrack)
}
nowPlayingContainer?.nowPlayingDetailsNext?.setOnClickListener {
CommandBus.send(Command.NextTrack)
}
nowPlayingContainer?.nowPlayingDetailsToggle?.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
binding.nowPlayingContainer?.nowPlayingDetailsProgress?.setOnSeekBarChangeListener(object :
SeekBar.OnSeekBarChangeListener {
override fun onStopTrackingTouch(view: SeekBar?) {}
override fun onStartTrackingTouch(view: SeekBar?) {}
override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
CommandBus.send(Command.Seek(progress))
}
}
})
landscapeQueue?.let {
supportFragmentManager.beginTransaction()
.replace(R.id.landscape_queue, LandscapeQueueFragment()).commit()
}
}
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
override fun onBackPressed() {
if (binding.nowPlaying.isOpened()) {
binding.nowPlaying.close()
return
}
super.onBackPressed()
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
this.menu = menu
return super.onPrepareOptionsMenu(menu)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.toolbar, menu)
menu.findItem(R.id.nav_all_music)?.let {
menu?.findItem(R.id.nav_all_music)?.let {
it.isChecked = Settings.getScopes().contains("all")
it.isEnabled = !it.isChecked
}
menu.findItem(R.id.nav_my_music)?.isChecked = Settings.getScopes().contains("me")
menu.findItem(R.id.nav_followed)?.isChecked = Settings.getScopes().contains("subscribed")
menu?.findItem(R.id.nav_my_music)?.isChecked = Settings.getScopes().contains("me")
menu?.findItem(R.id.nav_followed)?.isChecked = Settings.getScopes().contains("subscribed")
return true
}
var resultLauncher = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == ResultCode.LOGOUT.code) {
Intent(this, LoginActivity::class.java).apply {
FFA.get().deleteAllData(this@MainActivity)
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
stopService(Intent(this@MainActivity, PlayerService::class.java))
startActivity(this)
finish()
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
binding.nowPlayingBottomSheet.close()
navigation.popBackStack(R.id.browseFragment, false)
binding.nowPlaying.close()
(supportFragmentManager.fragments.last() as? BrowseFragment)?.let {
it.selectTabAt(0)
return true
}
launchFragment(BrowseFragment())
}
R.id.nav_queue -> launchDialog(QueueFragment())
R.id.nav_search -> navigation.navigate(BrowseFragmentDirections.browseToSearch())
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> {
menu?.let { menu ->
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
item.actionView = View(this)
item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem) = false
override fun onMenuItemActionCollapse(item: MenuItem) = false
override fun onMenuItemActionExpand(item: MenuItem?) = false
override fun onMenuItemActionCollapse(item: MenuItem?) = false
})
item.isChecked = !item.isChecked
@ -254,42 +279,118 @@ class MainActivity : AppCompatActivity() {
return false
}
}
R.id.nav_downloads -> startActivity(Intent(this, DownloadsActivity::class.java))
R.id.settings -> resultLauncher.launch(Intent(this, SettingsActivity::class.java))
R.id.settings -> startActivityForResult(Intent(this, SettingsActivity::class.java), 0)
}
return true
}
private fun addSiblingFragmentPadding() {
val anim = if (binding.nowPlayingBottomSheet.isHidden) {
ValueAnimator.ofInt(binding.nowPlayingBottomSheet.peekHeight, 0)
} else {
ValueAnimator.ofInt(0, binding.nowPlayingBottomSheet.peekHeight)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
anim.duration = 200
anim.addUpdateListener {
binding.navHostFragmentWrapper.setPadding(0, 0, 0, it.animatedValue as Int)
if (resultCode == ResultCode.LOGOUT.code) {
Intent(this, LoginActivity::class.java).apply {
FFA.get().deleteAllData(this@MainActivity)
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
stopService(Intent(this@MainActivity, PlayerService::class.java))
startActivity(this)
finish()
}
}
anim.start()
}
private fun launchDialog(fragment: DialogFragment) =
fragment.show(supportFragmentManager.beginTransaction(), "")
private fun launchFragment(fragment: Fragment) {
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
oldFragment.enterTransition = null
oldFragment.exitTransition = null
supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
supportFragmentManager
.beginTransaction()
.setCustomAnimations(0, 0, 0, 0)
.replace(R.id.container, fragment)
.commit()
}
private fun launchDialog(fragment: DialogFragment) {
supportFragmentManager.beginTransaction().let {
fragment.show(it, "")
}
}
@SuppressLint("NewApi")
private fun watchEventBus() {
lifecycleScope.launch(Main) {
EventBus.get().collect { event ->
when (event) {
is Event.LogOut -> logout()
is Event.PlaybackError -> toast(event.message)
is Event.PlaybackStopped -> binding.nowPlayingBottomSheet.hide()
is Event.TrackFinished -> incrementListenCount(event.track)
EventBus.get().collect { message ->
when (message) {
is Event.LogOut -> {
FFA.get().deleteAllData(this@MainActivity)
startActivity(
Intent(this@MainActivity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
}
)
finish()
}
is Event.PlaybackError -> toast(message.message)
is Event.Buffering -> {
when (message.value) {
true -> binding.nowPlayingContainer?.nowPlayingBuffering?.visibility = View.VISIBLE
false -> binding.nowPlayingContainer?.nowPlayingBuffering?.visibility = View.GONE
}
}
is Event.PlaybackStopped -> {
if (binding.nowPlaying.visibility == View.VISIBLE) {
(binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin / 2
}
binding.landscapeQueue?.let { landscape_queue ->
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin / 2
}
}
binding.nowPlaying.animate()
.alpha(0.0f)
.setDuration(400)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animator: Animator?) {
binding.nowPlaying.visibility = View.GONE
}
})
.start()
}
}
is Event.TrackFinished -> incrementListenCount(message.track)
is Event.StateChanged -> {
when (message.playing) {
true -> {
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.pause)
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = getDrawable(R.drawable.pause)
}
false -> {
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.play)
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon =
getDrawable(R.drawable.play)
}
}
}
is Event.QueueChanged -> {
if (binding.nowPlayingBottomSheet.isHidden) binding.nowPlayingBottomSheet.show()
findViewById<View>(R.id.nav_queue)?.let { view ->
ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).let {
it.duration = 500
@ -298,42 +399,263 @@ class MainActivity : AppCompatActivity() {
}
}
}
else -> {}
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().flowWithLifecycle(
this@MainActivity.lifecycle, Lifecycle.State.RESUMED
).collect { command ->
CommandBus.get().collect { command ->
when (command) {
is Command.StartService -> startService(command.command)
is Command.RefreshTrack -> refreshTrack(command.track)
is Command.AddToPlaylist -> AddToPlaylistDialog.show(
layoutInflater,
this@MainActivity,
lifecycleScope,
command.tracks
)
is Command.StartService -> {
Build.VERSION_CODES.O.onApi(
{
startForegroundService(
Intent(
this@MainActivity,
PlayerService::class.java
).apply {
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
}
)
},
{
startService(
Intent(this@MainActivity, PlayerService::class.java).apply {
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
}
)
}
)
}
else -> {}
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
AddToPlaylistDialog.show(
layoutInflater,
this@MainActivity,
lifecycleScope,
command.tracks
)
}
}
}
}
lifecycleScope.launch(Main) {
ProgressBus.get().collect { (current, duration, percent) ->
binding.nowPlayingContainer?.nowPlayingProgress?.progress = percent
binding.nowPlayingContainer?.nowPlayingDetailsProgress?.progress = percent
val currentMins = (current / 1000) / 60
val currentSecs = (current / 1000) % 60
val durationMins = duration / 60
val durationSecs = duration % 60
binding.nowPlayingContainer?.nowPlayingDetailsProgressCurrent?.text =
"%02d:%02d".format(currentMins, currentSecs)
binding.nowPlayingContainer?.nowPlayingDetailsProgressDuration?.text =
"%02d:%02d".format(durationMins, durationSecs)
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
if (binding.nowPlaying.visibility == View.GONE) {
binding.nowPlaying.visibility = View.VISIBLE
binding.nowPlaying.alpha = 0f
binding.nowPlaying.animate()
.alpha(1.0f)
.setDuration(400)
.setListener(null)
.start()
(binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2
}
binding.landscapeQueue?.let { landscape_queue ->
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2
}
}
}
binding.nowPlayingContainer?.nowPlayingTitle?.text = track.title
binding.nowPlayingContainer?.nowPlayingAlbum?.text = track.artist.name
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.pause)
binding.nowPlayingContainer?.nowPlayingDetailsTitle?.text = track.title
binding.nowPlayingContainer?.nowPlayingDetailsArtist?.text = track.artist.name
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = getDrawable(R.drawable.pause)
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover?.urls?.original))
.fit()
.centerCrop()
.into(binding.nowPlayingContainer?.nowPlayingCover)
binding.nowPlayingContainer?.nowPlayingDetailsCover?.let { nowPlayingDetailsCover ->
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(nowPlayingDetailsCover)
}
if (binding.nowPlayingContainer?.nowPlayingCover == null) {
lifecycleScope.launch(Default) {
val width = DisplayMetrics().apply {
windowManager.defaultDisplay.getMetrics(this)
}.widthPixels
val backgroundCover = Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.get()
.run { Bitmap.createScaledBitmap(this, width, width, false).toDrawable(resources) }
.apply {
alpha = 20
gravity = Gravity.CENTER
}
withContext(Main) {
binding.nowPlayingContainer?.nowPlayingDetails?.background = backgroundCover
}
}
}
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.let { now_playing_details_repeat ->
changeRepeatMode(FFACache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0)
now_playing_details_repeat.setOnClickListener {
val current = FFACache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0
changeRepeatMode((current + 1) % 3)
}
}
binding.nowPlayingContainer?.nowPlayingDetailsInfo?.let { nowPlayingDetailsInfo ->
nowPlayingDetailsInfo.setOnClickListener {
PopupMenu(
this@MainActivity,
nowPlayingDetailsInfo,
Gravity.START,
R.attr.actionOverflowMenuStyle,
0
).apply {
inflate(R.menu.track_info)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_info_artist -> ArtistsFragment.openAlbums(
this@MainActivity,
track.artist,
art = track.album?.cover()
)
R.id.track_info_album -> AlbumsFragment.openTracks(this@MainActivity, track.album)
R.id.track_info_details -> TrackInfoDetailsFragment.new(track)
.show(supportFragmentManager, "dialog")
}
binding.nowPlaying.close()
true
}
show()
}
}
}
binding.nowPlayingContainer?.nowPlayingDetailsFavorite?.let { now_playing_details_favorite ->
favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ ->
lifecycleScope.launch(Main) {
track.favorite = favorites.contains(track.id)
when (track.favorite) {
true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
}
}
}
now_playing_details_favorite.setOnClickListener {
when (track.favorite) {
true -> {
favoriteRepository.deleteFavorite(track.id)
now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
}
false -> {
favoriteRepository.addFavorite(track.id)
now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
}
}
track.favorite = !track.favorite
favoriteRepository.fetch(Repository.Origin.Network.origin)
}
binding.nowPlayingContainer?.nowPlayingDetailsAddToPlaylist?.setOnClickListener {
CommandBus.send(Command.AddToPlaylist(listOf(track)))
}
}
}
}
private fun startService(command: Command) {
val intent = Intent(this@MainActivity, PlayerService::class.java).apply {
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.toString())
}
ContextCompat.startForegroundService(this, intent)
}
private fun changeRepeatMode(index: Int) {
when (index) {
// From no repeat to repeat all
0 -> {
FFACache.set(this@MainActivity, "repeat", "0".toByteArray())
private fun refreshTrack(track: Track?) {
if (track != null) {
binding.nowPlayingBottomSheet.show()
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat)
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter(
ContextCompat.getColor(
this,
R.color.controlForeground
)
)
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 0.2f
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_OFF))
}
// From repeat all to repeat one
1 -> {
FFACache.set(this@MainActivity, "repeat", "1".toByteArray())
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat)
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter(
ContextCompat.getColor(
this,
R.color.controlForeground
)
)
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 1.0f
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ALL))
}
// From repeat one to no repeat
2 -> {
FFACache.set(this@MainActivity, "repeat", "2".toByteArray())
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat_one)
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter(
ContextCompat.getColor(
this,
R.color.controlForeground
)
)
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 1.0f
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ONE))
}
}
}
@ -344,7 +666,7 @@ class MainActivity : AppCompatActivity() {
try {
Fuel
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
.authorize(applicationContext, oAuth)
.authorize(this@MainActivity, oAuth)
.header("Content-Type", "application/json")
.body(Gson().toJson(mapOf("track" to track.id)))
.awaitStringResponse()
@ -354,15 +676,4 @@ class MainActivity : AppCompatActivity() {
}
}
}
private fun logout() {
FFA.get().deleteAllData(this@MainActivity)
startActivity(
Intent(this@MainActivity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
}
)
finish()
}
}

View File

@ -0,0 +1,197 @@
package audio.funkwhale.ffa.activities
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.SearchAdapter
import audio.funkwhale.ffa.databinding.ActivitySearchBinding
import audio.funkwhale.ffa.fragments.AddToPlaylistDialog
import audio.funkwhale.ffa.fragments.AlbumsFragment
import audio.funkwhale.ffa.fragments.ArtistsFragment
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.repositories.AlbumsSearchRepository
import audio.funkwhale.ffa.repositories.ArtistsSearchRepository
import audio.funkwhale.ffa.repositories.FavoritesRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.repositories.TracksSearchRepository
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.getMetadata
import audio.funkwhale.ffa.utils.untilNetwork
import com.google.android.exoplayer2.offline.Download
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import java.util.Locale
class SearchActivity : AppCompatActivity() {
private lateinit var adapter: SearchAdapter
private lateinit var artistsRepository: ArtistsSearchRepository
private lateinit var albumsRepository: AlbumsSearchRepository
private lateinit var tracksRepository: TracksSearchRepository
private lateinit var favoritesRepository: FavoritesRepository
private lateinit var binding: ActivitySearchBinding
var done = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
artistsRepository = ArtistsSearchRepository(this@SearchActivity, "")
albumsRepository = AlbumsSearchRepository(this@SearchActivity, "")
tracksRepository = TracksSearchRepository(this@SearchActivity, "")
favoritesRepository = FavoritesRepository(this@SearchActivity)
binding = ActivitySearchBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.search.requestFocus()
}
override fun onResume() {
super.onResume()
lifecycleScope.launch(Dispatchers.Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
AddToPlaylistDialog.show(
layoutInflater,
this@SearchActivity,
lifecycleScope,
command.tracks
)
}
}
}
}
lifecycleScope.launch(Dispatchers.IO) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
adapter =
SearchAdapter(
layoutInflater,
this,
SearchResultClickListener(),
FavoriteListener(favoritesRepository)
).also {
binding.results.layoutManager = LinearLayoutManager(this)
binding.results.adapter = it
}
binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(rawQuery: String?): Boolean {
binding.search.clearFocus()
rawQuery?.let {
done = 0
val query = URLEncoder.encode(it, "UTF-8")
artistsRepository.query = query.lowercase(Locale.ROOT)
albumsRepository.query = query.lowercase(Locale.ROOT)
tracksRepository.query = query.lowercase(Locale.ROOT)
binding.searchSpinner.visibility = View.VISIBLE
binding.searchEmpty.visibility = View.GONE
binding.searchNoResults.visibility = View.GONE
adapter.artists.clear()
adapter.albums.clear()
adapter.tracks.clear()
adapter.notifyDataSetChanged()
artistsRepository.fetch(Repository.Origin.Network.origin)
.untilNetwork(lifecycleScope) { artists, _, _, _ ->
done++
adapter.artists.addAll(artists)
refresh()
}
albumsRepository.fetch(Repository.Origin.Network.origin)
.untilNetwork(lifecycleScope) { albums, _, _, _ ->
done++
adapter.albums.addAll(albums)
refresh()
}
tracksRepository.fetch(Repository.Origin.Network.origin)
.untilNetwork(lifecycleScope) { tracks, _, _, _ ->
done++
adapter.tracks.addAll(tracks)
refresh()
}
}
return true
}
override fun onQueryTextChange(newText: String?) = true
})
}
private fun refresh() {
adapter.notifyDataSetChanged()
if (adapter.artists.size + adapter.albums.size + adapter.tracks.size == 0) {
binding.searchNoResults.visibility = View.VISIBLE
} else {
binding.searchNoResults.visibility = View.GONE
}
if (done == 3) {
binding.searchSpinner.visibility = View.INVISIBLE
}
}
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.tracks.withIndex().associate { it.value to it.index }
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Dispatchers.Main) {
adapter.tracks[match.second].downloaded = true
adapter.notifyItemChanged(
adapter.getPositionOf(
SearchAdapter.ResultType.Track,
match.second
)
)
}
}
}
}
}
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
override fun onArtistClick(holder: View?, artist: Artist) {
ArtistsFragment.openAlbums(this@SearchActivity, artist)
}
override fun onAlbumClick(holder: View?, album: Album) {
AlbumsFragment.openTracks(this@SearchActivity, album)
}
}
}

View File

@ -40,6 +40,8 @@ class SettingsActivity : AppCompatActivity() {
)
.commit()
}
fun getThemeResId(): Int = R.style.AppTheme
}
class SettingsFragment :
@ -49,7 +51,7 @@ class SettingsFragment :
override fun onResume() {
super.onResume()
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -58,14 +60,14 @@ class SettingsFragment :
updateValues()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
when (preference?.key) {
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java))
"crash" -> {
activity?.let { activity ->
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
FFACache.getLines(activity, "crashdump")?.joinToString("\n").also {
FFACache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
clip.setPrimaryClip(ClipData.newPlainText("Funkwhale logs", it))
Toast.makeText(
@ -114,14 +116,6 @@ class SettingsFragment :
}
}
preferenceManager.findPreference<ListPreference>("bandwidth_limitation")?.let {
it.summary = when (it.value) {
"unlimited" -> activity.getString(R.string.settings_bandwidth_limitation_summary_unlimited)
"limited" -> activity.getString(R.string.settings_bandwidth_limitation_summary_limited)
else -> activity.getString(R.string.settings_bandwidth_limitation_summary_unlimited)
}
}
preferenceManager.findPreference<ListPreference>("play_order")?.let {
it.summary = when (it.value) {
"shuffle" -> activity.getString(R.string.settings_play_order_shuffle_summary)
@ -156,7 +150,7 @@ class SettingsFragment :
}
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let {
it.summary = getString(R.string.settings_media_cache_size_summary, it.value as Int) // manual cast to address a bug in AGP
it.summary = getString(R.string.settings_media_cache_size_summary, it.value)
}
preferenceManager.findPreference<Preference>("version")?.let {

View File

@ -8,7 +8,9 @@ import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.databinding.RowAlbumBinding
import audio.funkwhale.ffa.fragments.FFAAdapter
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class AlbumsAdapter(
@ -43,7 +45,8 @@ class AlbumsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val album = data[position]
CoverArt.requestCreator(album.cover())
Picasso.get()
.maybeLoad(maybeNormalizeUrl(album.cover()))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)

View File

@ -8,8 +8,9 @@ import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.RowAlbumGridBinding
import audio.funkwhale.ffa.fragments.FFAAdapter
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class AlbumsGridAdapter(
@ -39,8 +40,10 @@ class AlbumsGridAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val album = data[position]
CoverArt.requestCreator(maybeNormalizeUrl(album.cover()))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(album.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)

View File

@ -9,8 +9,9 @@ import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.RowArtistBinding
import audio.funkwhale.ffa.fragments.FFAAdapter
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class ArtistsAdapter(
@ -61,11 +62,14 @@ class ArtistsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val artist = active[position]
artist.cover()?.let { coverUrl ->
CoverArt.requestCreator(maybeNormalizeUrl(coverUrl))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)
artist.albums?.let { albums ->
if (albums.isNotEmpty()) {
Picasso.get()
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)
}
}
holder.name.text = artist.name

View File

@ -1,7 +1,8 @@
package audio.funkwhale.ffa.adapters
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.fragments.AlbumsGridFragment
import audio.funkwhale.ffa.fragments.ArtistsFragment
@ -9,19 +10,32 @@ import audio.funkwhale.ffa.fragments.FavoritesFragment
import audio.funkwhale.ffa.fragments.PlaylistsFragment
import audio.funkwhale.ffa.fragments.RadiosFragment
class BrowseTabsAdapter(val context: Fragment) : FragmentStateAdapter(context) {
override fun getItemCount() = 5
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) :
FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
var tabs = mutableListOf<Fragment>()
override fun createFragment(position: Int): Fragment = when (position) {
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> RadiosFragment()
4 -> FavoritesFragment()
else -> ArtistsFragment()
override fun getCount() = 5
override fun getItem(position: Int): Fragment {
tabs.getOrNull(position)?.let {
return it
}
val fragment = when (position) {
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> RadiosFragment()
4 -> FavoritesFragment()
else -> ArtistsFragment()
}
tabs.add(position, fragment)
return fragment
}
fun tabText(position: Int): String {
override fun getPageTitle(position: Int): String {
return when (position) {
0 -> context.getString(R.string.artists)
1 -> context.getString(R.string.albums)

View File

@ -8,19 +8,18 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.RowTrackBinding
import audio.funkwhale.ffa.fragments.FFAAdapter
import audio.funkwhale.ffa.model.Favorite
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.toast
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import java.util.Collections
@ -29,7 +28,7 @@ class FavoritesAdapter(
private val context: Context?,
private val favoriteListener: FavoriteListener,
val fromQueue: Boolean = false,
) : FFAAdapter<Favorite, FavoritesAdapter.ViewHolder>() {
) : FFAAdapter<Track, FavoritesAdapter.ViewHolder>() {
init {
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
@ -38,7 +37,6 @@ class FavoritesAdapter(
private lateinit var binding: RowTrackBinding
var currentTrack: Track? = null
var filter = ""
override fun getItemCount() = data.size
@ -46,15 +44,6 @@ class FavoritesAdapter(
return data[position].id.toLong()
}
override fun applyFilter() {
data.clear()
getUnfilteredData().map {
if (it.track.matchesFilter(filter)) {
data.add(it)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
binding = RowTrackBinding.inflate(layoutInflater, parent, false)
@ -67,42 +56,46 @@ class FavoritesAdapter(
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val favorite = data[position]
val track = favorite.track
CoverArt.requestCreator(maybeNormalizeUrl(track.cover()))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(favorite.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = track.title
holder.artist.text = track.artist.name
holder.title.text = favorite.title
holder.artist.text = favorite.artist.name
context?.let {
holder.itemView.background = AppCompatResources.getDrawable(context, R.drawable.ripple)
holder.itemView.background = context.getDrawable(R.drawable.ripple)
}
if (track.id == currentTrack?.id) {
if (favorite.id == currentTrack?.id) {
context?.let {
holder.itemView.background = AppCompatResources.getDrawable(context, R.drawable.current)
holder.itemView.background = context.getDrawable(R.drawable.current)
}
}
context?.let {
holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
when (favorite.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
}
when (track.cached || track.downloaded) {
when (favorite.cached || favorite.downloaded) {
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
if (track.cached && !track.downloaded) {
if (favorite.cached && !favorite.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter =
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
}
}
if (track.downloaded) {
if (favorite.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter =
PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
@ -110,7 +103,8 @@ class FavoritesAdapter(
}
holder.favorite.setOnClickListener {
favoriteListener.onToggleFavorite(track.id, !track.favorite)
favoriteListener.onToggleFavorite(favorite.id, !favorite.favorite)
data.remove(favorite)
notifyItemRemoved(holder.bindingAdapterPosition)
}
@ -123,10 +117,10 @@ class FavoritesAdapter(
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite))
R.id.track_pin -> CommandBus.send(Command.PinTrack(favorite))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite))
}
true
@ -167,13 +161,11 @@ class FavoritesAdapter(
when (fromQueue) {
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
false -> {
data
.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition))
.map { it.track }
.apply {
CommandBus.send(Command.ReplaceQueue(this))
context.toast("All tracks were added to your queue")
}
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
CommandBus.send(Command.ReplaceQueue(this))
context.toast("All tracks were added to your queue")
}
}
}
}

View File

@ -20,9 +20,10 @@ import audio.funkwhale.ffa.model.PlaylistTrack
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.toast
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import java.util.Collections
@ -69,37 +70,39 @@ class PlaylistTracksAdapter(
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val playlistTrack = data[position]
val track = data[position]
CoverArt.requestCreator(maybeNormalizeUrl(playlistTrack.track.cover()))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = playlistTrack.track.title
holder.artist.text = playlistTrack.track.artist.name
holder.title.text = track.track.title
holder.artist.text = track.track.artist.name
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
}
if (playlistTrack.track == currentTrack || playlistTrack.track.current) {
if (track.track == currentTrack || track.track.current) {
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
}
}
context?.let {
when (playlistTrack.track.favorite) {
when (track.track.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
}
holder.favorite.setOnClickListener {
favoriteListener.let {
favoriteListener.onToggleFavorite(playlistTrack.track.id, !playlistTrack.track.favorite)
favoriteListener.onToggleFavorite(track.track.id, !track.track.favorite)
playlistTrack.track.favorite = !playlistTrack.track.favorite
track.track.favorite = !track.track.favorite
notifyItemChanged(position)
}
}
@ -114,11 +117,11 @@ class PlaylistTracksAdapter(
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(playlistTrack.track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(playlistTrack.track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(playlistTrack.track))
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track.track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track.track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track.track))
R.id.track_remove_from_playlist -> playlistListener.onRemoveTrackFromPlaylist(
playlistTrack.track,
track.track,
position
)
}

View File

@ -10,8 +10,8 @@ import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.RowPlaylistBinding
import audio.funkwhale.ffa.fragments.FFAAdapter
import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.toDurationString
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class PlaylistsAdapter(
@ -79,7 +79,8 @@ class PlaylistsAdapter(
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
}
CoverArt.requestCreator(url)
Picasso.get()
.load(url)
.transform(RoundedCornersTransformation(32, 0, corner))
.into(imageView)
}

View File

@ -17,6 +17,7 @@ import audio.funkwhale.ffa.views.LoadingImageView
import com.preference.PowerPreference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosAdapter(
@ -189,10 +190,12 @@ class RadiosAdapter(
art.setColorFilter(context.getColor(R.color.controlForeground))
scope.launch(Main) {
EventBus.get().collect { event ->
if (event is Event.RadioStarted) {
art.colorFilter = originalColorFilter
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted -> {
art.colorFilter = originalColorFilter
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
}
}
}
}

View File

@ -7,10 +7,10 @@ import android.graphics.PorterDuffColorFilter
import android.graphics.Typeface
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.RowSearchHeaderBinding
@ -20,17 +20,16 @@ import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.onApi
import audio.funkwhale.ffa.utils.toast
import audio.funkwhale.ffa.viewmodel.SearchViewModel
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class SearchAdapter(
viewModel: SearchViewModel,
private val fragment: Fragment,
private val layoutInflater: LayoutInflater,
private val context: Context?,
private val listener: OnSearchResultClickListener,
private val favoriteListener: FavoriteListener
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
@ -52,27 +51,12 @@ class SearchAdapter(
val sectionCount = 3
var artists = listOf<Artist>()
var albums = listOf<Album>()
var tracks = listOf<Track>()
var artists: MutableList<Artist> = mutableListOf()
var albums: MutableList<Album> = mutableListOf()
var tracks: MutableList<Track> = mutableListOf()
var currentTrack: Track? = null
init {
viewModel.artistResults.observe(fragment.viewLifecycleOwner) {
artists = it
this.notifyDataSetChanged()
}
viewModel.albumResults.observe(fragment.viewLifecycleOwner) {
albums = it
this.notifyDataSetChanged()
}
viewModel.trackResults.observe(fragment.viewLifecycleOwner) {
tracks = it
this.notifyDataSetChanged()
}
}
override fun getItemCount() = sectionCount + artists.size + albums.size + tracks.size
override fun getItemId(position: Int): Long {
@ -84,7 +68,7 @@ class SearchAdapter(
}
ResultType.Artist.ordinal -> artists[position].id.toLong()
ResultType.Album.ordinal -> albums[position - artists.size - 2].id.toLong()
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong()
ResultType.Track.ordinal ->
tracks[position - artists.size - albums.size - sectionCount].id.toLong()
else -> 0
@ -103,12 +87,12 @@ class SearchAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
ResultType.Header.ordinal -> {
searchHeaderBinding = RowSearchHeaderBinding.inflate(fragment.layoutInflater, parent, false)
SearchHeaderViewHolder(searchHeaderBinding, fragment.requireContext())
searchHeaderBinding = RowSearchHeaderBinding.inflate(layoutInflater, parent, false)
SearchHeaderViewHolder(searchHeaderBinding, context)
}
else -> {
rowTrackBinding = RowTrackBinding.inflate(fragment.layoutInflater, parent, false)
RowTrackViewHolder(rowTrackBinding, fragment.requireContext()).also {
rowTrackBinding = RowTrackBinding.inflate(layoutInflater, parent, false)
RowTrackViewHolder(rowTrackBinding, context).also {
rowTrackBinding.root.setOnClickListener(it)
}
}
@ -122,45 +106,47 @@ class SearchAdapter(
val rowTrackViewHolder = holder as? RowTrackViewHolder
if (resultType == ResultType.Header.ordinal) {
if (position == 0) {
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.artists)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
context?.let { context ->
if (position == 0) {
searchHeaderViewHolder?.title?.text = context.getString(R.string.artists)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
if (artists.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
if (artists.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
}
}
if (position == (artists.size + 1)) {
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.albums)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
if (position == (artists.size + 1)) {
searchHeaderViewHolder?.title?.text = context.getString(R.string.albums)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
if (albums.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
if (albums.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
}
}
if (position == (artists.size + albums.size + 2)) {
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.tracks)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
if (position == (artists.size + albums.size + 2)) {
searchHeaderViewHolder?.title?.text = context.getString(R.string.tracks)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
if (tracks.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
if (tracks.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
}
}
@ -189,7 +175,8 @@ class SearchAdapter(
else -> tracks[position]
}
CoverArt.requestCreator(maybeNormalizeUrl(item.cover()))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(item.cover()))
.fit()
.transform(RoundedCornersTransformation(16, 0))
.into(rowTrackViewHolder?.cover)
@ -231,91 +218,90 @@ class SearchAdapter(
}
ResultType.Track.ordinal -> {
(item as? Track)?.let { track ->
if (track == currentTrack || track.current) {
searchHeaderViewHolder?.title?.setTypeface(
searchHeaderViewHolder.title.typeface,
Typeface.BOLD
)
rowTrackViewHolder?.artist?.setTypeface(
rowTrackViewHolder.artist.typeface,
Typeface.BOLD
)
}
when (track.favorite) {
true -> rowTrackViewHolder?.favorite?.setColorFilter(
fragment.requireContext().getColor(R.color.colorFavorite)
)
false -> rowTrackViewHolder?.favorite?.setColorFilter(
fragment.requireContext().getColor(R.color.colorSelected)
)
}
rowTrackViewHolder?.favorite?.setOnClickListener {
favoriteListener.let {
favoriteListener.onToggleFavorite(track.id, !track.favorite)
tracks[position - artists.size - albums.size - sectionCount].favorite =
!track.favorite
notifyItemChanged(position)
context?.let { context ->
if (track == currentTrack || track.current) {
searchHeaderViewHolder?.title?.setTypeface(
searchHeaderViewHolder.title.typeface,
Typeface.BOLD
)
rowTrackViewHolder?.artist?.setTypeface(
rowTrackViewHolder.artist.typeface,
Typeface.BOLD
)
}
}
when (track.cached || track.downloaded) {
true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
R.drawable.downloaded, 0, 0, 0
)
false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
0, 0, 0, 0
)
}
if (track.cached && !track.downloaded) {
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
it?.colorFilter =
PorterDuffColorFilter(
fragment.requireContext().getColor(R.color.cached),
PorterDuff.Mode.SRC_IN
)
when (track.favorite) {
true -> rowTrackViewHolder?.favorite?.setColorFilter(
context.getColor(R.color.colorFavorite)
)
false -> rowTrackViewHolder?.favorite?.setColorFilter(
context.getColor(R.color.colorSelected)
)
}
}
if (track.downloaded) {
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
it?.colorFilter =
PorterDuffColorFilter(
fragment.requireContext().getColor(R.color.downloaded),
PorterDuff.Mode.SRC_IN
)
rowTrackViewHolder?.favorite?.setOnClickListener {
favoriteListener.let {
favoriteListener.onToggleFavorite(track.id, !track.favorite)
tracks[position - artists.size - albums.size - sectionCount].favorite =
!track.favorite
notifyItemChanged(position)
}
}
}
rowTrackViewHolder?.actions?.setOnClickListener {
PopupMenu(
fragment.requireContext(),
rowTrackViewHolder.actions,
Gravity.START,
R.attr.actionOverflowMenuStyle,
0
).apply {
inflate(R.menu.row_track)
when (track.cached || track.downloaded) {
true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
R.drawable.downloaded, 0, 0, 0
)
false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
0, 0, 0, 0
)
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
R.id.track_add_to_playlist -> CommandBus.send(
Command.AddToPlaylist(listOf(track))
if (track.cached && !track.downloaded) {
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
it?.colorFilter =
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
}
}
if (track.downloaded) {
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
it?.colorFilter =
PorterDuffColorFilter(
context.getColor(R.color.downloaded),
PorterDuff.Mode.SRC_IN
)
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
}
}
rowTrackViewHolder?.actions?.setOnClickListener {
PopupMenu(
context,
rowTrackViewHolder.actions,
Gravity.START,
R.attr.actionOverflowMenuStyle,
0
).apply {
inflate(R.menu.row_track)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
R.id.track_add_to_playlist -> CommandBus.send(
Command.AddToPlaylist(listOf(track))
)
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
}
true
}
true
show()
}
show()
}
}
}
@ -332,12 +318,12 @@ class SearchAdapter(
}
}
inner class SearchHeaderViewHolder(val binding: RowSearchHeaderBinding, context: Context) :
inner class SearchHeaderViewHolder(val binding: RowSearchHeaderBinding, context: Context?) :
ViewHolder(binding.root, context) {
val title = binding.title
}
inner class RowTrackViewHolder(val binding: RowTrackBinding, context: Context) :
inner class RowTrackViewHolder(val binding: RowTrackBinding, context: Context?) :
ViewHolder(binding.root, context), View.OnClickListener {
val title = binding.title
val cover = binding.cover

View File

@ -21,9 +21,10 @@ import audio.funkwhale.ffa.fragments.FFAAdapter
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.toast
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import java.util.Collections
@ -70,7 +71,8 @@ class TracksAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val track = data[position]
CoverArt.requestCreator(maybeNormalizeUrl(track.cover()))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.cover)
@ -191,6 +193,7 @@ class TracksAdapter(
false -> {
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
CommandBus.send(Command.ReplaceQueue(this))
context.toast("All tracks were added to your queue")
}
}

View File

@ -106,7 +106,7 @@ object AddToPlaylistDialog {
fetch().untilNetwork(lifecycleScope) { data, isCache, _, hasMore ->
if (isCache) {
adapter.setUnfilteredData(data.toMutableList())
adapter.data = data.toMutableList()
adapter.notifyDataSetChanged()
return@untilNetwork
@ -124,7 +124,7 @@ object AddToPlaylistDialog {
FFACache.set(
context,
cacheId,
Gson().toJson(cache(adapter.data)).toString()
Gson().toJson(cache(adapter.data)).toByteArray()
)
} catch (e: ConcurrentModificationException) {
}

View File

@ -1,28 +1,37 @@
package audio.funkwhale.ffa.fragments
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.transition.Fade
import androidx.transition.Slide
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.activities.MainActivity
import audio.funkwhale.ffa.adapters.AlbumsAdapter
import audio.funkwhale.ffa.databinding.FragmentAlbumsBinding
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.repositories.AlbumsRepository
import audio.funkwhale.ffa.repositories.ArtistTracksRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import com.preference.PowerPreference
import audio.funkwhale.ffa.utils.onViewPager
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
@ -36,22 +45,77 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
override val recycler: RecyclerView get() = binding.albums
override val alwaysRefresh = false
private val args by navArgs<AlbumsFragmentArgs>()
private val artistArt: String get() = when {
!args.cover.isNullOrBlank() -> args.cover!!
else -> args.artist.cover() ?: ""
}
private var _binding: FragmentAlbumsBinding? = null
private val binding get() = _binding!!
private lateinit var artistTracksRepository: ArtistTracksRepository
private var artistId = 0
private var artistName = ""
private var artistArt = ""
companion object {
fun new(artist: Artist, _art: String? = null): AlbumsFragment {
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.cover() else ""
return AlbumsFragment().apply {
arguments = bundleOf(
"artistId" to artist.id,
"artistName" to artist.name,
"artistArt" to art
)
}
}
fun openTracks(context: Context?, album: Album?, fragment: Fragment? = null) {
if (album == null) {
return
}
(context as? MainActivity)?.let {
fragment?.let { fragment ->
fragment.onViewPager {
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
}
}
}
}
}
(context as? AppCompatActivity)?.let { activity ->
val nextFragment = TracksFragment.new(album).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
}
}
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, nextFragment)
.addToBackStack(null)
.commit()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
artistId = getInt("artistId")
artistName = getString("artistName") ?: ""
artistArt = getString("artistArt") ?: ""
}
adapter = AlbumsAdapter(layoutInflater, context, OnAlbumClickListener())
repository = AlbumsRepository(context, args.artist.id)
artistTracksRepository = ArtistTracksRepository(context, args.artist.id)
repository = AlbumsRepository(context, artistId)
artistTracksRepository = ArtistTracksRepository(context, artistId)
}
override fun onCreateView(
@ -61,12 +125,6 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
): View {
_binding = FragmentAlbumsBinding.inflate(inflater)
swiper = binding.swiper
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> binding.play.text = getString(R.string.playback_play)
else -> binding.play.text = getString(R.string.playback_shuffle)
}
return binding.root
}
@ -79,7 +137,8 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
super.onViewCreated(view, savedInstanceState)
binding.cover.let { cover ->
CoverArt.requestCreator(maybeNormalizeUrl(artistArt))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(artistArt))
.noFade()
.fit()
.centerCrop()
@ -87,7 +146,36 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
.into(cover)
}
binding.artist.text = args.artist.name
binding.artist.text = artistName
binding.play.setOnClickListener {
val loader = CircularProgressDrawable(requireContext()).apply {
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
strokeWidth = 4f
}
loader.start()
binding.play.icon = loader
binding.play.isClickable = false
lifecycleScope.launch(IO) {
artistTracksRepository.fetch(Repository.Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
.shuffled()
.also {
CommandBus.send(Command.ReplaceQueue(it))
withContext(Main) {
binding.play.icon =
AppCompatResources.getDrawable(binding.root.context, R.drawable.play)
binding.play.isClickable = true
}
}
}
}
}
override fun onResume() {
@ -106,46 +194,11 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
binding.cover.alpha = (height - scrollY.toFloat()) / height
}
}
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> binding.play.text = getString(R.string.playback_play)
else -> binding.play.text = getString(R.string.playback_shuffle)
}
binding.play.setOnClickListener {
val loader = CircularProgressDrawable(requireContext()).apply {
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
strokeWidth = 4f
}
loader.start()
binding.play.icon = loader
binding.play.isClickable = false
lifecycleScope.launch(IO) {
val tracks = artistTracksRepository.fetch(Repository.Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> CommandBus.send(Command.ReplaceQueue(tracks))
else -> CommandBus.send(Command.ReplaceQueue(tracks.shuffled()))
}
withContext(Main) {
binding.play.icon =
AppCompatResources.getDrawable(binding.root.context, R.drawable.play)
binding.play.isClickable = true
}
}
}
}
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: Album) {
findNavController().navigate(AlbumsFragmentDirections.albumsToTracks(album))
openTracks(context, album, fragment = this@AlbumsFragment)
}
}
}

View File

@ -4,13 +4,18 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.Fade
import androidx.transition.Slide
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.activities.MainActivity
import audio.funkwhale.ffa.adapters.AlbumsGridAdapter
import audio.funkwhale.ffa.databinding.FragmentAlbumsGridBinding
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.repositories.AlbumsRepository
import audio.funkwhale.ffa.utils.AppContext
class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
@ -44,7 +49,29 @@ class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: Album) {
findNavController().navigate(BrowseFragmentDirections.browseToTracks(album))
(context as? MainActivity)?.let { activity ->
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
}
}
val fragment = TracksFragment.new(album).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
}
}
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, fragment)
.addToBackStack(null)
.commit()
}
}
}
}

View File

@ -1,17 +1,27 @@
package audio.funkwhale.ffa.fragments
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.Fade
import androidx.transition.Slide
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.activities.MainActivity
import audio.funkwhale.ffa.adapters.ArtistsAdapter
import audio.funkwhale.ffa.databinding.FragmentArtistsBinding
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.repositories.ArtistsRepository
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.onViewPager
class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() {
private var _binding: FragmentArtistsBinding? = null
private val binding get() = _binding!!
@ -40,9 +50,49 @@ class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() {
_binding = null
}
companion object {
fun openAlbums(
context: Context?,
artist: Artist,
fragment: Fragment? = null,
art: String? = null
) {
(context as? MainActivity)?.let {
fragment?.let { fragment ->
fragment.onViewPager {
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
}
}
}
}
}
(context as? AppCompatActivity)?.let { activity ->
val nextFragment = AlbumsFragment.new(artist, art).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
}
}
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, nextFragment)
.addToBackStack(null)
.commit()
}
}
}
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
override fun onClick(holder: View?, artist: Artist) {
findNavController().navigate(BrowseFragmentDirections.browseToAlbums(artist))
openAlbums(context, artist, fragment = this@ArtistsFragment)
}
}
}

View File

@ -7,13 +7,19 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment
import audio.funkwhale.ffa.adapters.BrowseTabsAdapter
import audio.funkwhale.ffa.databinding.FragmentBrowseBinding
import com.google.android.material.tabs.TabLayoutMediator
class BrowseFragment : Fragment() {
private var _binding: FragmentBrowseBinding? = null
private val binding get() = _binding!!
private var adapter: BrowseTabsAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = BrowseTabsAdapter(this, childFragmentManager)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -21,14 +27,11 @@ class BrowseFragment : Fragment() {
): View {
_binding = FragmentBrowseBinding.inflate(inflater)
return binding.root.apply {
binding.tabs.setupWithViewPager(binding.pager)
binding.tabs.getTabAt(0)?.select()
val adapter = BrowseTabsAdapter(this@BrowseFragment)
binding.pager.adapter = adapter
binding.pager.offscreenPageLimit = 3
TabLayoutMediator(binding.tabs, binding.pager) { tab, position ->
tab.text = adapter.tabText(position)
}.attach()
}
}
@ -36,4 +39,8 @@ class BrowseFragment : Fragment() {
super.onDestroyView()
_binding = null
}
fun selectTabAt(position: Int) {
binding.tabs.getTabAt(position)?.select()
}
}

View File

@ -18,26 +18,12 @@ import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
abstract class FFAAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
var data: MutableList<D> = mutableListOf()
private var unfilteredData: MutableList<D> = mutableListOf()
fun getUnfilteredData(): MutableList<D> {
return unfilteredData
}
fun setUnfilteredData(data: MutableList<D>) {
unfilteredData = data
applyFilter()
}
open fun applyFilter() {
data.clear()
data.addAll(unfilteredData)
}
init {
super.setHasStableIds(true)
@ -46,7 +32,7 @@ abstract class FFAAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapte
abstract override fun getItemId(position: Int): Long
}
abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>> : Fragment() {
abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
companion object {
const val OFFSCREEN_PAGES = 20
}
@ -144,20 +130,19 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>> : Fragment() {
if (isCache) {
moreLoading = false
adapter.setUnfilteredData(data.toMutableList())
adapter.data = data.toMutableList()
adapter.notifyDataSetChanged()
return@launch
}
if (first) {
adapter.getUnfilteredData().clear()
adapter.data.clear()
}
onDataFetched(data)
adapter.getUnfilteredData().addAll(data)
adapter.applyFilter()
adapter.data.addAll(data)
withContext(IO) {
try {
@ -165,7 +150,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>> : Fragment() {
FFACache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.getUnfilteredData())).toString()
Gson().toJson(repository.cache(adapter.data)).toByteArray()
)
}
} catch (e: ConcurrentModificationException) {
@ -176,7 +161,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>> : Fragment() {
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (!isCache && upstream.behavior == HttpUpstream.Behavior.Progressive) {
if (first || needsMoreOffscreenPages()) {
fetch(Repository.Origin.Network.origin, adapter.getUnfilteredData().size)
fetch(Repository.Origin.Network.origin, adapter.data.size)
} else {
moreLoading = false
}

View File

@ -1,8 +1,6 @@
package audio.funkwhale.ffa.fragments
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -11,7 +9,6 @@ import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.FavoritesAdapter
import audio.funkwhale.ffa.databinding.FragmentFavoritesBinding
import audio.funkwhale.ffa.model.Favorite
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.FavoritesRepository
import audio.funkwhale.ffa.repositories.TracksRepository
@ -28,11 +25,12 @@ import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
@ -56,20 +54,6 @@ class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
): View {
_binding = FragmentFavoritesBinding.inflate(inflater)
swiper = binding.swiper
binding.filterTracks.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {
adapter.applyFilter()
adapter.notifyDataSetChanged()
}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
adapter.filter = s.toString()
}
})
return binding.root
}
@ -94,20 +78,24 @@ class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
}
binding.play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
}
}
private fun watchEventBus() {
lifecycleScope.launch(Main) {
EventBus.get().collect { event ->
if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download)
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
if (command is Command.RefreshTrack) refreshCurrentTrack(command.track)
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
@ -122,13 +110,11 @@ class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
withContext(Main) {
val data = adapter.data.map {
it.track.downloaded = downloaded.contains(it.id)
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.setUnfilteredData(data)
adapter.notifyDataSetChanged()
}
}
@ -139,7 +125,7 @@ class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }
.toList().getOrNull(0)?.let { match ->
withContext(Main) {
adapter.data[match.second].track.downloaded = true
adapter.data[match.second].downloaded = true
adapter.notifyItemChanged(match.second)
}
}

View File

@ -92,7 +92,7 @@ class LandscapeQueueFragment : Fragment() {
activity?.lifecycleScope?.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
adapter?.let {
it.setUnfilteredData(response.queue.toMutableList())
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
@ -110,13 +110,17 @@ class LandscapeQueueFragment : Fragment() {
private fun watchEventBus() {
activity?.lifecycleScope?.launch(Main) {
EventBus.get().collect { message ->
if (message is Event.QueueChanged) refresh()
when (message) {
is Event.QueueChanged -> refresh()
}
}
}
activity?.lifecycleScope?.launch(Main) {
CommandBus.get().collect { command ->
if (command is Command.RefreshTrack) refresh()
when (command) {
is Command.RefreshTrack -> refresh()
}
}
}
}

View File

@ -1,244 +0,0 @@
package audio.funkwhale.ffa.fragments
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.liveData
import androidx.lifecycle.map
import androidx.navigation.fragment.findNavController
import audio.funkwhale.ffa.MainNavDirections
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.FragmentNowPlayingBinding
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.FavoritedRepository
import audio.funkwhale.ffa.repositories.FavoritesRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.FFACache
import audio.funkwhale.ffa.utils.ProgressBus
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.toIntOrElse
import audio.funkwhale.ffa.utils.untilNetwork
import audio.funkwhale.ffa.viewmodel.NowPlayingViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.lang.Float.max
class NowPlayingFragment: Fragment(R.layout.fragment_now_playing) {
private val binding by lazy { FragmentNowPlayingBinding.bind(requireView()) }
private val viewModel by viewModels<NowPlayingViewModel>()
private val favoriteRepository by lazy { FavoritesRepository(requireContext()) }
private val favoritedRepository by lazy { FavoritedRepository(requireContext()) }
private var onDetailsMenuItemClickedCb: () -> Unit = {}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.lifecycleOwner = viewLifecycleOwner
viewModel.currentTrack.distinctUntilChanged().observe(viewLifecycleOwner, ::onTrackChange)
with(binding.controls) {
currentTrackTitle = viewModel.currentTrackTitle
currentTrackArtist = viewModel.currentTrackArtist
isCurrentTrackFavorite = viewModel.isCurrentTrackFavorite
repeatModeResource = viewModel.repeatModeResource
repeatModeAlpha = viewModel.repeatModeAlpha
currentProgressText = viewModel.currentProgressText
currentDurationText = viewModel.currentDurationText
isPlaying = viewModel.isPlaying
progress = viewModel.progress
nowPlayingDetailsPrevious.setOnClickListener {
CommandBus.send(Command.PreviousTrack)
}
nowPlayingDetailsNext.setOnClickListener {
CommandBus.send(Command.NextTrack)
}
nowPlayingDetailsToggle.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
nowPlayingDetailsRepeat.setOnClickListener { toggleRepeatMode() }
nowPlayingDetailsProgress.setOnSeekBarChangeListener(OnSeekBarChanged())
nowPlayingDetailsFavorite.setOnClickListener { onFavorite() }
nowPlayingDetailsAddToPlaylist.setOnClickListener { onAddToPlaylist() }
}
binding.nowPlayingDetailsInfo.setOnClickListener { openInfoMenu() }
with(binding.header) {
lifecycleOwner = viewLifecycleOwner
isBuffering = viewModel.isBuffering
isPlaying = viewModel.isPlaying
progress = viewModel.progress
currentTrackTitle = viewModel.currentTrackTitle
currentTrackArtist = viewModel.currentTrackArtist
nowPlayingNext.setOnClickListener {
CommandBus.send(Command.NextTrack)
}
nowPlayingToggle.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
}
lifecycleScope.launch(Dispatchers.Main) {
CommandBus.get().collect { onCommand(it) }
}
lifecycleScope.launch(Dispatchers.Main) {
ProgressBus.get().collect { onProgress(it) }
}
}
fun onBottomSheetDrag(value: Float) {
binding.nowPlayingRoot.progress = max(value, 0f)
}
fun onDetailsMenuItemClicked(cb: () -> Unit) {
onDetailsMenuItemClickedCb = cb
}
private fun toggleRepeatMode() {
val cachedRepeatMode = FFACache.getLine(requireContext(), "repeat").toIntOrElse(0)
val iteratedRepeatMode = (cachedRepeatMode + 1) % 3
FFACache.set(requireContext(), "repeat", "$iteratedRepeatMode")
CommandBus.send(Command.SetRepeatMode(iteratedRepeatMode))
}
private fun onAddToPlaylist() {
val currentTrack = viewModel.currentTrack.value ?: return
CommandBus.send(Command.AddToPlaylist(listOf(currentTrack)))
}
private fun onCommand(command: Command) = when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
is Command.SetRepeatMode -> viewModel.repeatMode.postValue(command.mode)
else -> {}
}
private fun onFavorite() {
val currentTrack = viewModel.currentTrack.value ?: return
if (currentTrack.favorite) favoriteRepository.deleteFavorite(currentTrack.id)
else favoriteRepository.addFavorite(currentTrack.id)
currentTrack.favorite = !currentTrack.favorite
// Trigger UI refresh
viewModel.currentTrack.postValue(viewModel.currentTrack.value)
favoritedRepository.fetch(Repository.Origin.Network.origin)
}
private fun onProgress(state: Triple<Int, Int, Int>) {
val (current, duration, percent) = state
val currentMins = (current / 1000) / 60
val currentSecs = (current / 1000) % 60
val durationMins = duration / 60
val durationSecs = duration % 60
viewModel.progress.postValue(percent)
viewModel.currentProgressText.postValue("%02d:%02d".format(currentMins, currentSecs))
viewModel.currentDurationText.postValue("%02d:%02d".format(durationMins, durationSecs))
}
private fun onTrackChange(track: Track?) {
if (track == null) {
binding.header.nowPlayingCover.setImageResource(R.drawable.cover)
return
}
CoverArt.requestCreator(maybeNormalizeUrl(track.album?.cover()))
.into(binding.header.nowPlayingCover)
}
private fun openInfoMenu() {
val currentTrack = viewModel.currentTrack.value ?: return
PopupMenu(
requireContext(),
binding.nowPlayingDetailsInfo,
Gravity.START,
R.attr.actionOverflowMenuStyle,
0
).apply {
inflate(R.menu.track_info)
setOnMenuItemClickListener {
onDetailsMenuItemClickedCb()
when (it.itemId) {
R.id.track_info_artist -> findNavController().navigate(
MainNavDirections.globalBrowseToAlbums(
currentTrack.artist,
currentTrack.album?.cover()
)
)
R.id.track_info_album -> currentTrack.album?.let { album ->
findNavController().navigate(MainNavDirections.globalBrowseTracks(album))
}
R.id.track_info_details -> TrackInfoDetailsFragment.new(currentTrack).show(
requireActivity().supportFragmentManager, "dialog"
)
}
true
}
show()
}
}
private fun refreshCurrentTrack(track: Track?) {
viewModel.currentTrack.postValue(track)
val cachedRepeatMode = FFACache.getLine(requireContext(), "repeat").toIntOrElse(0)
viewModel.repeatMode.postValue(cachedRepeatMode % 3)
// At this point, a non-null track is required
if (track == null) return
favoritedRepository.fetch().untilNetwork(lifecycleScope, Dispatchers.IO) { favorites, _, _, _ ->
lifecycleScope.launch(Dispatchers.Main) {
track.favorite = favorites.contains(track.id)
// Trigger UI refresh
viewModel.currentTrack.postValue(viewModel.currentTrack.value)
}
}
}
inner class OnSeekBarChanged : OnSeekBarChangeListener {
override fun onStopTrackingTouch(view: SeekBar?) {}
override fun onStartTrackingTouch(view: SeekBar?) {}
override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
CommandBus.send(Command.Seek(progress))
}
}
}
}

View File

@ -6,13 +6,14 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.PlaylistTracksAdapter
import audio.funkwhale.ffa.databinding.FragmentTracksBinding
import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.model.PlaylistTrack
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.FavoritesRepository
@ -20,21 +21,22 @@ import audio.funkwhale.ffa.repositories.ManagementPlaylistsRepository
import audio.funkwhale.ffa.repositories.PlaylistTracksRepository
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.Request
import audio.funkwhale.ffa.utils.RequestBus
import audio.funkwhale.ffa.utils.Response
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.toast
import audio.funkwhale.ffa.utils.wait
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>() {
override val recycler: RecyclerView get() = binding.tracks
private val args by navArgs<PlaylistTracksFragmentArgs>()
override val recycler: RecyclerView get() = binding.tracks
private var _binding: FragmentTracksBinding? = null
private val binding get() = _binding!!
@ -42,19 +44,39 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
lateinit var favoritesRepository: FavoritesRepository
lateinit var playlistsRepository: ManagementPlaylistsRepository
var albumId = 0
var albumArtist = ""
var albumTitle = ""
var albumCover = ""
companion object {
fun new(playlist: Playlist): PlaylistTracksFragment {
return PlaylistTracksFragment().apply {
arguments = bundleOf(
"albumId" to playlist.id,
"albumArtist" to "N/A",
"albumTitle" to playlist.name,
"albumCover" to ""
)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
albumId = getInt("albumId")
albumArtist = getString("albumArtist") ?: ""
albumTitle = getString("albumTitle") ?: ""
albumCover = getString("albumCover") ?: ""
}
favoritesRepository = FavoritesRepository(context)
playlistsRepository = ManagementPlaylistsRepository(context)
adapter = PlaylistTracksAdapter(
layoutInflater,
context,
FavoriteListener(favoritesRepository),
PlaylistListener()
)
repository = PlaylistTracksRepository(context, args.playlist.id)
adapter = PlaylistTracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository), PlaylistListener())
repository = PlaylistTracksRepository(context, albumId)
watchEventBus()
}
@ -80,8 +102,8 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
binding.cover.visibility = View.INVISIBLE
binding.covers.visibility = View.VISIBLE
binding.artist.text = getString(R.string.playlist)
binding.title.text = args.playlist.name
binding.artist.text = "Playlist"
binding.title.text = albumTitle
}
override fun onResume() {
@ -110,6 +132,7 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
binding.play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
context.toast("All tracks were added to your queue")
}
@ -145,42 +168,39 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
}
override fun onDataFetched(data: List<PlaylistTrack>) {
data.map { it.track.album }
.toSet()
.map { it?.cover() }
.take(4)
.forEachIndexed { index, url ->
val imageView = when (index) {
0 -> binding.coverTopLeft
1 -> binding.coverTopRight
2 -> binding.coverBottomLeft
3 -> binding.coverBottomRight
else -> binding.coverTopLeft
}
val corner = when (index) {
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
}
lifecycleScope.launch(Main) {
CoverArt.requestCreator(maybeNormalizeUrl(url))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0, corner))
.into(imageView)
}
data.map { it.track.album }.toSet().map { it?.cover() }.take(4).forEachIndexed { index, url ->
val imageView = when (index) {
0 -> binding.coverTopLeft
1 -> binding.coverTopRight
2 -> binding.coverBottomLeft
3 -> binding.coverBottomRight
else -> binding.coverTopLeft
}
val corner = when (index) {
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
}
lifecycleScope.launch(Main) {
Picasso.get()
.maybeLoad(maybeNormalizeUrl(url))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0, corner))
.into(imageView)
}
}
}
private fun watchEventBus() {
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
if (command is Command.RefreshTrack) {
refreshCurrentTrack(command.track)
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
@ -195,12 +215,12 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener {
override fun onMoveTrack(from: Int, to: Int) {
playlistsRepository.move(args.playlist.id, from, to)
playlistsRepository.move(albumId, from, to)
}
override fun onRemoveTrackFromPlaylist(track: Track, index: Int) {
lifecycleScope.launch(Main) {
playlistsRepository.remove(args.playlist.id, index)
playlistsRepository.remove(albumId, index)
update()
}
}

View File

@ -4,12 +4,17 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.Fade
import androidx.transition.Slide
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.activities.MainActivity
import audio.funkwhale.ffa.adapters.PlaylistsAdapter
import audio.funkwhale.ffa.databinding.FragmentPlaylistsBinding
import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.repositories.PlaylistsRepository
import audio.funkwhale.ffa.utils.AppContext
class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
@ -43,7 +48,29 @@ class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener {
override fun onClick(holder: View?, playlist: Playlist) {
findNavController().navigate(BrowseFragmentDirections.browseToPlaylistTracks(playlist))
(context as? MainActivity)?.let { activity ->
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
}
}
val fragment = PlaylistTracksFragment.new(playlist).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
}
}
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, fragment)
.addToBackStack(null)
.commit()
}
}
}
}

View File

@ -50,9 +50,7 @@ class QueueFragment : BottomSheetDialogFragment() {
return super.onCreateDialog(savedInstanceState).apply {
setOnShowListener {
findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let {
val behavior = BottomSheetBehavior.from(it)
behavior.skipCollapsed = true
behavior.state = BottomSheetBehavior.STATE_EXPANDED
BottomSheetBehavior.from(it).skipCollapsed = true
}
}
}
@ -102,15 +100,15 @@ class QueueFragment : BottomSheetDialogFragment() {
CommandBus.send(Command.ClearQueue)
}
refresh(true)
refresh()
}
private fun refresh(scroll: Boolean) {
private fun refresh() {
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
binding.included.let { included ->
adapter?.let {
it.setUnfilteredData(response.queue.toMutableList())
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
@ -122,11 +120,6 @@ class QueueFragment : BottomSheetDialogFragment() {
}
}
}
if (scroll) {
RequestBus.send(Request.GetCurrentTrackIndex).wait<Response.CurrentTrackIndex>()?.let { sresp ->
binding.included.queue.scrollToPosition(sresp.index)
}
}
}
}
}
@ -134,16 +127,16 @@ class QueueFragment : BottomSheetDialogFragment() {
private fun watchEventBus() {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
if (message is Event.QueueChanged) {
refresh(false)
when (message) {
is Event.QueueChanged -> refresh()
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
if (command is Command.RefreshTrack) {
refresh(false)
when (command) {
is Command.RefreshTrack -> refresh()
}
}
}

View File

@ -62,11 +62,12 @@ class RadiosFragment : FFAFragment<Radio, RadiosAdapter>() {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
if (message is Event.RadioStarted) {
recycler.forEach {
it.isEnabled = true
it.isClickable = true
}
when (message) {
is Event.RadioStarted ->
recycler.forEach {
it.isEnabled = true
it.isClickable = true
}
}
}
}

View File

@ -1,136 +0,0 @@
package audio.funkwhale.ffa.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.SearchAdapter
import audio.funkwhale.ffa.databinding.FragmentSearchBinding
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.repositories.FavoritesRepository
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.getMetadata
import audio.funkwhale.ffa.viewmodel.SearchViewModel
import com.google.android.exoplayer2.offline.Download
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SearchFragment : Fragment() {
private lateinit var adapter: SearchAdapter
private lateinit var binding: FragmentSearchBinding
private val viewModel by activityViewModels<SearchViewModel>()
private val noSearchYet = MutableLiveData(true)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSearchBinding.inflate(layoutInflater, container, false)
binding.lifecycleOwner = this
binding.isLoadingData = viewModel.isLoadingData
binding.hasResults = viewModel.hasResults
binding.noSearchYet = noSearchYet
return binding.root
}
override fun onResume() {
super.onResume()
binding.search.requestFocus()
lifecycleScope.launch(Dispatchers.Main) {
CommandBus.get().collect { command ->
if (command is Command.AddToPlaylist) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
AddToPlaylistDialog.show(
layoutInflater,
requireActivity(),
lifecycleScope,
command.tracks
)
}
}
}
}
lifecycleScope.launch(Dispatchers.IO) {
EventBus.get().collect { event ->
if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download)
}
}
adapter =
SearchAdapter(
viewModel,
this,
SearchResultClickListener(),
FavoriteListener(FavoritesRepository(requireContext()))
).also {
binding.results.layoutManager = LinearLayoutManager(requireContext())
binding.results.adapter = it
}
binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
binding.search.clearFocus()
noSearchYet.value = false
viewModel.query.postValue(query)
return true
}
override fun onQueryTextChange(newText: String) = true
})
}
override fun onDestroy() {
super.onDestroy()
// Empty the research to prevent result recall the next time
viewModel.query.value = ""
}
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.tracks.withIndex().associate { it.value to it.index }
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Dispatchers.Main) {
adapter.tracks[match.second].downloaded = true
adapter.notifyItemChanged(
adapter.getPositionOf(
SearchAdapter.ResultType.Track,
match.second
)
)
}
}
}
}
}
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
override fun onArtistClick(holder: View?, artist: Artist) {
findNavController().navigate(SearchFragmentDirections.searchToAlbums(artist))
}
override fun onAlbumClick(holder: View?, album: Album) {
findNavController().navigate(SearchFragmentDirections.searchToTracks(album))
}
}
}

View File

@ -8,41 +8,44 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.TracksAdapter
import audio.funkwhale.ffa.databinding.FragmentTracksBinding
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.FavoritedRepository
import audio.funkwhale.ffa.repositories.FavoritesRepository
import audio.funkwhale.ffa.repositories.TracksRepository
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.Request
import audio.funkwhale.ffa.utils.RequestBus
import audio.funkwhale.ffa.utils.Response
import audio.funkwhale.ffa.utils.getMetadata
import audio.funkwhale.ffa.utils.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.toast
import audio.funkwhale.ffa.utils.wait
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject
class TracksFragment : FFAFragment<Track, TracksAdapter>() {
private val args by navArgs<TracksFragmentArgs>()
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
override val recycler: RecyclerView get() = binding.tracks
@ -53,12 +56,37 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
private lateinit var favoritesRepository: FavoritesRepository
private lateinit var favoritedRepository: FavoritedRepository
private var albumId = 0
private var albumArtist = ""
private var albumTitle = ""
private var albumCover = ""
companion object {
fun new(album: Album): TracksFragment {
return TracksFragment().apply {
arguments = bundleOf(
"albumId" to album.id,
"albumArtist" to album.artist.name,
"albumTitle" to album.title,
"albumCover" to album.cover()
)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
albumId = getInt("albumId")
albumArtist = getString("albumArtist") ?: ""
albumTitle = getString("albumTitle") ?: ""
albumCover = getString("albumCover") ?: ""
}
favoritesRepository = FavoritesRepository(context)
favoritedRepository = FavoritedRepository(context)
repository = TracksRepository(context, args.album.id)
repository = TracksRepository(context, albumId)
adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository))
@ -101,12 +129,6 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
): View {
_binding = FragmentTracksBinding.inflate(inflater)
swiper = binding.swiper
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> binding.play.text = getString(R.string.playback_play)
else -> binding.play.text = getString(R.string.playback_shuffle)
}
return binding.root
}
@ -118,15 +140,16 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
CoverArt.requestCreator(maybeNormalizeUrl(args.album.cover()))
Picasso.get()
.maybeLoad(maybeNormalizeUrl(albumCover))
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(binding.cover)
binding.artist.text = args.album.artist.name
binding.title.text = args.album.title
binding.artist.text = albumArtist
binding.title.text = albumTitle
}
override fun onResume() {
@ -165,6 +188,7 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data))
else -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
}
context.toast("All tracks were added to your queue")
}
@ -220,16 +244,16 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
private fun watchEventBus() {
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
if (message is Event.DownloadChanged) {
refreshDownloadedTrack(message.download)
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
if (command is Command.RefreshTrack) {
refreshCurrentTrack(command.track)
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
@ -239,12 +263,10 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
withContext(Main) {
adapter.setUnfilteredData(
adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
)
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.notifyDataSetChanged()
}

View File

@ -6,7 +6,7 @@ import audio.funkwhale.ffa.playback.MediaSession
import audio.funkwhale.ffa.utils.AuthorizationServiceFactory
import audio.funkwhale.ffa.utils.OAuth
import com.google.android.exoplayer2.database.DatabaseProvider
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.database.ExoDatabaseProvider
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.upstream.cache.Cache
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
@ -19,7 +19,7 @@ import org.koin.dsl.module
fun exoplayerModule(context: Context) = module {
single<DatabaseProvider>(named("exoDatabase")) {
StandaloneDatabaseProvider(context)
ExoDatabaseProvider(context)
}
single {

View File

@ -1,18 +1,13 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Album(
val id: Int,
val artist: Artist,
val title: String,
private val cover: Covers?,
val cover: Covers?,
val release_date: String?
) : SearchResult, Parcelable {
@Parcelize
data class Artist(val name: String) : Parcelable
) : SearchResult {
data class Artist(val name: String)
override fun cover() = cover?.urls?.original
override fun title() = title

View File

@ -1,31 +1,16 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.Calendar.DAY_OF_YEAR
import java.util.GregorianCalendar
@Parcelize
data class Artist(
val id: Int,
val name: String,
val albums: List<Album>?
) : SearchResult, Parcelable {
@Parcelize
) : SearchResult {
data class Album(
val title: String,
val cover: Covers?
) : Parcelable
override fun cover(): String? = albums?.mapNotNull { it.cover?.urls?.original }?.let { covers ->
if (covers.isEmpty()) {
return@let null
}
// Inject a little whimsy: rotate through the album covers daily
val index = GregorianCalendar().get(DAY_OF_YEAR) % covers.size
covers.getOrNull(index)
}
)
override fun cover(): String? = albums?.getOrNull(0)?.cover?.urls?.original
override fun title() = name
override fun subtitle() = "Artist"
}

View File

@ -5,7 +5,6 @@ sealed class CacheItem<D : Any>(val data: List<D>)
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
class TracksCache(data: List<Track>) : CacheItem<Track>(data)
class FavoritesCache(data: List<Favorite>) : CacheItem<Favorite>(data)
class PlaylistsCache(data: List<Playlist>) : CacheItem<Playlist>(data)
class PlaylistTracksCache(data: List<PlaylistTrack>) : CacheItem<PlaylistTrack>(data)
class RadiosCache(data: List<Radio>) : CacheItem<Radio>(data)

View File

@ -1,7 +1,3 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class CoverUrls(val original: String) : Parcelable
data class CoverUrls(val original: String)

View File

@ -1,7 +1,3 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Covers(val urls: CoverUrls) : Parcelable
data class Covers(val urls: CoverUrls)

View File

@ -1,10 +0,0 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Favorite(
val id: Int = 0,
val track: Track
) : Parcelable

View File

@ -1,9 +0,0 @@
package audio.funkwhale.ffa.model
data class FavoritesResponse(
override val count: Int,
override val next: String?,
val results: List<Favorite>
) : FFAResponse<Favorite>() {
override fun getData() = results
}

View File

@ -1,13 +1,9 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Playlist(
val id: Int,
val name: String,
val album_covers: List<String>,
val tracks_count: Int,
val duration: Int
) : Parcelable
)

View File

@ -1,16 +1,10 @@
package audio.funkwhale.ffa.model
import android.os.Parcelable
import audio.funkwhale.ffa.utils.containsIgnoringCase
import com.preference.PowerPreference
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class Track(
val id: Int = 0,
val title: String,
private val cover: Covers? ,
val artist: Artist,
val album: Album?,
val disc_number: Int = 0,
@ -18,18 +12,10 @@ data class Track(
val uploads: List<Upload> = listOf(),
val copyright: String? = null,
val license: String? = null
) : SearchResult, Parcelable {
@IgnoredOnParcel
) : SearchResult {
var current: Boolean = false
@IgnoredOnParcel
var favorite: Boolean = false
@IgnoredOnParcel
var cached: Boolean = false
@IgnoredOnParcel
var downloaded: Boolean = false
companion object {
@ -37,21 +23,17 @@ data class Track(
fun fromDownload(download: DownloadInfo): Track = Track(
id = download.id,
title = download.title,
cover = Covers(CoverUrls("")),
artist = Artist(0, download.artist, listOf()),
album = Album(0, Album.Artist(""), "", Covers(CoverUrls("")), ""),
uploads = listOf(Upload(download.contentId, 0, 0))
)
}
@Parcelize
data class Upload(val listen_url: String, val duration: Int, val bitrate: Int) : Parcelable
fun matchesFilter(filter: String): Boolean {
return title.containsIgnoringCase(filter) ||
artist.name.containsIgnoringCase(filter) ||
album?.title.containsIgnoringCase(filter)
}
data class Upload(
val listen_url: String,
val duration: Int,
val bitrate: Int
)
override fun equals(other: Any?): Boolean {
return when (other) {
@ -67,30 +49,14 @@ data class Track(
fun bestUpload(): Upload? {
if (uploads.isEmpty()) return null
var bestUpload = when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
return when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
"quality" -> uploads.maxByOrNull { it.bitrate } ?: uploads[0]
"size" -> uploads.minByOrNull { it.bitrate } ?: uploads[0]
else -> uploads.maxByOrNull { it.bitrate } ?: uploads[0]
}
return when (PowerPreference.getDefaultFile().getString("bandwidth_limitation")) {
"unlimited" -> bestUpload
"limited" -> {
var listenUrl = bestUpload.listen_url
Upload(listenUrl.plus("&to=mp3&max_bitrate=320"), uploads[0].duration, 320_000)
}
else -> bestUpload
}
}
override fun cover(): String? {
return if (cover?.urls?.original != null) {
cover.urls.original
} else {
album?.cover()
}
}
override fun cover() = album?.cover?.urls?.original
override fun title() = title
override fun subtitle() = artist.name

View File

@ -2,7 +2,6 @@ package audio.funkwhale.ffa.playback
import android.app.Notification
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.Service
import android.content.Intent
import android.support.v4.media.session.MediaSessionCompat
@ -15,18 +14,14 @@ import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.activities.MainActivity
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
class MediaControlsManager(
val context: Service,
private val scope: CoroutineScope,
private val mediaSession: MediaSessionCompat
) {
class MediaControlsManager(val context: Service, private val scope: CoroutineScope, private val mediaSession: MediaSessionCompat) {
companion object {
const val NOTIFICATION_ACTION_OPEN_QUEUE = 0
@ -46,10 +41,8 @@ class MediaControlsManager(
}
scope.launch(Default) {
val openIntent = Intent(context, MainActivity::class.java).apply {
action = NOTIFICATION_ACTION_OPEN_QUEUE.toString()
}
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, FLAG_IMMUTABLE)
val openIntent = Intent(context, MainActivity::class.java).apply { action = NOTIFICATION_ACTION_OPEN_QUEUE.toString() }
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, 0)
val coverUrl = maybeNormalizeUrl(track.album?.cover())
@ -68,7 +61,7 @@ class MediaControlsManager(
.run {
coverUrl?.let {
try {
setLargeIcon(CoverArt.requestCreator(coverUrl).get())
setLargeIcon(Picasso.get().load(coverUrl).get())
} catch (_: Exception) {
}
@ -105,8 +98,7 @@ class MediaControlsManager(
if (playing) {
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
} else {
NotificationManagerCompat.from(context)
.notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
}
}

View File

@ -2,13 +2,13 @@ package audio.funkwhale.ffa.playback
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import com.google.android.exoplayer2.ControlDispatcher
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
@ -30,6 +30,7 @@ class MediaSession(private val context: Context) {
val session: MediaSessionCompat by lazy {
MediaSessionCompat(context, context.packageName).apply {
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
setPlaybackState(playbackStateBuilder.build())
isActive = true
@ -41,19 +42,15 @@ class MediaSession(private val context: Context) {
MediaSessionConnector(session).also {
it.setQueueNavigator(FFAQueueNavigator())
it.setMediaButtonEventHandler { _, intent ->
it.setMediaButtonEventHandler { _, _, intent ->
if (!active) {
Intent(context, PlayerService::class.java).let { player ->
player.action = intent.action
context.startService(
Intent(context, PlayerService::class.java).apply {
action = intent.action
intent.extras?.let { extras -> player.putExtras(extras) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(player)
} else {
context.startService(player)
intent.extras?.let { extras -> putExtras(extras) }
}
}
)
return@setMediaButtonEventHandler true
}
@ -65,11 +62,13 @@ class MediaSession(private val context: Context) {
}
class FFAQueueNavigator : MediaSessionConnector.QueueNavigator {
override fun onSkipToQueueItem(player: Player, id: Long) {
override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) {
CommandBus.send(Command.PlayTrack(id.toInt()))
}
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = true
override fun onCurrentWindowIndexChanged(player: Player) {}
override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true
override fun getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or
@ -78,13 +77,13 @@ class FFAQueueNavigator : MediaSessionConnector.QueueNavigator {
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
}
override fun onSkipToNext(player: Player) {
override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {
CommandBus.send(Command.NextTrack)
}
override fun getActiveQueueItemId(player: Player?) = player?.currentMediaItemIndex?.toLong() ?: 0
override fun getActiveQueueItemId(player: Player?) = player?.currentWindowIndex?.toLong() ?: 0
override fun onSkipToPrevious(player: Player) {
override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {
CommandBus.send(Command.PreviousTrack)
}

View File

@ -24,6 +24,7 @@ import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent
import java.util.Collections
@ -34,7 +35,6 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
private val exoDownloadManager: DownloadManager by KoinJavaComponent.inject(DownloadManager::class.java)
companion object {
fun download(context: Context, track: Track) {
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
@ -48,7 +48,7 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
)
).toByteArray()
val request = DownloadRequest.Builder(url.toUri().toString(), url.toUri())
val request = DownloadRequest.Builder(track.id.toString(), url.toUri())
.setData(data)
.setStreamKeys(Collections.emptyList())
.build()
@ -63,8 +63,8 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
scope.launch(Main) {
RequestBus.get().collect { request ->
if (request is Request.GetDownloads) {
request.channel?.trySend(Response.Downloads(getDownloads()))?.isSuccess
when (request) {
is Request.GetDownloads -> request.channel?.trySend(Response.Downloads(getDownloads()))?.isSuccess
}
}
}
@ -72,28 +72,20 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
return super.onStartCommand(intent, flags, startId)
}
override fun getDownloadManager(): DownloadManager {
return exoDownloadManager.apply {
addListener(DownloadListener())
}
override fun getDownloadManager() = exoDownloadManager.apply {
addListener(DownloadListener())
}
override fun getScheduler(): Scheduler? = null
override fun getForegroundNotification(
downloads: MutableList<Download>,
notMetRequirements: Int
): Notification {
override fun getForegroundNotification(downloads: MutableList<Download>): Notification {
val description =
resources.getQuantityString(R.plurals.downloads_description, downloads.size, downloads.size)
return DownloadNotificationHelper(
this,
AppContext.NOTIFICATION_CHANNEL_DOWNLOADS
).buildProgressNotification(
this, R.drawable.downloads, null, description,
downloads, notMetRequirements
)
).buildProgressNotification(this, R.drawable.downloads, null, description, downloads)
}
private fun getDownloads() = downloadManager.downloadIndex.getDownloads()

View File

@ -12,7 +12,6 @@ import android.media.MediaMetadata
import android.os.Build
import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import android.view.KeyEvent
import androidx.core.app.NotificationManagerCompat
import androidx.media.session.MediaButtonReceiver
@ -20,7 +19,6 @@ import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.FFACache
@ -33,18 +31,19 @@ import audio.funkwhale.ffa.utils.log
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.onApi
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.IllegalSeekPositionException
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Tracks
import com.preference.PowerPreference
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.squareup.picasso.Picasso
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.java.KoinJavaComponent.inject
@ -66,7 +65,7 @@ class PlayerService : Service() {
private lateinit var queue: QueueManager
private lateinit var mediaControlsManager: MediaControlsManager
private lateinit var player: ExoPlayer
private lateinit var player: SimpleExoPlayer
private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
@ -133,13 +132,12 @@ class PlayerService : Service() {
mediaControlsManager = MediaControlsManager(this, scope, mediaSession.session)
player = ExoPlayer.Builder(this).build().apply {
player = SimpleExoPlayer.Builder(this).build().apply {
playWhenReady = false
playerEventListener = PlayerEventListener().also {
addListener(it)
}
EventBus.send(Event.StateChanged(this.isPlaying()))
}
mediaSession.active = true
@ -153,20 +151,14 @@ class PlayerService : Service() {
}
if (queue.current > -1) {
player.setMediaSource(queue.dataSources)
player.prepare()
player.prepare(queue.dataSources)
FFACache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong())
val (current, duration, percent) = getProgress(true)
FFACache.getLine(this, "progress")?.let {
try {
player.seekTo(queue.current, it.toLong())
val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
} catch (e: IllegalSeekPositionException) {
// The app remembered an incorrect position, let's reset it
FFACache.set(this, "current", "-1")
}
ProgressBus.send(current, duration, percent)
}
}
@ -179,60 +171,61 @@ class PlayerService : Service() {
private fun watchEventBus() {
scope.launch(Main) {
CommandBus.get().collect { command ->
if (command is Command.RefreshService) {
if (queue.metadata.isNotEmpty()) {
CommandBus.send(Command.RefreshTrack(queue.current()))
EventBus.send(Event.StateChanged(player.playWhenReady))
when (command) {
is Command.RefreshService -> {
if (queue.metadata.isNotEmpty()) {
CommandBus.send(Command.RefreshTrack(queue.current()))
EventBus.send(Event.StateChanged(player.playWhenReady))
}
}
} else if (command is Command.ReplaceQueue) {
if (!command.fromRadio) radioPlayer.stop()
queue.replace(command.queue)
player.setMediaSource(queue.dataSources)
player.prepare()
is Command.ReplaceQueue -> {
if (!command.fromRadio) radioPlayer.stop()
setPlaybackState(true)
queue.replace(command.queue)
player.prepare(queue.dataSources, true, true)
CommandBus.send(Command.RefreshTrack(queue.current()))
} else if (command is Command.AddToQueue) {
queue.append(command.tracks)
} else if (command is Command.PlayNext) {
queue.insertNext(command.track)
} else if (command is Command.RemoveFromQueue) {
queue.remove(command.track)
} else if (command is Command.MoveFromQueue) {
queue.move(command.oldPosition, command.newPosition)
} else if (command is Command.PlayTrack) {
queue.current = command.index
player.seekTo(command.index, C.TIME_UNSET)
setPlaybackState(true)
setPlaybackState(true)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
CommandBus.send(Command.RefreshTrack(queue.current()))
} else if (command is Command.ToggleState) {
togglePlayback()
} else if (command is Command.SetState) {
setPlaybackState(command.state)
} else if (command is Command.NextTrack) {
skipToNextTrack()
} else if (command is Command.PreviousTrack) {
skipToPreviousTrack()
} else if (command is Command.Seek) {
seek(command.progress)
} else if (command is Command.ClearQueue) {
queue.clear()
player.stop()
} else if (command is Command.ShuffleQueue) {
queue.shuffle()
} else if (command is Command.PlayRadio) {
queue.clear()
radioPlayer.play(command.radio)
} else if (command is Command.SetRepeatMode) {
player.repeatMode = command.mode
} else if (command is Command.PinTrack) {
PinService.download(this@PlayerService, command.track)
} else if (command is Command.PinTracks) {
command.tracks.forEach {
is Command.AddToQueue -> queue.append(command.tracks)
is Command.PlayNext -> queue.insertNext(command.track)
is Command.RemoveFromQueue -> queue.remove(command.track)
is Command.MoveFromQueue -> queue.move(command.oldPosition, command.newPosition)
is Command.PlayTrack -> {
queue.current = command.index
player.seekTo(command.index, C.TIME_UNSET)
setPlaybackState(true)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
is Command.ToggleState -> togglePlayback()
is Command.SetState -> setPlaybackState(command.state)
is Command.NextTrack -> skipToNextTrack()
is Command.PreviousTrack -> skipToPreviousTrack()
is Command.Seek -> seek(command.progress)
is Command.ClearQueue -> {
queue.clear()
player.stop()
}
is Command.ShuffleQueue -> queue.shuffle()
is Command.PlayRadio -> {
queue.clear()
radioPlayer.play(command.radio)
}
is Command.SetRepeatMode -> player.repeatMode = command.mode
is Command.PinTrack -> PinService.download(this@PlayerService, command.track)
is Command.PinTracks -> command.tracks.forEach {
PinService.download(
this@PlayerService,
it
@ -244,14 +237,10 @@ class PlayerService : Service() {
scope.launch(Main) {
RequestBus.get().collect { request ->
if (request is Request.GetCurrentTrack) {
request.channel?.trySend(Response.CurrentTrack(queue.current()))?.isSuccess
} else if (request is Request.GetCurrentTrackIndex) {
request.channel?.trySend(Response.CurrentTrackIndex(queue.currentIndex()))?.isSuccess
} else if (request is Request.GetState) {
request.channel?.trySend(Response.State(player.playWhenReady))?.isSuccess
} else if (request is Request.GetQueue) {
request.channel?.trySend(Response.Queue(queue.get()))?.isSuccess
when (request) {
is Request.GetCurrentTrack -> request.channel?.trySend(Response.CurrentTrack(queue.current()))?.isSuccess
is Request.GetState -> request.channel?.trySend(Response.State(player.playWhenReady))?.isSuccess
is Request.GetQueue -> request.channel?.trySend(Response.Queue(queue.get()))?.isSuccess
}
}
}
@ -314,12 +303,11 @@ class PlayerService : Service() {
if (!state) {
val (progress, _, _) = getProgress()
FFACache.set(this@PlayerService, "progress", progress.toString())
FFACache.set(this@PlayerService, "progress", progress.toString().toByteArray())
}
if (state && player.playbackState == Player.STATE_IDLE) {
player.setMediaSource(queue.dataSources)
player.prepare()
player.prepare(queue.dataSources)
}
if (hasAudioFocus(state)) {
@ -330,7 +318,7 @@ class PlayerService : Service() {
}
private fun togglePlayback() {
setPlaybackState(!player.isPlaying)
setPlaybackState(!player.playWhenReady)
}
private fun skipToPreviousTrack() {
@ -338,13 +326,13 @@ class PlayerService : Service() {
return player.seekTo(0)
}
player.seekToPrevious()
player.previous()
}
private fun skipToNextTrack() {
player.seekToNext()
player.next()
FFACache.set(this@PlayerService, "progress", "0")
FFACache.set(this@PlayerService, "progress", "0".toByteArray())
ProgressBus.send(0, 0, 0)
}
@ -385,10 +373,10 @@ class PlayerService : Service() {
runBlocking(IO) {
this@apply.putBitmap(
MediaMetadataCompat.METADATA_KEY_ALBUM_ART,
CoverArt.requestCreator(coverUrl).get()
Picasso.get().load(coverUrl).get()
)
}
} catch (_: Exception) {
} catch (e: Exception) {
}
}.build()
}
@ -430,28 +418,10 @@ class PlayerService : Service() {
return allowed
}
private fun skipBackwardsAfterPause(): Int {
val deltaPref = PowerPreference.getDefaultFile().getString("auto_skip_backwards_on_pause")
val delta = deltaPref.toFloatOrNull()
return if (delta == null) 0 else (delta * 1000).toInt()
}
@SuppressLint("NewApi")
inner class PlayerEventListener : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
mediaControlsManager.updateNotification(queue.current(), isPlaying)
if (!isPlaying) {
val delta = skipBackwardsAfterPause()
val (current, duration, _) = getProgress(true)
val position = if (current > delta) current - delta else 0
player.seekTo(position.toLong())
ProgressBus.send(position, duration, ((position.toFloat()) / duration / 10).toInt())
}
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
inner class PlayerEventListener : Player.EventListener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
super.onPlayerStateChanged(playWhenReady, playbackState)
EventBus.send(Event.StateChanged(playWhenReady))
@ -459,56 +429,59 @@ class PlayerService : Service() {
CommandBus.send(Command.RefreshTrack(queue.current()))
}
if (!playWhenReady) {
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
{ stopForeground(false) }
)
}
}
when (playWhenReady) {
true -> {
when (playbackState) {
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
Player.STATE_ENDED -> {
setPlaybackState(false)
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
queue.current = 0
player.seekTo(0, C.TIME_UNSET)
when (playbackState) {
Player.STATE_BUFFERING -> {
EventBus.send(Event.Buffering(true))
}
Player.STATE_ENDED -> {
setPlaybackState(false)
ProgressBus.send(0, 0, 0)
}
queue.current = 0
player.seekTo(0, C.TIME_UNSET)
Player.STATE_IDLE -> {
setPlaybackState(false)
ProgressBus.send(0, 0, 0)
return EventBus.send(Event.PlaybackStopped)
}
}
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
}
Player.STATE_IDLE -> {
setPlaybackState(false)
false -> {
EventBus.send(Event.Buffering(false))
EventBus.send(Event.PlaybackStopped)
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
{ stopForeground(false) }
)
if (!player.playWhenReady) {
mediaControlsManager.remove()
when (playbackState) {
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
Player.STATE_IDLE -> mediaControlsManager.remove()
}
}
Player.STATE_READY -> {
EventBus.send(Event.Buffering(false))
}
}
}
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
override fun onTracksChanged(
trackGroups: TrackGroupArray,
trackSelections: TrackSelectionArray
) {
super.onTracksChanged(trackGroups, trackSelections)
if (queue.current != player.currentMediaItemIndex) {
queue.current = player.currentMediaItemIndex
mediaControlsManager.updateNotification(queue.current(), player.isPlaying)
if (queue.current != player.currentWindowIndex) {
queue.current = player.currentWindowIndex
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
}
if (queue.get().isNotEmpty() &&
queue.current() == queue.get().last() && radioPlayer.isActive()
if (queue.get().isNotEmpty() && queue.current() == queue.get()
.last() && radioPlayer.isActive()
) {
scope.launch(IO) {
if (radioPlayer.lock.tryAcquire()) {
@ -518,7 +491,7 @@ class PlayerService : Service() {
}
}
FFACache.set(this@PlayerService, "current", queue.current.toString())
FFACache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
CommandBus.send(Command.RefreshTrack(queue.current()))
}
@ -537,14 +510,13 @@ class PlayerService : Service() {
}
}
override fun onPlayerError(error: PlaybackException) {
override fun onPlayerError(error: ExoPlaybackException) {
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
if (player.playWhenReady) {
queue.current++
player.setMediaSource(queue.dataSources, true)
player.prepare(queue.dataSources, true, true)
player.seekTo(queue.current, 0)
player.prepare()
CommandBus.send(Command.RefreshTrack(queue.current()))
}

View File

@ -12,7 +12,6 @@ import audio.funkwhale.ffa.utils.FFACache
import audio.funkwhale.ffa.utils.log
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.gson.Gson
@ -29,8 +28,8 @@ class QueueManager(val context: Context) {
var current = -1
init {
FFACache.getLine(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json.reader())?.let { cache ->
FFACache.get(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
metadata = cache.data.toMutableList()
val factory = cacheDataSourceFactoryProvider.create(context)
@ -39,15 +38,15 @@ class QueueManager(val context: Context) {
metadata.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
ProgressiveMediaSource.Factory(factory).setTag(track.title)
.createMediaSource(Uri.parse(url))
}
)
}
}
FFACache.getLine(context, "current")?.let {
current = it.toInt()
FFACache.get(context, "current")?.let { string ->
current = string.readLine().toInt()
}
}
@ -55,7 +54,7 @@ class QueueManager(val context: Context) {
FFACache.set(
context,
"queue",
Gson().toJson(QueueCache(metadata)).toString()
Gson().toJson(QueueCache(metadata)).toByteArray()
)
}
@ -64,8 +63,8 @@ class QueueManager(val context: Context) {
val factory = cacheDataSourceFactoryProvider.create(context)
val sources = tracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
}
metadata = tracks.toMutableList()
@ -85,8 +84,7 @@ class QueueManager(val context: Context) {
val sources = missingTracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
}
metadata.addAll(tracks)
@ -103,8 +101,7 @@ class QueueManager(val context: Context) {
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
if (metadata.indexOf(track) == -1) {
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem).let {
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url)).let {
dataSources.addMediaSource(current + 1, it)
metadata.add(current + 1, track)
}
@ -167,8 +164,6 @@ class QueueManager(val context: Context) {
return metadata.getOrNull(current)
}
fun currentIndex(): Int = (if (current == -1) 0 else current)
fun clear() {
metadata = mutableListOf()
dataSources.clear()

View File

@ -53,10 +53,10 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
private val favoritedRepository = FavoritedRepository(context)
init {
FFACache.getLine(context, "radio_type")?.let { radio_type ->
FFACache.getLine(context, "radio_id")?.toInt()?.let { radio_id ->
FFACache.getLine(context, "radio_session")?.toInt()?.let { radio_session ->
val cachedCookie = FFACache.getLine(context, "radio_cookie")
FFACache.get(context, "radio_type")?.readLine()?.let { radio_type ->
FFACache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id ->
FFACache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
val cachedCookie = FFACache.get(context, "radio_cookie")?.readLine()
currentRadio = Radio(radio_id, radio_type, "", "")
session = radio_session
@ -107,10 +107,10 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
session = result.get().id
cookie = response.header("set-cookie").joinToString(";")
FFACache.set(context, "radio_type", radio.radio_type)
FFACache.set(context, "radio_id", radio.id.toString())
FFACache.set(context, "radio_session", session.toString())
FFACache.set(context, "radio_cookie", cookie.toString())
FFACache.set(context, "radio_type", radio.radio_type.toByteArray())
FFACache.set(context, "radio_id", radio.id.toString().toByteArray())
FFACache.set(context, "radio_session", session.toString().toByteArray())
FFACache.set(context, "radio_cookie", cookie.toString().toByteArray())
prepareNextTrack(true)
} catch (e: Exception) {

View File

@ -8,6 +8,7 @@ import audio.funkwhale.ffa.utils.OAuth
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
Repository<Album, AlbumsCache>() {
@ -34,6 +35,6 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
}
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
}

View File

@ -9,6 +9,7 @@ import audio.funkwhale.ffa.utils.OAuth
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) :
Repository<Track, TracksCache>() {
@ -26,6 +27,6 @@ class ArtistTracksRepository(override val context: Context?, private val artistI
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
}

View File

@ -9,6 +9,7 @@ import audio.funkwhale.ffa.utils.OAuth
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
@ -25,6 +26,6 @@ class ArtistsRepository(override val context: Context?) : Repository<Artist, Art
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
}

View File

@ -2,11 +2,11 @@ package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.model.FFAResponse
import audio.funkwhale.ffa.model.Favorite
import audio.funkwhale.ffa.model.FavoritesResponse
import audio.funkwhale.ffa.model.FavoritedCache
import audio.funkwhale.ffa.model.FavoritedResponse
import audio.funkwhale.ffa.model.FavoritesCache
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.model.TracksCache
import audio.funkwhale.ffa.model.TracksResponse
import audio.funkwhale.ffa.utils.FFACache
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Settings
@ -27,8 +27,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Favorite, FavoritesCache>() {
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
private val exoCache: Cache by inject(Cache::class.java, named("exoCache"))
@ -36,34 +37,34 @@ class FavoritesRepository(override val context: Context?) : Repository<Favorite,
override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Favorite, FFAResponse<Favorite>>(
override val upstream = HttpUpstream<Track, FFAResponse<Track>>(
context!!,
HttpUpstream.Behavior.AtOnce,
"/api/v1/favorites/tracks/?scope=all&ordering=-creation_date",
object : TypeToken<FavoritesResponse>() {}.type,
"/api/v1/tracks/?favorites=true&playable=true&ordering=title",
object : TypeToken<TracksResponse>() {}.type,
oAuth
)
override fun cache(data: List<Favorite>) = FavoritesCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(FavoritesCache::class.java).deserialize(json.reader())
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
private val favoritedRepository = FavoritedRepository(context!!)
override fun onDataFetched(data: List<Favorite>): List<Favorite> = runBlocking {
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
data.map { favorite ->
favorite.track.favorite = true
favorite.track.downloaded = downloaded.contains(favorite.track.id)
data.map { track ->
track.favorite = true
track.downloaded = downloaded.contains(track.id)
favorite.track.bestUpload()?.let { upload ->
track.bestUpload()?.let { upload ->
maybeNormalizeUrl(upload.listen_url)?.let { url ->
favorite.track.cached = exoCache.isCached(url, 0, upload.duration * 1000L)
track.cached = exoCache.isCached(url, 0, upload.duration * 1000L)
}
}
favorite
track
}
}
@ -126,12 +127,12 @@ class FavoritedRepository(override val context: Context?) : Repository<Int, Favo
)
override fun cache(data: List<Int>) = FavoritedCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(FavoritedCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
fun update(context: Context?, scope: CoroutineScope) {
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
FFACache.set(context, cacheId, Gson().toJson(cache(favorites)).toString())
FFACache.set(context, cacheId, Gson().toJson(cache(favorites)).toByteArray())
}
}
}

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) :
Repository<PlaylistTrack, PlaylistTracksCache>() {
@ -29,8 +30,8 @@ class PlaylistTracksRepository(override val context: Context?, playlistId: Int)
)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)

View File

@ -19,6 +19,7 @@ import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
data class PlaylistAdd(val tracks: List<Int>, val allow_duplicates: Boolean)
@ -37,8 +38,8 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
}
class ManagementPlaylistsRepository(override val context: Context?) :
@ -57,8 +58,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
suspend fun new(name: String): Int? {
context?.let {
@ -107,7 +108,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
}
suspend fun remove(albumId: Int, index: Int) {
if (context != null) {
context?.let {
val body = mapOf("index" to index)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$albumId/remove/")).apply {
@ -121,13 +122,12 @@ class ManagementPlaylistsRepository(override val context: Context?) :
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
} else {
throw IllegalStateException("Illegal state: context is null")
}
throw IllegalStateException("Illegal state: context is null")
}
fun move(id: Int, from: Int, to: Int) {
if (context != null) {
context?.let {
val body = mapOf("from" to from, "to" to to)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
@ -143,8 +143,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
} else {
throw IllegalStateException("Illegal state: context is null")
}
throw IllegalStateException("Illegal state: context is null")
}
}

View File

@ -9,6 +9,7 @@ import audio.funkwhale.ffa.utils.OAuth
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
@ -25,8 +26,8 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio
)
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Radio>): List<Radio> {
return data

View File

@ -8,9 +8,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import java.io.BufferedReader
import kotlin.math.ceil
interface Upstream<D> {
@ -32,7 +34,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
abstract val upstream: Upstream<D>
open fun cache(data: List<D>): C? = null
protected open fun uncache(json: String): C? = null
protected open fun uncache(reader: BufferedReader): C? = null
fun fetch(
upstreams: Int = Origin.Cache.origin and Origin.Network.origin,
@ -44,8 +46,8 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
private fun fromCache() = flow {
cacheId?.let { cacheId ->
FFACache.getLine(context, cacheId)?.let { line ->
uncache(line)?.let { cache ->
FFACache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
return@flow emit(
Response(
Origin.Cache,

View File

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class TracksSearchRepository(override val context: Context?, var query: String) :
Repository<Track, TracksCache>() {
@ -41,8 +42,8 @@ class TracksSearchRepository(override val context: Context?, var query: String)
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
@ -83,8 +84,8 @@ class ArtistsSearchRepository(override val context: Context?, var query: String)
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
}
class AlbumsSearchRepository(override val context: Context?, var query: String) :
@ -103,6 +104,6 @@ class AlbumsSearchRepository(override val context: Context?, var query: String)
)
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
}

View File

@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class TracksRepository(override val context: Context?, albumId: Int) :
Repository<Track, TracksCache>() {
@ -37,23 +38,24 @@ class TracksRepository(override val context: Context?, albumId: Int) :
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
companion object {
fun getDownloadedIds(exoDownloadManager: DownloadManager): List<Int>? {
val cursor = exoDownloadManager.downloadIndex.getDownloads()
val ids: MutableList<Int> = mutableListOf()
exoDownloadManager.downloadIndex.getDownloads()
.use { cursor ->
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let {
if (download.state == Download.STATE_COMPLETED) {
ids.add(it.id)
}
}
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let {
if (download.state == Download.STATE_COMPLETED) {
ids.add(it.id)
}
}
}
return ids
}
}

View File

@ -1,6 +1,7 @@
package audio.funkwhale.ffa.utils
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.BroadcastReceiver
@ -22,7 +23,7 @@ object AppContext {
const val PAGE_SIZE = 50
const val TRANSITION_DURATION = 300L
fun init(context: Context) {
fun init(context: Activity) {
setupNotificationChannels(context)
// CastContext.getSharedInstance(context)

View File

@ -1,89 +0,0 @@
package audio.funkwhale.ffa.utils
import java.lang.ref.WeakReference
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
/**
* Similar to a Map, but with the semantic that operations single-thread on a per-key basis.
* That is: given concurrent accesses to keys "apple" and "banana", one "apple" thread
* will block all other "apple" threads, but not any "banana" threads.
* In practical terms, we use this to make sure we don't get weird edge cases when working
* with the filesystem cache.
*/
class Bottleneck<T> {
// It would be nice to use LruCache here, but its behavior of
// replacing values doesn't get us the right results.
// As it is, this should be a trivial amount of memory compared to
// images and media.
// We single-thread this, so it doesn't need to be concurrent.
private val keys = WeakHashMap<String, String>()
// This one needs to be concurrent, as we don't want to single-thread it.
private val values = ConcurrentHashMap<String, WeakReference<T>>()
/**
* As you would expect from the Map function of the same name, except concurrent
* accesses to the same key will block on each other. If the first call succeeds,
* all other calls will fall through with the same result. (Unlike LRUCache.)
*/
fun getOrCompute(key: String, materialize: (key: String) -> T?): T? {
// First, get the lockable version of the key, no matter how
// many copies of the key exist.
// This map doesn't need to be a synchronized collection, because
// we single-thread access to it. (And there's no compute, so
// it should be low-contention.)
val sharedKey: String = canonical(key)
synchronized(sharedKey) {
val ref = values[sharedKey]
var value = ref?.get()
if (value == null) {
if (ref != null) {
values.remove(sharedKey) // empty ref
}
value = materialize(sharedKey)
if (value != null) {
values[sharedKey] = WeakReference(value)
}
}
return value
}
}
/**
* The beating heart of this system: each key is is "upgraded" to
* the one which we use for locking. This does mean we block on
* access to `keys` for all concurrent access, but as it's so light-
* weight, this shouldn't be much of a problem in practical terms.
* The hope here is that this is slightly better than interning.
* In theory we could convert this over to also use WeakReference.
*/
private fun canonical(key: String): String {
val sharedKey: String
synchronized(keys) {
val maybeShared = keys[key]
if (maybeShared == null) {
keys[key] = key // first key of its value becomes canonical
sharedKey = key
} else {
sharedKey = maybeShared
}
}
return sharedKey
}
/**
* Invalidate a key and run the supplied bi-consumer with the old value.
* Note that this will <em>always</em> run the supplied block, even if
* the value is not in the cache.
*/
fun remove(key: String, andDo: ((T?, String) -> Unit)?) {
val sharedKey = canonical(key)
synchronized(sharedKey) {
val oldValue = values.remove(sharedKey)
if (andDo != null) {
andDo(oldValue?.get(), sharedKey)
}
}
}
}

View File

@ -1,10 +0,0 @@
package audio.funkwhale.ffa.utils
import androidx.customview.widget.Openable
interface BottomSheetIneractable: Openable {
val isHidden: Boolean
fun show()
fun hide()
fun toggle()
}

View File

@ -1,5 +1,6 @@
package audio.funkwhale.ffa.utils
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.model.Radio
import audio.funkwhale.ffa.model.Track
import com.google.android.exoplayer2.offline.Download
@ -7,10 +8,8 @@ import com.google.android.exoplayer2.offline.DownloadCursor
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
sealed class Command {
@ -61,7 +60,6 @@ sealed class Request(var channel: Channel<Response>? = null) {
object GetState : Request()
object GetQueue : Request()
object GetCurrentTrack : Request()
object GetCurrentTrackIndex : Request()
object GetDownloads : Request()
}
@ -69,59 +67,51 @@ sealed class Response {
class State(val playing: Boolean) : Response()
class Queue(val queue: List<Track>) : Response()
class CurrentTrack(val track: Track?) : Response()
class CurrentTrackIndex(val index: Int) : Response()
class Downloads(val cursor: DownloadCursor) : Response()
}
object EventBus {
private var _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
fun send(event: Event) {
GlobalScope.launch(IO) {
_events.emit(event)
FFA.get().eventBus.trySend(event).isSuccess
}
}
fun get() = events
fun get() = FFA.get().eventBus.asFlow()
}
object CommandBus {
private var _commands = MutableSharedFlow<Command>()
var commands = _commands.asSharedFlow()
fun send(command: Command) {
GlobalScope.launch(IO) {
_commands.emit(command)
FFA.get().commandBus.trySend(command).isSuccess
}
}
fun get() = commands
fun get() = FFA.get().commandBus.asFlow()
}
object RequestBus {
// `replay` allows send requests before the PlayerService starts listening
private var _requests = MutableSharedFlow<Request>(replay = 100)
var requests = _requests.asSharedFlow()
fun send(request: Request): Channel<Response> {
return Channel<Response>().also {
GlobalScope.launch(IO) {
request.channel = it
_requests.emit(request)
FFA.get().requestBus.trySend(request).isSuccess
}
}
}
fun get() = requests
fun get() = FFA.get().requestBus.asFlow()
}
object ProgressBus {
private var _progress = MutableStateFlow(Triple(0, 0, 0))
val progress = _progress.asStateFlow()
fun send(current: Int, duration: Int, percent: Int) {
_progress.value = Triple(current, duration, percent)
GlobalScope.launch(IO) {
FFA.get().progressBus.send(Triple(current, duration, percent))
}
}
fun get() = progress
fun get() = FFA.get().progressBus.asFlow().conflate()
}
suspend inline fun <reified T> Channel<Response>.wait(): T? {

View File

@ -1,266 +0,0 @@
package audio.funkwhale.ffa.utils
import android.content.Context
import android.net.Uri
import android.transition.CircularPropagation
import android.util.Log
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import audio.funkwhale.ffa.BuildConfig
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import com.squareup.picasso.Downloader
import com.squareup.picasso.NetworkPolicy
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import com.squareup.picasso.Picasso.LoadedFrom
import com.squareup.picasso.Request
import com.squareup.picasso.RequestCreator
import com.squareup.picasso.RequestHandler
import okhttp3.CacheControl
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okio.Okio
import java.io.File
import java.security.MessageDigest
/**
* Represent bytes as hex values.
*/
fun ByteArray.toHex(): String = joinToString("") { b -> "%02x".format(b) }
/**
* Convert the string to its SHA-256 hash in hex format.
*/
fun String.sha256(): String =
let { MessageDigest.getInstance("SHA-256").digest(it.encodeToByteArray()).toHex() }
/**
* Remove the query string and fragment from a URI.
* Mostly, this is to get rid of pre-signed URL silliness.
* If we ever need to keep some query params, we'll need a more robust approach.
*/
fun Uri.asStableKey(): String = buildUpon().clearQuery().fragment("").build().toString()
/**
* Try to extract a file suffix from the URI. This isn't strictly
* necessary, but it can make debugging easier when you're going through
* the app cache with a filesystem browser.
*/
fun Uri.fileSuffix(): String = let {
val p = it.path
val ext = p?.substringAfterLast(".", "")?.lowercase() ?: ""
if (ext == "") ext else ".$ext"
}
/**
* Wrapper around Picasso with some smarter caching of image files.
*/
open class CoverArt private constructor() {
companion object {
// For logging
val TAG: String = CoverArt::class.java.simpleName
// This is just a nice-to-have for API admins
private const val userAgent =
"${BuildConfig.APPLICATION_ID} ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
// This client has the UA above, and has caching intentionally disabled.
// (Because we cache the images ourselves and cannot rely on replaying requests.)
private var httpClient: OkHttpClient? = null
// Same: this has caching disabled.
private var downloader: OkHttp3Downloader? = null
// Cache with some useful concurrency semantics. See its docs for details.
val fileCache = Bottleneck<File>()
private val picasso = with (FFA.get()) {
Picasso.Builder(this)
.addRequestHandler(CoverNetworkRequestHandler(this))
// Be careful with this. There's at least one place in Picasso where it
// doesn't null-check when logging, so it'll throw errors in places you
// wouldn't get them with logging turned off. /sigh
.loggingEnabled(false) // (BuildConfig.DEBUG)
// Occasionally, we may get transient HTTP issues, or bogus files.
// Listen for Picasso errors and invalidate those files
.listener(invalidateIn(this))
.build()
}
/**
* We don't need to hang onto the Context, just the Path it gets us.
*/
fun cacheDirForContext(context: Context): File {
return context.applicationContext.cacheDir.resolve("covers")
}
/**
* Shim for Picasso which acts like a NetworkRequestHandler, but is opinionated
* about how we want to use it.
*/
open class CoverNetworkRequestHandler(context: Context) : RequestHandler() {
/**
* Path to the actual cache directory.
*/
val coverCacheDir: File
/**
* This goes out with every request and never changes.
*/
val noCacheControl: CacheControl = CacheControl.Builder()
.noCache()
.noStore()
.noTransform()
.build()
init {
coverCacheDir = cacheDirForContext(context)
// Make the cache directory if it doesn't already exist.
if (!coverCacheDir.isDirectory) {
coverCacheDir.mkdir()
}
}
/**
* The primary logic of going from a Request to a usable File.
* tl;dr: Use a local file if you can, otherwise download it and use that.
*/
private fun materializeFile(request: Request): (String) -> File? {
return fun(fileName: String): File? {
val existing = coverCacheDir.resolve(fileName)
if (existing.isFile) {
return existing
}
val key = request.stableKey ?: request.uri.asStableKey()
val httpUrl = HttpUrl.parse(request.uri.toString()) ?: return null
return fetchToFile(httpUrl, fileName, key)
}
}
/**
* Required by Picasso, we only want to handle HTTP traffic.
*/
override fun canHandleRequest(data: Request?): Boolean {
return data != null && ("http" == data.uri.scheme || "https" == data.uri.scheme)
}
/**
* Required by Picasso, this is the main entrypoint.
*/
override fun load(request: Request?, networkPolicy: Int): Result? {
if (request == null || !NetworkPolicy.shouldReadFromDiskCache(networkPolicy)) {
return null
}
// Ditch any query params.
val key = request.stableKey ?: request.uri.asStableKey()
// Convert to a short, stable filename.
val fileName =
key.sha256() + request.uri.fileSuffix() // file extension for easier forensics
// Actually find or fetch the file.
val file = fileCache.getOrCompute(fileName, materializeFile(request))
// Hand it back to Picasso in a way it can understand.
return if (file == null) null else Result(Okio.source(file), LoadedFrom.DISK)
}
/**
* The actual fetch logic is straightforward: download to a file.
* Sadly, this is more manual than you might expect.
*/
private fun fetchToFile(httpUrl: HttpUrl, fileName: String, cacheKey: String): File? {
val httpRequest = okhttp3.Request.Builder()
.get()
.url(httpUrl)
.cacheControl(noCacheControl)
.build()
val response = nonCachingDownloader().load(httpRequest)
if (!response.isSuccessful) {
return null
}
val body = response.body() ?: return null
val file = coverCacheDir.resolve(fileName)
if (BuildConfig.DEBUG) {
Log.d(TAG, "fetchToFile($cacheKey) <- $fileName <- NETWORK")
}
val bytesWritten: Long
body.use { b ->
Okio.buffer(Okio.sink(file)).use { sink ->
bytesWritten = sink.writeAll(b.source())
}
}
return if (bytesWritten > 0) file else null
}
}
/**
* Picasso can send back notification that files are busted.
* In those cases, it could be a transient problem, or credentials, etc.
* We probably don't want to trust the file, so we invalidate it
* from the memory cache and delete it from the filesystem.
* This uses Bottleneck, so it's thread-safe.
*/
fun invalidateIn(context: Context): (Picasso, Uri, Exception) -> Unit {
val coverCacheDir = cacheDirForContext(context)
return fun(_, uri: Uri, _) {
val key = uri.asStableKey()
val fileName = key.sha256() + uri.fileSuffix()
fileCache.remove(fileName) { f, _ ->
val file = f ?: coverCacheDir.resolve(fileName)
if (file.isFile) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Deleting failed cover: $file")
}
file.delete()
}
}
}
}
/**
* Low-level Picasso wiring.
*/
/**
* We don't want to cache the HTTP part of the flow, because:
* 1. It's double-caching, since we're saving the images already.
* 2. The URL may include pre-signed credentials, which expire, making the URL useless.
*/
protected fun nonCachingDownloader(): Downloader {
val downloader = this.downloader ?: OkHttp3Downloader(nonCachingHttpClient())
if (this.downloader == null) {
this.downloader = downloader
}
return downloader
}
/**
* Same here: build a non-caching version just for cover art.
*/
protected fun nonCachingHttpClient(): OkHttpClient {
val hc = httpClient ?: OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request()
.newBuilder()
.addHeader("User-Agent", userAgent)
.build()
)
}
.cache(null) // No cache here, intentionally
.build()
if (httpClient == null) {
httpClient = hc
}
return hc
}
/**
* The primary entrypoint for the codebase.
*/
fun requestCreator(url: String?): RequestCreator {
val request = picasso.load(url)
if(url == null) request.placeholder(R.drawable.cover)
else request.placeholder(CircularProgressDrawable(FFA.get()))
return request.error(R.drawable.cover)
}
}
}

View File

@ -3,18 +3,22 @@ package audio.funkwhale.ffa.utils
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.fragment.app.Fragment
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.fragments.BrowseFragment
import audio.funkwhale.ffa.model.DownloadInfo
import audio.funkwhale.ffa.repositories.Repository
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.Request
import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import net.openid.appauth.ClientSecretPost
@ -34,6 +38,14 @@ inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
}
}
fun Fragment.onViewPager(block: Fragment.() -> Unit) {
for (f in activity?.supportFragmentManager?.fragments ?: listOf()) {
if (f is BrowseFragment) {
f.block()
}
}
}
fun <T> Int.onApi(block: () -> T) {
if (Build.VERSION.SDK_INT >= this) {
block()
@ -48,23 +60,26 @@ fun <T, U> Int.onApi(block: () -> T, elseBlock: (() -> U)) {
}
}
fun Picasso.maybeLoad(url: String?): RequestCreator {
return if (url == null) load(R.drawable.cover)
else load(url)
}
fun Request.authorize(context: Context, oAuth: OAuth): Request {
return runBlocking {
this@authorize.apply {
if (!Settings.isAnonymous()) {
oAuth.state().let { state ->
state.accessTokenExpirationTime?.let {
Log.i("Request.authorize()", "Accesstoken expiration: ${Date(it).format()}")
}
val old = state.accessToken
val auth = ClientSecretPost(oAuth.state().clientSecret)
val done = CompletableDeferred<Boolean>()
val tokenService = oAuth.service(context)
state.performActionWithFreshTokens(tokenService, auth) { token, _, e ->
if (e != null) {
Log.e("Request.authorize()", "performActionWithFreshToken failed: $e")
if (e.type != 2 || e.code != 2002) {
Log.e("Request.authorize()", Log.getStackTraceString(e))
EventBus.send(Event.LogOut)
}
state.performActionWithFreshTokens(oAuth.service(context), auth) { token, _, _ ->
if (token == old) {
Log.i("Request.authorize()", "Accesstoken not renewed")
}
if (token != old && token != null) {
state.save()
@ -73,7 +88,6 @@ fun Request.authorize(context: Context, oAuth: OAuth): Request {
done.complete(true)
}
done.await()
tokenService.dispose()
return@runBlocking this
}
}
@ -93,58 +107,3 @@ val ISO_8601_DATE_TIME_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
fun Date.format(): String {
return ISO_8601_DATE_TIME_FORMAT.format(this)
}
fun String?.containsIgnoringCase(candidate: String): Boolean =
this != null && this.lowercase().contains(candidate.lowercase())
inline fun <T, U, V, R> LiveData<T>.mergeWith(
u: LiveData<U>,
v: LiveData<V>,
crossinline block: (valT: T, valU: U, valV: V) -> R
): LiveData<R> = MediatorLiveData<R>().apply {
addSource(this@mergeWith) {
if (u.value != null && v.value != null) {
postValue(block(it, u.value!!, v.value!!))
}
}
addSource(u) {
if (this@mergeWith.value != null && u.value != null) {
postValue(block(this@mergeWith.value!!, it, v.value!!))
}
}
addSource(v) {
if (this@mergeWith.value != null && u.value != null) {
postValue(block(this@mergeWith.value!!, u.value!!, it))
}
}
}
inline fun <T, U, V, W, R> LiveData<T>.mergeWith(
u: LiveData<U>,
v: LiveData<V>,
w: LiveData<W>,
crossinline block: (valT: T, valU: U, valV: V, valW: W) -> R
): LiveData<R> = MediatorLiveData<R>().apply {
addSource(this@mergeWith) {
if (u.value != null && v.value != null && w.value != null) {
postValue(block(it, u.value!!, v.value!!, w.value!!))
}
}
addSource(u) {
if (this@mergeWith.value != null && v.value != null && w.value != null) {
postValue(block(this@mergeWith.value!!, it, v.value!!, w.value!!))
}
}
addSource(v) {
if (this@mergeWith.value != null && u.value != null && w.value != null) {
postValue(block(this@mergeWith.value!!, u.value!!, it, w.value!!))
}
}
addSource(w) {
if (this@mergeWith.value != null && u.value != null && v.value != null) {
postValue(block(this@mergeWith.value!!, u.value!!, v.value!!, it))
}
}
}
public fun String?.toIntOrElse(default: Int): Int = this?.toIntOrNull(radix = 10) ?: default

View File

@ -12,41 +12,16 @@ object FFACache {
val md = MessageDigest.getInstance("SHA-1")
val digest = md.digest(key.toByteArray(Charset.defaultCharset()))
return digest.fold("") { acc, it -> acc + "%02x".format(it) }
return digest.fold("", { acc, it -> acc + "%02x".format(it) })
}
fun set(context: Context?, key: String, value: String) {
set(context, key, value.toByteArray())
}
fun set(context: Context?, key: String, value: ByteArray) {
context?.let {
with(File(it.cacheDir, key(key))) {
writeBytes(value)
}
}
}
fun getLine(context: Context?, key: String): String? = get(context, key)?.let {
val line = it.readLine()
it.close()
line
}
fun getLines(context: Context?, key: String): List<String>? = get(context, key)
?.let { reader ->
val lines = reader.readLines()
reader.close()
lines
}
fun delete(context: Context?, key: String) = context?.let {
fun set(context: Context?, key: String, value: ByteArray) = context?.let {
with(File(it.cacheDir, key(key))) {
delete()
writeBytes(value)
}
}
private fun get(context: Context?, key: String): BufferedReader? = context?.let {
fun get(context: Context?, key: String): BufferedReader? = context?.let {
try {
with(File(it.cacheDir, key(key))) {
bufferedReader()
@ -55,4 +30,10 @@ object FFACache {
return null
}
}
fun delete(context: Context?, key: String) = context?.let {
with(File(it.cacheDir, key(key))) {
delete()
}
}
}

View File

@ -71,8 +71,9 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
} else {
false
}
)
.also { it.logInfo("isAuthorized()") }
).also {
it.logInfo("isAuthorized()")
}
}
private fun AuthState.validAuthorization() = this.isAuthorized && !this.needsTokenRefresh
@ -83,7 +84,7 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
refreshAccessToken(state, context)
} else {
state.isAuthorized
}
}.also { it.logInfo("tryRefreshAccessToken()") }
}
return false
}
@ -97,23 +98,15 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
return if (state.refreshToken != null) {
val refreshRequest = state.createTokenRefreshRequest()
val auth = ClientSecretPost(state.clientSecret)
val refreshService = service(context)
runBlocking {
refreshService.performTokenRequest(refreshRequest, auth) { response, e ->
if (e != null) {
Log.e("OAuth", "performTokenRequest failed: $e")
Log.e("OAuth", Log.getStackTraceString(e))
EventBus.send(Event.LogOut)
} else {
state.apply {
Log.i("OAuth", "applying new authState")
update(response, e)
save()
}
service(context).performTokenRequest(refreshRequest, auth) { response, e ->
state.apply {
Log.i("OAuth", "applying new authState")
update(response, e)
save()
}
}
}
refreshService.dispose()
true
} else {
false
@ -185,10 +178,11 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
)
}
fun authorizeIntent(activity: Activity): Intent? {
fun authorize(activity: Activity) {
val authService = service(activity)
return authorizationRequest()?.let { it ->
authService.getAuthorizationRequestIntent(it)
authorizationRequest()?.let { it ->
val intent = authService.getAuthorizationRequestIntent(it)
activity.startActivityForResult(intent, 0)
}
}
@ -208,23 +202,17 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
AuthorizationResponse.fromIntent(authorization)?.let {
val auth = ClientSecretPost(state().clientSecret)
val requestService = service(context)
requestService.performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
if (e != null) {
Log.e("FFA", "performTokenRequest failed: $e")
Log.e("FFA", Log.getStackTraceString(e))
} else {
state.apply {
service(context).performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
state
.apply {
update(response, e)
save()
}
}
if (response != null) success()
else Log.e("FFA", "performTokenRequest() not successful")
}
requestService.dispose()
}
}
}

View File

@ -1,25 +0,0 @@
package audio.funkwhale.ffa.utils
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.util.Log
import android.widget.ImageButton
import androidx.annotation.ColorRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.databinding.BindingAdapter
@BindingAdapter("srcCompat")
fun setImageViewResource(imageView: AppCompatImageView, resource: Any?) = when (resource) {
is Bitmap -> imageView.setImageBitmap(resource)
is Int -> imageView.setImageResource(resource)
is Drawable -> imageView.setImageDrawable(resource)
else -> imageView.setImageDrawable(ColorDrawable(Color.TRANSPARENT))
}
@BindingAdapter("tint")
fun setTint(imageView: ImageButton, @ColorRes resource: Int) = resource.let {
imageView.setColorFilter(resource)
}

View File

@ -1,78 +0,0 @@
package audio.funkwhale.ffa.viewmodel
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.liveData
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import com.google.android.exoplayer2.Player
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class NowPlayingViewModel(app: Application) : AndroidViewModel(app) {
val isBuffering = EventBus.get()
.filter { it is Event.Buffering }
.map { (it as Event.Buffering).value }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
.asLiveData(viewModelScope.coroutineContext)
.distinctUntilChanged()
val isPlaying = EventBus.get()
.filter { it is Event.StateChanged }
.map { (it as Event.StateChanged).playing }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
.asLiveData(viewModelScope.coroutineContext)
.distinctUntilChanged()
val repeatMode = MutableLiveData(0)
val progress = MutableLiveData(0)
val currentTrack = MutableLiveData<Track?>(null)
val currentProgressText = MutableLiveData("")
val currentDurationText = MutableLiveData("")
// Calling distinctUntilChanged() prevents triggering an event when the track hasn't changed
val currentTrackTitle = currentTrack.distinctUntilChanged().map { it?.title ?: "" }
val currentTrackArtist = currentTrack.distinctUntilChanged().map { it?.artist?.name ?: "" }
// Not calling distinctUntilChanged() here as we need to process every event
val isCurrentTrackFavorite = currentTrack.map {
it?.favorite ?: false
}
val repeatModeResource = repeatMode.distinctUntilChanged().map {
when (it) {
Player.REPEAT_MODE_ONE -> AppCompatResources.getDrawable(context, R.drawable.repeat_one)
else -> AppCompatResources.getDrawable(context, R.drawable.repeat)
}
}
val repeatModeAlpha = repeatMode.distinctUntilChanged().map {
when (it) {
Player.REPEAT_MODE_OFF -> 0.2f
else -> 1f
}
}
private val context: Context
get() = getApplication<FFA>().applicationContext
}

View File

@ -1,118 +0,0 @@
package audio.funkwhale.ffa.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.AlbumsSearchRepository
import audio.funkwhale.ffa.repositories.ArtistsSearchRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.repositories.TracksSearchRepository
import audio.funkwhale.ffa.utils.mergeWith
import audio.funkwhale.ffa.utils.untilNetwork
import kotlinx.coroutines.Dispatchers
import java.net.URLEncoder
import java.util.Locale
class SearchViewModel(app: Application) : AndroidViewModel(app), Observer<String> {
private val artistResultsLoading = MutableLiveData(false)
private val albumResultsLoading = MutableLiveData(false)
private val tackResultsLoading = MutableLiveData(false)
private val artistsRepository =
ArtistsSearchRepository(getApplication<FFA>().applicationContext, "")
private val albumsRepository =
AlbumsSearchRepository(getApplication<FFA>().applicationContext, "")
private val tracksRepository =
TracksSearchRepository(getApplication<FFA>().applicationContext, "")
private val dedupQuery: LiveData<String>
val query = MutableLiveData("")
val artistResults: LiveData<List<Artist>> = MutableLiveData(listOf())
val albumResults: LiveData<List<Album>> = MutableLiveData(listOf())
val trackResults: LiveData<List<Track>> = MutableLiveData(listOf())
val isLoadingData: LiveData<Boolean> = artistResultsLoading.mergeWith(
albumResultsLoading, tackResultsLoading
) { b1, b2, b3 -> b1 || b2 || b3 }
val hasResults: LiveData<Boolean> = isLoadingData.mergeWith(
artistResults, albumResults, trackResults
) { b, r1, r2, r3 -> b || r1.isNotEmpty() || r2.isNotEmpty() || r3.isNotEmpty() }
init {
dedupQuery = query.map { it.trim().lowercase(Locale.ROOT) }.distinctUntilChanged()
dedupQuery.observeForever(this)
}
override fun onChanged(token: String) {
if (token.isBlank()) { // Empty search
(artistResults as MutableLiveData).postValue(listOf())
(albumResults as MutableLiveData).postValue(listOf())
(trackResults as MutableLiveData).postValue(listOf())
return
}
artistResultsLoading.postValue(true)
albumResultsLoading.postValue(true)
tackResultsLoading.postValue(true)
val encoded = URLEncoder.encode(token, "UTF-8")
(artistResults as MutableLiveData).postValue(listOf())
artistsRepository.apply {
query = encoded
fetch(Repository.Origin.Network.origin).untilNetwork(
viewModelScope,
Dispatchers.IO
) { data, _, _, hasMore ->
artistResults.postValue(artistResults.value!! + data)
if (!hasMore) {
artistResultsLoading.postValue(false)
}
}
}
(albumResults as MutableLiveData).postValue(listOf())
albumsRepository.apply {
query = encoded
fetch(Repository.Origin.Network.origin).untilNetwork(
viewModelScope,
Dispatchers.IO
) { data, _, _, hasMore ->
albumResults.postValue(albumResults.value!! + data)
if (!hasMore) {
albumResultsLoading.postValue(false)
}
}
}
(trackResults as MutableLiveData).postValue(listOf())
tracksRepository.apply {
query = encoded
fetch(Repository.Origin.Network.origin).untilNetwork(
viewModelScope,
Dispatchers.IO
) { data, _, _, hasMore ->
trackResults.postValue(trackResults.value!! + data)
if (!hasMore) {
tackResultsLoading.postValue(false)
}
}
}
}
override fun onCleared() {
dedupQuery.removeObserver(this)
}
}

View File

@ -1,91 +0,0 @@
package audio.funkwhale.ffa.views
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.cardview.widget.CardView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.use
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.utils.BottomSheetIneractable
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
class NowPlayingBottomSheet @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : CardView(context, attrs, defStyleAttr), BottomSheetIneractable {
private val behavior = BottomSheetBehavior<NowPlayingBottomSheet>()
private val targetHeaderId: Int
val peekHeight get() = behavior.peekHeight
init {
targetHeaderId = context.theme.obtainStyledAttributes(
attrs, R.styleable.NowPlaying, defStyleAttr, 0
).use {
it.getResourceId(R.styleable.NowPlaying_target_header, NO_ID)
}
// Put default peek height to actionBarSize so it is not 0
val tv = TypedValue()
if (context.theme.resolveAttribute(android.R.attr.actionBarSize, tv, true)) {
behavior.peekHeight = TypedValue.complexToDimensionPixelSize(
tv.data, resources.displayMetrics
)
}
}
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
super.setLayoutParams(params)
(params as CoordinatorLayout.LayoutParams).behavior = behavior
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
findViewById<View>(targetHeaderId)?.apply {
behavior.setPeekHeight(this.height, false)
this.setOnClickListener { this@NowPlayingBottomSheet.toggle() }
} ?: hide()
}
override fun onTouchEvent(event: MotionEvent): Boolean = true
fun addBottomSheetCallback(callback: BottomSheetCallback) {
behavior.addBottomSheetCallback(callback)
}
// Bottom sheet interactions
override val isHidden: Boolean get() = behavior.state == BottomSheetBehavior.STATE_HIDDEN
override fun isOpen(): Boolean = behavior.state == BottomSheetBehavior.STATE_EXPANDED
override fun open() {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
override fun close() {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
override fun show() {
behavior.isHideable = false
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
close()
}
}
override fun hide() {
behavior.isHideable = true
behavior.state = BottomSheetBehavior.STATE_HIDDEN
}
override fun toggle() {
if (isHidden) return
if (isOpen) close() else open()
}
}

View File

@ -0,0 +1,255 @@
package audio.funkwhale.ffa.views
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewTreeObserver
import android.view.animation.DecelerateInterpolator
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.PartialNowPlayingBinding
import com.google.android.material.card.MaterialCardView
import kotlin.math.abs
import kotlin.math.min
class NowPlayingView : MaterialCardView {
val activity: Context
var gestureDetector: GestureDetector? = null
var gestureDetectorCallback: OnGestureDetection? = null
private val binding =
PartialNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
constructor(context: Context) : super(context) {
activity = context
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
activity = context
}
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) {
activity = context
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
binding.nowPlayingRoot.measure(
widthMeasureSpec,
MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED)
)
}
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (visibility == View.VISIBLE && gestureDetector == null) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
gestureDetectorCallback = OnGestureDetection()
gestureDetector = GestureDetector(context, gestureDetectorCallback)
setOnTouchListener { _, motionEvent ->
val ret = gestureDetector?.onTouchEvent(motionEvent) ?: false
if (motionEvent.actionMasked == MotionEvent.ACTION_UP) {
if (gestureDetectorCallback?.isScrolling == true) {
gestureDetectorCallback?.onUp()
}
}
performClick()
ret
}
viewTreeObserver.removeOnGlobalLayoutListener(this)
}
})
}
}
fun isOpened(): Boolean = gestureDetectorCallback?.isOpened() ?: false
fun close() {
gestureDetectorCallback?.close()
}
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
private var maxHeight = 0
private var minHeight = 0
private var maxMargin = 0
private var initialTouchY = 0f
private var lastTouchY = 0f
var isScrolling = false
private var flingAnimator: ValueAnimator? = null
init {
(layoutParams as? MarginLayoutParams)?.let {
maxMargin = it.marginStart
}
minHeight = TypedValue().let {
activity.theme.resolveAttribute(R.attr.actionBarSize, it, true)
TypedValue.complexToDimensionPixelSize(it.data, resources.displayMetrics)
}
maxHeight = binding.nowPlayingDetails.measuredHeight + (2 * maxMargin)
}
override fun onDown(e: MotionEvent): Boolean {
initialTouchY = e.rawY
lastTouchY = e.rawY
return true
}
fun onUp(): Boolean {
isScrolling = false
layoutParams.let {
val offsetToMax = maxHeight - height
val offsetToMin = height - minHeight
flingAnimator =
if (offsetToMin < offsetToMax) ValueAnimator.ofInt(it.height, minHeight)
else ValueAnimator.ofInt(it.height, maxHeight)
animateFling(500)
return true
}
}
override fun onFling(
firstMotionEvent: MotionEvent?,
secondMotionEvent: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
isScrolling = false
layoutParams.let {
val diff =
if (velocityY < 0) maxHeight - it.height
else it.height - minHeight
flingAnimator =
if (velocityY < 0) ValueAnimator.ofInt(it.height, maxHeight)
else ValueAnimator.ofInt(it.height, minHeight)
animateFling(min(abs((diff.toFloat() / velocityY * 1000).toLong()), 600))
}
return true
}
override fun onScroll(
firstMotionEvent: MotionEvent,
secondMotionEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
isScrolling = true
layoutParams.let {
val newHeight = it.height + lastTouchY - secondMotionEvent.rawY
val progress = (newHeight - minHeight) / (maxHeight - minHeight)
val newMargin = maxMargin - (maxMargin * progress)
(layoutParams as? MarginLayoutParams)?.let { params ->
params.marginStart = newMargin.toInt()
params.marginEnd = newMargin.toInt()
params.bottomMargin = newMargin.toInt()
}
layoutParams = layoutParams.apply {
when {
newHeight <= minHeight -> {
height = minHeight
return true
}
newHeight >= maxHeight -> {
height = maxHeight
return true
}
else -> height = newHeight.toInt()
}
}
binding.summary.alpha = 1f - progress
binding.summary.layoutParams = binding.summary.layoutParams.apply {
height = (minHeight * (1f - progress)).toInt()
}
}
lastTouchY = secondMotionEvent.rawY
return true
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
layoutParams.let {
if (height != minHeight) return true
flingAnimator = ValueAnimator.ofInt(it.height, maxHeight)
animateFling(300)
}
return true
}
fun isOpened(): Boolean = layoutParams.height == maxHeight
fun close(): Boolean {
layoutParams.let {
if (it.height == minHeight) return true
flingAnimator = ValueAnimator.ofInt(it.height, minHeight)
animateFling(300)
}
return true
}
private fun animateFling(dur: Long) {
flingAnimator?.apply {
duration = dur
interpolator = DecelerateInterpolator()
addUpdateListener { valueAnimator ->
layoutParams = layoutParams.apply {
val newHeight = valueAnimator.animatedValue as Int
val progress = (newHeight.toFloat() - minHeight) / (maxHeight - minHeight)
val newMargin = maxMargin - (maxMargin * progress)
(layoutParams as? MarginLayoutParams)?.let {
it.marginStart = newMargin.toInt()
it.marginEnd = newMargin.toInt()
it.bottomMargin = newMargin.toInt()
}
height = newHeight
binding.summary.alpha = 1f - progress
binding.summary.layoutParams = binding.summary.layoutParams.apply {
height = (minHeight * (1f - progress)).toInt()
}
}
}
start()
}
}
}
}

View File

@ -0,0 +1,17 @@
package audio.funkwhale.ffa.views
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
class SquareImageView : AppCompatImageView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(measuredWidth, measuredWidth)
}
}

View File

@ -1,36 +0,0 @@
package audio.funkwhale.ffa.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.AppCompatImageButton
import androidx.appcompat.widget.AppCompatImageView
open class SquareView : View {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth
setMeasuredDimension(dimension, dimension)
}
}
open class SquareImageView : AppCompatImageView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth
setMeasuredDimension(dimension, dimension)
}
}

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:startOffset="@integer/transitionDuration"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:duration="@integer/transitionDuration"
/>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:duration="@integer/transitionDuration"
/>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:toAlpha="0.0"
android:fromAlpha="1.0"
android:duration="@integer/transitionDuration"
/>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromAlpha="1.0"
android:toAlpha="1.0"
/>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromYDelta="0"
android:toYDelta="100%"
android:duration="@integer/transitionDuration"
/>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromYDelta="100%"
android:toYDelta="0"
android:duration="@integer/transitionDuration"
/>

View File

@ -1,4 +0,0 @@
<vector android:height="24dp" android:viewportHeight="48"
android:viewportWidth="48" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="m4.05,44 l40,-40v40ZM34.3,41h6.75L41.05,11.2l-6.75,6.75Z"/>
</vector>

View File

@ -1,6 +0,0 @@
<vector android:height="24dp" android:viewportHeight="48"
android:viewportWidth="48" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M44.051,4L4.051,44L17.098,44L17.098,32.91L34.301,32.91L34.301,17.949L41.051,11.199L41.051,32.91L44.051,32.91L44.051,4z"/>
<path android:fillColor="#FF000000"
android:pathData="M17.873,33.639L17.873,47.316L47.16,47.316L47.16,33.639L17.873,33.639zM20.283,35.08L21.391,35.08L21.391,38.76C21.897,37.995 22.615,37.613 23.549,37.613C24.473,37.613 25.203,37.942 25.736,38.6C26.27,39.257 26.537,40.15 26.537,41.279C26.537,42.435 26.26,43.364 25.709,44.066C25.158,44.76 24.421,45.105 23.496,45.105C22.545,45.105 21.808,44.706 21.283,43.906L21.283,44.799L20.283,44.799L20.283,35.08zM35.256,35.893L36.363,35.893L36.363,37.813L37.51,37.813L37.51,38.719L36.363,38.719L36.363,43.506C36.363,43.755 36.402,43.923 36.482,44.012C36.571,44.092 36.737,44.133 36.977,44.133C37.199,44.133 37.376,44.116 37.51,44.08L37.51,45.012C37.163,45.074 36.861,45.105 36.604,45.105C36.168,45.105 35.835,45.008 35.604,44.813C35.372,44.626 35.256,44.356 35.256,44L35.256,38.719L34.311,38.719L34.311,37.813L35.256,37.813L35.256,35.893zM30.537,37.613C32.608,37.613 33.643,38.969 33.643,41.68L28.496,41.68C28.514,42.409 28.701,42.99 29.057,43.426C29.421,43.861 29.918,44.08 30.549,44.08C31.455,44.08 32.066,43.613 32.377,42.68L33.496,42.68C33.354,43.444 33.021,44.04 32.496,44.467C31.972,44.893 31.31,45.105 30.51,45.105C29.532,45.105 28.758,44.777 28.189,44.119C27.621,43.452 27.336,42.545 27.336,41.398C27.336,40.252 27.625,39.337 28.203,38.652C28.79,37.959 29.568,37.613 30.537,37.613zM41.283,37.613C42.145,37.613 42.798,37.777 43.242,38.105C43.687,38.425 43.91,38.897 43.91,39.52L43.91,43.625C43.91,43.989 44.11,44.172 44.51,44.172C44.59,44.172 44.67,44.164 44.75,44.146L44.75,44.986C44.439,45.066 44.186,45.105 43.99,45.105C43.635,45.105 43.362,45.022 43.176,44.854C42.998,44.694 42.888,44.436 42.844,44.08C42.097,44.765 41.304,45.105 40.469,45.105C39.767,45.105 39.207,44.92 38.789,44.547C38.38,44.174 38.176,43.67 38.176,43.039C38.176,42.835 38.195,42.647 38.23,42.479C38.275,42.31 38.319,42.164 38.363,42.039C38.417,41.906 38.504,41.786 38.629,41.68C38.753,41.564 38.856,41.47 38.936,41.398C39.024,41.327 39.168,41.261 39.363,41.199C39.568,41.128 39.723,41.079 39.83,41.053C39.937,41.017 40.124,40.978 40.391,40.934C40.657,40.889 40.852,40.858 40.977,40.84C41.101,40.822 41.323,40.791 41.643,40.746C42.078,40.693 42.38,40.608 42.549,40.492C42.718,40.377 42.803,40.204 42.803,39.973L42.803,39.68C42.803,39.342 42.666,39.084 42.391,38.906C42.124,38.728 41.74,38.639 41.242,38.639C40.727,38.639 40.337,38.741 40.07,38.945C39.804,39.141 39.648,39.452 39.604,39.879L38.482,39.879C38.536,38.368 39.47,37.613 41.283,37.613zM30.523,38.639C29.963,38.639 29.501,38.835 29.137,39.227C28.772,39.609 28.568,40.125 28.523,40.773L32.457,40.773C32.457,40.169 32.275,39.661 31.91,39.252C31.546,38.843 31.083,38.639 30.523,38.639zM23.336,38.652C22.749,38.652 22.279,38.901 21.924,39.398C21.568,39.887 21.391,40.542 21.391,41.359C21.391,42.177 21.568,42.834 21.924,43.332C22.279,43.821 22.749,44.066 23.336,44.066C23.94,44.066 24.429,43.821 24.803,43.332C25.185,42.834 25.377,42.19 25.377,41.398C25.377,40.563 25.19,39.896 24.816,39.398C24.452,38.901 23.958,38.652 23.336,38.652zM42.803,41.346C42.581,41.452 42.242,41.542 41.789,41.613C41.345,41.684 40.958,41.745 40.629,41.799C40.3,41.852 40.003,41.981 39.736,42.186C39.47,42.381 39.336,42.656 39.336,43.012C39.336,43.367 39.457,43.644 39.697,43.84C39.937,44.035 40.273,44.133 40.709,44.133C41.322,44.133 41.826,43.972 42.217,43.652C42.608,43.323 42.803,42.973 42.803,42.6L42.803,41.346z" android:strokeWidth="0.935758"/>
</vector>

View File

@ -1,67 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.coordinatorlayout.widget.CoordinatorLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/appbar">
<LinearLayout
android:id="@+id/nav_host_fragment_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:baselineAligned="false"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/container"
android:layout_width="0dp"
android:layout_height="match_parent"
android:orientation="horizontal">
android:layout_marginBottom="?attr/actionBarSize"
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
app:defaultNavHost="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:navGraph="@navigation/main_nav"
tools:layout="@layout/fragment_artists" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/landscape_queue"
android:name="audio.funkwhale.ffa.fragments.LandscapeQueueFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:layout="@layout/partial_queue" />
</LinearLayout>
<audio.funkwhale.ffa.views.NowPlayingBottomSheet
android:id="@+id/now_playing_bottom_sheet"
android:layout_width="match_parent"
<FrameLayout
android:id="@+id/landscape_queue"
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="@color/elevatedSurface"
app:cardElevation="8dp"
app:target_header="@id/constraint_layout_placeholder">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/now_playing"
android:name="audio.funkwhale.ffa.fragments.NowPlayingFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout="@layout/fragment_now_playing" />
</audio.funkwhale.ffa.views.NowPlayingBottomSheet>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
android:layout_marginBottom="?attr/actionBarSize"
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<androidx.appcompat.widget.Toolbar
android:id="@+id/appbar"
</LinearLayout>
<audio.funkwhale.ffa.views.NowPlayingView
android:id="@+id/now_playing"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_constraintBottom_toBottomOf="parent"
android:theme="@style/AppTheme.AppBar"
app:navigationIcon="@drawable/funkwhaleshape"
tools:menu="@menu/toolbar"
android:layout_gravity="bottom"
android:layout_margin="8dp"
android:alpha="0"
android:visibility="gone"
app:cardCornerRadius="8dp"
app:cardElevation="12dp"
app:layout_dodgeInsetEdges="bottom"
tools:alpha="1"
tools:visibility="visible">
/>
</androidx.constraintlayout.widget.ConstraintLayout>
<include layout="@layout/partial_now_playing" />
</audio.funkwhale.ffa.views.NowPlayingView>
<com.google.android.material.bottomappbar.BottomAppBar
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:theme="@style/AppTheme.AppBar"
app:backgroundTint="@color/colorPrimaryDark"
app:layout_insetEdge="bottom"
app:navigationIcon="@drawable/funkwhaleshape"
tools:menu="@menu/toolbar" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="androidx.lifecycle.LiveData" />
<import type="android.view.View" />
<import type="android.graphics.drawable.Drawable" />
<variable name="isBuffering" type="LiveData&lt;Boolean>" />
<variable name="isPlaying" type="LiveData&lt;Boolean>" />
<variable name="progress" type="LiveData&lt;Integer>" />
<variable name="currentTrackTitle" type="LiveData&lt;String>" />
<variable name="currentTrackArtist" type="LiveData&lt;String>" />
</data>
<androidx.constraintlayout.motion.widget.MotionLayout
android:id="@+id/now_playing_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/fragment_now_playing_scene">
<include android:id="@+id/header" layout="@layout/partial_now_playing_header" />
<audio.funkwhale.ffa.views.SquareView
android:id="@+id/detail_image_placeholder"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/now_playing_progress"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<ImageButton
android:id="@+id/now_playing_details_info"
style="@style/IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="8dp"
android:background="@drawable/circle"
android:contentDescription="@string/alt_track_info"
android:src="@drawable/more"
app:layout_constraintEnd_toEndOf="@id/detail_image_placeholder"
app:layout_constraintTop_toTopOf="@id/detail_image_placeholder"
app:tint="@color/controlForeground"
/>
<include
android:id="@+id/controls"
layout="@layout/partial_now_playing_controls"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
app:layout_constraintTop_toTopOf="@id/detail_image_placeholder"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/detail_image_placeholder"
android:alpha="0"
android:background="@color/elevatedSurface"
/>
</androidx.constraintlayout.motion.widget.MotionLayout>
</layout>

View File

@ -0,0 +1,251 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/now_playing_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/elevatedSurface"
android:orientation="vertical">
<LinearLayout
android:id="@+id/summary"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="vertical">
<ProgressBar
android:id="@+id/now_playing_progress"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-6dp"
android:layout_marginBottom="-6dp"
android:progress="40"
android:progressTint="@color/colorPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
android:layout_marginEnd="16dp">
<audio.funkwhale.ffa.views.SquareImageView
android:id="@+id/now_playing_cover"
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
tools:src="@tools:sample/avatars" />
<ProgressBar
android:id="@+id/now_playing_buffering"
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
android:indeterminate="true"
android:indeterminateTint="@color/controlForeground"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="2"
android:orientation="vertical">
<TextView
android:id="@+id/now_playing_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/itemTitle"
tools:text="Supermassive Black Hole" />
<TextView
android:id="@+id/now_playing_album"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Muse" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/now_playing_toggle"
style="@style/AppTheme.OutlinedButton"
android:layout_width="?attr/actionBarSize"
android:layout_height="match_parent"
android:layout_marginEnd="16dp"
app:icon="@drawable/play" />
<ImageButton
android:id="@+id/now_playing_next"
style="@style/IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/control_next"
android:src="@drawable/next" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/now_playing_details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="32dp"
android:paddingTop="16dp"
android:paddingEnd="32dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/now_playing_details_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/itemTitle"
android:textSize="18sp"
tools:text="Supermassive Black Hole" />
<TextView
android:id="@+id/now_playing_details_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Muse" />
</LinearLayout>
<ImageButton
android:id="@+id/now_playing_details_add_to_playlist"
style="@style/IconButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:contentDescription="@string/alt_album_cover"
android:src="@drawable/add_to_playlist" />
<ImageButton
android:id="@+id/now_playing_details_favorite"
style="@style/IconButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:contentDescription="@string/alt_album_cover"
android:src="@drawable/favorite" />
<ImageButton
android:id="@+id/now_playing_details_info"
style="@style/IconButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:contentDescription="@string/alt_track_info"
android:src="@drawable/more"
app:tint="@color/controlForeground" />
</LinearLayout>
<SeekBar
android:id="@+id/now_playing_details_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:max="100"
android:progressBackgroundTint="#cacaca"
android:progressTint="@color/controlForeground"
android:thumbOffset="3dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:thumbTint="@color/controlForeground" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/now_playing_details_progress_current"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/now_playing_details_progress_duration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAlignment="textEnd" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/now_playing_details_previous"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/control_previous"
android:src="@drawable/previous" />
<com.google.android.material.button.MaterialButton
android:id="@+id/now_playing_details_toggle"
style="@style/AppTheme.OutlinedButton"
android:layout_width="64dp"
android:layout_height="64dp"
app:cornerRadius="64dp"
app:icon="@drawable/play"
app:iconSize="32dp" />
<ImageButton
android:id="@+id/now_playing_details_next"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:contentDescription="@string/control_next"
android:src="@drawable/next" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/now_playing_details_repeat"
style="@style/IconButton"
android:layout_width="28dp"
android:layout_height="28dp"
android:contentDescription="@string/control_next"
android:src="@drawable/repeat" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -1,58 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.coordinatorlayout.widget.CoordinatorLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/appbar">
android:layout_height="match_parent"
android:background="@color/surface">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/nav_host_fragment_wrapper">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
<audio.funkwhale.ffa.views.DisableableFrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:navGraph="@navigation/main_nav"
tools:layout="@layout/fragment_artists"
/>
</FrameLayout>
android:layout_marginBottom="?attr/actionBarSize"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<audio.funkwhale.ffa.views.NowPlayingBottomSheet
android:id="@+id/now_playing_bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="@color/elevatedSurface"
app:cardElevation="16dp"
app:target_header="@id/constraint_layout_placeholder">
<androidx.fragment.app.FragmentContainerView
<audio.funkwhale.ffa.views.NowPlayingView
android:id="@+id/now_playing"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="audio.funkwhale.ffa.fragments.NowPlayingFragment"
tools:layout="@layout/fragment_now_playing"
/>
</audio.funkwhale.ffa.views.NowPlayingBottomSheet>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
android:layout_height="?attr/actionBarSize"
android:layout_gravity="bottom"
android:layout_margin="8dp"
android:alpha="0"
android:visibility="gone"
app:cardCornerRadius="3dp"
app:cardElevation="12dp"
app:layout_dodgeInsetEdges="bottom"
tools:alpha="1"
tools:visibility="visible">
<androidx.appcompat.widget.Toolbar
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_constraintBottom_toBottomOf="parent"
android:theme="@style/AppTheme.AppBar"
android:background="@color/elevatedSurface"
app:navigationIcon="@drawable/funkwhaleshape"
tools:menu="@menu/toolbar"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
<include
android:id="@+id/now_playing_container"
layout="@layout/partial_now_playing" />
</audio.funkwhale.ffa.views.NowPlayingView>
<com.google.android.material.bottomappbar.BottomAppBar
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:theme="@style/AppTheme.AppBar"
app:backgroundTint="@color/elevatedSurface"
app:layout_insetEdge="bottom"
app:navigationIcon="@drawable/funkwhaleshape"
tools:menu="@menu/toolbar" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:context=".activities.SearchActivity">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="0dp"
android:elevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.SearchView
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:iconifiedByDefault="false"
app:queryBackground="@android:color/transparent"
app:queryHint="@string/search_placeholder" />
<ProgressBar
android:id="@+id/search_spinner"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-12dp"
android:layout_marginBottom="-12dp"
android:indeterminate="true"
android:visibility="invisible" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/search_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:drawablePadding="16dp"
android:text="@string/search_welcome"
android:textAlignment="center"
android:textSize="14sp"
app:drawableTint="#525252"
app:drawableTopCompat="@drawable/funkwhaleshape" />
<TextView
android:id="@+id/search_no_results"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:drawableTopCompat="@drawable/funkwhaleshape"
android:drawablePadding="16dp"
app:drawableTint="#525252"
android:text="@string/search_no_results"
android:textAlignment="center"
android:textSize="14sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/results"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="10"
tools:listitem="@layout/row_track" />
</LinearLayout>
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -17,7 +17,7 @@
app:tabSelectedTextColor="@color/controlColor"
app:tabTextColor="@color/colorPrimary" />
<androidx.viewpager2.widget.ViewPager2
<androidx.viewpager.widget.ViewPager
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@ -39,27 +39,14 @@
android:clipChildren="false"
app:layout_collapseMode="parallax">
<EditText
android:id="@+id/filter_tracks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/favorites_title"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:ems="10"
android:inputType="text"
android:hint="@string/filters" />
<TextView
style="@style/AppTheme.Title"
android:id="@+id/favorites_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="64dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="6dp"
android:layout_marginBottom="16dp"
android:text="@string/favorites" />
<com.google.android.material.button.MaterialButton

View File

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="androidx.lifecycle.LiveData" />
<import type="android.view.View" />
<import type="android.graphics.drawable.Drawable" />
<variable name="isBuffering" type="LiveData&lt;Boolean>" />
<variable name="isPlaying" type="LiveData&lt;Boolean>" />
<variable name="progress" type="LiveData&lt;Integer>" />
<variable name="currentTrackTitle" type="LiveData&lt;String>" />
<variable name="currentTrackArtist" type="LiveData&lt;String>" />
</data>
<androidx.constraintlayout.motion.widget.MotionLayout
android:id="@+id/now_playing_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/fragment_now_playing_scene">
<include
android:id="@+id/header"
layout="@layout/partial_now_playing_header"
/>
<audio.funkwhale.ffa.views.SquareView
android:id="@+id/detail_image_placeholder"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/now_playing_progress"
/>
<ImageButton
android:id="@+id/now_playing_details_info"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="8dp"
app:layout_constraintEnd_toEndOf="@id/detail_image_placeholder"
app:layout_constraintTop_toTopOf="@id/detail_image_placeholder"
style="@style/IconButton"
android:background="@drawable/circle"
android:contentDescription="@string/alt_track_info"
android:src="@drawable/more"
app:tint="@color/controlForeground"
/>
<include
android:id="@+id/controls"
layout="@layout/partial_now_playing_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/detail_image_placeholder"
/>
</androidx.constraintlayout.motion.widget.MotionLayout>
</layout>

View File

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="androidx.lifecycle.LiveData" />
<import type="android.view.View" />
<variable name="noSearchYet" type="LiveData&lt;Boolean>" />
<variable name="isLoadingData" type="LiveData&lt;Boolean>" />
<variable name="hasResults" type="LiveData&lt;Boolean>" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/surface">
<LinearLayout
android:id="@+id/search_bar_and_messages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="0dp"
android:elevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.SearchView
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:iconifiedByDefault="false"
app:queryBackground="@android:color/transparent"
app:queryHint="@string/search_placeholder" />
<ProgressBar
android:id="@+id/search_spinner"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-12dp"
android:layout_marginBottom="-12dp"
android:indeterminate="true"
android:visibility="@{isLoadingData ? View.VISIBLE : View.INVISIBLE, default=invisible}" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/search_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/search_welcome"
android:textAlignment="center"
android:textSize="14sp"
android:visibility="@{noSearchYet ? View.VISIBLE : View.GONE, default=visible}"
app:drawableTint="#525252"
app:drawableTopCompat="@drawable/funkwhaleshape" />
<TextView
android:id="@+id/search_no_results"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/search_no_results"
android:textAlignment="center"
android:textSize="14sp"
android:visibility="@{noSearchYet || hasResults ? View.GONE : View.VISIBLE, default=gone}"
app:drawableTint="#525252"
app:drawableTopCompat="@drawable/funkwhaleshape" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/results"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/surface"
app:layout_constraintTop_toBottomOf="@+id/search_bar_and_messages"
app:layout_constraintBottom_toBottomOf="parent"
tools:itemCount="10"
tools:listitem="@layout/row_track" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
</merge>

View File

@ -0,0 +1,283 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/now_playing_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/elevatedSurface"
android:orientation="vertical">
<LinearLayout
android:id="@+id/summary"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="vertical">
<ProgressBar
android:id="@+id/now_playing_progress"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-6dp"
android:layout_marginBottom="-6dp"
android:progress="40"
android:progressTint="@color/colorPrimaryDark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
android:layout_marginEnd="16dp">
<audio.funkwhale.ffa.views.SquareImageView
android:id="@+id/now_playing_cover"
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
tools:src="@tools:sample/avatars" />
<ProgressBar
android:id="@+id/now_playing_buffering"
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
android:indeterminate="true"
android:indeterminateTint="@color/controlForeground"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="8dp"
android:layout_weight="2"
android:orientation="vertical">
<TextView
android:id="@+id/now_playing_title"
style="@style/AppTheme.ItemTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
tools:text="Supermassive Black Hole" />
<TextView
android:id="@+id/now_playing_album"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
tools:text="Muse" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/now_playing_toggle"
style="@style/AppTheme.OutlinedButton"
android:layout_width="?attr/actionBarSize"
android:layout_height="match_parent"
android:layout_marginEnd="16dp"
app:icon="@drawable/play" />
<ImageButton
android:id="@+id/now_playing_next"
style="@style/IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/control_next"
android:src="@drawable/next" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/now_playing_details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="8dp">
<audio.funkwhale.ffa.views.SquareImageView
android:id="@+id/now_playing_details_cover"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:adjustViewBounds="true"
android:src="@drawable/funkwhaleshape"
tools:src="@tools:sample/avatars" />
<ImageButton
android:id="@+id/now_playing_details_info"
style="@style/IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:background="@drawable/circle"
android:contentDescription="@string/alt_track_info"
android:src="@drawable/more"
app:tint="@color/controlForeground" />
</FrameLayout>
<LinearLayout
android:id="@+id/now_playing_details_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:orientation="vertical"
android:paddingTop="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/now_playing_details_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/itemTitle"
android:textSize="18sp"
tools:text="Supermassive Black Hole" />
<TextView
android:id="@+id/now_playing_details_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Muse" />
</LinearLayout>
<ImageButton
android:id="@+id/now_playing_details_add_to_playlist"
style="@style/IconButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:contentDescription="@string/playlist_add_to"
android:src="@drawable/add_to_playlist" />
<ImageButton
android:id="@+id/now_playing_details_favorite"
style="@style/IconButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:contentDescription="@string/alt_album_cover"
android:src="@drawable/favorite" />
</LinearLayout>
<SeekBar
android:id="@+id/now_playing_details_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:max="100"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:progressBackgroundTint="#cacaca"
android:progressTint="@color/controlForeground"
android:thumbOffset="3dp"
android:thumbTint="@color/controlForeground" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/now_playing_details_progress_current"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/now_playing_details_progress_duration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAlignment="textEnd" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/now_playing_details_previous"
style="@style/IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/control_previous"
android:src="@drawable/previous" />
<com.google.android.material.button.MaterialButton
android:id="@+id/now_playing_details_toggle"
style="@style/AppTheme.OutlinedButton"
android:layout_width="64dp"
android:layout_height="64dp"
app:cornerRadius="64dp"
app:icon="@drawable/play"
app:iconSize="32dp" />
<ImageButton
android:id="@+id/now_playing_details_next"
style="@style/IconButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="16dp"
android:contentDescription="@string/control_next"
android:src="@drawable/next" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/now_playing_details_repeat"
style="@style/IconButton"
android:layout_width="28dp"
android:layout_height="28dp"
android:contentDescription="@string/control_next"
android:src="@drawable/repeat" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -1,161 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="androidx.lifecycle.LiveData" />
<import type="android.graphics.drawable.Drawable" />
<variable name="currentTrackTitle" type="LiveData&lt;String>" />
<variable name="currentTrackArtist" type="LiveData&lt;String>" />
<variable name="isCurrentTrackFavorite" type="LiveData&lt;Boolean>" />
<variable name="repeatModeResource" type="LiveData&lt;Drawable>" />
<variable name="repeatModeAlpha" type="LiveData&lt;Float>" />
<variable name="currentProgressText" type="LiveData&lt;String>" />
<variable name="currentDurationText" type="LiveData&lt;String>" />
<variable name="isPlaying" type="LiveData&lt;Boolean>" />
<variable name="progress" type="LiveData&lt;Integer>" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0">
<TextView
android:id="@+id/current_playing_details_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="@{currentTrackTitle}"
android:textColor="@color/itemTitle"
android:textSize="18sp"
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_add_to_playlist"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" tools:text="Supermassive Black Hole" />
<TextView
android:id="@+id/current_playing_details_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="@{currentTrackArtist}"
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_add_to_playlist"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/current_playing_details_title"
tools:text="Muse" />
<ImageButton
android:id="@+id/now_playing_details_add_to_playlist"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="8dp"
android:contentDescription="@string/playlist_add_to"
android:src="@drawable/add_to_playlist"
app:layout_constraintBottom_toBottomOf="@+id/current_playing_details_artist"
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_favorite"
app:layout_constraintTop_toTopOf="@+id/current_playing_details_title" />
<ImageButton
android:id="@+id/now_playing_details_favorite"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="8dp"
android:contentDescription="@string/control_add_to_favorites"
android:src="@drawable/favorite"
app:layout_constraintBottom_toBottomOf="@+id/current_playing_details_artist"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/current_playing_details_title"
app:tint="@{isCurrentTrackFavorite ? @color/colorFavorite : @color/controlForeground, default=@color/controlForeground}" />
<TextView
android:id="@+id/now_playing_details_progress_current"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text='@{currentProgressText, default="5:04"}'
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_progress"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/now_playing_details_progress" />
<SeekBar
android:id="@+id/now_playing_details_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:max="100"
android:progress="@{progress, default=40}"
android:progressBackgroundTint="#cacaca"
android:progressTint="@color/controlForeground"
android:thumbOffset="3dp"
android:thumbTint="@color/controlForeground"
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_progress_duration"
app:layout_constraintStart_toEndOf="@+id/now_playing_details_progress_current"
app:layout_constraintTop_toBottomOf="@+id/current_playing_details_artist" />
<TextView
android:id="@+id/now_playing_details_progress_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text='@{currentDurationText, default="5:04"}'
android:textAlignment="textEnd"
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/now_playing_details_progress" />
<ImageButton
android:id="@+id/now_playing_details_previous"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="8dp"
android:contentDescription="@string/control_previous"
android:src="@drawable/previous"
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_toggle"
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_toggle"
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress" />
<com.google.android.material.button.MaterialButton
android:id="@+id/now_playing_details_toggle"
style="@style/AppTheme.OutlinedButton"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_margin="8dp"
app:cornerRadius="64dp"
app:icon="@{isPlaying ? @drawable/pause : @drawable/play, default=@drawable/play}"
app:iconSize="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress" />
<ImageButton
android:id="@+id/now_playing_details_next"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="8dp"
android:contentDescription="@string/control_next"
android:src="@drawable/next"
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_toggle"
app:layout_constraintStart_toEndOf="@+id/now_playing_details_toggle"
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress" />
<ImageButton
android:id="@+id/now_playing_details_repeat"
style="@style/IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="8dp"
android:alpha="@{repeatModeAlpha, default=1}"
android:contentDescription="@string/control_repeat_mode"
android:src="@{repeatModeResource, default=@drawable/repeat}"
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_toggle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress"
app:tint="@color/controlForeground" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Some files were not shown because too many files have changed in this diff Show More