Compare commits

...

376 Commits

Author SHA1 Message Date
Umut Solmaz 2d705609df Translated using Weblate (Turkish)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/tr/
2024-05-07 15:50:34 +00:00
josé m 5de5adfda0 Translated using Weblate (Galician)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/gl/
2024-05-07 15:50:30 +00:00
Umut Solmaz 93123f2df6 Added translation using Weblate (Turkish) 2024-05-06 14:36:45 +00:00
Bruno-Van-den-Bosch 52a45bc5df Translated using Weblate (Dutch)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/nl/
2024-04-12 13:50:30 +00:00
Renovate Bot bea9c5b75a chore(deps): update dependency io.insert-koin:koin-test to v3.5.3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/347>
2024-02-22 19:01:22 +00:00
Renovate Bot 3ebf11a6f6 chore(deps): update dependency io.insert-koin:koin-core to v3.5.3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/346>
2024-02-22 18:24:22 +00:00
Hugh Daschbach 5ee798abfb Remember hostname of last login.
Seed the login screen with saved host name, checkboxes.

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/342>
2024-02-22 18:15:04 +00:00
Hugh Daschbach 6f24535b79 Auto logout on unrecoverable authentication error.
When an unrecoverable authentication error occurs, automatically log
the user out.  This seems better than leaving the user wondering why
the UI is unresponsive or why each track they try to play fails with a
quickly disappearing toast.

Unrecoverable authentication errors typically mean the server has
timed out the session out or the session token has been deleted on the
server.  Tokens expire after 14 days without use.

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/342>
2024-02-22 18:15:04 +00:00
Hugh Daschbach 467556d75c Suppress some authentication noise in the log.
Most of this was added to debug issue !102.  So these are vestigial.

The exception here is the handling of AuthorizationException type 2.
These are produced by racing authentication requests and are
successfully managed.  So we need not report these.

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/342>
2024-02-22 18:15:04 +00:00
Renovate Bot 10035aa5fe chore(deps): update dependency io.insert-koin:koin-android to v3.5.3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/345>
2024-02-22 09:44:21 +00:00
Georg Krause 8b9a1201af fix(copy): Use correct spelling of favorites
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/344>
2024-02-22 09:35:03 +00:00
Hugh Daschbach 2088e06a68 Do not close NowPlayingBottomSheet between tracks.
If the user opens the NowPlayingBottomSheet whilst playing a non empty
queue, leave the BottomSheet open at the end of the playing track.

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/343>
2024-02-21 21:59:28 -08:00
Renovate Bot 22a72d9e83 chore(deps): update dependency androidx.preference:preference-ktx to v1.2.1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/merge_requests/341>
2024-01-25 22:44:39 +00:00
Bruno-Van-den-Bosch 2bdf904804 Translated using Weblate (Dutch)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/nl/
2023-12-30 12:07:45 +00:00
mittwerk 042d6b4d6e Translated using Weblate (Russian)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/ru/
2023-12-30 12:07:44 +00:00
mittwerk 67aa47a4cb Translated using Weblate (Russian)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/ru/
2023-12-17 14:50:32 +00:00
Hugh Daschbach 01c676acd8 Update build tools path.
Gradle now fetches command line tools 30.0.3.
2023-12-14 22:14:31 -08:00
Hugh Daschbach b27e4c85ee Adjustments for Gradle 8 build environment.
Gradle 8 requires JDK version 11.
Update build class path to pick up new Gradle version.
2023-12-14 22:13:18 -08:00
Renovate Bot c061c64c3d chore(deps): update dependency gradle to v8 2023-12-12 13:08:10 +00:00
Georg Krause 554bc0ca5c Update version information for F-Droid 2023-12-12 13:44:40 +01:00
Georg Krause ef54aad835 Update changelog for version 0.3.0 2023-12-12 13:44:40 +01:00
Aitor d23456d334 Translated using Weblate (Basque)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/eu/
2023-12-12 11:50:28 +00:00
Georg Krause fef2d5b05f Added translation using Weblate (Bengali (Bangladesh)) 2023-12-09 12:31:10 +00:00
josé m 64f947aa23 Translated using Weblate (Galician)
Currently translated at 99.2% (126 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/gl/
2023-12-06 03:50:26 +00:00
Thomas 9666cccd5b Translated using Weblate (French)
Currently translated at 100.0% (127 of 127 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/fr/
2023-12-06 03:50:26 +00:00
Hugh Daschbach 36f1c7ba66 Enable landscape mode (auto-rotation). 2023-11-15 11:28:29 -08:00
Georg Krause 1978fc4fb4 Add missing changelog snippets 2023-11-15 07:53:24 +00:00
Hugh Daschbach c9056a2dbe Fix Android 14 authentication breakage.
Workaround to fix issue #148: authentication failure to redirect back
to FFA.

Google issue tracker: https://issuetracker.google.com/issues/210886001

Workaround suggested by AppAuth:
https://github.com/openid/AppAuth-Android/issues/977#issuecomment-1785604118
2023-11-08 09:06:06 +00:00
Hugh Daschbach c1eb9d6b2a Fix landscape view induced MainActivity leak.
With landscape view enabled (e.g. e06b2c7) in the app and auto
rotation enabled on the phone, switching between portrait and
landscape orientations leaks instances of MainActivity.  This prevents
garbage collection of not just the MainActivity object, but fragments
and other objects referenced by the Activity.

This is caused by repositories, the AppContext instance, the player
service, and authentication code maintaining a reference to the
context which with they are initialized.  So rather than initialize
these with an Activity context, pass them the Application context.

Activities are torn down and rebuilt on screen rotation.  The
Application context is not.

To enable instantiation of the FavoritedRepository with the
Application context, delay that repository’s initialization until
first use.  This ensures the Application context is fully initialized.
It is not fully initialized until the MainActivity has been fully
initialized.
2023-11-07 08:33:36 +00:00
Hugh Daschbach b9ade47988 Increase player controls touchpoint size.
Adopting AndroidStudio suggestion to help those of us with fat
fingers.
2023-11-07 08:33:36 +00:00
Hugh Daschbach 2133d4a4fb Prevent BottomSheet tap leaking to nav panels.
With the BottomSheet open, while trying to tap one of the
controls (esp. add to playlist and favorite buttons) it is easy to
miss the touch point and tap directly on the BottomSheet.

This tap bleeds through to whatever fragment is currently displayed in
the navigation area (Artist, Album, Playlists, etc.).  That tap
changes the view in the navigation panel.  For example, if the Artist
fragment it current, it will open a list of the artists albums.

That change may be surprising when the BottomSheet is toggled closed.
So, ignore BottomSheet taps outside the active controls.
2023-11-07 08:33:36 +00:00
Hugh Daschbach feb86fe9c0 Refactor CoverArt.withContext().
Having changed the context object in CoverArt from a received function
parameter to an initialization time derived variable, withContext no
longer needs a Context parameter.

That leaves the method misnamed.  So rename withContext ->
requestCreator and drop the first parameter.
2023-11-07 08:33:36 +00:00
Hugh Daschbach f65e29af39 Do not create unnecessary Picasso objects.
Address "java.lang.IllegalStateException: Too many receivers"
exceptions.  (See Issue #145).  Each new Picasso object registers its
own NetworkBroadcastReceiver.  Worse, we create a new Picasso object
each time we transform an AlbumCover image.  So do not create
unnecessary Picasso objects.

Rather than depend on receiving a Context object when called to load
an cover art, fetch the Application context as returned from FFA.get()
at singleton construction time.  The Application context is long
lived.

This has an additional advantage.  Not generation new Picasso objects
for each CoverArt image avoids holding a reference to an object that
cannot, later, be garbage collected.
2023-11-07 08:33:36 +00:00
Hugh Daschbach 4dba9e29dd chore(deps): update dependency io.insert-koin:koin-test to v3.5.0 2023-11-06 12:21:47 -08:00
Hugh Daschbach 629ce2b309 chore(deps): update dependency io.insert-koin:koin-core to v3.5.0 2023-11-06 12:21:07 -08:00
Renovate Bot 080ba21c35 chore(deps): update dependency io.insert-koin:koin-android to v3.5.0 2023-11-06 12:19:23 -08:00
Christophe Henry 31908b6175 Fix buffering progress bar display 2023-10-02 20:30:09 +02:00
Christophe Henry 1a050c2d73 Fixes form peer review 2023-09-28 17:32:53 +02:00
Christophe Henry 056e3a4d66 Use MotionLayout to animate bottom sheet opening 2023-09-27 15:56:15 +02:00
Christophe Henry b924a0c655 Fix bottom sheet being hidden in certain conditions 2023-09-18 20:18:52 +02:00
Christophe Henry 822adcac4a Fix overlap between main fragment and player bottom bar 2023-09-18 17:35:26 +02:00
Christophe Henry fbbd90111d Fix a few regressions with the new bottom sheet 2023-09-18 17:35:26 +02:00
Christophe Henry 45773aac8d Improve player bottom sheet, in particular fling support 2023-09-18 17:35:26 +02:00
josé m 6472a3743e Translated using Weblate (Galician)
Currently translated at 99.2% (124 of 125 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/gl/
2023-06-03 20:50:20 +00:00
Thomas ada0b09a66 Translated using Weblate (French)
Currently translated at 100.0% (125 of 125 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/fr/
2023-06-03 20:50:15 +00:00
Hugh Daschbach 9c3d965a7e Fix Java version for gradle:7.4.2.
When Gradle version was bumped to 7.0.0, it required Java version 11.
In 82b9121 (2022/01/05), .sdkmanrc was adjusted to accommodate.  But
app/build.gradle.kts was not.

More recent Gradle releases have started to complain:
,----
| > Could not resolve all files for configuration ':classpath'.
|    > Could not resolve com.android.tools.build:gradle:7.4.2.
|      Required by:
|          project :
|       > No matching variant of com.android.tools.build:gradle:7.4.2 was found. The consumer was configured to find a library for use during runtime, compatible with Java 8, packaged as a jar, and its dependencies declared externally, as well as attribute 'org.gradle.plugin
| .api-version' with value '8.0.2' but:
|           - Variant 'apiElements' capability com.android.tools.build:gradle:7.4.2 declares a library, packaged as a jar, and its dependencies declared externally:
|               - Incompatible because this component declares a component for use during compile-time, compatible with Java 11 and the consumer needed a component for use during runtime, compatible with Java 8
|               - Other compatible attribute:
|                   - Doesn't say anything about org.gradle.plugin.api-version (required '8.0.2')
`----

Adjust gradle’s specification to suit.
2023-06-03 15:20:48 +00:00
Dylan Gageot 5c5d86a728 Add beta sign on network bandwidth limitation icon 2023-04-24 18:14:14 +02:00
Dylan Gageot 1288e050fd Add translations for other languanges than default 2023-04-24 17:25:24 +02:00
Dylan Gageot 8e09dccb9f Transcode at 320kbps when bandwidth limitation is enabled 2023-04-23 17:50:32 +00:00
Dylan Gageot 45ad4bdb8e Add summary for bandwidth limitation 2023-04-23 17:50:32 +00:00
Dylan Gageot 27e751df35 Add network icon for bandwidth limitation setting 2023-04-23 17:50:32 +00:00
Dylan Gageot 33938e3705 Add bandwidth limitation setting in Settings activity 2023-04-23 17:50:32 +00:00
Renovate Bot 1d5578febf chore(deps): update plugin com.github.triplet.play to v3.8.1 2023-04-19 10:30:51 +00:00
Georg krause e1be5b1303 Fix: Make check for proprietary code working with releases as well 2023-04-19 12:05:39 +02:00
Georg krause 53bff969cd Update version information for F-Droid 2023-04-18 12:03:55 +02:00
Georg krause cd82472c27 Update changelog for version 0.2.1 2023-04-18 12:03:55 +02:00
Georg krause 89c5718ac8 fix: Remove missing screenshots from Readme 2023-04-18 12:02:48 +02:00
Georg krause de1cd69646 chore: Remove dependency that pulls in proprietary libs 2023-04-18 11:27:41 +02:00
Georg krause 8da3cc78be Fix: Make sure to fetch stderr as well 2023-04-18 11:15:48 +02:00
Georg krause cc004dafdf Escape special yaml characters to make check work 2023-04-18 10:56:54 +02:00
Georg krause 22899ed2aa Fix evaluation of fdroid scanner 2023-04-17 18:17:57 +02:00
Georg krause de8343a973 chore: Make pipeline fail if non-free deps are found 2023-04-17 14:46:01 +02:00
Georg krause aab9e28e00 ci: Check for nonfree dependencies 2023-04-17 13:50:03 +02:00
Renovate Bot f8838bae64 chore(deps): update dependency com.github.ben-manes:gradle-versions-plugin to v0.46.0 2023-04-06 08:31:16 +00:00
Georg krause 0075c10442 feat: Add sentry reporting to dev builds 2023-04-06 08:02:20 +00:00
Matyáš Caras 103cac4145 Translated using Weblate (Czech)
Currently translated at 99.1% (119 of 120 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/cs/
2023-04-06 06:43:09 +00:00
Georg krause fb436ac43c Update version information for F-Droid 2023-04-05 13:53:51 +02:00
Georg krause 006051dfa5 Update changelog for version 0.2.0 2023-04-05 13:53:51 +02:00
Renovate Bot 879d873156 chore(deps): update dependency io.mockk:mockk-android to v1.13.4 2023-04-05 01:30:48 +00:00
Renovate Bot 8832b74cb4 chore(deps): update dependency com.android.tools.build:gradle to v7.4.2 2023-04-04 12:00:47 +00:00
Renovate Bot 8295d0e3e7 chore(deps): update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v1.8.20 2023-04-04 11:12:53 +00:00
Renovate Bot 3d03067069 chore(deps): update dependency io.mockk:mockk to v1.13.4 2023-04-04 10:01:08 +00:00
Renovate Bot 77eb1899fd chore(deps): update dependency androidx.appcompat:appcompat to v1.6.1 2023-04-04 08:18:50 +00:00
Georg krause 87be45ba8a chore(renovate): Limit to one MR at a time 2023-04-04 10:17:08 +02:00
Renovate Bot 7822ce2cf0 chore(deps): update dependency gradle to v7.6.1 2023-04-04 07:31:59 +00:00
Renovate Bot 201e8722b7 chore(deps): update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.8.20 2023-04-03 18:06:29 +00:00
Georg krause f2c0e2cb3c feat(renovate): Group exoplayer packages 2023-04-03 19:48:34 +02:00
Christophe Henry d25f29b4c1 Prevent IllegalSeekPositionException when initializing the player 2023-04-03 14:51:10 +00:00
Georg Krause df98e6fb99 Merge branch 'renovate/lifecycleversion' into 'develop'
chore(deps): update lifecycleversion to v2.6.1

See merge request funkwhale/funkwhale-android!326
2023-04-02 10:25:28 +00:00
Renovate Bot ea00a832a9 chore(deps): update lifecycleversion to v2.6.1 2023-03-22 17:31:12 +00:00
Matyáš Caras 04a659cc82 Translated using Weblate (Czech)
Currently translated at 22.5% (27 of 120 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/cs/
2023-02-24 21:42:47 +00:00
Matyáš Caras a55656d0f4 Added translation using Weblate (Czech) 2023-02-23 21:09:48 +00:00
Ryan Harg 514cd46036 Merge branch 'renovate/org.jlleitschuh.gradle.ktlint-11.x' into 'develop'
chore(deps): update plugin org.jlleitschuh.gradle.ktlint to v11.2.0

See merge request funkwhale/funkwhale-android!318
2023-02-22 18:54:00 +00:00
Renovate Bot e04f718335 chore(deps): update plugin org.jlleitschuh.gradle.ktlint to v11.2.0 2023-02-20 14:01:30 +00:00
Ryan Harg 79c869d51b Merge branch 'renovate/curlimages-curl-7.x' into 'develop'
chore(deps): update curlimages/curl docker tag to v7.88.1

See merge request funkwhale/funkwhale-android!322
2023-02-20 13:35:54 +00:00
Renovate Bot d0a47953dc chore(deps): update curlimages/curl docker tag to v7.88.1 2023-02-20 11:31:13 +00:00
vicdorke 95dcbf4616 Translated using Weblate (Chinese (Simplified))
Currently translated at 98.3% (118 of 120 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/zh_Hans/
2023-02-14 07:42:43 +00:00
omarmaciasmolina 7b16d46982 Translated using Weblate (Catalan)
Currently translated at 100.0% (120 of 120 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/ca/
2023-02-10 18:42:42 +00:00
Ryan Harg 61ab3a918b Merge branch 'renovate/com.github.aliasadi-powerpreference-2.x' into 'develop'
chore(deps): update dependency com.github.aliasadi:powerpreference to v2.1.1

See merge request funkwhale/funkwhale-android!306
2023-01-31 10:29:51 +00:00
Renovate Bot 0a32036558 chore(deps): update dependency com.github.aliasadi:powerpreference to v2.1.1 2023-01-31 00:30:34 +00:00
Ryan Harg b892bd5c3c Merge branch 'renovate/org.jlleitschuh.gradle.ktlint-11.x' into 'develop'
chore(deps): update plugin org.jlleitschuh.gradle.ktlint to v11.1.0

See merge request funkwhale/funkwhale-android!302
2023-01-30 13:33:59 +00:00
Renovate Bot 62ce3c7f60 chore(deps): update plugin org.jlleitschuh.gradle.ktlint to v11.1.0 2023-01-27 18:33:33 +00:00
Ryan Harg 019962c5a1 Merge branch 'renovate/com.google.android.material-material-1.x' into 'develop'
chore(deps): update dependency com.google.android.material:material to v1.8.0

See merge request funkwhale/funkwhale-android!301
2023-01-27 11:18:44 +00:00
Renovate Bot fb09b95fb0 chore(deps): update dependency com.google.android.material:material to v1.8.0 2023-01-24 22:33:36 +00:00
aventijn ef0701cd35 Translated using Weblate (Dutch)
Currently translated at 100.0% (120 of 120 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/nl/
2023-01-22 21:42:37 +00:00
aventijn 0b1be2d572 Added translation using Weblate (Dutch) 2023-01-21 20:21:54 +00:00
Georg Krause a666490bd0
ci: Use images from funkwhale/ci instead of building one ourself 2023-01-14 17:03:02 +01:00
Thomas 585af743f2 Translated using Weblate (French)
Currently translated at 100.0% (120 of 120 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/fr/
2023-01-14 13:42:35 +00:00
Ryan Harg ca63e0d60c Merge branch 'feature/129-favourite-sorting' into 'develop'
Sort Favourites by time

Closes #129

See merge request funkwhale/funkwhale-android!299
2023-01-13 13:11:06 +00:00
Ryan Harg f1947f3b88
Sort Favourites by time 2023-01-13 12:52:52 +01:00
Ryan Harg 3c21c0baec Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
chore(deps): update dependency com.android.tools.build:gradle to v7.4.0

See merge request funkwhale/funkwhale-android!298
2023-01-13 07:47:19 +00:00
Renovate Bot fb80909fee chore(deps): update dependency com.android.tools.build:gradle to v7.4.0 2023-01-12 22:33:34 +00:00
Ryan Harg 4fc6bf978a Merge branch 'renovate/androidx.appcompat-appcompat-1.x' into 'develop'
chore(deps): update dependency androidx.appcompat:appcompat to v1.6.0

See merge request funkwhale/funkwhale-android!297
2023-01-12 12:51:36 +00:00
Renovate Bot 56792e1940 chore(deps): update dependency androidx.appcompat:appcompat to v1.6.0 2023-01-11 20:33:34 +00:00
Ryan Harg a83cd24185 Merge branch 'feature/138-track-cover' into 'develop'
Use track cover over album cover if present

Closes #138

See merge request funkwhale/funkwhale-android!296
2023-01-11 12:41:05 +00:00
Ryan Harg b4b988da48 Use track cover over album cover if present 2023-01-11 12:41:04 +00:00
Ryan Harg 14d583f0ae Merge branch 'documentation/update-readme' into 'develop'
Update readme to reflect state of available app versions

See merge request funkwhale/funkwhale-android!295
2023-01-11 09:47:06 +00:00
Ryan Harg d0579caac8
Update readme to reflect state of available app versions 2023-01-11 10:38:39 +01:00
Ryan Harg bd61b50403 Merge branch 'renovate/androidx.appcompat-appcompat-1.x' into 'develop'
chore(deps): update dependency androidx.appcompat:appcompat to v1.5.1

See merge request funkwhale/funkwhale-android!294
2023-01-11 08:42:12 +00:00
Renovate Bot dd1f7ddca8 chore(deps): update dependency androidx.appcompat:appcompat to v1.5.1 2023-01-11 08:33:08 +00:00
Ryan Harg b80a54c87f Merge branch 'renovate/navversion' into 'develop'
chore(deps): update navversion to v2.5.3

See merge request funkwhale/funkwhale-android!290
2023-01-11 08:32:46 +00:00
Renovate Bot 28e85d60d0 chore(deps): update navversion to v2.5.3 2023-01-10 20:03:46 +00:00
Georg Krause c15d83a550
Fix curl invocation 2023-01-10 21:00:00 +01:00
Georg Krause b1809d97e7
fix: Fix imagename and webhook url 2023-01-10 20:42:29 +01:00
Georg Krause bda67a449b
fix(ci): Don't trigger fdroid stable index update on each commit 2023-01-10 20:34:10 +01:00
Georg Krause 6771b1a8a9
feat: Trigger fdroid update using webhook 2023-01-10 20:33:04 +01:00
Ryan Harg eaf9275086 Merge branch 'player-always-on-top' into 'develop'
Keep the player always on top

Closes #107

See merge request funkwhale/funkwhale-android!289
2023-01-10 12:56:20 +00:00
Ryan Harg c10b3d4a75 Keep the player always on top 2023-01-10 12:56:20 +00:00
Ryan Harg bdbe14278e Merge branch 'artist-cover-art' into 'develop'
Custom cache layer for cover art which ignores (pre-signed URL) query

See merge request funkwhale/funkwhale-android!288
2023-01-10 10:00:42 +00:00
Ryan Harg a810e13cfb Custom cache layer for cover art which ignores (pre-signed URL) query 2023-01-10 10:00:41 +00:00
Ryan Harg 9202cc8dd0 Merge branch 'remoce-buildSrc' into 'develop'
Remove buildSrc directory

See merge request funkwhale/funkwhale-android!287
2023-01-09 09:45:05 +00:00
Christophe Henry 2fb74b775e Remove buildSrc directory 2023-01-09 08:33:40 +00:00
Ryan Harg 66b8888327 Merge branch 'renovate/com.google.code.gson-gson-2.x' into 'develop'
Update dependency com.google.code.gson:gson to v2.10.1

See merge request funkwhale/funkwhale-android!286
2023-01-09 08:26:58 +00:00
Renovate Bot 83cf417e5a Update dependency com.google.code.gson:gson to v2.10.1 2023-01-06 16:31:02 +00:00
Ryan Harg b24647663d Merge branch 'renovate/org.jetbrains.kotlin-kotlin-stdlib-jdk7-1.x' into 'develop'
Update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.8.0

See merge request funkwhale/funkwhale-android!280
2023-01-05 13:09:31 +00:00
RenovateBot 7abbd8dbaa Update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.8.0 2023-01-05 13:09:31 +00:00
Ryan Harg 7a01dc3a64 Merge branch 'scroll-queue-to-current' into 'develop'
Open queue scrolled to current track.

Closes #128

See merge request funkwhale/funkwhale-android!191
2023-01-04 13:28:44 +00:00
Hugh Daschbach 1566d1fbcf Open queue scrolled to current track. 2023-01-04 13:28:44 +00:00
Ryan Harg c0b7e37cb4 Merge branch 'renovate/io.insert-koin-koin-core-3.x' into 'develop'
Update dependency io.insert-koin:koin-core to v3.3.2

See merge request funkwhale/funkwhale-android!282
2023-01-04 12:35:33 +00:00
RenovateBot 7f0671b055 Update dependency io.insert-koin:koin-core to v3.3.2 2023-01-04 12:35:33 +00:00
Ryan Harg b9724fb7b9 Merge branch 'renovate/org.robolectric-robolectric-4.x' into 'develop'
Update dependency org.robolectric:robolectric to v4.9.2

See merge request funkwhale/funkwhale-android!278
2023-01-04 12:16:45 +00:00
Renovate Bot ecc9e6e096 Update dependency org.robolectric:robolectric to v4.9.2 2023-01-02 21:30:47 +00:00
Thomas ef7811dc6e Translated using Weblate (French)
Currently translated at 100.0% (118 of 118 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/fr/
2023-01-02 21:25:33 +00:00
Philipp Wolfer 435bbad122 Translated using Weblate (German)
Currently translated at 98.3% (116 of 118 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/de/
2022-12-21 09:25:30 +00:00
Ryan Harg 0dbd9c2b9f Merge branch 'renovate/org.robolectric-robolectric-4.x' into 'develop'
Update dependency org.robolectric:robolectric to v4.9.1

See merge request funkwhale/funkwhale-android!277
2022-12-21 08:31:34 +00:00
Renovate Bot 1fb05f567d Update dependency org.robolectric:robolectric to v4.9.1 2022-12-20 09:30:51 +00:00
Ryan Harg b188005be3 Merge branch 'backup-on-pause' into 'develop'
Allow automatic backward skip of a configurable number of seconds on pause (#134).

Closes #134

See merge request funkwhale/funkwhale-android!273
2022-12-20 09:13:37 +00:00
Hugh Daschbach ec6187aeac Allow automatic backward skip of a configurable number of seconds on pause (#134). 2022-12-20 09:13:36 +00:00
Ryan Harg 2d272d13c9 Merge branch 'renovate/io.insert-koin-koin-core-3.x' into 'develop'
Update dependency io.insert-koin:koin-core to v3.3.0

See merge request funkwhale/funkwhale-android!275
2022-12-15 08:53:02 +00:00
RenovateBot bfab20a2b3 Update dependency io.insert-koin:koin-core to v3.3.0 2022-12-15 08:53:01 +00:00
Ryan Harg d7afcbb1a1 Merge branch 'technical/update-android-build' into 'develop'
Update dockerfile with latest build values

See merge request funkwhale/funkwhale-android!270
2022-12-10 13:29:51 +00:00
Ryan Harg 826d10a702 Update dockerfile with latest build values 2022-12-09 10:26:53 +00:00
Ryan Harg aa8e0ce1a6 Merge branch 'filter-favorites' into 'develop'
Filter favorites

Closes #132

See merge request funkwhale/funkwhale-android!268
2022-12-09 08:49:41 +00:00
Ryan Harg 87a0ef5a42 Filter favorites 2022-12-09 08:49:41 +00:00
Ryan Harg cf5d6a21fe Merge branch 'picasso-cache-stablekey' into 'develop'
Use Picasso stableKey for better caching against pre-signed URLs

Closes #133

See merge request funkwhale/funkwhale-android!269
2022-12-08 13:29:36 +00:00
Ryan Harg 566dca1518 Use Picasso stableKey for better caching against pre-signed URLs 2022-12-08 13:29:34 +00:00
Ryan Harg 708daa8464 Merge branch 'renovate/androidx.core-core-ktx-1.x' into 'develop'
Update dependency androidx.core:core-ktx to v1.9.0

See merge request funkwhale/funkwhale-android!245
2022-12-08 09:36:21 +00:00
Renovate Bot 5c35c7e389 Update dependency androidx.core:core-ktx to v1.9.0 2022-12-08 09:24:43 +00:00
Ryan Harg 8c0f96ad42 Merge branch 'renovate/com.google.android.material-material-1.x' into 'develop'
Update dependency com.google.android.material:material to v1.7.0

See merge request funkwhale/funkwhale-android!253
2022-12-08 09:23:58 +00:00
RenovateBot 9f7f0294f6 Update dependency com.google.android.material:material to v1.7.0 2022-12-08 09:23:58 +00:00
Ryan Harg 6c652f2735 Merge branch 'renovate/org.jetbrains.kotlin-kotlin-gradle-plugin-1.x' into 'develop'
Update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v1.7.22

See merge request funkwhale/funkwhale-android!260
2022-12-08 08:21:07 +00:00
Renovate Bot 10242b0d01 Update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v1.7.22 2022-12-07 14:30:39 +00:00
Ryan Harg bd83872075 Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.6

See merge request funkwhale/funkwhale-android!259
2022-12-07 14:01:36 +00:00
Renovate Bot 5bbf6b5ffa
Update dependency gradle to v7.6 2022-12-07 14:45:24 +01:00
Ryan Harg 4ba25fce48 Merge branch 'renovate/org.jetbrains.kotlin-kotlin-stdlib-jdk7-1.x' into 'develop'
Update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.7.22

See merge request funkwhale/funkwhale-android!261
2022-12-07 13:43:23 +00:00
Renovate Bot 922aa16b8c Update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.7.22 2022-12-07 13:00:27 +00:00
Ryan Harg f235c06b86 Merge branch 'technical/upgrade-dangling-mock-android-dependency' into 'develop'
Upgrade mockk android dependency versionto general mockk version

See merge request funkwhale/funkwhale-android!265
2022-12-07 12:51:25 +00:00
Ryan Harg bbc82d8be5 Upgrade mockk android dependency versionto general mockk version 2022-12-07 12:51:24 +00:00
Ryan Harg 82d0dd544d Merge branch 'renovate/io.insert-koin-koin-core-3.x' into 'develop'
Update dependency io.insert-koin:koin-core to v3.2.2

See merge request funkwhale/funkwhale-android!255
2022-12-07 11:00:50 +00:00
RenovateBot 159c7d8d47 Update dependency io.insert-koin:koin-core to v3.2.2 2022-12-07 11:00:49 +00:00
Ryan Harg 10e67f1e80 Merge branch 'warnings-cleanup' into 'develop'
Cleanup most build warnings.

See merge request funkwhale/funkwhale-android!211
2022-12-07 10:24:03 +00:00
Ryan Harg fa48937b56
Set required flag for pendingIntent 2022-12-06 09:37:20 +01:00
Ryan Harg 2de6ca303e
Necessary upgrades to compileSdk and targetSdk and adjusting code 2022-12-06 09:35:33 +01:00
Hugh Daschbach d734953b54
Replace deprecated SimpleExoPlayer with ExoPlayer.
This is part of an effort to resolve deprecation warnings.

Most of this is simple refactoring of interfaces that change between
the two Player implementations.  There are a few other changes that
deserve further explanation.

Testing indicated that the play/pause button was being reset to pause
in MainActivity:refreshCurrentTrack.  In the past this was likely
masked by the ordering of other callbacks.  We have removed the
nowPlayingToggle.icon update from MainActivity, leaving that UI update
to PlayerService.

One of the bigger refactorings in PlayerService was forced by the
deprecation of Player.EventListener.onPlayerStateChanged.  That forced
separation of handling playWhenReady and playbackState transitions.
In the SimpleExoPlayer implementations, where these transitions were
combined, the module attempted to work out playing state from a
combination of these two state variables.

In addition to separating the reaction to these state changes, we have
added a listener to onIsPlayingChanged, eliminating the need for some
of the earlier logic in Player.EventListener.onPlayerStateChanged.
This addition, along with the separation of state transition
processing, seems to provide a simpler implementation.  But it is,
certainly, a possible source of bugs.
2022-12-06 09:35:33 +01:00
Hugh Daschbach 24de54c7e0
MainActivity: startActivityForResult deprecated.
Migrate startActivityForResult/onActivityResult to
StartActivityForResult/registerForActivityResult in MainActivity.
2022-12-06 09:35:33 +01:00
Hugh Daschbach bea1d1f397
LoginActivity: startActivityForResult deprecated.
Migrate startActivityForResult/onActivityResult to
StartActivityForResult/registerForActivityResult in
LoginActivity/OAuth.

This moves responsibility for scheduling the starting Intent from
OAuth to LoginActivity.

OAuth still generates the Intent.  But instead of starting the intent
directly in OAuth, the intent is returned to LoginActivity.  This
better associates processing the activity result with its invocation.

OAuthTest module updated to accommodate internal API change.
2022-12-06 09:35:33 +01:00
Hugh Daschbach 38a3183b9d
Resolve warning: FragmentPagerAdapter deprecated.
Replace FragmentPagerAdapter with FragmentStateAdapter in
BrowseTabsAdapter.kt.  Refactored getPageTitle as a function that
returns tab name.  Tab text update moved to BrowseFragment.

This requires replacement of setupWithViewPager with
TabMediator.attach in BrowseFragment.

Also requires replacing widget declaration
androidx.viewpager.widget.ViewPager with
androidx.viewpager2.widget.ViewPager2 in fragment_browwse.xml.
2022-12-06 09:35:32 +01:00
Hugh Daschbach 8878e3e68f
Resolve warning: ExoDatabaseProvider deprecated.
Replace ExoDatabaseProvider with StandaloneDatabaseProvider.
2022-12-06 09:35:32 +01:00
Hugh Daschbach 7d49819450
Resolve warning "Unnecessary safe call". 2022-12-06 09:35:32 +01:00
Hugh Daschbach 4827fbccc1
RequentBus: replace deprecated implementation.
Convert RequestBus from deprecated BroadcastChannel to a SharedFlow.
2022-12-06 09:35:32 +01:00
Hugh Daschbach 1a038b2355
CommandBus: replace deprecated implementation.
Convert CommandBus from deprecated BroadcastChannel to a SharedFlow.
2022-12-06 09:35:32 +01:00
Hugh Daschbach be8901390e
EventBus: replace deprecated implementation.
Convert EventBus from deprecated BroadcastChannel to a SharedFlow.
2022-12-06 09:35:32 +01:00
Hugh Daschbach 6d1ad9cd78
ProgressBus: replace deprecated implementation.
Convert Progress from deprecated BroadcastChannel to a StateFlow.
2022-12-06 09:35:31 +01:00
Hugh Daschbach 72b4aea35a
Resolve deprecation warning in MediaSession.
Remove setting of MediaSession flags that are now mandatory and
assumed set.
2022-12-06 09:35:31 +01:00
Ryan Harg ef01386f16 Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.13.3

See merge request funkwhale/funkwhale-android!262
2022-12-05 09:25:42 +00:00
Renovate Bot a9aafef28b Update dependency io.mockk:mockk to v1.13.3 2022-11-29 13:30:42 +00:00
omarmaciasmolina b94363e035 Added translation using Weblate (Catalan) 2022-11-28 21:08:29 +00:00
Ryan Harg 234b4d79dd Merge branch 'renovate/org.jetbrains.kotlin-kotlin-gradle-plugin-1.x' into 'develop'
Update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v1.7.21

See merge request funkwhale/funkwhale-android!241
2022-11-22 09:16:10 +00:00
Renovate Bot c30d89cdea Update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v1.7.21 2022-11-21 14:00:30 +00:00
Ryan Harg a718f0a626 Merge branch 'renovate/haynes-jacoco2cobertura-1.x' into 'develop'
Update haynes/jacoco2cobertura Docker tag to v1.0.9

See merge request funkwhale/funkwhale-android!243
2022-11-21 13:39:01 +00:00
Renovate Bot b26e6eb78b Update haynes/jacoco2cobertura Docker tag to v1.0.9 2022-11-21 13:00:30 +00:00
Ryan Harg 73b112ad5d Merge branch 'renovate/org.jetbrains.kotlin-kotlin-stdlib-jdk7-1.x' into 'develop'
Update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.7.21

See merge request funkwhale/funkwhale-android!242
2022-11-21 12:52:15 +00:00
Renovate Bot 960adee40e Update dependency org.jetbrains.kotlin:kotlin-stdlib-jdk7 to v1.7.21 2022-11-21 12:30:46 +00:00
Ryan Harg 8070aa0198 Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.3.1

See merge request funkwhale/funkwhale-android!240
2022-11-21 12:14:05 +00:00
Renovate Bot adba9e01b1 Update dependency com.android.tools.build:gradle to v7.3.1 2022-11-21 11:00:26 +00:00
Ryan Harg 84ab3826b1 Merge branch 'renovate/com.github.ben-manes-gradle-versions-plugin-0.x' into 'develop'
Update dependency com.github.ben-manes:gradle-versions-plugin to v0.44.0

See merge request funkwhale/funkwhale-android!246
2022-11-21 10:46:08 +00:00
Renovate Bot 298c682a60 Update dependency com.github.ben-manes:gradle-versions-plugin to v0.44.0 2022-11-21 10:28:44 +00:00
Ryan Harg ef386be333 Merge branch 'renovate/org.robolectric-robolectric-4.x' into 'develop'
Update dependency org.robolectric:robolectric to v4.9

See merge request funkwhale/funkwhale-android!251
2022-11-21 10:12:51 +00:00
Renovate Bot 470a32434b Update dependency org.robolectric:robolectric to v4.9 2022-11-21 10:00:53 +00:00
Ryan Harg 0314a8dd7d Merge branch 'renovate/com.google.code.gson-gson-2.x' into 'develop'
Update dependency com.google.code.gson:gson to v2.10

See merge request funkwhale/funkwhale-android!248
2022-11-21 09:44:56 +00:00
Renovate Bot 5d7307206e Update dependency com.google.code.gson:gson to v2.10 2022-11-21 09:31:12 +00:00
Ryan Harg 4127a97327 Merge branch 'renovate/androidx.test-core-1.x' into 'develop'
Update dependency androidx.test:core to v1.5.0

See merge request funkwhale/funkwhale-android!252
2022-11-21 09:03:28 +00:00
Renovate Bot 05717f1067 Update dependency androidx.test:core to v1.5.0 2022-11-08 20:30:47 +00:00
Ryan Harg a6561266e4 Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.13.2

See merge request funkwhale/funkwhale-android!215
2022-10-06 08:30:41 +00:00
Renovate Bot 072dbaf0af Update dependency io.mockk:mockk to v1.13.2 2022-10-03 11:00:46 +00:00
Ryan Harg 0e7994ec4d Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.3.0

See merge request funkwhale/funkwhale-android!216
2022-10-03 10:39:02 +00:00
Renovate Bot 6fbe4e4e7a Update dependency com.android.tools.build:gradle to v7.3.0 2022-09-15 17:00:29 +00:00
Ryan Harg bd8cec2d27 Merge branch 'minor-fixup' into 'develop'
Minor cleanup: consistent deserialization.

See merge request funkwhale/funkwhale-android!190
2022-09-03 11:09:21 +00:00
Hugh Daschbach 48570e24ea Minor cleanup: consistent deserialization. 2022-09-03 11:09:21 +00:00
Ryan Harg 3da84cb6b5 Merge branch 'renovate/org.jacoco-org.jacoco.core-0.x' into 'develop'
Update dependency org.jacoco:org.jacoco.core to v0.8.8

See merge request funkwhale/funkwhale-android!197
2022-08-29 19:24:01 +00:00
Renovate Bot d139da56de Update dependency org.jacoco:org.jacoco.core to v0.8.8 2022-08-26 12:23:58 +00:00
Ryan Harg 8d9cdbb441 Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.12.7

See merge request funkwhale/funkwhale-android!186
2022-08-26 12:23:02 +00:00
Renovate Bot c1c218eb6f
Update dependency io.mockk:mockk to v1.12.7 2022-08-26 14:07:39 +02:00
Ryan Harg 6ce043893e Merge branch 'technical/upgrade-kotlin' into 'develop'
Upgrade to Kotlin 1.7.0

See merge request funkwhale/funkwhale-android!210
2022-08-26 12:06:41 +00:00
Ryan Harg bfdac03d0c Upgrade to Kotlin 1.7.0 2022-08-26 12:06:41 +00:00
Ryan Harg 2d449549b0 Merge branch 'renovate/org.robolectric-robolectric-4.x' into 'develop'
Update dependency org.robolectric:robolectric to v4.8.2

See merge request funkwhale/funkwhale-android!209
2022-08-26 10:01:21 +00:00
Renovate Bot 19155a9c25 Update dependency org.robolectric:robolectric to v4.8.2 2022-08-26 09:31:48 +00:00
Ryan Harg f4abf4084a Merge branch 'renovate/org.jlleitschuh.gradle.ktlint-11.x' into 'develop'
Update plugin org.jlleitschuh.gradle.ktlint to v11

See merge request funkwhale/funkwhale-android!193
2022-08-26 09:29:35 +00:00
Renovate Bot 05ab1d7dc2 Update plugin org.jlleitschuh.gradle.ktlint to v11 2022-08-26 08:31:40 +00:00
Ryan Harg 6db00911b4 Merge branch 'renovate/com.github.ben-manes-gradle-versions-plugin-0.x' into 'develop'
Update dependency com.github.ben-manes:gradle-versions-plugin to v0.42.0

See merge request funkwhale/funkwhale-android!198
2022-08-26 08:14:41 +00:00
Renovate Bot b2baea2b38 Update dependency com.github.ben-manes:gradle-versions-plugin to v0.42.0 2022-08-26 07:30:47 +00:00
Ryan Harg fd6804dc20 Merge branch 'renovate/com.github.bjoernq-unmockplugin-0.x' into 'develop'
Update dependency com.github.bjoernq:unmockplugin to v0.7.9

See merge request funkwhale/funkwhale-android!195
2022-08-26 07:24:57 +00:00
Renovate Bot 8208f33d48 Update dependency com.github.bjoernq:unmockplugin to v0.7.9 2022-08-26 06:30:19 +00:00
Ryan Harg 4e567dde41 Merge branch 'renovate/com.google.code.gson-gson-2.x' into 'develop'
Update dependency com.google.code.gson:gson to v2.9.1

See merge request funkwhale/funkwhale-android!202
2022-08-26 06:26:12 +00:00
Renovate Bot 83c73ee046 Update dependency com.google.code.gson:gson to v2.9.1 2022-08-26 06:10:03 +00:00
Ryan Harg fba19f1a46 Merge branch 'renovate/io.strikt-strikt-core-0.x' into 'develop'
Update dependency io.strikt:strikt-core to v0.34.1

See merge request funkwhale/funkwhale-android!206
2022-08-26 06:09:15 +00:00
Renovate Bot 5e789a2f28 Update dependency io.strikt:strikt-core to v0.34.1 2022-08-25 13:03:02 +00:00
Ryan Harg 117a17d0bf Merge branch 'technical/remove-versions-object' into 'develop'
Remove Versions object

See merge request funkwhale/funkwhale-android!194
2022-08-25 12:58:19 +00:00
Ryan Harg 7c91e819c9 Remove Versions object 2022-08-25 12:58:19 +00:00
Ryan Harg b9335e545e Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.5.1

See merge request funkwhale/funkwhale-android!189
2022-08-25 11:41:36 +00:00
Renovate Bot 61d3fdac31 Update dependency gradle to v7.5.1 2022-08-25 09:31:45 +00:00
Ryan Harg e93ad7a418 Merge branch 'renovate/androidx.lifecycle-lifecycle-runtime-ktx-2.x' into 'develop'
Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.5.1

See merge request funkwhale/funkwhale-android!187
2022-08-25 09:00:21 +00:00
Renovate Bot a1de2611a0 Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.5.1 2022-08-25 08:30:15 +00:00
Ryan Harg d4b8d0a684 Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.2.2

See merge request funkwhale/funkwhale-android!188
2022-08-25 08:08:11 +00:00
Renovate Bot 5b95e03886 Update dependency com.android.tools.build:gradle to v7.2.2 2022-08-21 12:00:21 +00:00
Georg Krause c56d6a6452
Apply registry upload for releases 2022-08-21 13:43:27 +02:00
Georg Krause fdf9198a76
Push packages to gitlab registry 2022-08-21 12:04:44 +02:00
Ryan Harg 75c8453cca Merge branch 'bugfix/124' into 'develop'
Fix issue #124 - empty queue on restart.

Closes #124

See merge request funkwhale/funkwhale-android!185
2022-07-21 11:32:00 +00:00
Hugh Daschbach 79e27578e5 Fix issue #124 - empty queue on restart.
The Gson deserializer required parameter is a reader object.  It
silently fails when passed a string.
2022-07-21 11:15:54 +00:00
Ryan Harg bc752b3057 Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-android-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.4

See merge request funkwhale/funkwhale-android!181
2022-07-18 07:38:08 +00:00
Renovate Bot a6d3e0b597
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.4 2022-07-18 09:17:19 +02:00
Ryan Harg 4bc646a849 Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.4

See merge request funkwhale/funkwhale-android!182
2022-07-18 07:13:39 +00:00
Renovate Bot faadfc1da2 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.4 2022-07-18 06:46:30 +00:00
Ryan Harg 7a05e50eb2 Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.5

See merge request funkwhale/funkwhale-android!183
2022-07-18 06:45:38 +00:00
Renovate Bot 5224c9208a Update dependency gradle to v7.5 2022-07-14 15:36:30 +00:00
Ryan Harg 1f1fc26a1a Merge branch 'renovate/androidx.lifecycle-lifecycle-runtime-ktx-2.x' into 'develop'
Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.5.0

See merge request funkwhale/funkwhale-android!178
2022-07-06 11:29:28 +00:00
Renovate Bot f60dae75e9 Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.5.0 2022-07-06 11:14:20 +00:00
Ryan Harg 84816623e8 Merge branch 'improve-release-script' into 'develop'
Improve release script

See merge request funkwhale/funkwhale-android!180
2022-07-06 11:14:03 +00:00
Georg Krause 2b240709fc Improve release script 2022-07-06 11:14:03 +00:00
Ryan Harg b36c121a84 Merge branch 'technical/empty-changelog-after-tagging' into 'develop'
Empty pending changelog after 0.1.5

See merge request funkwhale/funkwhale-android!179
2022-07-05 16:25:52 +00:00
Ryan Harg 96cfb42bde
Empty pending changelog after 0.1.5 2022-07-04 11:23:06 +02:00
Ryan Harg 6fe879833e
Update version information for F-Droid 2022-07-04 11:19:50 +02:00
Ryan Harg 8570d79d56
Update changelog for version 0.1.5 2022-07-04 11:19:49 +02:00
Georg Krause d1c4bbfd29 Added translation using Weblate (Basque) 2022-06-26 11:35:43 +00:00
Ryan Harg a10cb53b4f Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.3

See merge request funkwhale/funkwhale-android!177
2022-06-24 11:39:42 +00:00
Renovate Bot 8ceaa85ac8 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.3 2022-06-24 09:30:40 +00:00
Ryan Harg c8c3ab89b3 Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-android-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.3

See merge request funkwhale/funkwhale-android!176
2022-06-24 09:08:33 +00:00
Renovate Bot b93decac7a Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.3 2022-06-21 08:17:22 +00:00
Ryan Harg 90c2af7347 Merge branch 'bugfix/122-fix-resource-leakage' into 'develop'
Bugfix/122 fix resource leakage

Closes #122

See merge request funkwhale/funkwhale-android!175
2022-06-21 08:03:47 +00:00
Ryan Harg 8f1f565652 Bugfix/122 fix resource leakage 2022-06-21 08:03:46 +00:00
Ryan Harg 5ace27caef Merge branch 'bugfix/117-delete-downloads' into 'develop'
#117: Use the same contentId when adding and removing downloads

Closes #117

See merge request funkwhale/funkwhale-android!174
2022-06-17 09:50:42 +00:00
Ryan Harg c43baae8e8 #117: Use the same contentId when adding and removing downloads 2022-06-17 09:50:39 +00:00
Ryan Harg b9401d75a9 Merge branch 'bugfix/119-implement-string-deserialization-on-gson-deserializer' into 'develop'
#119: Default deserializer has no string deserialization implementation

See merge request funkwhale/funkwhale-android!173
2022-06-16 14:44:52 +00:00
Ryan Harg 1b0381fde4 #119: Default deserializer has no string deserialization implementation 2022-06-16 13:10:10 +00:00
Ryan Harg eee0dacfdd Merge branch 'fix/update-changelog-entries' into 'develop'
Correct and add references to fix authors

See merge request funkwhale/funkwhale-android!172
2022-06-16 12:55:40 +00:00
Ryan Harg aac3995b87
Correct and add references to fix authors 2022-06-16 14:26:53 +02:00
Ryan Harg 9cac0e9aed Merge branch 'bugfix/120-bluetooth-buttons-unresponsive' into 'develop'
Fix Bluetooth control button unresponsiveness.

Closes #120

See merge request funkwhale/funkwhale-android!171
2022-06-16 12:21:59 +00:00
Ryan Harg 857129efb5
#120: Add changelog entry 2022-06-16 14:04:21 +02:00
Hugh Daschbach 37e270071a
Fix Bluetooth control button unresponsiveness.
With Oreo and later, Bluetooth control buttons may kill FFA if it is
not the foreground application.  Once this happens to resume playback,
one needs to restart playback from the phone, rather than the
play/pause action of Bluetooth headset.

For example:
    D MediaSessionService: Sending KeyEvent { action=ACTION_UP, keyCode=KEYCODE_MEDIA_PLAY, scanCode=0, metaState=0, flags=0x0, repeatCount=0, eventTime=0, downTime=0, deviceId=-1, source=0x0 } to audio.funkwhale.ffa.dev/audio.funkwhale.ffa.dev (

    W ActivityManager: Background start not allowed: service Intent { act=android.intent.action.MEDIA_BUTTON cmp=audio.funkwhale.ffa.dev/audio.funkwhale.ffa.playback.PlayerService (has extras) } to audio.funkwhale.ffa.dev/audio.funkwhale.ffa.play
   549 uid=10149 pkg=audio.funkwhale.ffa.dev startFg?=false
    D AndroidRuntime: Shutting down VM
   --------- beginning of crash
    E AndroidRuntime: FATAL EXCEPTION: main
    E AndroidRuntime: Process: audio.funkwhale.ffa.dev, PID: 14549
    E AndroidRuntime: java.lang.IllegalStateException: Not allowed to start service Intent { act=android.intent.action.MEDIA_BUTTON cmp=audio.funkwhale.ffa.dev/audio.funkwhale.ffa.playback.PlayerService (has extras) }: app is in background uid UidRecord{72fa8f8 u0a149 CAC  bg:+11m56s597ms idle change:cached procs:1 seq(0,0,0)}
    E AndroidRuntime:        at android.app.ContextImpl.startServiceCommon(ContextImpl.java:1577)
    E AndroidRuntime:        at android.app.ContextImpl.startService(ContextImpl.java:1532)
    E AndroidRuntime:        at android.content.ContextWrapper.startService(ContextWrapper.java:664)
    E AndroidRuntime:        at audio.funkwhale.ffa.playback.MediaSession$connector$2.invoke$lambda-3$lambda-2(MediaSession.kt:47)
    E AndroidRuntime:        at audio.funkwhale.ffa.playback.MediaSession$connector$2.$r8$lambda$jU84j_zRyeYuvwLrRY0b6XyQBMs(Unknown Source:0)
    E AndroidRuntime:        at audio.funkwhale.ffa.playback.MediaSession$connector$2$$ExternalSyntheticLambda0.onMediaButtonEvent(Unknown Source:2)
    E AndroidRuntime:        at com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector$ComponentListener.onMediaButtonEvent(MediaSessionConnector.java:1396)
    E AndroidRuntime:        at android.support.v4.media.session.MediaSessionCompat$Callback$MediaSessionCallbackApi21.onMediaButtonEvent(MediaSessionCompat.java:1602)
    E AndroidRuntime:        at android.media.session.MediaSession$CallbackMessageHandler.handleMessage(MediaSession.java:1471)
    E AndroidRuntime:        at android.os.Handler.dispatchMessage(Handler.java:106)
    E AndroidRuntime:        at android.os.Looper.loop(Looper.java:193)
    E AndroidRuntime:        at android.app.ActivityThread.main(ActivityThread.java:6718)
    E AndroidRuntime:        at java.lang.reflect.Method.invoke(Native Method)
    E AndroidRuntime:        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:491)
    E AndroidRuntime:        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)
    W ActivityManager:   Force finishing activity audio.funkwhale.ffa.dev/audio.funkwhale.ffa.activities.MainActivity

xref: https://stackoverflow.com/questions/46445265/android-8-0-java-lang-illegalstateexception-not-allowed-to-start-service-inten
2022-06-16 14:02:20 +02:00
Ryan Harg c11be2c84f Merge branch 'bugfix/fix-oauth-problem' into 'develop'
#102: Add changelog entry

Closes #102

See merge request funkwhale/funkwhale-android!170
2022-06-12 13:27:31 +00:00
Ryan Harg 302c950b19 #102: Add changelog entry 2022-06-12 12:48:51 +00:00
Ryan Harg 0d100a592b Merge branch 'bugfix/missing-close' into 'develop'
Fix "A resource failed to call close." warnings.

Closes #119

See merge request funkwhale/funkwhale-android!169
2022-06-12 12:48:32 +00:00
Ryan Harg 70d9ba241b Fix "A resource failed to call close." warnings. 2022-06-12 12:48:32 +00:00
Ryan Harg 3f6e010ace Merge branch 'service-leak' into 'develop'
fix authorization

See merge request funkwhale/funkwhale-android!168
2022-06-11 14:37:39 +00:00
Ryan Harg 20ee27da21 fix authorization 2022-06-11 14:37:38 +00:00
Ryan Harg 2bdc7f09de Merge branch 'fix-pipeline-gitlab-15' into 'develop'
Make Pipeline work with Gitlab 15

See merge request funkwhale/funkwhale-android!165
2022-06-07 18:43:00 +00:00
Georg Krause fdadc5853a
Make Pipeline work with Gitlab 15 2022-06-07 19:18:21 +02:00
Micha Gläß-Stöcker 084b0c2faf the ssh port of our apps vm has changed, this commit reflects this change 2022-06-05 23:20:06 +02:00
Ryan Harg 1d29876943 Merge branch 'renovate/androidx.appcompat-appcompat-1.x' into 'develop'
Update dependency androidx.appcompat:appcompat to v1.4.2

See merge request funkwhale/funkwhale-android!162
2022-06-05 11:47:00 +00:00
Renovate Bot 1171f6bd1f Update dependency androidx.appcompat:appcompat to v1.4.2 2022-06-04 06:01:18 +00:00
Ryan Harg 2e7634fe6d Merge branch 'renovate/androidx.core-core-ktx-1.x' into 'develop'
Update dependency androidx.core:core-ktx to v1.8.0

See merge request funkwhale/funkwhale-android!163
2022-06-04 05:53:37 +00:00
Renovate Bot 87965863d4 Update dependency androidx.core:core-ktx to v1.8.0 2022-06-01 17:00:34 +00:00
Ryan Harg fd1d72bf21 Merge branch 'renovate/com.google.android.material-material-1.x' into 'develop'
Update dependency com.google.android.material:material to v1.6.1

See merge request funkwhale/funkwhale-android!161
2022-06-01 11:20:41 +00:00
Renovate Bot 58d9a57a53 Update dependency com.google.android.material:material to v1.6.1 2022-06-01 10:49:19 +00:00
Ryan Harg bcbee1fa6c Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.2

See merge request funkwhale/funkwhale-android!160
2022-06-01 10:49:03 +00:00
Renovate Bot 4a9d65624b Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.2 2022-06-01 09:30:43 +00:00
Ryan Harg 5e18294e10 Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-android-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.2

See merge request funkwhale/funkwhale-android!159
2022-06-01 09:15:40 +00:00
Renovate Bot 926075d591 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.2 2022-06-01 08:55:30 +00:00
Ryan Harg 340073dc2a Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.2.1

See merge request funkwhale/funkwhale-android!157
2022-06-01 08:53:14 +00:00
Renovate Bot c09b66b347 Update dependency com.android.tools.build:gradle to v7.2.1 2022-05-27 19:31:00 +00:00
Micha Gläß-Stöcker 1ec04d0841 fix the hardcoded image in the ci config 2022-05-27 19:27:18 +00:00
Ryan Harg cdfd2b72b1 Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.12.4

See merge request funkwhale/funkwhale-android!158
2022-05-25 08:41:38 +00:00
Renovate Bot f8ad760fda Update dependency io.mockk:mockk to v1.12.4 2022-05-11 09:01:28 +00:00
Ryan Harg c92e40ec17 Merge branch 'renovate/com.google.android.material-material-1.x' into 'develop'
Update dependency com.google.android.material:material to v1.6.0

See merge request funkwhale/funkwhale-android!155
2022-05-06 12:09:06 +00:00
Renovate Bot 23f7c509ee Update dependency com.google.android.material:material to v1.6.0 2022-05-06 11:53:46 +00:00
Ryan Harg d6df4f3613 Merge branch 'renovate/org.jlleitschuh.gradle.ktlint-10.x' into 'develop'
Update plugin org.jlleitschuh.gradle.ktlint to v10.3.0

See merge request funkwhale/funkwhale-android!154
2022-05-06 11:53:00 +00:00
Renovate Bot 987f5a482a Update plugin org.jlleitschuh.gradle.ktlint to v10.3.0 2022-05-03 17:01:36 +00:00
Ryan Harg 9f24b2e6c2 Merge branch 'bugfix/116-fix-playback-order' into 'develop'
#116: Fix playback order to respect preference setting on albums fragment

See merge request funkwhale/funkwhale-android!153
2022-04-22 12:06:46 +00:00
Ryan Harg a6b1730c4a #116: Fix playback order to respect preference setting on albums fragment 2022-04-22 11:39:14 +00:00
Ryan Harg 2996c85fa2 Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.1

See merge request funkwhale/funkwhale-android!151
2022-04-22 08:14:00 +00:00
Renovate Bot e17dc7531d Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.1 2022-04-22 08:00:53 +00:00
Ryan Harg a218dd94ed Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-android-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.1

See merge request funkwhale/funkwhale-android!150
2022-04-22 07:47:32 +00:00
Renovate Bot 7d92d7d06b Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.1 2022-04-21 06:01:19 +00:00
Éilias McTalún f77b2e51e5 Translated using Weblate (Irish)
Currently translated at 13.7% (16 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/ga/
2022-04-21 05:59:34 +00:00
Éilias McTalún 02a6112eeb Added translation using Weblate (Irish) 2022-04-16 03:46:19 +00:00
Ryan Harg d924d510ba Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.1.3

See merge request funkwhale/funkwhale-android!152
2022-04-13 11:14:01 +00:00
Renovate Bot 1ad0513120 Update dependency com.android.tools.build:gradle to v7.1.3 2022-04-07 16:03:10 +00:00
Ryan Harg caa0307fdf Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.4.2

See merge request funkwhale/funkwhale-android!149
2022-04-05 07:10:30 +00:00
Renovate Bot 1fcf154993 Update dependency gradle to v7.4.2 2022-03-31 16:04:27 +00:00
Ryan Harg b881dd0f38 Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.4.1

See merge request funkwhale/funkwhale-android!147
2022-03-11 09:36:18 +00:00
Renovate Bot cf634a1eac Update dependency gradle to v7.4.1 2022-03-09 17:35:27 +00:00
Ryan Harg fd90f2d1a0 Merge branch 'renovate/androidx.preference-preference-ktx-1.x' into 'develop'
Update dependency androidx.preference:preference-ktx to v1.2.0

See merge request funkwhale/funkwhale-android!139
2022-03-07 08:32:54 +00:00
RenovateBot 89d4515cd4 Update dependency androidx.preference:preference-ktx to v1.2.0 2022-03-07 08:32:52 +00:00
Ryan Harg 5e2e823422 Merge branch 'housekeeping/update-openidappauth-version' into 'develop'
Update appauth version

See merge request funkwhale/funkwhale-android!146
2022-03-04 10:37:43 +00:00
Ryan Harg 2d1c2f34e5
Update appauth version 2022-03-04 11:16:48 +01:00
Ryan Harg 54c3d1ef63 Merge branch 'fix-linting-errors' into 'develop'
Fix linting errors

See merge request funkwhale/funkwhale-android!145
2022-03-04 08:45:02 +00:00
Ryan Harg 45ef5eb189
Fix linting errors 2022-03-04 09:30:03 +01:00
Ryan Harg b6f0afc5c3 Merge branch 'renovate/androidx.coordinatorlayout-coordinatorlayout-1.x' into 'develop'
Update dependency androidx.coordinatorlayout:coordinatorlayout to v1.2.0

See merge request funkwhale/funkwhale-android!135
2022-03-04 08:12:20 +00:00
Renovate Bot 56784b5871 Update dependency androidx.coordinatorlayout:coordinatorlayout to v1.2.0 2022-03-03 10:03:18 +00:00
Ryan Harg 7e7efab063 Merge branch 'renovate/androidx.lifecycle-lifecycle-runtime-ktx-2.x' into 'develop'
Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.4.1

See merge request funkwhale/funkwhale-android!142
2022-03-03 09:38:46 +00:00
Renovate Bot 1b9cd0895f Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.4.1 2022-03-03 09:01:20 +00:00
Ryan Harg 8efdd9ee8c Merge branch 'renovate/com.google.android.material-material-1.x' into 'develop'
Update dependency com.google.android.material:material to v1.5.0

See merge request funkwhale/funkwhale-android!137
2022-03-03 08:48:59 +00:00
Renovate Bot bbbefaaf60 Update dependency com.google.android.material:material to v1.5.0 2022-03-02 10:32:33 +00:00
Ryan Harg 4ee3f73fe3 Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.1.2

See merge request funkwhale/funkwhale-android!140
2022-03-02 10:03:24 +00:00
Renovate Bot 6ad4a3f770 Update dependency com.android.tools.build:gradle to v7.1.2 2022-03-02 06:03:29 +00:00
Ryan Harg c9ae07a330 Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.12.3

See merge request funkwhale/funkwhale-android!144
2022-03-02 05:42:21 +00:00
Renovate Bot db6c484d56 Update dependency io.mockk:mockk to v1.12.3 2022-02-28 15:02:00 +00:00
Ryan Harg 431b28ecd4 Merge branch 'bugfix/113-fix-invalid-null-handling-playlist' into 'develop'
Bugfix/113 fix invalid null handling playlist

See merge request funkwhale/funkwhale-android!143
2022-02-25 09:35:03 +00:00
Mouath Ibrahim c29e36c697 Bugfix/113 fix invalid null handling playlist 2022-02-25 09:35:01 +00:00
ghose 41519bda81 Translated using Weblate (Galician)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/gl/
2022-02-19 14:17:02 +00:00
Ryan Harg 36751f9469 Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.4

See merge request funkwhale/funkwhale-android!141
2022-02-18 10:05:30 +00:00
Renovate Bot 44d54ac730 Update dependency gradle to v7.4 2022-02-08 11:06:57 +00:00
Michael Long 8529fc441d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/zh_Hans/
2022-01-31 19:36:49 +00:00
Ryan Harg 83c0ad7f1b Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.1.0

See merge request funkwhale/funkwhale-android!138
2022-01-30 20:01:08 +00:00
Renovate Bot d211e006e3 Update dependency com.android.tools.build:gradle to v7.1.0 2022-01-26 08:02:25 +00:00
Ryan Harg a21ed67d93 Merge branch 'renovate/androidx.appcompat-appcompat-1.x' into 'develop'
Update dependency androidx.appcompat:appcompat to v1.4.1

See merge request funkwhale/funkwhale-android!136
2022-01-14 07:36:50 +00:00
Renovate Bot 3ae3ee7f5c Update dependency androidx.appcompat:appcompat to v1.4.1 2022-01-13 05:31:16 +00:00
JuniorJPDJ fb0e6985f7 Translated using Weblate (Polish)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/pl/
2022-01-07 23:36:40 +00:00
Ryan Harg b67f4f872b Merge branch 'technical/update-default-jdk-for-sdk' into 'develop'
Set default jdk to 11.0.13 temurin

See merge request funkwhale/funkwhale-android!133
2022-01-05 08:30:05 +00:00
Ryan Harg 82b9121433
Set default jdk to 11.0.13 temurin 2022-01-05 09:11:55 +01:00
Kristoffer Grundström 1522a5884b Translated using Weblate (Swedish)
Currently translated at 95.6% (111 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/sv/
2022-01-04 20:36:40 +00:00
Dignified Silence a93afe4533 Translated using Weblate (Japanese)
Currently translated at 96.5% (112 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/ja/
2022-01-04 20:36:39 +00:00
Ryan Harg 0ce41dec1e Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-android-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.0

See merge request funkwhale/funkwhale-android!123
2022-01-03 16:01:56 +00:00
Renovate Bot 519bb79ea7 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.6.0 2022-01-03 15:08:43 +00:00
Ryan Harg 45de070cd5 Merge branch 'renovate/androidx.appcompat-appcompat-1.x' into 'develop'
Update dependency androidx.appcompat:appcompat to v1.4.0

See merge request funkwhale/funkwhale-android!117
2022-01-03 15:08:14 +00:00
Renovate Bot 29c2784f13 Update dependency androidx.appcompat:appcompat to v1.4.0 2022-01-03 14:31:31 +00:00
Ryan Harg 253ce9e0f2 Merge branch 'technical/configure-gitlab-cache' into 'develop'
Technical/configure gitlab cache

See merge request funkwhale/funkwhale-android!130
2022-01-03 13:53:50 +00:00
Ryan Harg ad0c35b574 Reconfigure gitlab cache 2022-01-03 13:36:44 +00:00
Ryan Harg 060db0afdf Merge branch 'renovate/androidx.core-core-ktx-1.x' into 'develop'
Update dependency androidx.core:core-ktx to v1.7.0

See merge request funkwhale/funkwhale-android!118
2022-01-03 13:21:40 +00:00
Renovate Bot 658dd78b7f
Update dependency androidx.core:core-ktx to v1.7.0 2022-01-03 13:14:35 +01:00
Ryan Harg c3abbe4891 Merge branch 'renovate/androidx.lifecycle-lifecycle-runtime-ktx-2.x' into 'develop'
Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.4.0

See merge request funkwhale/funkwhale-android!109
2022-01-03 12:12:07 +00:00
Renovate Bot ba24fdd820 Update dependency androidx.lifecycle:lifecycle-runtime-ktx to v2.4.0 2022-01-03 11:31:27 +00:00
Ryan Harg a0c5b83c0d Merge branch 'technical/disalbe-tests-for-deploy-stages' into 'develop'
Disable tests for deploy stages

See merge request funkwhale/funkwhale-android!129
2022-01-03 11:16:47 +00:00
Ryan Harg b54db01488 Disable tests for deploy stages 2022-01-03 09:58:54 +00:00
Ryan Harg aa874866a6 Merge branch 'technical/update-compile-sdk-version' into 'develop'
Increase compileSdkVersion to 31

See merge request funkwhale/funkwhale-android!128
2022-01-03 09:58:12 +00:00
Ryan Harg be67a5e593 Increase compileSdkVersion to 31 2022-01-03 08:59:32 +00:00
Ryan Harg 0c95675c91 Merge branch 'technical/move-back-to-original-jacoco2cobertura-image' into 'develop'
Move back to original image as bug is fixed now

See merge request funkwhale/funkwhale-android!127
2022-01-03 08:58:55 +00:00
Ryan Harg 5f6d38051d Move back to original image as bug is fixed now 2022-01-03 08:35:26 +00:00
Ryan Harg de1c60bfb2 Merge branch 'renovate/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.x' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.0

See merge request funkwhale/funkwhale-android!124
2022-01-03 08:29:00 +00:00
Renovate Bot 709bbd29bd Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-core to v1.6.0 2021-12-31 16:31:18 +00:00
Ryan Harg 3450103dea Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.3.3

See merge request funkwhale/funkwhale-android!122
2021-12-31 16:22:26 +00:00
Renovate Bot 24eb65ec08 Update dependency gradle to v7.3.3 2021-12-31 14:31:39 +00:00
Ryan Harg 9e0843b758 Merge branch 'renovate/org.jlleitschuh.gradle.ktlint-10.x' into 'develop'
Update plugin org.jlleitschuh.gradle.ktlint to v10.2.1

See merge request funkwhale/funkwhale-android!125
2021-12-31 13:42:27 +00:00
Renovate Bot fa2830634b Update plugin org.jlleitschuh.gradle.ktlint to v10.2.1 2021-12-31 13:13:33 +00:00
Ryan Harg b12779ed39 Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.12.2

See merge request funkwhale/funkwhale-android!126
2021-12-31 13:13:12 +00:00
Renovate Bot d604629f3a Update dependency io.mockk:mockk to v1.12.2 2021-12-30 10:31:05 +00:00
Ryan Harg b842baa33b Merge branch 'renovate/gradle-7.x' into 'develop'
Update dependency gradle to v7.3.1

See merge request funkwhale/funkwhale-android!119
2021-12-25 13:19:39 +00:00
Renovate Bot 86f6dd2d54 Update dependency gradle to v7.3.1 2021-12-24 20:55:17 +00:00
Ryan Harg 23084cae4e Merge branch 'renovate/com.android.tools.build-gradle-7.x' into 'develop'
Update dependency com.android.tools.build:gradle to v7.0.4

See merge request funkwhale/funkwhale-android!115
2021-12-24 20:54:53 +00:00
Renovate Bot 59c8c265ff Update dependency com.android.tools.build:gradle to v7.0.4 2021-12-23 12:13:27 +00:00
Ryan Harg d992b936d4 Merge branch 'renovate/org.jlleitschuh.gradle.ktlint-10.x' into 'develop'
Update plugin org.jlleitschuh.gradle.ktlint to v10.2.0

See merge request funkwhale/funkwhale-android!110
2021-12-23 10:47:22 +00:00
Renovate Bot 37ad6eaf5b Update plugin org.jlleitschuh.gradle.ktlint to v10.2.0 2021-12-23 10:33:09 +00:00
Ryan Harg d9cd6afa0d Merge branch 'renovate/io.mockk-mockk-1.x' into 'develop'
Update dependency io.mockk:mockk to v1.12.1

See merge request funkwhale/funkwhale-android!116
2021-12-23 10:32:23 +00:00
Renovate Bot dc8a27535e Update dependency io.mockk:mockk to v1.12.1 2021-12-23 09:51:50 +00:00
Ryan Harg eec30e8582 Merge branch 'renovate/com.github.triplet.play-3.x' into 'develop'
Update plugin com.github.triplet.play to v3.7.0

See merge request funkwhale/funkwhale-android!120
2021-12-23 09:51:27 +00:00
Renovate Bot fe31e185fa Update plugin com.github.triplet.play to v3.7.0 2021-12-23 09:25:02 +00:00
Thomas 0fa69d837e Translated using Weblate (French)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/fr/
2021-12-22 22:36:35 +00:00
Ryan Harg 1fc0f6e8ac Merge branch 'housekeeping/custom-build-image' into 'develop'
Custom Build Image

See merge request funkwhale/funkwhale-android!121
2021-12-22 14:54:56 +00:00
Georg Krause 37409bdd7a Custom Build Image 2021-12-22 14:54:55 +00:00
Erik Präntare e000aa5e6a Translated using Weblate (Swedish)
Currently translated at 25.0% (29 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/sv/
2021-12-13 16:36:31 +00:00
Erik Präntare 5d7583e7f5 Added translation using Weblate (Swedish) 2021-12-12 16:09:19 +00:00
Burp af9342428e Translated using Weblate (Spanish)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/es/
2021-12-11 10:36:27 +00:00
milotype ed9f4e0e88 Translated using Weblate (Croatian)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/hr/
2021-10-25 19:03:42 +00:00
Dignified Silence 049e61ab7f Translated using Weblate (Japanese)
Currently translated at 85.3% (99 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/ja/
2021-10-22 17:48:17 +00:00
ghose d3043fc8da Translated using Weblate (Galician)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/gl/
2021-10-09 05:36:23 +00:00
danigarau5dd7796dc359494b 6a70540e0e Translated using Weblate (Italian)
Currently translated at 100.0% (116 of 116 strings)

Translation: Funkwhale/Funkwhale For Android
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/ffa/it/
2021-09-29 10:36:21 +00:00
151 changed files with 4909 additions and 2946 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,4 +1,5 @@
image: jangrewe/gitlab-ci-android
# This image lives in https://dev.funkwhale.audio/funkwhale/ci
image: $CI_REGISTRY/funkwhale/ci/android:latest
variables:
COBERTURA_REPORT: '$CI_PROJECT_DIR/app/build/reports/cobertura.xml'
@ -6,11 +7,20 @@ 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
@ -19,11 +29,6 @@ stages:
script:
- echo "Overwrite me"
cache:
key: ${CI_PROJECT_ID}
paths:
- .gradle/
.build:
stage: build
variables:
@ -34,7 +39,7 @@ stages:
before_script:
- git fetch --unshallow --tags
after_script:
- 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`
- 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`
- apt update && apt install gettext-base
- cat $metadata_template | envsubst > $metadata_file
extends: .gradle-default
@ -43,6 +48,9 @@ stages:
- $apk_file
- $metadata_file
- $output_metadata
cache:
# inherit all global cache settings
<<: *global_cache
test:
extends: .gradle-default
@ -50,17 +58,30 @@ test:
except:
- tags
script:
- ./gradlew test jacocoTestReport
- ./gradlew --no-daemon --stacktrace 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: gjrtimmer/jacoco2cobertura:1.0.8
image: haynes/jacoco2cobertura:1.0.9
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'
@ -71,13 +92,15 @@ coverage:
- tags
artifacts:
reports:
cobertura: $COBERTURA_REPORT
coverage_report:
coverage_format: cobertura
path: $COBERTURA_REPORT
build-develop:
extends: .build
script:
- echo -n $PREVIEW_SIGNING_KEY_STORE | base64 -d > app/android.keystore
- ./gradlew assembleDebug -Psigning.store=android.keystore -Psigning.store_passphrase=$PREVIEW_SIGNING_KEY_PASS -Psigning.key_passphrase=$PREVIEW_SIGNING_KEY_PASS
- ./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
only:
- develop
@ -90,45 +113,51 @@ build-release:
extends: .build
script:
- echo -n $SIGNING_KEY_STORE | base64 -d > app/android.keystore
- ./gradlew assembleRelease -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
- ./gradlew --stacktrace --no-daemon 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 assembleDebug
- ./gradlew --stacktrace --no-daemon -x check assembleDebug
except:
- develop
- tags
.deploy:
image: debian
before_script:
- apt update && apt -y install openssh-server
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"'
deploy-develop:
extends: .deploy
stage: deploy
only:
- develop
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'
variables:
FILE: app/build/outputs/apk/debug/app-debug.apk
PACKAGE: audio.funkwhale.ffa.dev
deploy-release:
extends: .deploy
stage: deploy
only:
- tags
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'
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"

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.11.hs-adpt
java=11.0.13-tem

1
.tool-versions Normal file
View File

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

View File

@ -1,3 +1,54 @@
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,9 +7,8 @@ You can get help and discuss Funkwhale on Matrix on [#funkwhale-android:matrix.o
## Installation
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".
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".
## State
@ -31,13 +30,9 @@ 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="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" />
<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%" />
## Translation

View File

@ -5,12 +5,16 @@ 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 "10.1.0"
id("org.jlleitschuh.gradle.ktlint") version "11.2.0"
id("com.gladed.androidgitversion") version "0.4.14"
id("com.github.triplet.play") version "3.6.0"
id("com.github.triplet.play") version "3.8.1"
id("de.mobilej.unmock")
id("com.github.ben-manes.versions")
id("org.jetbrains.kotlin.android")
jacoco
}
@ -32,27 +36,35 @@ androidGitVersion {
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
namespace = "audio.funkwhale.ffa"
testCoverage {
version = Versions.jacoco
version = "0.8.7"
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
jvmTarget = JavaVersion.VERSION_17.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 = 30
compileSdk = 33
defaultConfig {
@ -62,7 +74,7 @@ android {
versionName = androidGitVersion.name()
minSdk = 24
targetSdk = 30
targetSdk = 33
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -151,54 +163,65 @@ 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:${Versions.kotlin}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
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("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.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.recyclerview:recyclerview:1.2.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("com.google.android.material:material:1.4.0")
implementation("com.android.support.constraint:constraint-layout:2.0.4")
implementation("com.google.android.material:material:1.9.0") {
exclude("androidx.constraintlayout")
}
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
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("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("io.insert-koin:koin-core:${Versions.koin}")
implementation("io.insert-koin:koin-android:${Versions.koin}")
testImplementation("io.insert-koin:koin-test:${Versions.koin}")
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("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:${Versions.exoPlayerExtensions}") {
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:789a4f83169cff5c7a91655bb828fde2cfde671a") {
isTransitive = false
}
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:${Versions.exoPlayerExtensions}") {
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:789a4f83169cff5c7a91655bb828fde2cfde671a") {
isTransitive = false
}
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.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.squareup.picasso:picasso:2.71828")
implementation("jp.wasabeef:picasso-transformations:2.4.0")
implementation("net.openid:appauth:${Versions.openIdAppAuth}")
implementation("net.openid:appauth:0.11.1")
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
testImplementation("junit:junit:4.13.2")
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}")
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")
androidTestImplementation("io.mockk:mockk-android:${Versions.mockk}")
androidTestImplementation("io.mockk:mockk-android:1.13.4")
androidTestImplementation("androidx.navigation:navigation-testing:$navVersion")
}
project.afterEvaluate {

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="audio.funkwhale.ffa">
xmlns:tools="http://schemas.android.com/tools">
<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" />
@ -22,7 +23,7 @@
android:name=".activities.SplashActivity"
android:launchMode="singleInstance"
android:noHistory="true"
android:screenOrientation="portrait">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -40,12 +41,7 @@
android:screenOrientation="portrait" />
<activity
android:name=".activities.MainActivity"
android:screenOrientation="portrait" />
<activity
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
android:name=".activities.MainActivity" />
<activity
android:name=".activities.DownloadsActivity"
@ -59,9 +55,15 @@
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:foregroundServiceType="mediaPlayback"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
@ -80,12 +82,14 @@
</service>
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<receiver android:name="androidx.media.session.MediaButtonReceiver"
android:exported="false">
<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,13 +6,8 @@ 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
@ -28,11 +23,6 @@ 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()
@ -83,7 +73,7 @@ class FFA : Application() {
builder.appendLine(e.toString())
FFACache.set(this@FFA, "crashdump", builder.toString().toByteArray())
FFACache.set(this@FFA, "crashdump", builder.toString())
}
}

View File

@ -14,7 +14,6 @@ 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
@ -65,20 +64,19 @@ class DownloadsActivity : AppCompatActivity() {
private fun refresh() {
lifecycleScope.launch(Main) {
val cursor = exoDownloadManager.downloadIndex.getDownloads()
adapter.downloads.clear()
exoDownloadManager.downloadIndex.getDownloads()
.use { cursor ->
while (cursor.moveToNext()) {
val download = cursor.download
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let { info ->
adapter.downloads.add(
info.apply { this.download = download }
)
download.getMetadata()?.let { info ->
adapter.downloads.add(
info.apply { this.download = download }
)
}
}
}
}
adapter.notifyDataSetChanged()
}
}
@ -101,26 +99,29 @@ class DownloadsActivity : AppCompatActivity() {
}
private suspend fun refreshProgress() {
val cursor = exoDownloadManager.downloadIndex.getDownloads()
exoDownloadManager.downloadIndex.getDownloads()
.use { cursor ->
while (cursor.moveToNext()) {
val download = cursor.download
while (cursor.moveToNext()) {
val download = cursor.download
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
}
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)
}
}
adapter.notifyItemChanged(match.second)
}
}
}
}
}
}
}
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {

View File

@ -1,10 +1,13 @@
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
@ -40,34 +43,36 @@ class LoginActivity : AppCompatActivity() {
limitContainerWidth()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
private var resultLauncher =
registerForActivityResult(StartActivityForResult()) { result ->
result.data?.let {
oAuth.exchange(this, it) {
PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.setBoolean("anonymous", false)
data?.let {
when (requestCode) {
0 -> {
oAuth.exchange(this, data) {
PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.setBoolean("anonymous", false)
lifecycleScope.launch(Main) {
Userinfo.get(this@LoginActivity, oAuth)?.let {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
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))
return@launch finish()
}
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('/')
@ -100,6 +105,12 @@ 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()
}
}
}
}
@ -134,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
oAuth.init(hostname)
return oAuth.register {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
oAuth.authorize(this)
resultLauncher.launch(oAuth.authorizeIntent(this))
}
}

View File

@ -1,236 +1,211 @@
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 android.widget.SeekBar
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout
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.AlbumsFragment
import audio.funkwhale.ffa.fragments.ArtistsFragment
import audio.funkwhale.ffa.fragments.BrowseFragment
import audio.funkwhale.ffa.fragments.LandscapeQueueFragment
import audio.funkwhale.ffa.fragments.BrowseFragmentDirections
import audio.funkwhale.ffa.fragments.NowPlayingFragment
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.ProgressBus
import audio.funkwhale.ffa.utils.Request
import audio.funkwhale.ffa.utils.RequestBus
import audio.funkwhale.ffa.utils.Response
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.untilNetwork
import audio.funkwhale.ffa.views.DisableableFrameLayout
import audio.funkwhale.ffa.utils.wait
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 favoriteRepository = FavoritesRepository(this)
private val favoritedRepository = FavoritedRepository(this)
private val favoritedRepository by lazy {
FavoritedRepository(applicationContext)
}
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(this)
AppContext.init(applicationContext)
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())
}
supportFragmentManager
.beginTransaction()
.replace(R.id.container, BrowseFragment())
.commit()
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()
}
watchEventBus()
}
override fun onResume() {
super.onResume()
(binding.container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
if (binding.nowPlaying.isOpened()) {
binding.nowPlaying.close()
binding.nowPlaying.getFragment<NowPlayingFragment>().apply {
favoritedRepository.update(applicationContext, lifecycleScope)
return@setShouldRegisterTouch false
}
startService(Intent(applicationContext, PlayerService::class.java))
DownloadService.start(applicationContext, PinService::class.java)
true
}
CommandBus.send(Command.RefreshService)
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()
lifecycleScope.launch(IO) {
Userinfo.get(applicationContext, oAuth)
}
}
}
override fun onBackPressed() {
if (binding.nowPlaying.isOpened()) {
binding.nowPlaying.close()
return
}
super.onBackPressed()
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
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.nowPlaying.close()
(supportFragmentManager.fragments.last() as? BrowseFragment)?.let {
it.selectTabAt(0)
return true
}
launchFragment(BrowseFragment())
binding.nowPlayingBottomSheet.close()
navigation.popBackStack(R.id.browseFragment, false)
}
R.id.nav_queue -> launchDialog(QueueFragment())
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
R.id.nav_search -> navigation.navigate(BrowseFragmentDirections.browseToSearch())
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
@ -279,118 +254,42 @@ class MainActivity : AppCompatActivity() {
return false
}
}
R.id.nav_downloads -> startActivity(Intent(this, DownloadsActivity::class.java))
R.id.settings -> startActivityForResult(Intent(this, SettingsActivity::class.java), 0)
R.id.settings -> resultLauncher.launch(Intent(this, SettingsActivity::class.java))
}
return true
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
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()
}
}
}
private fun launchFragment(fragment: Fragment) {
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
oldFragment.enterTransition = null
oldFragment.exitTransition = null
supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
private fun addSiblingFragmentPadding() {
val anim = if (binding.nowPlayingBottomSheet.isHidden) {
ValueAnimator.ofInt(binding.nowPlayingBottomSheet.peekHeight, 0)
} else {
ValueAnimator.ofInt(0, binding.nowPlayingBottomSheet.peekHeight)
}
supportFragmentManager
.beginTransaction()
.setCustomAnimations(0, 0, 0, 0)
.replace(R.id.container, fragment)
.commit()
anim.duration = 200
anim.addUpdateListener {
binding.navHostFragmentWrapper.setPadding(0, 0, 0, it.animatedValue as Int)
}
anim.start()
}
private fun launchDialog(fragment: DialogFragment) {
supportFragmentManager.beginTransaction().let {
fragment.show(it, "")
}
}
private fun launchDialog(fragment: DialogFragment) =
fragment.show(supportFragmentManager.beginTransaction(), "")
@SuppressLint("NewApi")
private fun watchEventBus() {
lifecycleScope.launch(Main) {
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)
}
}
}
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)
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
@ -399,263 +298,42 @@ class MainActivity : AppCompatActivity() {
}
}
}
else -> {}
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
CommandBus.get().flowWithLifecycle(
this@MainActivity.lifecycle, Lifecycle.State.RESUMED
).collect { command ->
when (command) {
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())
}
)
}
)
}
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(
is Command.StartService -> startService(command.command)
is Command.RefreshTrack -> refreshTrack(command.track)
is Command.AddToPlaylist -> AddToPlaylistDialog.show(
layoutInflater,
this@MainActivity,
nowPlayingDetailsInfo,
Gravity.START,
R.attr.actionOverflowMenuStyle,
0
).apply {
inflate(R.menu.track_info)
lifecycleScope,
command.tracks
)
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)))
else -> {}
}
}
}
}
private fun changeRepeatMode(index: Int) {
when (index) {
// From no repeat to repeat all
0 -> {
FFACache.set(this@MainActivity, "repeat", "0".toByteArray())
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)
}
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))
}
private fun refreshTrack(track: Track?) {
if (track != null) {
binding.nowPlayingBottomSheet.show()
}
}
@ -666,7 +344,7 @@ class MainActivity : AppCompatActivity() {
try {
Fuel
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
.authorize(this@MainActivity, oAuth)
.authorize(applicationContext, oAuth)
.header("Content-Type", "application/json")
.body(Gson().toJson(mapOf("track" to track.id)))
.awaitStringResponse()
@ -676,4 +354,15 @@ 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

@ -1,197 +0,0 @@
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,8 +40,6 @@ class SettingsActivity : AppCompatActivity() {
)
.commit()
}
fun getThemeResId(): Int = R.style.AppTheme
}
class SettingsFragment :
@ -51,7 +49,7 @@ class SettingsFragment :
override fun onResume() {
super.onResume()
preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -60,14 +58,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.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
FFACache.getLines(activity, "crashdump")?.joinToString("\n").also {
clip.setPrimaryClip(ClipData.newPlainText("Funkwhale logs", it))
Toast.makeText(
@ -116,6 +114,14 @@ 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)
@ -150,7 +156,7 @@ class SettingsFragment :
}
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let {
it.summary = getString(R.string.settings_media_cache_size_summary, it.value)
it.summary = getString(R.string.settings_media_cache_size_summary, it.value as Int) // manual cast to address a bug in AGP
}
preferenceManager.findPreference<Preference>("version")?.let {

View File

@ -8,9 +8,7 @@ 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.maybeLoad
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import audio.funkwhale.ffa.utils.CoverArt
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class AlbumsAdapter(
@ -45,8 +43,7 @@ class AlbumsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val album = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(album.cover()))
CoverArt.requestCreator(album.cover())
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)

View File

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

View File

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

View File

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

View File

@ -8,18 +8,19 @@ 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.maybeLoad
import audio.funkwhale.ffa.utils.CoverArt
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
@ -28,7 +29,7 @@ class FavoritesAdapter(
private val context: Context?,
private val favoriteListener: FavoriteListener,
val fromQueue: Boolean = false,
) : FFAAdapter<Track, FavoritesAdapter.ViewHolder>() {
) : FFAAdapter<Favorite, FavoritesAdapter.ViewHolder>() {
init {
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
@ -37,6 +38,7 @@ class FavoritesAdapter(
private lateinit var binding: RowTrackBinding
var currentTrack: Track? = null
var filter = ""
override fun getItemCount() = data.size
@ -44,6 +46,15 @@ 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)
@ -56,46 +67,42 @@ class FavoritesAdapter(
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val favorite = data[position]
val track = favorite.track
Picasso.get()
.maybeLoad(maybeNormalizeUrl(favorite.album?.cover()))
CoverArt.requestCreator(maybeNormalizeUrl(track.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = favorite.title
holder.artist.text = favorite.artist.name
holder.title.text = track.title
holder.artist.text = track.artist.name
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.ripple)
holder.itemView.background = AppCompatResources.getDrawable(context, R.drawable.ripple)
}
if (favorite.id == currentTrack?.id) {
if (track.id == currentTrack?.id) {
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.current)
holder.itemView.background = AppCompatResources.getDrawable(context, R.drawable.current)
}
}
context?.let {
when (favorite.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
}
holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
when (favorite.cached || favorite.downloaded) {
when (track.cached || track.downloaded) {
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
if (favorite.cached && !favorite.downloaded) {
if (track.cached && !track.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter =
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
}
}
if (favorite.downloaded) {
if (track.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter =
PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
@ -103,8 +110,7 @@ class FavoritesAdapter(
}
holder.favorite.setOnClickListener {
favoriteListener.onToggleFavorite(favorite.id, !favorite.favorite)
favoriteListener.onToggleFavorite(track.id, !track.favorite)
data.remove(favorite)
notifyItemRemoved(holder.bindingAdapterPosition)
}
@ -117,10 +123,10 @@ class FavoritesAdapter(
setOnMenuItemClickListener {
when (it.itemId) {
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))
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))
}
true
@ -161,11 +167,13 @@ class FavoritesAdapter(
when (fromQueue) {
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
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")
}
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")
}
}
}
}

View File

@ -20,10 +20,9 @@ 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.maybeLoad
import audio.funkwhale.ffa.utils.CoverArt
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,39 +69,37 @@ class PlaylistTracksAdapter(
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val track = data[position]
val playlistTrack = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
CoverArt.requestCreator(maybeNormalizeUrl(playlistTrack.track.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = track.track.title
holder.artist.text = track.track.artist.name
holder.title.text = playlistTrack.track.title
holder.artist.text = playlistTrack.track.artist.name
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
}
if (track.track == currentTrack || track.track.current) {
if (playlistTrack.track == currentTrack || playlistTrack.track.current) {
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
}
}
context?.let {
when (track.track.favorite) {
when (playlistTrack.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(track.track.id, !track.track.favorite)
favoriteListener.onToggleFavorite(playlistTrack.track.id, !playlistTrack.track.favorite)
track.track.favorite = !track.track.favorite
playlistTrack.track.favorite = !playlistTrack.track.favorite
notifyItemChanged(position)
}
}
@ -117,11 +114,11 @@ class PlaylistTracksAdapter(
setOnMenuItemClickListener {
when (it.itemId) {
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_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_remove_from_playlist -> playlistListener.onRemoveTrackFromPlaylist(
track.track,
playlistTrack.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,8 +79,7 @@ class PlaylistsAdapter(
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
}
Picasso.get()
.load(url)
CoverArt.requestCreator(url)
.transform(RoundedCornersTransformation(32, 0, corner))
.into(imageView)
}

View File

@ -17,7 +17,6 @@ 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(
@ -190,12 +189,10 @@ class RadiosAdapter(
art.setColorFilter(context.getColor(R.color.controlForeground))
scope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted -> {
art.colorFilter = originalColorFilter
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
}
EventBus.get().collect { event ->
if (event 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,16 +20,17 @@ 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.maybeLoad
import audio.funkwhale.ffa.utils.CoverArt
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(
private val layoutInflater: LayoutInflater,
private val context: Context?,
viewModel: SearchViewModel,
private val fragment: Fragment,
private val listener: OnSearchResultClickListener,
private val favoriteListener: FavoriteListener
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
@ -51,12 +52,27 @@ class SearchAdapter(
val sectionCount = 3
var artists: MutableList<Artist> = mutableListOf()
var albums: MutableList<Album> = mutableListOf()
var tracks: MutableList<Track> = mutableListOf()
var artists = listOf<Artist>()
var albums = listOf<Album>()
var tracks = listOf<Track>()
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 {
@ -68,7 +84,7 @@ class SearchAdapter(
}
ResultType.Artist.ordinal -> artists[position].id.toLong()
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong()
ResultType.Album.ordinal -> albums[position - artists.size - 2].id.toLong()
ResultType.Track.ordinal ->
tracks[position - artists.size - albums.size - sectionCount].id.toLong()
else -> 0
@ -87,12 +103,12 @@ class SearchAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
ResultType.Header.ordinal -> {
searchHeaderBinding = RowSearchHeaderBinding.inflate(layoutInflater, parent, false)
SearchHeaderViewHolder(searchHeaderBinding, context)
searchHeaderBinding = RowSearchHeaderBinding.inflate(fragment.layoutInflater, parent, false)
SearchHeaderViewHolder(searchHeaderBinding, fragment.requireContext())
}
else -> {
rowTrackBinding = RowTrackBinding.inflate(layoutInflater, parent, false)
RowTrackViewHolder(rowTrackBinding, context).also {
rowTrackBinding = RowTrackBinding.inflate(fragment.layoutInflater, parent, false)
RowTrackViewHolder(rowTrackBinding, fragment.requireContext()).also {
rowTrackBinding.root.setOnClickListener(it)
}
}
@ -106,47 +122,45 @@ class SearchAdapter(
val rowTrackViewHolder = holder as? RowTrackViewHolder
if (resultType == ResultType.Header.ordinal) {
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 (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
)
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 = context.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 = 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 (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 = context.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 = 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 (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)
}
}
@ -175,8 +189,7 @@ class SearchAdapter(
else -> tracks[position]
}
Picasso.get()
.maybeLoad(maybeNormalizeUrl(item.cover()))
CoverArt.requestCreator(maybeNormalizeUrl(item.cover()))
.fit()
.transform(RoundedCornersTransformation(16, 0))
.into(rowTrackViewHolder?.cover)
@ -218,90 +231,91 @@ class SearchAdapter(
}
ResultType.Track.ordinal -> {
(item as? Track)?.let { track ->
context?.let { context ->
if (track == currentTrack || track.current) {
searchHeaderViewHolder?.title?.setTypeface(
searchHeaderViewHolder.title.typeface,
Typeface.BOLD
)
rowTrackViewHolder?.artist?.setTypeface(
rowTrackViewHolder.artist.typeface,
Typeface.BOLD
)
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)
}
}
when (track.favorite) {
true -> rowTrackViewHolder?.favorite?.setColorFilter(
context.getColor(R.color.colorFavorite)
)
false -> rowTrackViewHolder?.favorite?.setColorFilter(
context.getColor(R.color.colorSelected)
)
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
)
}
}
rowTrackViewHolder?.favorite?.setOnClickListener {
favoriteListener.let {
favoriteListener.onToggleFavorite(track.id, !track.favorite)
tracks[position - artists.size - albums.size - sectionCount].favorite =
!track.favorite
notifyItemChanged(position)
}
if (track.downloaded) {
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
it?.colorFilter =
PorterDuffColorFilter(
fragment.requireContext().getColor(R.color.downloaded),
PorterDuff.Mode.SRC_IN
)
}
}
when (track.cached || track.downloaded) {
true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
R.drawable.downloaded, 0, 0, 0
)
false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
0, 0, 0, 0
)
}
rowTrackViewHolder?.actions?.setOnClickListener {
PopupMenu(
fragment.requireContext(),
rowTrackViewHolder.actions,
Gravity.START,
R.attr.actionOverflowMenuStyle,
0
).apply {
inflate(R.menu.row_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
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))
)
}
}
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
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
}
show()
true
}
show()
}
}
}
@ -318,12 +332,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,10 +21,9 @@ 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.maybeLoad
import audio.funkwhale.ffa.utils.CoverArt
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
@ -71,8 +70,7 @@ class TracksAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val track = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
CoverArt.requestCreator(maybeNormalizeUrl(track.cover()))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.cover)
@ -193,7 +191,6 @@ 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.data = data.toMutableList()
adapter.setUnfilteredData(data.toMutableList())
adapter.notifyDataSetChanged()
return@untilNetwork
@ -124,7 +124,7 @@ object AddToPlaylistDialog {
FFACache.set(
context,
cacheId,
Gson().toJson(cache(adapter.data)).toByteArray()
Gson().toJson(cache(adapter.data)).toString()
)
} catch (e: ConcurrentModificationException) {
}

View File

@ -1,37 +1,28 @@
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.maybeLoad
import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.onViewPager
import com.squareup.picasso.Picasso
import com.preference.PowerPreference
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
@ -45,77 +36,22 @@ 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, artistId)
artistTracksRepository = ArtistTracksRepository(context, artistId)
repository = AlbumsRepository(context, args.artist.id)
artistTracksRepository = ArtistTracksRepository(context, args.artist.id)
}
override fun onCreateView(
@ -125,6 +61,12 @@ 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
}
@ -137,8 +79,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
super.onViewCreated(view, savedInstanceState)
binding.cover.let { cover ->
Picasso.get()
.maybeLoad(maybeNormalizeUrl(artistArt))
CoverArt.requestCreator(maybeNormalizeUrl(artistArt))
.noFade()
.fit()
.centerCrop()
@ -146,36 +87,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
.into(cover)
}
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
}
}
}
}
binding.artist.text = args.artist.name
}
override fun onResume() {
@ -194,11 +106,46 @@ 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) {
openTracks(context, album, fragment = this@AlbumsFragment)
findNavController().navigate(AlbumsFragmentDirections.albumsToTracks(album))
}
}
}

View File

@ -4,18 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.navigation.fragment.findNavController
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>() {
@ -49,29 +44,7 @@ class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: 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()
}
findNavController().navigate(BrowseFragmentDirections.browseToTracks(album))
}
}
}

View File

@ -1,27 +1,17 @@
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.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
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!!
@ -50,49 +40,9 @@ 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) {
openAlbums(context, artist, fragment = this@ArtistsFragment)
findNavController().navigate(BrowseFragmentDirections.browseToAlbums(artist))
}
}
}

View File

@ -7,19 +7,13 @@ 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?,
@ -27,11 +21,14 @@ 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()
}
}
@ -39,8 +36,4 @@ class BrowseFragment : Fragment() {
super.onDestroyView()
_binding = null
}
fun selectTabAt(position: Int) {
binding.tabs.getTabAt(position)?.select()
}
}

View File

@ -18,12 +18,26 @@ 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)
@ -32,7 +46,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
}
@ -130,19 +144,20 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
if (isCache) {
moreLoading = false
adapter.data = data.toMutableList()
adapter.setUnfilteredData(data.toMutableList())
adapter.notifyDataSetChanged()
return@launch
}
if (first) {
adapter.data.clear()
adapter.getUnfilteredData().clear()
}
onDataFetched(data)
adapter.data.addAll(data)
adapter.getUnfilteredData().addAll(data)
adapter.applyFilter()
withContext(IO) {
try {
@ -150,7 +165,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
FFACache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.data)).toByteArray()
Gson().toJson(repository.cache(adapter.getUnfilteredData())).toString()
)
}
} catch (e: ConcurrentModificationException) {
@ -161,7 +176,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.data.size)
fetch(Repository.Origin.Network.origin, adapter.getUnfilteredData().size)
} else {
moreLoading = false
}

View File

@ -1,6 +1,8 @@
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
@ -9,6 +11,7 @@ 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
@ -25,12 +28,11 @@ 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<Track, FavoritesAdapter>() {
class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
@ -54,6 +56,20 @@ class FavoritesFragment : FFAFragment<Track, 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
}
@ -78,24 +94,20 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
}
binding.play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
}
}
private fun watchEventBus() {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
EventBus.get().collect { event ->
if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download)
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
if (command is Command.RefreshTrack) refreshCurrentTrack(command.track)
}
}
}
@ -110,11 +122,13 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
withContext(Main) {
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
val data = adapter.data.map {
it.track.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.setUnfilteredData(data)
adapter.notifyDataSetChanged()
}
}
@ -125,7 +139,7 @@ class FavoritesFragment : FFAFragment<Track, 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].downloaded = true
adapter.data[match.second].track.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.data = response.queue.toMutableList()
it.setUnfilteredData(response.queue.toMutableList())
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
@ -110,17 +110,13 @@ class LandscapeQueueFragment : Fragment() {
private fun watchEventBus() {
activity?.lifecycleScope?.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.QueueChanged -> refresh()
}
if (message is Event.QueueChanged) refresh()
}
}
activity?.lifecycleScope?.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refresh()
}
if (command is Command.RefreshTrack) refresh()
}
}
}

View File

@ -0,0 +1,244 @@
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,14 +6,13 @@ 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
@ -21,62 +20,41 @@ 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>()
private var _binding: FragmentTracksBinding? = null
private val binding get() = _binding!!
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, albumId)
adapter = PlaylistTracksAdapter(
layoutInflater,
context,
FavoriteListener(favoritesRepository),
PlaylistListener()
)
repository = PlaylistTracksRepository(context, args.playlist.id)
watchEventBus()
}
@ -102,8 +80,8 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
binding.cover.visibility = View.INVISIBLE
binding.covers.visibility = View.VISIBLE
binding.artist.text = "Playlist"
binding.title.text = albumTitle
binding.artist.text = getString(R.string.playlist)
binding.title.text = args.playlist.name
}
override fun onResume() {
@ -132,7 +110,6 @@ 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")
}
@ -168,39 +145,42 @@ 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
}
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
}
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)
lifecycleScope.launch(Main) {
CoverArt.requestCreator(maybeNormalizeUrl(url))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0, corner))
.into(imageView)
}
}
}
}
private fun watchEventBus() {
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
if (command is Command.RefreshTrack) {
refreshCurrentTrack(command.track)
}
}
}
@ -215,12 +195,12 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener {
override fun onMoveTrack(from: Int, to: Int) {
playlistsRepository.move(albumId, from, to)
playlistsRepository.move(args.playlist.id, from, to)
}
override fun onRemoveTrackFromPlaylist(track: Track, index: Int) {
lifecycleScope.launch(Main) {
playlistsRepository.remove(albumId, index)
playlistsRepository.remove(args.playlist.id, index)
update()
}
}

View File

@ -4,17 +4,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.navigation.fragment.findNavController
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>() {
@ -48,29 +43,7 @@ class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener {
override fun onClick(holder: View?, playlist: 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()
}
findNavController().navigate(BrowseFragmentDirections.browseToPlaylistTracks(playlist))
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,136 @@
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,44 +8,41 @@ 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
@ -56,37 +53,12 @@ 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, albumId)
repository = TracksRepository(context, args.album.id)
adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository))
@ -129,6 +101,12 @@ 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
}
@ -140,16 +118,15 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Picasso.get()
.maybeLoad(maybeNormalizeUrl(albumCover))
CoverArt.requestCreator(maybeNormalizeUrl(args.album.cover()))
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(binding.cover)
binding.artist.text = albumArtist
binding.title.text = albumTitle
binding.artist.text = args.album.artist.name
binding.title.text = args.album.title
}
override fun onResume() {
@ -188,7 +165,6 @@ 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")
}
@ -244,16 +220,16 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
private fun watchEventBus() {
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
if (message is Event.DownloadChanged) {
refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
if (command is Command.RefreshTrack) {
refreshCurrentTrack(command.track)
}
}
}
@ -263,10 +239,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
withContext(Main) {
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.setUnfilteredData(
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.ExoDatabaseProvider
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
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")) {
ExoDatabaseProvider(context)
StandaloneDatabaseProvider(context)
}
single {

View File

@ -1,13 +1,18 @@
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,
val cover: Covers?,
private val cover: Covers?,
val release_date: String?
) : SearchResult {
data class Artist(val name: String)
) : SearchResult, Parcelable {
@Parcelize
data class Artist(val name: String) : Parcelable
override fun cover() = cover?.urls?.original
override fun title() = title

View File

@ -1,16 +1,31 @@
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 {
) : SearchResult, Parcelable {
@Parcelize
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,6 +5,7 @@ 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,3 +1,7 @@
package audio.funkwhale.ffa.model
data class CoverUrls(val original: String)
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class CoverUrls(val original: String) : Parcelable

View File

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

View File

@ -0,0 +1,10 @@
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

@ -0,0 +1,9 @@
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,9 +1,13 @@
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,10 +1,16 @@
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,
@ -12,10 +18,18 @@ data class Track(
val uploads: List<Upload> = listOf(),
val copyright: String? = null,
val license: String? = null
) : SearchResult {
) : SearchResult, Parcelable {
@IgnoredOnParcel
var current: Boolean = false
@IgnoredOnParcel
var favorite: Boolean = false
@IgnoredOnParcel
var cached: Boolean = false
@IgnoredOnParcel
var downloaded: Boolean = false
companion object {
@ -23,17 +37,21 @@ 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))
)
}
data class Upload(
val listen_url: String,
val duration: Int,
val bitrate: Int
)
@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)
}
override fun equals(other: Any?): Boolean {
return when (other) {
@ -49,14 +67,30 @@ data class Track(
fun bestUpload(): Upload? {
if (uploads.isEmpty()) return null
return when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
var bestUpload = 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,6 +2,7 @@ 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
@ -14,14 +15,18 @@ 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
@ -41,8 +46,10 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
}
scope.launch(Default) {
val openIntent = Intent(context, MainActivity::class.java).apply { action = NOTIFICATION_ACTION_OPEN_QUEUE.toString() }
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, 0)
val openIntent = Intent(context, MainActivity::class.java).apply {
action = NOTIFICATION_ACTION_OPEN_QUEUE.toString()
}
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, FLAG_IMMUTABLE)
val coverUrl = maybeNormalizeUrl(track.album?.cover())
@ -61,7 +68,7 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
.run {
coverUrl?.let {
try {
setLargeIcon(Picasso.get().load(coverUrl).get())
setLargeIcon(CoverArt.requestCreator(coverUrl).get())
} catch (_: Exception) {
}
@ -98,7 +105,8 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
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,7 +30,6 @@ 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
@ -42,15 +41,19 @@ class MediaSession(private val context: Context) {
MediaSessionConnector(session).also {
it.setQueueNavigator(FFAQueueNavigator())
it.setMediaButtonEventHandler { _, _, intent ->
it.setMediaButtonEventHandler { _, intent ->
if (!active) {
context.startService(
Intent(context, PlayerService::class.java).apply {
action = intent.action
Intent(context, PlayerService::class.java).let { player ->
player.action = intent.action
intent.extras?.let { extras -> putExtras(extras) }
intent.extras?.let { extras -> player.putExtras(extras) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(player)
} else {
context.startService(player)
}
)
}
return@setMediaButtonEventHandler true
}
@ -62,13 +65,11 @@ class MediaSession(private val context: Context) {
}
class FFAQueueNavigator : MediaSessionConnector.QueueNavigator {
override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) {
override fun onSkipToQueueItem(player: Player, id: Long) {
CommandBus.send(Command.PlayTrack(id.toInt()))
}
override fun onCurrentWindowIndexChanged(player: Player) {}
override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = true
override fun getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or
@ -77,13 +78,13 @@ class FFAQueueNavigator : MediaSessionConnector.QueueNavigator {
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
}
override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {
override fun onSkipToNext(player: Player) {
CommandBus.send(Command.NextTrack)
}
override fun getActiveQueueItemId(player: Player?) = player?.currentWindowIndex?.toLong() ?: 0
override fun getActiveQueueItemId(player: Player?) = player?.currentMediaItemIndex?.toLong() ?: 0
override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {
override fun onSkipToPrevious(player: Player) {
CommandBus.send(Command.PreviousTrack)
}

View File

@ -24,7 +24,6 @@ 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
@ -35,6 +34,7 @@ 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(track.id.toString(), url.toUri())
val request = DownloadRequest.Builder(url.toUri().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 ->
when (request) {
is Request.GetDownloads -> request.channel?.trySend(Response.Downloads(getDownloads()))?.isSuccess
if (request is Request.GetDownloads) {
request.channel?.trySend(Response.Downloads(getDownloads()))?.isSuccess
}
}
}
@ -72,20 +72,28 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
return super.onStartCommand(intent, flags, startId)
}
override fun getDownloadManager() = exoDownloadManager.apply {
addListener(DownloadListener())
override fun getDownloadManager(): DownloadManager {
return exoDownloadManager.apply {
addListener(DownloadListener())
}
}
override fun getScheduler(): Scheduler? = null
override fun getForegroundNotification(downloads: MutableList<Download>): Notification {
override fun getForegroundNotification(
downloads: MutableList<Download>,
notMetRequirements: Int
): 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)
).buildProgressNotification(
this, R.drawable.downloads, null, description,
downloads, notMetRequirements
)
}
private fun getDownloads() = downloadManager.downloadIndex.getDownloads()

View File

@ -12,6 +12,7 @@ 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
@ -19,6 +20,7 @@ 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
@ -31,19 +33,18 @@ 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.ExoPlaybackException
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.IllegalSeekPositionException
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
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 com.google.android.exoplayer2.Tracks
import com.preference.PowerPreference
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
@ -65,7 +66,7 @@ class PlayerService : Service() {
private lateinit var queue: QueueManager
private lateinit var mediaControlsManager: MediaControlsManager
private lateinit var player: SimpleExoPlayer
private lateinit var player: ExoPlayer
private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
@ -132,12 +133,13 @@ class PlayerService : Service() {
mediaControlsManager = MediaControlsManager(this, scope, mediaSession.session)
player = SimpleExoPlayer.Builder(this).build().apply {
player = ExoPlayer.Builder(this).build().apply {
playWhenReady = false
playerEventListener = PlayerEventListener().also {
addListener(it)
}
EventBus.send(Event.StateChanged(this.isPlaying()))
}
mediaSession.active = true
@ -151,14 +153,20 @@ class PlayerService : Service() {
}
if (queue.current > -1) {
player.prepare(queue.dataSources)
player.setMediaSource(queue.dataSources)
player.prepare()
FFACache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong())
val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
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")
}
}
}
@ -171,61 +179,60 @@ class PlayerService : Service() {
private fun watchEventBus() {
scope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshService -> {
if (queue.metadata.isNotEmpty()) {
CommandBus.send(Command.RefreshTrack(queue.current()))
EventBus.send(Event.StateChanged(player.playWhenReady))
}
}
is Command.ReplaceQueue -> {
if (!command.fromRadio) radioPlayer.stop()
queue.replace(command.queue)
player.prepare(queue.dataSources, true, true)
setPlaybackState(true)
if (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()
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)
queue.replace(command.queue)
player.setMediaSource(queue.dataSources)
player.prepare()
is Command.PlayTrack -> {
queue.current = command.index
player.seekTo(command.index, C.TIME_UNSET)
setPlaybackState(true)
setPlaybackState(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)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
setPlaybackState(true)
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 {
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 {
PinService.download(
this@PlayerService,
it
@ -237,10 +244,14 @@ class PlayerService : Service() {
scope.launch(Main) {
RequestBus.get().collect { request ->
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
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
}
}
}
@ -303,11 +314,12 @@ class PlayerService : Service() {
if (!state) {
val (progress, _, _) = getProgress()
FFACache.set(this@PlayerService, "progress", progress.toString().toByteArray())
FFACache.set(this@PlayerService, "progress", progress.toString())
}
if (state && player.playbackState == Player.STATE_IDLE) {
player.prepare(queue.dataSources)
player.setMediaSource(queue.dataSources)
player.prepare()
}
if (hasAudioFocus(state)) {
@ -318,7 +330,7 @@ class PlayerService : Service() {
}
private fun togglePlayback() {
setPlaybackState(!player.playWhenReady)
setPlaybackState(!player.isPlaying)
}
private fun skipToPreviousTrack() {
@ -326,13 +338,13 @@ class PlayerService : Service() {
return player.seekTo(0)
}
player.previous()
player.seekToPrevious()
}
private fun skipToNextTrack() {
player.next()
player.seekToNext()
FFACache.set(this@PlayerService, "progress", "0".toByteArray())
FFACache.set(this@PlayerService, "progress", "0")
ProgressBus.send(0, 0, 0)
}
@ -373,10 +385,10 @@ class PlayerService : Service() {
runBlocking(IO) {
this@apply.putBitmap(
MediaMetadataCompat.METADATA_KEY_ALBUM_ART,
Picasso.get().load(coverUrl).get()
CoverArt.requestCreator(coverUrl).get()
)
}
} catch (e: Exception) {
} catch (_: Exception) {
}
}.build()
}
@ -418,10 +430,28 @@ 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.EventListener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
super.onPlayerStateChanged(playWhenReady, playbackState)
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)
EventBus.send(Event.StateChanged(playWhenReady))
@ -429,59 +459,56 @@ class PlayerService : Service() {
CommandBus.send(Command.RefreshTrack(queue.current()))
}
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)
if (!playWhenReady) {
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
{ stopForeground(false) }
)
}
}
queue.current = 0
player.seekTo(0, C.TIME_UNSET)
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
ProgressBus.send(0, 0, 0)
}
when (playbackState) {
Player.STATE_BUFFERING -> {
EventBus.send(Event.Buffering(true))
}
Player.STATE_ENDED -> {
setPlaybackState(false)
Player.STATE_IDLE -> {
setPlaybackState(false)
queue.current = 0
player.seekTo(0, C.TIME_UNSET)
return EventBus.send(Event.PlaybackStopped)
}
}
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
ProgressBus.send(0, 0, 0)
}
false -> {
EventBus.send(Event.Buffering(false))
Player.STATE_IDLE -> {
setPlaybackState(false)
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
{ stopForeground(false) }
)
EventBus.send(Event.PlaybackStopped)
when (playbackState) {
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
Player.STATE_IDLE -> mediaControlsManager.remove()
if (!player.playWhenReady) {
mediaControlsManager.remove()
}
}
Player.STATE_READY -> {
EventBus.send(Event.Buffering(false))
}
}
}
override fun onTracksChanged(
trackGroups: TrackGroupArray,
trackSelections: TrackSelectionArray
) {
super.onTracksChanged(trackGroups, trackSelections)
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
if (queue.current != player.currentWindowIndex) {
queue.current = player.currentWindowIndex
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
if (queue.current != player.currentMediaItemIndex) {
queue.current = player.currentMediaItemIndex
mediaControlsManager.updateNotification(queue.current(), player.isPlaying)
}
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()) {
@ -491,7 +518,7 @@ class PlayerService : Service() {
}
}
FFACache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
FFACache.set(this@PlayerService, "current", queue.current.toString())
CommandBus.send(Command.RefreshTrack(queue.current()))
}
@ -510,13 +537,14 @@ class PlayerService : Service() {
}
}
override fun onPlayerError(error: ExoPlaybackException) {
override fun onPlayerError(error: PlaybackException) {
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
if (player.playWhenReady) {
queue.current++
player.prepare(queue.dataSources, true, true)
player.setMediaSource(queue.dataSources, true)
player.seekTo(queue.current, 0)
player.prepare()
CommandBus.send(Command.RefreshTrack(queue.current()))
}

View File

@ -12,6 +12,7 @@ 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
@ -28,8 +29,8 @@ class QueueManager(val context: Context) {
var current = -1
init {
FFACache.get(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
FFACache.getLine(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json.reader())?.let { cache ->
metadata = cache.data.toMutableList()
val factory = cacheDataSourceFactoryProvider.create(context)
@ -38,15 +39,15 @@ class QueueManager(val context: Context) {
metadata.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).setTag(track.title)
.createMediaSource(Uri.parse(url))
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
}
)
}
}
FFACache.get(context, "current")?.let { string ->
current = string.readLine().toInt()
FFACache.getLine(context, "current")?.let {
current = it.toInt()
}
}
@ -54,7 +55,7 @@ class QueueManager(val context: Context) {
FFACache.set(
context,
"queue",
Gson().toJson(QueueCache(metadata)).toByteArray()
Gson().toJson(QueueCache(metadata)).toString()
)
}
@ -63,8 +64,8 @@ class QueueManager(val context: Context) {
val factory = cacheDataSourceFactoryProvider.create(context)
val sources = tracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
}
metadata = tracks.toMutableList()
@ -84,7 +85,8 @@ class QueueManager(val context: Context) {
val sources = missingTracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
}
metadata.addAll(tracks)
@ -101,7 +103,8 @@ class QueueManager(val context: Context) {
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
if (metadata.indexOf(track) == -1) {
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url)).let {
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem).let {
dataSources.addMediaSource(current + 1, it)
metadata.add(current + 1, track)
}
@ -164,6 +167,8 @@ 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.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()
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")
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.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())
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())
prepareNextTrack(true)
} catch (e: Exception) {

View File

@ -8,7 +8,6 @@ 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>() {
@ -35,6 +34,6 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
}
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
}

View File

@ -9,7 +9,6 @@ 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>() {
@ -27,6 +26,6 @@ class ArtistTracksRepository(override val context: Context?, private val artistI
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
}

View File

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

View File

@ -12,7 +12,6 @@ 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>() {
@ -30,8 +29,8 @@ class PlaylistTracksRepository(override val context: Context?, playlistId: Int)
)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(json.reader())
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)

View File

@ -19,7 +19,6 @@ 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)
@ -38,8 +37,8 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
}
class ManagementPlaylistsRepository(override val context: Context?) :
@ -58,8 +57,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
suspend fun new(name: String): Int? {
context?.let {
@ -108,7 +107,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
}
suspend fun remove(albumId: Int, index: Int) {
context?.let {
if (context != null) {
val body = mapOf("index" to index)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$albumId/remove/")).apply {
@ -122,12 +121,13 @@ 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) {
context?.let {
if (context != null) {
val body = mapOf("from" to from, "to" to to)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
@ -143,7 +143,8 @@ 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,7 +9,6 @@ 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>() {
@ -26,8 +25,8 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio
)
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(json.reader())
override fun onDataFetched(data: List<Radio>): List<Radio> {
return data

View File

@ -8,11 +8,9 @@ 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> {
@ -34,7 +32,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(reader: BufferedReader): C? = null
protected open fun uncache(json: String): C? = null
fun fetch(
upstreams: Int = Origin.Cache.origin and Origin.Network.origin,
@ -46,8 +44,8 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
private fun fromCache() = flow {
cacheId?.let { cacheId ->
FFACache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
FFACache.getLine(context, cacheId)?.let { line ->
uncache(line)?.let { cache ->
return@flow emit(
Response(
Origin.Cache,

View File

@ -21,7 +21,6 @@ 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>() {
@ -42,8 +41,8 @@ class TracksSearchRepository(override val context: Context?, var query: String)
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
@ -84,8 +83,8 @@ class ArtistsSearchRepository(override val context: Context?, var query: String)
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
}
class AlbumsSearchRepository(override val context: Context?, var query: String) :
@ -104,6 +103,6 @@ class AlbumsSearchRepository(override val context: Context?, var query: String)
)
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
}

View File

@ -18,7 +18,6 @@ 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>() {
@ -38,24 +37,23 @@ class TracksRepository(override val context: Context?, albumId: Int) :
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
companion object {
fun getDownloadedIds(exoDownloadManager: DownloadManager): List<Int>? {
val cursor = exoDownloadManager.downloadIndex.getDownloads()
val ids: MutableList<Int> = mutableListOf()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let {
if (download.state == Download.STATE_COMPLETED) {
ids.add(it.id)
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)
}
}
}
}
}
return ids
}
}

View File

@ -1,7 +1,6 @@
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
@ -23,7 +22,7 @@ object AppContext {
const val PAGE_SIZE = 50
const val TRANSITION_DURATION = 300L
fun init(context: Activity) {
fun init(context: Context) {
setupNotificationChannels(context)
// CastContext.getSharedInstance(context)

View File

@ -0,0 +1,89 @@
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

@ -0,0 +1,10 @@
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,6 +1,5 @@
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
@ -8,8 +7,10 @@ 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.asFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class Command {
@ -60,6 +61,7 @@ sealed class Request(var channel: Channel<Response>? = null) {
object GetState : Request()
object GetQueue : Request()
object GetCurrentTrack : Request()
object GetCurrentTrackIndex : Request()
object GetDownloads : Request()
}
@ -67,51 +69,59 @@ 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) {
FFA.get().eventBus.trySend(event).isSuccess
_events.emit(event)
}
}
fun get() = FFA.get().eventBus.asFlow()
fun get() = events
}
object CommandBus {
private var _commands = MutableSharedFlow<Command>()
var commands = _commands.asSharedFlow()
fun send(command: Command) {
GlobalScope.launch(IO) {
FFA.get().commandBus.trySend(command).isSuccess
_commands.emit(command)
}
}
fun get() = FFA.get().commandBus.asFlow()
fun get() = commands
}
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
FFA.get().requestBus.trySend(request).isSuccess
_requests.emit(request)
}
}
}
fun get() = FFA.get().requestBus.asFlow()
fun get() = requests
}
object ProgressBus {
private var _progress = MutableStateFlow(Triple(0, 0, 0))
val progress = _progress.asStateFlow()
fun send(current: Int, duration: Int, percent: Int) {
GlobalScope.launch(IO) {
FFA.get().progressBus.send(Triple(current, duration, percent))
}
_progress.value = Triple(current, duration, percent)
}
fun get() = FFA.get().progressBus.asFlow().conflate()
fun get() = progress
}
suspend inline fun <reified T> Channel<Response>.wait(): T? {

View File

@ -0,0 +1,266 @@
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,22 +3,18 @@ package audio.funkwhale.ffa.utils
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.fragment.app.Fragment
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.fragments.BrowseFragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
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
@ -38,14 +34,6 @@ 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()
@ -60,26 +48,23 @@ 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(oAuth.service(context), auth) { token, _, _ ->
if (token == old) {
Log.i("Request.authorize()", "Accesstoken not renewed")
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)
}
}
if (token != old && token != null) {
state.save()
@ -88,6 +73,7 @@ fun Request.authorize(context: Context, oAuth: OAuth): Request {
done.complete(true)
}
done.await()
tokenService.dispose()
return@runBlocking this
}
}
@ -107,3 +93,58 @@ 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,16 +12,41 @@ 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: ByteArray) = context?.let {
with(File(it.cacheDir, key(key))) {
writeBytes(value)
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 get(context: Context?, key: String): BufferedReader? = context?.let {
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 {
with(File(it.cacheDir, key(key))) {
delete()
}
}
private fun get(context: Context?, key: String): BufferedReader? = context?.let {
try {
with(File(it.cacheDir, key(key))) {
bufferedReader()
@ -30,10 +55,4 @@ object FFACache {
return null
}
}
fun delete(context: Context?, key: String) = context?.let {
with(File(it.cacheDir, key(key))) {
delete()
}
}
}

View File

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

View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,78 @@
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

@ -0,0 +1,118 @@
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

@ -0,0 +1,91 @@
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

@ -1,255 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -0,0 +1,36 @@
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

@ -0,0 +1,9 @@
<?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

@ -0,0 +1,8 @@
<?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

@ -0,0 +1,8 @@
<?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

@ -0,0 +1,7 @@
<?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

@ -0,0 +1,9 @@
<?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

@ -0,0 +1,9 @@
<?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

@ -0,0 +1,4 @@
<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

@ -0,0 +1,6 @@
<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,61 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<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
<androidx.coordinatorlayout.widget.CoordinatorLayout
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="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:layout_marginBottom="?attr/actionBarSize"
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
android:orientation="horizontal">
<FrameLayout
android:id="@+id/landscape_queue"
android:layout_width="0dp"
<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"
android:layout_height="match_parent"
android:layout_marginBottom="?attr/actionBarSize"
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
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>
</LinearLayout>
<audio.funkwhale.ffa.views.NowPlayingView
android:id="@+id/now_playing"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
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">
<include layout="@layout/partial_now_playing" />
</audio.funkwhale.ffa.views.NowPlayingView>
<com.google.android.material.bottomappbar.BottomAppBar
<androidx.appcompat.widget.Toolbar
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_height="?attr/actionBarSize"
app:layout_constraintBottom_toBottomOf="parent"
android:theme="@style/AppTheme.AppBar"
app:backgroundTint="@color/colorPrimaryDark"
app:layout_insetEdge="bottom"
app:navigationIcon="@drawable/funkwhaleshape"
tools:menu="@menu/toolbar" />
tools:menu="@menu/toolbar"
</androidx.coordinatorlayout.widget.CoordinatorLayout>
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,65 @@
<?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

@ -1,251 +0,0 @@
<?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,47 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<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"
android:background="@color/surface">
<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">
<audio.funkwhale.ffa.views.DisableableFrameLayout
android:id="@+id/container"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/appbar">
<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"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="?attr/actionBarSize"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
app:defaultNavHost="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:navGraph="@navigation/main_nav"
tools:layout="@layout/fragment_artists"
/>
</FrameLayout>
<audio.funkwhale.ffa.views.NowPlayingView
<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
android:id="@+id/now_playing"
android:layout_width="match_parent"
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">
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>
<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>
<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>

View File

@ -1,100 +0,0 @@
<?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.viewpager.widget.ViewPager
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@ -39,14 +39,27 @@
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="16dp"
android:layout_marginBottom="6dp"
android:text="@string/favorites" />
<com.google.android.material.button.MaterialButton

View File

@ -0,0 +1,59 @@
<?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

@ -0,0 +1,96 @@
<?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

@ -0,0 +1,7 @@
<?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

@ -1,283 +0,0 @@
<?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

@ -0,0 +1,161 @@
<?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