Compare commits

...

241 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
156 changed files with 4455 additions and 2759 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: $CI_REGISTRY/funkwhale/funkwhale-android:latest
# 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'
@ -10,28 +11,9 @@ stages:
- test
- visualize
- build
- test-after-build
- deploy
build_ci_image:
stage: build_ci_env
image: egon0/docker-with-buildx-and-git:bash
tags:
- dind
services:
- docker:20-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $CI_REGISTRY_IMAGE docker
after_script:
- docker push $CI_REGISTRY_IMAGE
rules:
- if: '$BUILD_CI_IMAGE'
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
cache: &global_cache
key: ${CI_PIPELINE_ID}
paths:
@ -57,7 +39,7 @@ cache: &global_cache
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
@ -88,10 +70,18 @@ test:
<<: *global_cache
# override the policy
policy: pull-push
test_nonfree_code:
stage: test-after-build
image: registry.funkwhale.audio/funkwhale/ci/android-fdroidserver
script:
- fdroid scanner -v app/build/outputs/apk/*/app-*.apk |& tee output.txt
- cat output.txt
- (! grep "CRITICAL" output.txt)
coverage:
stage: visualize
image: haynes/jacoco2cobertura:1.0.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'
@ -136,32 +126,38 @@ build-bleeding-edge:
- 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 -P22103 -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 -P22103 -o StrictHostKeyChecking=no app/build/outputs/apk/debug/output-metadata.json fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/output-metadata.json
- scp -P22103 -o StrictHostKeyChecking=no metadata/audio.funkwhale.android.dev.yml fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/metadata/audio.funkwhale.ffa.dev.yml
- ssh -p22103 -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 -P22103 -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 -P22103 -o StrictHostKeyChecking=no app/build/outputs/apk/release/output-metadata.json fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/output-metadata.json
- scp -P22103 -o StrictHostKeyChecking=no metadata/audio.funkwhale.android.yml fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/metadata/audio.funkwhale.ffa.yml
- ssh -p22103 -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"

1
.tool-versions Normal file
View File

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

View File

@ -1,3 +1,42 @@
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:

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.3.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.7.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 = 31
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.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3")
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.4.2")
implementation("androidx.core:core-ktx:1.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.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.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.6.1")
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.4")
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()

View File

@ -107,8 +107,8 @@ class DownloadsActivity : AppCompatActivity() {
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)
if (download.state == Download.STATE_DOWNLOADING &&
download.percentDownloaded != (info.download?.percentDownloaded ?: 0)
) {
withContext(Main) {
adapter.downloads[match.second] = info.apply {

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,119 +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
@ -400,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.getLine(this@MainActivity, "repeat")?.toInt() ?: 0)
now_playing_details_repeat.setOnClickListener {
val current = FFACache.getLine(this@MainActivity, "repeat")?.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")
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")
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")
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()
}
}
@ -667,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()
@ -677,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,7 +40,6 @@ class SettingsActivity : AppCompatActivity() {
)
.commit()
}
}
class SettingsFragment :
@ -60,7 +59,7 @@ class SettingsFragment :
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference?.key) {
when (preference.key) {
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java))
"crash" -> {
@ -115,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)
@ -149,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

View File

@ -1,38 +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.preference.PowerPreference
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
@ -46,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(
@ -144,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()
@ -153,7 +87,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
.into(cover)
}
binding.artist.text = artistName
binding.artist.text = args.artist.name
}
override fun onResume() {
@ -211,7 +145,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
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)).toString()
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))
@ -146,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() {
@ -194,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")
}
@ -250,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)
}
}
}
@ -269,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

@ -9,7 +9,6 @@ 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
@ -31,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
@ -43,7 +41,7 @@ class MediaSession(private val context: Context) {
MediaSessionConnector(session).also {
it.setQueueNavigator(FFAQueueNavigator())
it.setMediaButtonEventHandler { _, _, intent ->
it.setMediaButtonEventHandler { _, intent ->
if (!active) {
Intent(context, PlayerService::class.java).let { player ->
player.action = intent.action
@ -67,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
@ -82,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

@ -7,7 +7,13 @@ import androidx.core.net.toUri
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.model.DownloadInfo
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.AppContext
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.mustNormalizeUrl
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloadRequest
@ -20,7 +26,7 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent
import java.util.*
import java.util.Collections
class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
@ -57,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
}
}
}
@ -74,14 +80,20 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
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.getLine(this, "progress")?.let {
player.seekTo(queue.current, it.toLong())
val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
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
}
}
}
@ -307,7 +318,8 @@ class PlayerService : Service() {
}
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,11 +338,11 @@ class PlayerService : Service() {
return player.seekTo(0)
}
player.previous()
player.seekToPrevious()
}
private fun skipToNextTrack() {
player.next()
player.seekToNext()
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()) {
@ -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
@ -29,7 +30,7 @@ class QueueManager(val context: Context) {
init {
FFACache.getLine(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json.reader())?.let { cache ->
metadata = cache.data.toMutableList()
val factory = cacheDataSourceFactoryProvider.create(context)
@ -38,8 +39,8 @@ 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)
}
)
}
@ -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

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

View File

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

View File

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

View File

@ -1,10 +1,22 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.model.*
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.model.FFAResponse
import audio.funkwhale.ffa.model.Favorite
import audio.funkwhale.ffa.model.FavoritesResponse
import audio.funkwhale.ffa.model.FavoritedCache
import audio.funkwhale.ffa.model.FavoritedResponse
import audio.funkwhale.ffa.model.FavoritesCache
import audio.funkwhale.ffa.utils.FFACache
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.authorize
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import audio.funkwhale.ffa.utils.untilNetwork
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.upstream.cache.Cache
import com.google.gson.Gson
@ -16,7 +28,7 @@ import kotlinx.coroutines.runBlocking
import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent.inject
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"))
@ -24,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 cache(data: List<Favorite>) = FavoritesCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json)
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
}
}
@ -115,7 +127,7 @@ class FavoritedRepository(override val context: Context?) : Repository<Int, Favo
override fun cache(data: List<Int>) = FavoritedCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(FavoritedCache::class.java).deserialize(json)
gsonDeserializerOf(FavoritedCache::class.java).deserialize(json.reader())
fun update(context: Context?, scope: CoroutineScope) {
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->

View File

@ -6,7 +6,7 @@ import audio.funkwhale.ffa.model.PlaylistTrack
import audio.funkwhale.ffa.model.PlaylistTracksCache
import audio.funkwhale.ffa.model.PlaylistTracksResponse
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.gsonDeserializerOf
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
@ -30,7 +30,7 @@ class PlaylistTracksRepository(override val context: Context?, playlistId: Int)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(json)
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

@ -1,11 +1,19 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.model.*
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.model.FFAResponse
import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.model.PlaylistsCache
import audio.funkwhale.ffa.model.PlaylistsResponse
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.authorize
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
@ -30,7 +38,7 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json)
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
}
class ManagementPlaylistsRepository(override val context: Context?) :
@ -50,7 +58,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json)
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
suspend fun new(name: String): Int? {
context?.let {

View File

@ -6,7 +6,7 @@ import audio.funkwhale.ffa.model.Radio
import audio.funkwhale.ffa.model.RadiosCache
import audio.funkwhale.ffa.model.RadiosResponse
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.gsonDeserializerOf
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import org.koin.java.KoinJavaComponent.inject
@ -26,7 +26,7 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(json)
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> {

View File

@ -1,10 +1,18 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.model.*
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.AlbumsCache
import audio.funkwhale.ffa.model.AlbumsResponse
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.model.ArtistsCache
import audio.funkwhale.ffa.model.ArtistsResponse
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.model.TracksCache
import audio.funkwhale.ffa.model.TracksResponse
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.gsonDeserializerOf
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.upstream.cache.Cache
import com.google.gson.reflect.TypeToken
@ -34,7 +42,7 @@ class TracksSearchRepository(override val context: Context?, var query: String)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json)
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
@ -76,7 +84,7 @@ class ArtistsSearchRepository(override val context: Context?, var query: String)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json)
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
}
class AlbumsSearchRepository(override val context: Context?, var query: String) :
@ -96,5 +104,5 @@ class AlbumsSearchRepository(override val context: Context?, var query: String)
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json)
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
}

View File

@ -7,8 +7,8 @@ import audio.funkwhale.ffa.model.TracksCache
import audio.funkwhale.ffa.model.TracksResponse
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.getMetadata
import audio.funkwhale.ffa.utils.gsonDeserializerOf
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.upstream.cache.Cache
@ -38,7 +38,7 @@ class TracksRepository(override val context: Context?, albumId: Int) :
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(json)
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
companion object {
fun getDownloadedIds(exoDownloadManager: DownloadManager): List<Int>? {

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,19 +3,14 @@ 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.github.kittinunf.fuel.core.ResponseDeserializable
import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
@ -23,9 +18,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import net.openid.appauth.ClientSecretPost
import java.io.Reader
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import kotlin.coroutines.CoroutineContext
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
@ -40,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()
@ -62,19 +48,11 @@ 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>()
@ -82,11 +60,11 @@ fun Request.authorize(context: Context, oAuth: OAuth): Request {
state.performActionWithFreshTokens(tokenService, auth) { token, _, e ->
if (e != null) {
Log.e("Request.authorize()", "performActionWithFreshToken failed: ${e}")
Log.e("Request.authorize()", Log.getStackTraceString(e))
}
if (token == old) {
Log.i("Request.authorize()", "Accesstoken not renewed")
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()
@ -116,14 +94,57 @@ fun Date.format(): String {
return ISO_8601_DATE_TIME_FORMAT.format(this)
}
inline fun <reified T : Any> gsonDeserializerOf(clazz: Class<T>) = gsonDeserializer<T>()
fun String?.containsIgnoringCase(candidate: String): Boolean =
this != null && this.lowercase().contains(candidate.lowercase())
inline fun <reified T : Any> gsonDeserializer(gson: Gson = Gson()) = object :
ResponseDeserializable<T> {
override fun deserialize(content: String): T? =
gson.fromJson<T>(content, object : TypeToken<T>() {}.type)
override fun deserialize(reader: Reader): T? =
gson.fromJson<T>(reader, object : TypeToken<T>() {}.type)
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

@ -15,7 +15,7 @@ object FFACache {
return digest.fold("") { acc, it -> acc + "%02x".format(it) }
}
fun set(context: Context?, key:String, value: String){
fun set(context: Context?, key: String, value: String) {
set(context, key, value.toByteArray())
}

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
}
@ -102,8 +101,9 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
runBlocking {
refreshService.performTokenRequest(refreshRequest, auth) { response, e ->
if (e != null) {
Log.e("OAuth", "performTokenRequest failed: ${e}")
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")
@ -185,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)
}
}
@ -213,13 +212,13 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
requestService.performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
if (e != null) {
Log.e("FFA", "performTokenRequest failed: ${e}")
Log.e("FFA", "performTokenRequest failed: $e")
Log.e("FFA", Log.getStackTraceString(e))
} else {
state.apply {
update(response, e)
save()
}
update(response, e)
save()
}
}
if (response != null) success()

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>

View File

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
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>
<merge>
<!-- Placeholder for setting constraints and interacting -->
<View
android:id="@+id/constraint_layout_placeholder"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_constraintTop_toTopOf="parent"
/>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/now_playing_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/constraint_layout_placeholder"
android:progress="@{progress, default=40}"
android:progressTint="@color/colorPrimaryDark"
/>
<audio.funkwhale.ffa.views.SquareImageView
android:id="@+id/now_playing_cover"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="@id/constraint_layout_placeholder"
app:layout_constraintTop_toBottomOf="@id/now_playing_progress"
app:layout_constraintBottom_toBottomOf="@id/constraint_layout_placeholder"
android:scaleType="centerCrop"
app:srcCompat="@drawable/cover"
tools:src="@tools:sample/avatars"
/>
<ProgressBar
android:id="@+id/now_playing_buffering"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="@id/now_playing_cover"
app:layout_constraintTop_toTopOf="@id/now_playing_cover"
app:layout_constraintBottom_toBottomOf="@id/now_playing_cover"
app:layout_constraintEnd_toEndOf="@id/now_playing_cover"
android:visibility="@{isBuffering ? View.VISIBLE : View.INVISIBLE, default=invisible}"
/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/header_controls"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintHorizontal_weight="10"
app:layout_constraintStart_toEndOf="@id/now_playing_cover"
app:layout_constraintEnd_toEndOf="@id/constraint_layout_placeholder"
app:layout_constraintTop_toBottomOf="@id/now_playing_progress"
app:layout_constraintBottom_toBottomOf="@id/constraint_layout_placeholder"
android:background="@color/elevatedSurface"
android:padding="4dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/now_playing_toggle"
style="@style/AppTheme.ItemTitle"
android:text="@{currentTrackTitle}"
android:ellipsize="end"
android:lines="1"
tools:text="Supermassive Black Hole"
/>
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/now_playing_toggle"
android:ellipsize="end"
android:lines="1"
android:text="@{currentTrackArtist}"
tools:text="Muse"
/>
<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"
app:layout_constraintEnd_toStartOf="@id/now_playing_next"
android:layout_marginEnd="16dp"
app:icon="@{isPlaying ? @drawable/pause : @drawable/play, default=@drawable/play}"
/>
<ImageButton
android:id="@+id/now_playing_next"
android:layout_width="32dp"
android:layout_height="match_parent"
app:layout_constraintEnd_toEndOf="parent"
style="@style/IconButton"
android:contentDescription="@string/control_next"
android:src="@drawable/next"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</merge>
</layout>

View File

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation 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/main_nav"
app:startDestination="@id/browseFragment">
<fragment
android:id="@+id/browseFragment"
android:name="audio.funkwhale.ffa.fragments.BrowseFragment"
android:label="BrowseFragment">
<action
android:id="@+id/browseToSearch"
app:destination="@id/searchFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToAlbums"
app:destination="@id/albumsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToTracks"
app:destination="@id/tracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToArtists"
app:destination="@id/artistsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToPlaylistTracks"
app:destination="@id/playlistTracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
</fragment>
<fragment
android:id="@+id/playlistTracksFragment"
android:name="audio.funkwhale.ffa.fragments.PlaylistTracksFragment"
android:label="PlaylistTracksFragment">
<argument
android:name="playlist"
app:argType="audio.funkwhale.ffa.model.Playlist" />
</fragment>
<fragment
android:id="@+id/tracksFragment"
android:name="audio.funkwhale.ffa.fragments.TracksFragment"
android:label="TracksFragment">
<argument
android:name="album"
app:argType="audio.funkwhale.ffa.model.Album" />
</fragment>
<fragment
android:id="@+id/albumsFragment"
android:name="audio.funkwhale.ffa.fragments.AlbumsFragment"
android:label="AlbumsFragment">
<argument
android:name="artist"
app:argType="audio.funkwhale.ffa.model.Artist" />
<argument
android:name="cover"
android:defaultValue="@null"
app:argType="string"
app:nullable="true" />
<action
android:id="@+id/albumsToTracks"
app:destination="@id/tracksFragment" />
</fragment>
<fragment
android:id="@+id/searchFragment"
android:name="audio.funkwhale.ffa.fragments.SearchFragment"
android:label="SearchFragment">
<action
android:id="@+id/searchToAlbums"
app:destination="@id/albumsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/searchToTracks"
app:destination="@id/tracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
</fragment>
<fragment
android:id="@+id/artistsFragment"
android:name="audio.funkwhale.ffa.fragments.ArtistsFragment"
android:label="ArtistsFragment" />
<action
android:id="@+id/globalBrowseToAlbums"
app:destination="@id/albumsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down"
/>
<action
android:id="@+id/globalBrowseTracks"
app:destination="@id/tracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down"
/>
</navigation>

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