Compare commits

...

285 Commits

Author SHA1 Message Date
Heimen Stoffels 0b0663e1aa Translated using Weblate (Dutch)
Currently translated at 46.6% (55 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/nl/
2023-06-20 17:50:11 +00:00
Jhoan Sebastian Espinosa Borrero 7c0032133e Translated using Weblate (Spanish)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/es/
2022-11-22 00:32:44 +00:00
@liimee 70fdfe3236 Translated using Weblate (Indonesian)
Currently translated at 36.4% (43 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/id/
2022-10-05 14:35:04 +00:00
@liimee 7067c6807a Added translation using Weblate (Indonesian) 2022-10-04 13:38:38 +00:00
ghose b024264750 Translated using Weblate (Galician)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/gl/
2022-04-23 13:59:36 +00:00
adil 232fc0aae1 Translated using Weblate (Russian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/ru/
2022-03-10 05:17:09 +00:00
emptyList() e59a369661 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/zh_Hans/
2022-01-22 10:36:45 +00:00
Sergio Varela 5d4944e87a Translated using Weblate (Spanish)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/es/
2021-12-11 15:36:31 +00:00
ghose e1d41ed675 Translated using Weblate (Galician)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/gl/
2021-11-16 19:17:30 +00:00
Allan Nordhøy 9d741bc6eb Translated using Weblate (Norwegian Bokmål)
Currently translated at 98.3% (116 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/nb_NO/
2021-11-13 05:17:30 +00:00
Allan Nordhøy ec1e4ff629 Translated using Weblate (English)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/en/
2021-11-13 05:17:29 +00:00
milotype dfe9783048 Translated using Weblate (Croatian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/hr/
2021-10-25 17:27:49 +00:00
Homer S bfa7a99015 Translated using Weblate (Norwegian Bokmål)
Currently translated at 21.1% (25 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/nb_NO/
2021-08-08 00:04:41 +00:00
Homer S 8cd278be7f Added translation using Weblate (Norwegian Bokmål) 2021-08-06 20:46:41 +00:00
Dignified Silence 9a1dfe59ad Translated using Weblate (Japanese)
Currently translated at 86.4% (102 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/ja/
2021-07-20 19:39:06 +00:00
Dignified Silence 6f24d4906b Translated using Weblate (Japanese)
Currently translated at 22.8% (27 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/ja/
2021-07-18 19:39:05 +00:00
Dignified Silence 1bf7f2ef38 Added translation using Weblate (Japanese) 2021-07-17 18:31:18 +00:00
Creak db171fb406 Translated using Weblate (French)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/fr/
2021-06-29 17:04:09 +00:00
Ryan Harg 5cfb0cbdaf Translated using Weblate (German)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2021-06-29 17:04:08 +00:00
helabasa a77d3c0222 Translated using Weblate (Sinhala)
Currently translated at 8.4% (10 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/si/
2021-03-12 05:13:51 +00:00
helabasa fe014cde1a Added translation using Weblate (Sinhala) 2021-03-11 04:14:53 +00:00
x 641ead2c53 Translated using Weblate (Italian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/it/
2021-02-24 04:27:48 +00:00
Luka Filipović 4972d0de47 Translated using Weblate (Croatian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/hr/
2021-01-11 12:15:56 +00:00
Luka Filipović c6899d7254 Added translation using Weblate (Croatian) 2021-01-10 14:11:17 +00:00
David dccb3f3520 Translated using Weblate (German)
Currently translated at 98.3% (116 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2021-01-10 05:05:28 +00:00
Storozhenko Evgeny Vladimirovich 5febaa5837 Translated using Weblate (Russian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/ru/
2021-01-09 11:29:55 +00:00
anonymous ff8eabb514 Translated using Weblate (Russian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/ru/
2021-01-08 11:08:42 +00:00
vicdorke 509133c654 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/zh_Hans/
2020-12-22 18:55:48 +00:00
Philipp Wolfer f630b0165c Translated using Weblate (German)
Currently translated at 95.7% (113 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-11-18 10:57:41 +00:00
Daniel 0d39d1f628 Translated using Weblate (German)
Currently translated at 95.7% (113 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-11-18 10:57:41 +00:00
ghose 8eac040142 Translated using Weblate (Galician)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/gl/
2020-11-15 05:43:32 +00:00
x d46d599fc3 Translated using Weblate (Italian)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/it/
2020-11-13 18:58:07 +00:00
x a1cf0c5f5b Added translation using Weblate (Italian) 2020-11-12 16:50:21 +00:00
ghose f188b5c449 Translated using Weblate (Galician)
Currently translated at 34.7% (41 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/gl/
2020-11-09 18:15:09 +00:00
ghose 13cd81825a Added translation using Weblate (Galician) 2020-11-09 08:04:17 +00:00
serxoz 1397bdd449 Translated using Weblate (Spanish)
Currently translated at 99.1% (117 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/es/
2020-10-24 14:56:26 +00:00
Daniel 95fd3a0a6a Translated using Weblate (German)
Currently translated at 89.8% (106 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-10-09 13:12:00 +00:00
Dominik Danelski ef67fa65c0 Translated using Weblate (Polish)
Currently translated at 100.0% (118 of 118 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/pl/
2020-10-02 02:07:40 +00:00
Dominik Danelski e20e941c7e Added translation using Weblate (Polish) 2020-10-01 10:50:06 +00:00
vicdorke 6d718747db Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (113 of 113 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/zh_Hans/
2020-09-30 16:57:20 +00:00
Antoine POPINEAU a7b469d690
#15: added "Add to playlist" action from the Now Playing screen. 2020-09-26 18:34:13 +02:00
Antoine POPINEAU 6bdefa1936
#81: added save current queue to playlist. 2020-09-26 18:23:12 +02:00
Antoine POPINEAU 785fa6ce19
#84: remove presumably unnecessary trigger to Event.QueueChanged. 2020-09-26 17:39:24 +02:00
Antoine POPINEAU 300cc54e97
Remove binary AAR for FLAC and OPUS codecs and use jitpack dependencies. 2020-09-07 09:58:57 +02:00
Antoine POPINEAU 7feac4e400
Changed background color when rearranging playlist items. 2020-09-06 15:36:14 +02:00
Antoine POPINEAU b0747658ae
Add toast when added to playlist. 2020-09-06 15:23:45 +02:00
Antoine POPINEAU ab654a08c4
#15: Enable reordering of playlist tracks. Fixed an algorithmic issue with reordering of queue items. 2020-09-06 14:56:46 +02:00
Antoine POPINEAU d2a981c368
#15: add Add to playlist in queue item overflow menu. 2020-09-06 14:56:45 +02:00
Antoine POPINEAU 049822005e
#15: implemented removing track from playlist. 2020-09-06 14:56:44 +02:00
Antoine POPINEAU d796fca26b
#15: Enabled "Add to playlist" in the search screen. Localized strings and improved UI. 2020-09-06 14:56:43 +02:00
Antoine POPINEAU 54d4dc2235
#15: initial support for adding tracks to a playlist. 2020-09-06 14:56:43 +02:00
Antoine POPINEAU 64ea222f08
#76: fixed Shuffle and Clear queue actions in landscape mode. 2020-09-05 16:12:22 +02:00
Ventura Pérez García 1380d1d2b9 Translated using Weblate (Spanish)
Currently translated at 100.0% (110 of 110 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/es/
2020-09-03 23:34:13 +00:00
Antoine POPINEAU e60814d28f
#52: Properly handle content filter selection and state. 2020-09-03 22:55:10 +02:00
Antoine POPINEAU ce8d956cee
#52: implemented UI and functionnal filtering for all, me and subscribed. Funkwhale still does not seem to respect that. 2020-09-03 22:55:09 +02:00
Ventura Pérez García 1e73ef6ee4 Added translation using Weblate (Spanish) 2020-09-03 11:50:17 +00:00
Antoine POPINEAU 63c8dbe09e
#65: abide by order preference when using "Queue" overflow menu option. 2020-09-02 14:24:59 +02:00
Antoine POPINEAU 9b0c8b0bf6
#65: added an option to select preference towards playback order. 2020-09-02 12:45:37 +02:00
Antoine POPINEAU b87766dad2
#66: Fixed behavior on queue shuffling, clearing and end of queue on no-repeat. 2020-09-02 12:05:31 +02:00
Antoine POPINEAU 50c8dac297
#66: add queue actions to clear or shuffle the queue. 2020-09-01 22:16:36 +02:00
Antoine POPINEAU 1dd38e87fb
Update dependencies. 2020-09-01 21:04:41 +02:00
Antoine POPINEAU 4cf77404a1
#73: fix track sorting order by fixing a mistake and taking disc_number into consideration. 2020-09-01 18:25:10 +02:00
Antoine POPINEAU 9beb5e6641
Prepare 1.0.21. 2020-08-29 15:25:26 +02:00
Antoine POPINEAU 998dab0fb5
Do not delete downloads on authentication error (prepare for new authentication system). 2020-08-29 15:14:59 +02:00
dulz 0056faee8e Translated using Weblate (French)
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/fr/
2020-08-14 16:27:24 +00:00
Arne 964c510312 Translated using Weblate (German)
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-08-11 12:56:41 +00:00
Keunes f999745a0c Translated using Weblate (Dutch)
Currently translated at 74.7% (77 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/nl/
2020-08-09 02:07:21 +00:00
Antoine POPINEAU 002ebec7ce
Do not delete downloaded tracks on log out.
Downloaded tracks can be quite precious when you have lots of them (as
redownloading all of them can be costly in terms of time and money).
This prevents downloaded tracks from being deleted along with your
session.

This must be included in 0.21, so we can safely implement Funkwhale's
new authentication mechanism in 0.22.
2020-08-08 14:58:50 +02:00
Antoine POPINEAU d76f76a222
Use the new schema for cover art URLs. 2020-08-08 14:51:39 +02:00
Antoine POPINEAU f062e62299
Apply current track style to playlist tracks. 2020-08-03 15:30:42 +02:00
Antoine POPINEAU d2e472d770
Update media session metadata as soon as track changes. 2020-08-03 15:30:41 +02:00
vicdorke 04d0dd9c09 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/zh_Hans/
2020-07-28 03:33:18 +00:00
Arne 3dafb1c51f Translated using Weblate (German)
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 06:40:07 +00:00
anonymous cbb147fc4b Translated using Weblate (German)
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:44:06 +00:00
Arne Schlag 748ef0d935 Translated using Weblate (German)
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:44:06 +00:00
anonymous 2c672ecbfa Translated using Weblate (German)
Currently translated at 97.0% (100 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:42:34 +00:00
Arne Schlag 79140f829e Translated using Weblate (German)
Currently translated at 97.0% (100 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:42:33 +00:00
anonymous 326fcefa62 Translated using Weblate (German)
Currently translated at 96.1% (99 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:41:18 +00:00
Arne Schlag 2c657ee85a Translated using Weblate (German)
Currently translated at 96.1% (99 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:41:18 +00:00
Arne Schlag c2ac66d992 Translated using Weblate (German)
Currently translated at 86.4% (89 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:39:50 +00:00
anonymous 684e11d904 Translated using Weblate (German)
Currently translated at 86.4% (89 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-07-24 01:39:49 +00:00
Bread Factory 0bec180cc5 Translated using Weblate (Russian)
Currently translated at 100.0% (103 of 103 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/ru/
2020-07-15 05:05:52 +00:00
Bread Factory 89db2a3880 Added translation using Weblate (Russian) 2020-07-15 04:10:07 +00:00
Antoine POPINEAU a7968e9a87
Fix dynamically changing between own music and all music. Dynamically reload all tabs instead of only active ones. 2020-07-13 10:31:36 +02:00
Antoine POPINEAU 5c684b6e67
Fixed audio focus stealing. 2020-07-12 20:46:33 +02:00
Antoine POPINEAU 85e9f14e2a
Cleaned up DisableableFrameLayout. 2020-07-12 19:05:51 +02:00
Antoine POPINEAU 1e62cc1f4e
Now Playing view: do not cancel fling event on touch, disable view behind it when opened. 2020-07-12 18:55:52 +02:00
Antoine POPINEAU b0640cf1b2
Streamline the way the media session is controled across devices. 2020-07-12 18:28:50 +02:00
Antoine POPINEAU e7cb5e4c6e
QueueNavigator now returns queue index. 2020-07-12 15:19:33 +02:00
Antoine POPINEAU 7035f073f2
Changed accent color. 2020-07-11 21:08:25 +02:00
Antoine POPINEAU 931cd0b42d
Stop PlayerService when logging out. 2020-07-11 21:05:19 +02:00
Antoine POPINEAU ba31a4efcf
Some tracks do not have album, this fixes a crash trying to retrieve their album cover. Changed the placeholder album cover to be less aggressive to the eye. 2020-07-11 21:01:09 +02:00
Antoine POPINEAU 9fb9d45e05
Add favorite and info button in landscape Now Playing view. 2020-07-11 19:30:10 +02:00
Antoine POPINEAU 8d7836172b
Reorganized Now Playing view to be more legible. 2020-07-11 19:24:38 +02:00
Antoine POPINEAU 308e7d7567
Improve performance of recycler views and prevent flickering on state change. 2020-07-11 18:15:40 +02:00
Antoine POPINEAU 7d95618ff5
Allow track downloading from the search results. 2020-07-11 17:41:41 +02:00
Antoine POPINEAU e4da4af3f3
Prevent keyboard to pop over result pages. 2020-07-11 17:28:01 +02:00
Antoine POPINEAU b9e9272336
Optimized workflow between two searches. 2020-07-11 17:17:26 +02:00
Antoine POPINEAU 61fdb116ad
Fixed an issue where favorited tracks would not show up erroneously in track lists. 2020-07-11 16:56:09 +02:00
Antoine POPINEAU d75e8ae17f
Add a dedicated support email on the Play Store. Mention GitHub and Matrix in the description [skip ci]. 2020-07-11 15:11:15 +02:00
Antoine POPINEAU dd86988518
Explicitely remove notification when paused and the app is swiped up. 2020-07-11 14:16:22 +02:00
Antoine POPINEAU b6b9e4c053
Reattach the detached service notification when the app is swiped (if not playing) for it be removed with the application. 2020-07-11 12:58:25 +02:00
Antoine POPINEAU eb6b7a807b
Fixed album position in track info and ensure safe callback to current recycler view. 2020-07-10 21:03:48 +02:00
Antoine POPINEAU 3a81d26cd9
Renamed some components to refer to Otter instead of Funkwhale. 2020-07-10 20:40:18 +02:00
Antoine POPINEAU 28949a8e17
Fixed loading wheel. 2020-07-10 20:37:28 +02:00
Antoine POPINEAU bc1e911b41
Globalize the use of caching for main sections data, improved handling of loading more pages. 2020-07-10 20:28:44 +02:00
Antoine POPINEAU 57692f2e42
Added copyright and license information (#58). 2020-07-10 20:25:35 +02:00
Antoine POPINEAU fe224b097a
Re-enabled media session on service start. Do not condition radio resumption to having a cookie, since those are only valid when authenticated anonymously. 2020-07-10 18:46:49 +02:00
Antoine POPINEAU 080c07eeab
Specify in the app name when using a develop build. 2020-07-10 17:23:02 +02:00
Antoine POPINEAU b34810d631
Allow media session resuming from media buttons when service is killed. 2020-07-10 17:18:29 +02:00
Antoine POPINEAU b14b703f05
Fix an issue where always retrieving favorites from the network could be really costly and introduce stack overflows (#60). 2020-07-10 15:50:56 +02:00
Antoine POPINEAU 4ecb607f45
Let the media session live when playback is paused.
As per Android policy and internal logic, we stopped the playback
foreground service when playback was paused. This made our PlayService
elligible for garbage collection by the OS. This had the consequences of
not allowing someone to pause playback and resume it after some time.
Android would always kill the service after around one minute.

This commit, on supported Android version (7.0+) detaches the
notification when stopping the foreground service, leaving the
notification in place even when the service is killed, allowing the user
to resume playback whenever they please.

We also had to move the MediaSession out of the service, for it to
remain alive between service killing and resurrection.
2020-07-09 23:01:35 +02:00
Antoine POPINEAU a3f84cc56c
Add an application ID suffix for development versions. 2020-07-09 23:00:50 +02:00
Antoine POPINEAU 4b2cf10e78
Fix IO thread performing UI task on some occasion (#59). 2020-07-09 10:45:52 +02:00
Antoine POPINEAU 5d397ab1fe
Sort radios, playlists and favorites by name. 2020-07-08 23:23:14 +02:00
Antoine POPINEAU f3bbca9c27
Fixed caching of metadata. 2020-07-08 23:21:47 +02:00
Antoine POPINEAU 37d5c7b7be
Load data from network on resume if cached data is empty. 2020-07-08 23:00:10 +02:00
Antoine POPINEAU 97bb621d7f
Enable network security setting to allow for user-configured CAs. 2020-07-08 22:20:38 +02:00
Antoine POPINEAU b2e6ec43a8
Improved loading of new and cached items.
Scrolling through large lists was a pain. The next page would only load
when the end of the list was reached, stopping the scroll action while the new
page was fetched.

This commits adds two items:

 * Artists, albums and playlists do not refresh data on resume, only
   using cached data until manually refreshed.
 * When manually refreshed, we initially fetch a few pages instead
   of only one. Also, on scroll, we try as best as we can to always keep
   10 pages (pages as in screen estate) worth of data loaded.
2020-07-08 22:11:50 +02:00
Antoine POPINEAU de0a494b43
Do not transform URLs to HTTPS now that we support cleartext connections. 2020-07-08 15:12:49 +02:00
Antoine POPINEAU 0facf09c94
Do not list artists without albums.
As of now, Otter cannot list albumless tracks (for example, tracks in
compilation, appearing under the original artist, but not part of an
album published by this artist). This created a lot of "empty" artists
(with no albums) in the Artists section of the app.

This may be rolled back if we are some day able to list "orphan"
tracks.
2020-07-08 14:09:48 +02:00
Antoine POPINEAU 2c4f8a4329
Added margin on the left of album release date. 2020-07-08 14:08:59 +02:00
Antoine POPINEAU e17c706ae3
Release date can be null, changed model and added checks. 2020-07-08 14:08:35 +02:00
Antoine POPINEAU 37f4b1da9e
Sort artists and global albums by name/title. Sort an artist's albums by release date. Display the release year in the albums view (#54). 2020-07-08 13:19:47 +02:00
Antoine POPINEAU b0d7ff393d
Changed track metadata reporting method so it could work similarly across devices (notification, ambient display, lockscreen, watches, ...) (#55). 2020-07-08 12:46:52 +02:00
Antoine POPINEAU a3f74af076
Fixed tag regex so that CI kicks in on release. 2020-07-07 21:11:45 +02:00
Antoine POPINEAU 34ddef8489
Prepare 1.0.20: provide missing debug values. 2020-07-07 21:04:24 +02:00
Antoine POPINEAU 7f6b748032
Prepare 1.0.20: resized screenshots to be accepted by the Play Store. 2020-07-07 20:38:41 +02:00
Antoine POPINEAU c5a63f88da
Merge branch 'master' into develop 2020-07-07 20:10:58 +02:00
Antoine POPINEAU 1a105654f0
Prepare 1.0.20. 2020-07-07 20:10:35 +02:00
Antoine POPINEAU 8b4537217b
Mention our brand new Matrix channel. 2020-07-07 19:52:15 +02:00
Antoine POPINEAU 1238931384
Updated banner with new logo. Added source files for logo and banner. 2020-06-26 20:37:47 +02:00
Antoine POPINEAU 100514cde6
Limit the width of the login screen in landscape mode to be easier on the eye. 2020-06-26 20:03:48 +02:00
Antoine POPINEAU 72ba8733b3
Added downloads option menu in landscape mode. 2020-06-26 19:43:32 +02:00
Antoine POPINEAU 49f5754f2b
Add more emphasis to currently playing track in listings. 2020-06-26 19:42:10 +02:00
Antoine POPINEAU 9b888ba17f
Do not skip track on error if the user paused playback. Fixed an issue where two track could be marked as playing at the same time in TracksFragment. 2020-06-26 19:05:11 +02:00
Antoine POPINEAU 212b44a22f
Actually disable caching if cache size is set to zero. 2020-06-26 18:50:17 +02:00
Antoine POPINEAU 441ca3249c
Added new full set of adaptive icons. 2020-06-26 12:34:08 +02:00
Antoine POPINEAU c420f26b88
Added the full-size logo to prevent blurry Otter icon. Resized album cover arts in landscape mode. 2020-06-25 22:33:01 +02:00
Antoine POPINEAU 921154edbb
Updated Gradle and Gradle plugin versions. Enabled artifact minification and resource shrinking (reduces APK size by more than 50%). 2020-06-25 22:29:33 +02:00
Antoine POPINEAU 9c61fcf462
Tidied up usage of GlobalScope to the profit of AndroidX's lifecycle coroutine scopes. 2020-06-25 01:26:15 +02:00
Antoine POPINEAU eb57b4c872
Updated README to reflect GitHub Actions build status. 2020-06-24 21:27:47 +02:00
Antoine POPINEAU 9dbaf509c2
Added FUNDING.yml (related to #18). 2020-06-24 21:08:04 +02:00
Antoine POPINEAU bedae61646
Migrated main release build from Travis to GitHub Actions. 2020-06-24 21:02:37 +02:00
Antoine POPINEAU f7a5a29eea
Pulled some fixes from dev/chromecast (080cce00ee). 2020-06-24 19:45:16 +02:00
Antoine POPINEAU 2b9eb789e8
Changed style for main shuffle button to be less conspicuous. 2020-06-24 16:04:36 +02:00
Antoine POPINEAU b2d26a8127
Refactored and rationalized some events and commands on the buses. 2020-06-24 14:54:13 +02:00
Antoine POPINEAU dc25a922c2
Changed icons (still need to make full set). 2020-06-23 23:23:46 +02:00
Antoine POPINEAU 1ee9f021ce
Login screen would briefly display an dummy error when authentication succeeded. 2020-06-23 09:40:18 +02:00
Antoine POPINEAU 7a72558d1a
Refresh every second instead of 500ms. 2020-06-22 22:25:03 +02:00
Antoine POPINEAU ff2a915ba4
Periodically refresh download progress while in DownloadsActivity. 2020-06-22 22:24:34 +02:00
Antoine POPINEAU 03fcf1a382
Fixed download and cache indicators on search screen. Fixed an issue with placeholder texts when some search terms did not return results. 2020-06-22 21:48:31 +02:00
Antoine POPINEAU 08a7a28c22
Nicest highlight of selected row. 2020-06-22 18:05:25 +02:00
Antoine POPINEAU 3a88e02ca0
Delete regular data cache on logout. 2020-06-21 18:51:22 +02:00
Antoine POPINEAU bab7040b8f
Delete downloaded tracks on logout. Cache is not deleted for now (until I find a way). 2020-06-21 18:06:38 +02:00
Antoine POPINEAU 874b79d0d5
Fixed blocking issue when leaving MainActivity where playback state would no longer be reflected in Now Playing. 2020-06-21 16:15:52 +02:00
Antoine POPINEAU 671940ed7a
Screenshots must be taken from develop. 2020-06-21 14:49:50 +02:00
Antoine POPINEAU 4d6b3d1ab2
Added screenshots to README. 2020-06-21 14:47:48 +02:00
Antoine POPINEAU a19e500f09
Prevent long-running requests to make the app crash when user is logging out. 2020-06-21 14:16:30 +02:00
Antoine POPINEAU 490de25b05
Handle radios when logged in anonymously.
On top this fix, this commit adds support for "My content" and
"Favorites" instance radios (fixes #51), as well as clearly separates instance
radios from user radios.

Radios were a bit unusable when not logged in with an actual authorized
user account, this commit fixes the following elements:

 * Anonymous users get a transient session cookie when starting a radio
   session that was not stored and forwarded on playback, meaning no
   radios would play;
 * Anonymous users do not have their own own content. Thus, only the
   "Random" radio makes sense in that context. This commit only display
   the instance radios that are relevant to your authentication status.

"My content" radios needs the user ID to function properly, this commit
also adds retrieving it from the /api/v1/users/users/me/ endpoint, which
now may be used in the future for other purposes.
2020-06-21 13:41:27 +02:00
Antoine POPINEAU 18e981fba5
Fixed an issue where the main playback UI would freeze when skipping an erroring track (in airplane mode, for example). 2020-06-20 22:10:13 +02:00
Antoine POPINEAU 1b98850a9c
Add a checkbox to allow cleartext connections to a Funkwhale instance. Should close #6. 2020-06-20 16:52:41 +02:00
Antoine POPINEAU 66c7915307
Prevent issue on queue item deletion. Should close #48. 2020-06-20 16:32:14 +02:00
Antoine POPINEAU e539cc26dd
Manage cached and downloaded tracks separately. Downloaded track are not automatically evicted and do not count towards cache storage limit. Contributes to #37. Fixed an issue where the event bus on main would be duplicated. 2020-06-20 15:42:10 +02:00
vicdorke 2eff3263d2 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (94 of 94 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/zh_Hans/
2020-06-19 12:33:43 +00:00
vicdorke fb22b9f79e Added translation using Weblate (Chinese (Simplified)) 2020-06-19 05:51:54 +00:00
Antoine POPINEAU 13f3c2d465
Add light animation when scrolling listings. 2020-06-15 00:26:22 +02:00
Antoine POPINEAU 098048ac49
Download state change would not be reflected in the list. 2020-06-14 21:15:13 +02:00
Antoine POPINEAU abff279df9
Fix performance issue on download tracking from the Favorites section. 2020-06-14 20:40:08 +02:00
Antoine POPINEAU a2c35595c7
Better handling of download progress and event. Added an option to retry failed downloads. Performance improvement around downloads UI. 2020-06-14 20:32:48 +02:00
Antoine POPINEAU 94fd3d51aa
Add downloaded indicators in favorites view. 2020-06-14 20:32:20 +02:00
Antoine POPINEAU a2caba8bd1
Added downloaded indicator on track rows. 2020-06-14 20:32:17 +02:00
Antoine POPINEAU 4127421132
Allow downloading whole albums. 2020-06-14 20:31:12 +02:00
Antoine POPINEAU 00fb833cfa
Added basic management of downloads and downloaded tracks. 2020-06-14 20:31:07 +02:00
Antoine POPINEAU 2dfabf74e9
Initial handling of track downloads. 2020-06-14 20:28:05 +02:00
Antoine POPINEAU a0e201e68f
Filter music according to own music setting (should close #33). 2020-06-14 20:26:47 +02:00
Antoine POPINEAU 746ae8897d
Frontend logic for displaying only own music. 2020-06-14 20:26:39 +02:00
Antoine POPINEAU 58215da685 Update issue templates 2020-06-13 22:56:27 +00:00
Antoine POPINEAU fc1419c2fb
Minor style enhancement. 2020-06-14 00:42:45 +02:00
Antoine POPINEAU dcc6da655f
Removed build dependency on NDK. 2020-06-13 23:24:43 +02:00
Antoine POPINEAU e7865004af
Prepare 1.0.19. 2020-06-13 22:27:26 +02:00
Antoine POPINEAU 17dace030e
Upgraded ExoPlayer, manually compiled FLAC extension. Related to #44. 2020-06-13 22:24:35 +02:00
Antoine POPINEAU 94dec8367f
Added more title contrast for item lists. Added icon for custom radios. 2020-06-13 13:41:12 +02:00
Antoine POPINEAU 81cf0835df
Prepare 1.0.18. 2020-06-13 12:26:53 +02:00
Antoine POPINEAU 725be6f8e5
Fix CI. 2020-06-12 00:56:46 +02:00
Antoine POPINEAU 4b552acacb
Do not tag on release. 2020-06-11 23:08:33 +02:00
Antoine POPINEAU d496a83387
Exempt tip from release CI. 2020-06-11 23:04:12 +02:00
Antoine POPINEAU aa9f2f03d7
Set up continuous build for develop tip with GitHub actions. 2020-06-11 22:34:49 +02:00
Keunes 7dc4d3e0ee
Translated using Weblate (Dutch)
Currently translated at 87.3% (76 of 87 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/nl/
2020-06-11 18:09:59 +02:00
Antoine POPINEAU 7bc3ab2010
Translated using Weblate (French)
Currently translated at 100.0% (87 of 87 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/fr/
2020-06-11 18:09:58 +02:00
Antoine POPINEAU 865603634a
Translated using Weblate (German)
Currently translated at 94.2% (82 of 87 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/de/
2020-06-11 18:09:57 +02:00
Antoine POPINEAU bfb1b90781
Translated using Weblate (English)
Currently translated at 100.0% (87 of 87 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/en/
2020-06-11 18:09:13 +02:00
Antoine POPINEAU d319705234
Skip continuous builds for now, still does not work. 2020-06-11 16:15:04 +02:00
Antoine POPINEAU 827170c34f
Build HEAD release on push to develop and bumped NDK versions. 2020-06-11 12:10:24 +02:00
Antoine POPINEAU eb97c3d4be
Promote Radios to stable! 2020-06-11 10:56:31 +02:00
Antoine POPINEAU 6dcd9afc31
Fixed an issue where RadiosFragment would wake up on track change even when the app is killed, causing a crash. 2020-06-11 08:08:20 +02:00
Antoine POPINEAU d76f820f9d
Left test exception throw... 2020-06-11 00:02:49 +02:00
Antoine POPINEAU e50a43a812
Lint. 2020-06-11 00:01:39 +02:00
Antoine POPINEAU a4b2907c07
Added Settings item to copy the latest logs in case of crash. 2020-06-10 23:59:09 +02:00
Antoine POPINEAU 54f911793a
Fixed skipping tracks on playback error. The faulty track is now removed from the queue. 2020-06-10 11:38:57 +02:00
Antoine POPINEAU 97b7dc2a61
Updated README to mention community localization effort. Fixed a crash with Android media controls when a cover art HTTP call would return an error status code. 2020-06-10 10:51:23 +02:00
Keunes 72a77221e6 Added translation using Weblate (Dutch) 2020-06-05 21:21:01 +00:00
Antoine POPINEAU 427dc8b4db Translated using Weblate (French)
Currently translated at 100.0% (87 of 87 strings)

Translation: Otter/Otter Android app
Translate-URL: https://translate.funkwhale.audio/projects/otter/android/fr/
2020-06-05 07:57:01 +00:00
Antoine POPINEAU a4b2af7640
Removed unused translation. Fixed untranslated string. 2020-06-02 19:02:34 +02:00
Arne Schlag 931c9d589b
added support for german (de) (#38)
* added support for german (de)
* Added spacing and missing plurals
* added missing translations
2020-06-02 17:01:30 +00:00
Antoine POPINEAU c75f2e45f6
Ability to shuffle play all tracks from an artist. Should close #21. Also added animations over long-running operations. 2020-06-02 18:50:46 +02:00
Antoine POPINEAU cb43615cb1
Fixed some linting issues. Fixed two issues related to current track emphasis and landscape now playing cover background. 2020-06-01 21:25:16 +02:00
Antoine POPINEAU dfeec64bf1
Explicitely set notifications as public. 2020-06-01 17:29:15 +02:00
Antoine POPINEAU 80554796d3
Resize native radios icons to be more legible. 2020-06-01 17:05:13 +02:00
Antoine POPINEAU ce05acad21
Send track played reports to Funkwhale whenever a track finishes playing. Closes #40. 2020-06-01 16:31:58 +02:00
Antoine POPINEAU dc7803acb4
Added support for native radios (random and less listened to radios). Advancing #8. 2020-06-01 14:38:50 +02:00
Antoine POPINEAU 7f2f81f0a8
CI: fix release artifact location so it will be automated again for next release. 2020-05-31 20:43:51 +02:00
Antoine POPINEAU 5f2cad4c42
Prepare 1.0.17 (again) with fixed release notes. 2020-05-31 20:23:05 +02:00
Antoine POPINEAU bf7763c8c3
Prepare 1.0.17. 2020-05-31 20:17:13 +02:00
Antoine POPINEAU 1ccfedca87
Merge pull request #39 from Skehmatics/feature/allowRfc3986Urls
Avoid java.net.URI's RFC 2396 parsing to allow more modern URLs (escaped characters in params would get unescaped after parsing).
2020-05-31 17:59:14 +00:00
Derek Schmidt c9159166d2
Avoid java.net.URL's RFC 2396 parsing 2020-05-30 18:54:57 -07:00
Antoine POPINEAU d5c1b89d3d
Cache if playback is in radio mode to be able to continue after app gets killed. 2020-05-31 02:39:32 +02:00
Antoine POPINEAU ae903aba65
Fix repeat icon color in dark mode. 2020-05-31 02:38:59 +02:00
Antoine POPINEAU 2d3bcde242
Add shuffle button in landscape mode. 2020-05-31 00:38:34 +02:00
Antoine POPINEAU 64c8887dcb
Updated README with IzzySoft link and radio support. 2020-05-30 23:49:03 +02:00
Antoine POPINEAU 307ecc4128
Try and generate release build. 2020-05-30 23:35:17 +02:00
Antoine POPINEAU d0d64bad9d
Fixed NDK version to bypass an issue on Travis. 2020-05-30 21:44:41 +02:00
Antoine POPINEAU 06f8ddf931
Updated build tools version in travis-ci.yml 2020-05-30 21:27:57 +02:00
Antoine POPINEAU fd1741ca53
Added experimental radios support. Fixed linter and fastlane metadata. 2020-05-30 21:16:28 +02:00
Antoine POPINEAU 3fb0bb55a4
Seek to start of song when nexting (progress cache would remain at the progress of the previous track. 2020-05-30 17:49:08 +02:00
Antoine POPINEAU 3180c886a2
Added Fastlane metadata listing for F-Droid and rationalized content between Triplet and Fastlane. Related to #10. 2020-05-30 16:16:04 +02:00
Antoine POPINEAU 415be3d235
Finish removing everything related to my Chromecast experiments, for now. 2020-05-30 16:00:24 +02:00
Antoine POPINEAU 159685bcc1
Added track details from Now Playing view. Should fix #30. 2020-05-30 15:49:06 +02:00
Antoine POPINEAU 1038ee00ff
Hide search results sections if no result for a kind. 2020-05-30 14:15:59 +02:00
Antoine POPINEAU 98b2b31e20
Open artists and albums in search results. 2020-05-30 14:12:04 +02:00
Antoine POPINEAU cf4cd16bed
Added a toggle for repeat mode on the Now Playing view. Should fix #26. 2020-05-29 23:42:03 +02:00
Antoine POPINEAU b554678500
Added links to artist and album on Now Playing view. Partially covers #30. 2020-05-29 21:40:01 +02:00
Antoine POPINEAU fa82f13a9c
Display search results for artists and albums. Only cosmetic for now, there is no action on them. 2020-05-29 13:19:28 +02:00
Antoine POPINEAU 55ab0ce71c
Cache playback position on pause. Should fix #19. 2020-05-29 10:32:09 +02:00
Antoine POPINEAU 9d0ee7f1b8
Added license to settings. 2020-05-29 01:42:03 +02:00
Antoine POPINEAU d9b7ed5b3f
Added version number to settings. 2020-05-29 01:30:05 +02:00
Antoine POPINEAU d53bee8f31
Added link to repository in settings. 2020-05-29 01:23:21 +02:00
Antoine POPINEAU 534e48e2c8
Upgrades dependencies. Enhanced login screen appearance. Fixed a seriously dumb issue where we would not transmit the token to the server. 2020-05-29 01:11:15 +02:00
Antoine POPINEAU aad0ec439c
Allow for anonymous connection if server supports it. Should provide basic support for #14. 2019-11-25 23:16:18 +01:00
Antoine POPINEAU 3101fa5302
Fixed a bug where lists would crash if a second page was loaded. 2019-11-25 21:39:10 +01:00
Antoine POPINEAU a55986343f
Format code and fixed favoriting issue on favorite tab. 2019-11-23 14:45:56 +01:00
Antoine POPINEAU aaf8874699
Remove "Add to playlist" from overflow menu since it is not yet implemented. 2019-11-22 20:24:56 +01:00
Antoine POPINEAU fbe5ea4db9
Sort album view by track position. 2019-11-22 20:24:08 +01:00
Antoine POPINEAU b7db24ea11
Fixed issue with merging around favorite management. 2019-11-22 20:14:05 +01:00
Antoine POPINEAU 40117122c7
Display error messages from Funkwhale's API on login error. 2019-11-22 00:13:22 +01:00
Antoine POPINEAU 9ea5446f58
Wrap LoginActivity in ScrollView to account for small screens and landscape mode. 2019-11-21 23:41:01 +01:00
Antoine POPINEAU c36616ab92
Promote Favorites tab to stable. 2019-11-21 23:26:35 +01:00
Antoine POPINEAU 0cb4bda212
Added support for landscape mode. 2019-11-21 23:26:34 +01:00
Antoine POPINEAU cac32332e0
Favorites button is now async. Added favorite button management in queue and search. 2019-11-21 22:13:59 +01:00
Antoine POPINEAU 09ada772e6
Prepared 1.0.15. 2019-11-17 01:03:59 +01:00
Antoine POPINEAU 4d9fb1c53c
Removed F-Droid symlink. 2019-11-17 00:25:48 +01:00
Antoine POPINEAU 02715389d2
Regenerated app icons to use proper background color and round shape on all API versions. 2019-11-16 17:50:24 +01:00
Antoine POPINEAU e4e91cd013
Finish LoginActivity on successful login to prevent backpressing back to it. 2019-11-16 17:26:43 +01:00
Antoine POPINEAU b735e20fbd
Limit track info to one line on now playing preview. Properly encode search terms. 2019-11-16 16:52:44 +01:00
Antoine POPINEAU 98b7812a47
We failed to remove bold typeface for non-currently playing tracks when recycling views. This is fixed. 2019-11-16 16:52:43 +01:00
Antoine POPINEAU a21cafdbe0
Fix miscellaneous bugs to Flow implementation. 2019-11-16 16:52:42 +01:00
Antoine POPINEAU ba12854e6c
Migrate to Flows. 2019-11-16 16:52:39 +01:00
Antoine POPINEAU 4e60906fe9
Wrong version code generator. [skip ci] 2019-11-06 09:05:48 +01:00
Antoine POPINEAU 2c87e7c983
Moved androidGitVersion to top of the file, else it is not taken into account. [skip ci] 2019-11-06 09:01:39 +01:00
Antoine POPINEAU 7084be81de
Remove -dirty suffix from version name if workdir is not clean (for F-Droid). [skip ci] 2019-11-06 08:14:02 +01:00
Antoine POPINEAU 3cd1d77b85
Trying and fix F-Droid build. [skip ci] 2019-11-06 01:08:30 +01:00
Antoine POPINEAU 28d395e1da
Fixed publish script [ci skip]. 2019-10-31 13:59:14 +01:00
Antoine POPINEAU 28761b63c0
Prepare release 1.0.13. 2019-10-31 11:45:41 +01:00
Antoine POPINEAU 668394e798
Merge branch 'master' of github.com:apognu/otter 2019-10-31 11:20:26 +01:00
Antoine POPINEAU 22c99d384c
Fixed issue #7 to display all search results properly. 2019-10-31 11:20:12 +01:00
Antoine POPINEAU c180456e9d
Fixed Travis URL in README [ci skip]. 2019-10-31 01:03:37 +01:00
Antoine POPINEAU 657c72480e
Some vendors' battery optimizers killed the background service quite
quickly when the app is in the background and *not* playing music. This
had the effect of breaking all player actions within the app (queue
listing, playback control, etc.).

This "starts" PlayerService every time MainActivity is resumed, which is
a noop if the service is still running, but allows retrieving app
functionality otherwise.
2019-10-31 00:46:35 +01:00
Antoine POPINEAU bf1bba1162
Added README. 2019-10-30 22:59:05 +01:00
Antoine POPINEAU 993e780d54
Remove underline under SearchView. 2019-10-30 22:28:31 +01:00
Antoine POPINEAU 43ffffa68f
Added album cover to MediaSession (for WearOS media controls, and future Chromecast support). 2019-10-30 22:21:02 +01:00
Antoine POPINEAU d40a2e3702
Install NDK for Flac and OPUS codec stripping. 2019-10-30 22:10:50 +01:00
Antoine POPINEAU eac875b9cb
Clear queue on logout. 2019-10-30 22:06:57 +01:00
Antoine POPINEAU 5c1498bb95
Automate editing of store listing. 2019-10-30 21:58:24 +01:00
Antoine POPINEAU 07d00ee69c
Fixed GitHub releases. 2019-10-30 15:09:15 +01:00
Antoine POPINEAU a4fd317e5c
Sign tags. 2019-10-30 15:01:01 +01:00
Antoine POPINEAU f45e5f7a36
Integrating CI. Configure GitHub releases. 2019-10-30 14:47:04 +01:00
Antoine POPINEAU 7c9a71d6d7
Put buggy features behind an experiments gate (favorites, for now). Optimized layouts to be able to load lots of content. Fixed Funkwhale API URLs to try and be backward compatible. 2019-10-29 23:41:44 +01:00
Antoine POPINEAU a63f3f7e68
Added ability to not fetch on create for fragments. Added animation on queue change. 2019-10-24 12:35:34 +02:00
Antoine POPINEAU 2d5e73dcd4
Fix crash on illegal characters in URL. Fixed caret and error color on login form. 2019-10-23 22:05:56 +02:00
Antoine POPINEAU 0fa0b5d212
Added support for Flac. 2019-10-23 21:41:50 +02:00
Antoine POPINEAU e84455390b
Several improvements in UI (better colors for night mode, added icons).
Better handling of startup (login activity would reset if put in the background).
Allow use of schemeless hostname for login.
Destroy main activity and clear cache on logout.
Change of endpoint for favorites retrieval for one with much better performance.
2019-10-23 20:21:18 +02:00
Antoine POPINEAU 78468167ca
Fixed crash on coverless albums. 2019-10-22 21:56:33 +02:00
Antoine POPINEAU 6139591bd1
Fixed build script. 2019-10-22 20:19:05 +02:00
Antoine POPINEAU 9e7d1cfe29
Added missing ripple effects. Fixed padding around list items. Moved event buses into Application object. 2019-10-22 20:03:52 +02:00
Antoine POPINEAU 68e5f7e1d1
Migrated to Kotlin for Gradle scripts. 2019-10-21 23:26:06 +02:00
239 changed files with 8540 additions and 1901 deletions

2
.editorconfig Normal file
View File

@ -0,0 +1,2 @@
[*.{kt,kts}]
indent_size=2

4
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,4 @@
github: ["apognu"]
custom: [
"https://www.paypal.me/apognu"
]

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**How to reproduce**
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment details**
- Device:
- Android version:
- App version:
- If public Funkwhale instance, its URL:
**Logs**
Add any related logs from ADB or from the "Copy logs" setting.

26
.github/workflows/develop.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Continuous develop build
on:
push:
branches:
- develop
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Build with Gradle
run: |
mkdir -p /home/runner/.android && touch /home/runner/.android/repositories.cfg
./gradlew assembleDebug
- name: Create release
uses: eine/tip@gha-tip
with:
token: ${{ secrets.GITHUB_TOKEN }}
cwd: ${{ github.workspace }}
files: app/build/outputs/apk/debug/app-debug.apk

45
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Release build
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Setting up publication keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" > ${HOME}/release.jks.asc
gpg -q --yes --batch -d --passphrase="${{ secrets.ENCRYPTION_KEY }}" -o ${HOME}/release.jks ${HOME}/release.jks.asc
echo -e "signing.store=${HOME}/release.jks\nsigning.key_passphrase=${{ secrets.ANDROID_KEYSTORE_KEY_PASSPHRASE }}\nsigning.alias=release\nsigning.store_passphrase=${{ secrets.ANDROID_KEYSTORE_STORE_PASSPHRASE }}" > local.properties
- name: Set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Build Otter
run: |
mkdir -p /home/runner/.android && touch /home/runner/.android/repositories.cfg
./gradlew assembleRelease
- name: Create Otter's release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: true
- name: Upload Otter's artifact (full version)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/release/app-release.apk
asset_name: otter-full-release.apk
asset_content_type: application/zip

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# Otter for Funkwhale
![](https://img.shields.io/github/license/apognu/otter?style=flat-square)
[![](https://img.shields.io/github/workflow/status/apognu/otter/Continuous%20develop%20build?label=develop&style=flat-square)](https://github.com/apognu/otter/actions?query=workflow%3A%22Continuous+develop+build%22)
[![](https://img.shields.io/badge/Play%20Store-otter-informational?style=flat-square)](https://play.google.com/store/apps/details?id=com.github.apognu.otter)
[![](https://img.shields.io/badge/IzzySoft-otter-informational?style=flat-square)](https://apt.izzysoft.de/fdroid/index/apk/com.github.apognu.otter)
[![](https://img.shields.io/badge/APK-otter-informational?style=flat-square)](https://github.com/apognu/otter/releases) [![](https://translate.funkwhale.audio/widgets/otter/-/android/svg-badge.svg)](https://translate.funkwhale.audio/projects/otter/android/)
Otter is a native Android music player for [Funkwhale](https://funkwhale.audio), native to both Android (developed in Kotlin) and to Funkwhale (uses its native API instead of Subsonic).
You can get help and discuss Otter on Matrix on [#otter:matrix.org](https://matrix.to/#/#otter:matrix.org).
![Otter graphic](https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png)
## State
A beta version of the app can be downloaded on [Google Play](https://play.google.com/store/apps/details?id=com.github.apognu.otter), on [IzzySoft](https://apt.izzysoft.de/fdroid/index/apk/com.github.apognu.otter) (F-Droid-compatible repository) or through [GitHub releases](https://github.com/apognu/otter/releases). Please bear with it, there **will** be bugs, there **will** be crashes and there **will** be performance or UX issues.
Otter's features, as of this writing, are the following:
* Basic collection browsing (artists, albums and tracks)
* Playlists listing
* Favorites management (listing and add/remove)
* Track search
* Queue management
* Caching of played tracks (played tracks work offline)
* Download tracks for offline playback
* Radios playback
* Dark mode! 🎉
Otter will try to behave as you would expect a mobile music player to, meaning integrating with the OS's media controls (including headset controls) or pause on incoming calls. If there is anything you would like it to do, please [open an issue](https://github.com/apognu/otter/issues/new).
## Screenshots
<img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/6.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/7.png" width="200" />
## Translation
Otter is being translated by the community through [Weblate](https://translate.funkwhale.audio/projects/otter/android/). If you would like to contribute to its localization or add a new language, you can help out there.
Thanks to the Funkwhale project for hosting us on their instance.

View File

@ -1,92 +0,0 @@
plugins {
id 'com.github.triplet.play' version '2.4.2'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
def props = new Properties()
props.load(new FileInputStream(rootProject.file('local.properties')))
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
compileSdkVersion 29
defaultConfig {
applicationId "com.github.apognu.otter"
minSdkVersion 23
targetSdkVersion 29
versionCode androidGitVersion.code()
versionName androidGitVersion.name()
}
signingConfigs {
release {
storeFile file(props.get('signing.store'))
storePassword props.get('signing.store_passphrase')
keyAlias props.get('signing.alias')
keyPassword props.get('signing.key_passphrase')
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
androidGitVersion {
codeFormat = 'MNNPP'
}
}
play {
serviceAccountCredentials = file(props.get('play.credentials'))
defaultToAppBundles = true
track = "beta"
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0-beta01'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.0.0'
implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0'
implementation 'com.google.android.material:material:1.1.0-beta01'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.google.android.exoplayer:exoplayer:2.10.3'
implementation 'com.google.android.exoplayer:extension-mediasession:2.10.6'
implementation 'com.google.android.exoplayer:extension-cast:2.10.6'
implementation 'com.aliassadi:power-preference-lib:1.4.1'
implementation 'com.github.kittinunf.fuel:fuel:2.1.0'
implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.1.0'
implementation 'com.github.kittinunf.fuel:fuel-android:2.1.0'
implementation 'com.github.kittinunf.fuel:fuel-gson:2.1.0'
implementation 'com.google.code.gson:gson:2.8.5'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'jp.wasabeef:picasso-transformations:2.2.1'
}

150
app/build.gradle.kts Normal file
View File

@ -0,0 +1,150 @@
import org.jetbrains.kotlin.konan.properties.hasProperty
import java.io.FileInputStream
import java.util.*
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-android-extensions")
id("org.jlleitschuh.gradle.ktlint") version "8.1.0"
id("com.gladed.androidgitversion") version "0.4.10"
id("com.github.triplet.play") version "2.4.2"
}
val props = Properties().apply {
try {
load(FileInputStream(rootProject.file("local.properties")))
} catch (e: Exception) {
}
}
androidGitVersion {
codeFormat = "MNNNPPP"
format = "%tag%%-count%%-commit%%-branch%"
}
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildToolsVersion = "29.0.3"
compileSdkVersion(29)
defaultConfig {
applicationId = "com.github.apognu.otter"
minSdkVersion(23)
targetSdkVersion(29)
ndkVersion = "21.3.6528147"
versionCode = androidGitVersion.code()
versionName = androidGitVersion.name()
}
signingConfigs {
create("release") {
if (props.hasProperty("signing.store")) {
storeFile = file(props.getProperty("signing.store"))
storePassword = props.getProperty("signing.store_passphrase")
keyAlias = props.getProperty("signing.alias").toString()
keyPassword = props.getProperty("signing.key_passphrase")
}
}
}
buildTypes {
getByName("debug") {
isDebuggable = true
applicationIdSuffix = ".dev"
manifestPlaceholders = mapOf(
"app_name" to "Otter (develop)"
)
resValue("string", "debug.hostname", props.getProperty("debug.hostname", ""))
resValue("string", "debug.username", props.getProperty("debug.username", ""))
resValue("string", "debug.password", props.getProperty("debug.password", ""))
}
getByName("release") {
manifestPlaceholders = mapOf(
"app_name" to "Otter"
)
if (props.hasProperty("signing.store")) {
signingConfig = signingConfigs.getByName("release")
}
resValue("string", "debug.hostname", "")
resValue("string", "debug.username", "")
resValue("string", "debug.password", "")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
ktlint {
debug.set(false)
verbose.set(false)
}
play {
isEnabled = props.hasProperty("play.credentials")
if (isEnabled) {
serviceAccountCredentials = file(props.getProperty("play.credentials"))
defaultToAppBundles = true
track = "beta"
}
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.72")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
implementation("androidx.appcompat:appcompat:1.2.0")
implementation("androidx.core:core-ktx:1.5.0-alpha02")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha07")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
implementation("androidx.preference:preference:1.1.1")
implementation("androidx.recyclerview:recyclerview:1.1.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("com.google.android.material:material:1.3.0-alpha02")
implementation("com.android.support.constraint:constraint-layout:2.0.1")
implementation("com.google.android.exoplayer:exoplayer-core:2.11.5")
implementation("com.google.android.exoplayer:exoplayer-ui:2.11.5")
implementation("com.google.android.exoplayer:extension-mediasession:2.11.5")
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:2.11.4") {
isTransitive = false
}
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:2.11.4" ){
isTransitive = false
}
implementation("com.aliassadi:power-preference-lib:1.4.1")
implementation("com.github.kittinunf.fuel:fuel:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-android:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-gson:2.1.0")
implementation("com.google.code.gson:gson:2.8.6")
implementation("com.squareup.picasso:picasso:2.71828")
implementation("jp.wasabeef:picasso-transformations:2.2.1")
}

View File

@ -1,21 +1 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class com.github.apognu.otter.** { *; }

View File

@ -1,44 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.github.apognu.otter">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL"/>
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<application
android:name="com.github.apognu.otter.Otter"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="${app_name}"
android:networkSecurityConfig="@xml/security"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:screenOrientation="portrait"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<!-- <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider"/> -->
<activity
android:name=".activities.SplashActivity"
android:launchMode="singleInstance"
android:noHistory="true">
<activity android:name="com.github.apognu.otter.activities.LoginActivity" android:noHistory="true" android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.github.apognu.otter.activities.MainActivity"/>
<activity android:name="com.github.apognu.otter.activities.SearchActivity" android:launchMode="singleTop"/>
<activity android:name="com.github.apognu.otter.activities.SettingsActivity"/>
<activity android:name="com.github.apognu.otter.activities.LicencesActivity"/>
<activity
android:name=".activities.LoginActivity"
android:configChanges="screenSize|orientation"
android:launchMode="singleInstance" />
<service android:name="com.github.apognu.otter.playback.PlayerService"/>
<activity android:name=".activities.MainActivity" />
<receiver android:name="com.github.apognu.otter.playback.MediaControlActionReceiver"/>
<activity
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
<activity android:name=".activities.DownloadsActivity" />
<activity android:name=".activities.SettingsActivity" />
<activity android:name=".activities.LicencesActivity" />
<service
android:name=".playback.PlayerService"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<service
android:name=".playback.PinService"
android:exported="false">
<intent-filter>
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -2,16 +2,113 @@ package com.github.apognu.otter
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.github.apognu.otter.playback.MediaSession
import com.github.apognu.otter.playback.QueueManager
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.database.ExoDatabaseProvider
import com.google.android.exoplayer2.offline.DefaultDownloadIndex
import com.google.android.exoplayer2.offline.DefaultDownloaderFactory
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloaderConstructorHelper
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.preference.PowerPreference
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import java.text.SimpleDateFormat
import java.util.*
class Otter : Application() {
companion object {
private var instance: Otter = Otter()
fun get(): Otter = instance
}
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()
private val exoDatabase: ExoDatabaseProvider by lazy { ExoDatabaseProvider(this) }
val exoCache: SimpleCache by lazy {
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().let {
val cacheSize = if (it == 0L) 0 else it * 1024 * 1024 * 1024
SimpleCache(
cacheDir.resolve("media"),
LeastRecentlyUsedCacheEvictor(cacheSize),
exoDatabase
)
}
}
val exoDownloadCache: SimpleCache by lazy {
SimpleCache(
cacheDir.resolve("downloads"),
NoOpCacheEvictor(),
exoDatabase
)
}
val exoDownloadManager: DownloadManager by lazy {
DownloaderConstructorHelper(exoDownloadCache, QueueManager.factory(this)).run {
DownloadManager(this@Otter, DefaultDownloadIndex(exoDatabase), DefaultDownloaderFactory(this))
}
}
val mediaSession = MediaSession(this)
override fun onCreate() {
super.onCreate()
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())
instance = this
when (PowerPreference.getDefaultFile().getString("night_mode")) {
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
"off" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
}
fun deleteAllData() {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
cacheDir.listFiles()?.forEach {
it.delete()
}
cacheDir.resolve("picasso-cache").deleteRecursively()
}
inner class CrashReportHandler : Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
val now = Date(Date().time - (5 * 60 * 1000))
val formatter = SimpleDateFormat("MM-dd kk:mm:ss.000", Locale.US)
Runtime.getRuntime().exec(listOf("logcat", "-d", "-T", formatter.format(now)).toTypedArray()).also {
it.inputStream.bufferedReader().also { reader ->
val builder = StringBuilder()
while (true) {
builder.appendln(reader.readLine() ?: break)
}
builder.appendln(e.toString())
Cache.set(this@Otter, "crashdump", builder.toString().toByteArray())
}
}
defaultExceptionHandler?.uncaughtException(t, e)
}
}
}

View File

@ -0,0 +1,125 @@
package com.github.apognu.otter.activities
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.DownloadsAdapter
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.getMetadata
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.activity_downloads.*
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DownloadsActivity : AppCompatActivity() {
lateinit var adapter: DownloadsAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_downloads)
downloads.itemAnimator = null
adapter = DownloadsAdapter(this, DownloadChangedListener()).also {
it.setHasStableIds(true)
downloads.layoutManager = LinearLayoutManager(this)
downloads.adapter = it
}
lifecycleScope.launch(Default) {
while (true) {
delay(1000)
refreshProgress()
}
}
}
override fun onResume() {
super.onResume()
lifecycleScope.launch(Default) {
EventBus.get().collect { event ->
if (event is Event.DownloadChanged) {
refreshTrack(event.download)
}
}
}
refresh()
}
private fun refresh() {
lifecycleScope.launch(Main) {
val cursor = Otter.get().exoDownloadManager.downloadIndex.getDownloads()
adapter.downloads.clear()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let { info ->
adapter.downloads.add(info.apply {
this.download = download
})
}
}
adapter.notifyDataSetChanged()
}
}
private suspend fun refreshTrack(download: Download) {
download.getMetadata()?.let { info ->
adapter.downloads.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
if (download.state != info.download?.state) {
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
adapter.notifyItemChanged(match.second)
}
}
}
}
}
private suspend fun refreshProgress() {
val cursor = Otter.get().exoDownloadManager.downloadIndex.getDownloads()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let { info ->
adapter.downloads.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
if (download.state == Download.STATE_DOWNLOADING && download.percentDownloaded != info.download?.percentDownloaded ?: 0) {
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
adapter.notifyItemChanged(match.second)
}
}
}
}
}
}
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {
override fun onItemRemoved(index: Int) {
adapter.downloads.removeAt(index)
adapter.notifyDataSetChanged()
}
}
}

View File

@ -38,6 +38,11 @@ class LicencesActivity : AppCompatActivity() {
"Apache License 2.0",
"https://github.com/google/ExoPlayer/blob/release-v2/LICENSE"
),
Licence(
"ExoPlayer-Extensions",
"Apache License 2.0",
"https://github.com/PaulWoitaschek/ExoPlayer-Extensions/blob/master/LICENSE"
),
Licence(
"Fuel",
"MIT License",

View File

@ -1,51 +1,78 @@
package com.github.apognu.otter.activities
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnLayout
import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.LoginDialog
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Userinfo
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
data class FwCredentials(val token: String)
data class FwCredentials(val token: String, val non_field_errors: List<String>?)
class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
limitContainerWidth()
}
override fun onResume() {
super.onResume()
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
when (contains("access_token")) {
true -> Intent(this@LoginActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
}
false -> setContentView(R.layout.activity_login)
anonymous?.setOnCheckedChangeListener { _, isChecked ->
val state = when (isChecked) {
true -> View.GONE
false -> View.VISIBLE
}
username_field.visibility = state
password_field.visibility = state
}
login?.setOnClickListener {
val hostname = hostname.text.toString().trim()
var hostname = hostname.text.toString().trim()
val username = username.text.toString()
val password = password.text.toString()
try {
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
val url = Uri.parse(hostname)
Uri.parse(hostname).apply {
if (!cleartext.isChecked && scheme == "http") {
throw Exception(getString(R.string.login_error_hostname_https))
}
if (url.scheme != "https") {
throw Exception(getString(R.string.login_error_hostname_https))
if (scheme == null) {
hostname = when (cleartext.isChecked) {
true -> "http://$hostname"
false -> "https://$hostname"
}
}
}
hostname_field.error = ""
when (anonymous.isChecked) {
false -> authedLogin(hostname, username, password)
true -> anonymousLogin(hostname)
}
} catch (e: Exception) {
val message =
@ -53,44 +80,127 @@ class LoginActivity : AppCompatActivity() {
else e.message
hostname_field.error = message
return@setOnClickListener
}
}
}
hostname_field.error = ""
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val body = mapOf(
"username" to username,
"password" to password
).toList()
limitContainerWidth()
}
val dialog = LoginDialog().apply {
show(supportFragmentManager, "LoginDialog")
}
private fun authedLogin(hostname: String, username: String, password: String) {
val body = mapOf(
"username" to username,
"password" to password
).toList()
GlobalScope.launch(Main) {
val result = Fuel.post("$hostname/api/v1/token", body)
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
val dialog = LoginDialog().apply {
show(supportFragmentManager, "LoginDialog")
}
result.fold(
{ data ->
lifecycleScope.launch(Main) {
try {
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
when (result) {
is Result.Success -> {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("hostname", hostname)
setBoolean("anonymous", false)
setString("username", username)
setString("password", password)
setString("access_token", data.token)
setString("access_token", result.get().token)
}
Userinfo.get()?.let {
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
return@launch finish()
}
throw Exception(getString(R.string.login_error_userinfo))
}
is Result.Failure -> {
dialog.dismiss()
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
hostname_field.error = null
username_field.error = null
if (error != null && error.non_field_errors?.isNotEmpty() == true) {
username_field.error = error.non_field_errors[0]
} else {
hostname_field.error = result.error.localizedMessage
}
}
}
} catch (e: Exception) {
dialog.dismiss()
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
hostname_field.error = message
}
}
}
private fun anonymousLogin(hostname: String) {
val dialog = LoginDialog().apply {
show(supportFragmentManager, "LoginDialog")
}
lifecycleScope.launch(Main) {
try {
val (_, _, result) = Fuel.get("$hostname/api/v1/tracks/")
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
when (result) {
is Result.Success -> {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("hostname", hostname)
setBoolean("anonymous", true)
}
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
},
{ error ->
finish()
}
is Result.Failure -> {
dialog.dismiss()
hostname_field.error = error.localizedMessage
hostname_field.error = result.error.localizedMessage
}
)
}
} catch (e: Exception) {
dialog.dismiss()
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
hostname_field.error = message
}
}
}
private fun limitContainerWidth() {
container.doOnLayout {
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && container.width >= 1440) {
container.layoutParams.width = 1440
} else {
container.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
}
container.requestLayout()
}
}
}

View File

@ -2,37 +2,61 @@ package com.github.apognu.otter.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.util.DisplayMetrics
import android.view.*
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.fragments.QueueFragment
import com.github.apognu.otter.fragments.*
import com.github.apognu.otter.playback.MediaControlsManager
import com.github.apognu.otter.playback.PinService
import com.github.apognu.otter.playback.PlayerService
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.views.DisableableFrameLayout
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitStringResponse
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.offline.DownloadService
import com.google.gson.Gson
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.partial_now_playing.*
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
enum class ResultCode(val code: Int) {
LOGOUT(1001)
}
private val favoriteRepository = FavoritesRepository(this)
private val favoritedRepository = FavoritedRepository(this)
private var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -51,16 +75,33 @@ class MainActivity : AppCompatActivity() {
.replace(R.id.container, BrowseFragment())
.commit()
startService(Intent(this, PlayerService::class.java))
watchEventBus()
CommandBus.send(Command.RefreshService)
}
override fun onResume() {
super.onResume()
(container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
if (now_playing.isOpened()) {
now_playing.close()
return@setShouldRegisterTouch false
}
true
}
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()
}
now_playing_toggle.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
@ -92,6 +133,10 @@ class MainActivity : AppCompatActivity() {
}
}
})
landscape_queue?.let {
supportFragmentManager.beginTransaction().replace(R.id.landscape_queue, LandscapeQueueFragment()).commit()
}
}
override fun onBackPressed() {
@ -103,10 +148,22 @@ class MainActivity : AppCompatActivity() {
super.onBackPressed()
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
this.menu = menu
return super.onPrepareOptionsMenu(menu)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.toolbar, menu)
// CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.cast)
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")
return true
}
@ -127,12 +184,85 @@ class MainActivity : AppCompatActivity() {
R.id.nav_queue -> launchDialog(QueueFragment())
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
R.id.settings -> startActivity(Intent(this, SettingsActivity::class.java))
R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> {
menu?.let { menu ->
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
item.actionView = View(this)
item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?) = false
override fun onMenuItemActionCollapse(item: MenuItem?) = false
})
item.isChecked = !item.isChecked
val scopes = Settings.getScopes().toMutableSet()
val new = when (item.itemId) {
R.id.nav_my_music -> "me"
R.id.nav_followed -> "subscribed"
else -> {
menu.findItem(R.id.nav_all_music).isEnabled = false
menu.findItem(R.id.nav_my_music).isChecked = false
menu.findItem(R.id.nav_followed).isChecked = false
PowerPreference.getDefaultFile().set("scope", "all")
EventBus.send(Event.ListingsChanged)
return false
}
}
menu.findItem(R.id.nav_all_music).let {
it.isChecked = false
it.isEnabled = true
}
scopes.remove("all")
when (item.isChecked) {
true -> scopes.add(new)
false -> scopes.remove(new)
}
if (scopes.isEmpty()) {
menu.findItem(R.id.nav_all_music).let {
it.isChecked = true
it.isEnabled = false
}
scopes.add("all")
}
PowerPreference.getDefaultFile().set("scope", scopes.joinToString(","))
EventBus.send(Event.ListingsChanged)
return false
}
}
R.id.nav_downloads -> startActivity(Intent(this, DownloadsActivity::class.java))
R.id.settings -> startActivityForResult(Intent(this, SettingsActivity::class.java), 0)
}
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 {
Otter.get().deleteAllData()
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
@ -156,11 +286,11 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("NewApi")
private fun watchEventBus() {
GlobalScope.launch(Main) {
for (message in EventBus.asChannel<Event>()) {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.LogOut -> {
PowerPreference.clearAllData()
Otter.get().deleteAllData()
startActivity(Intent(this@MainActivity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
@ -184,6 +314,12 @@ class MainActivity : AppCompatActivity() {
it.bottomMargin = it.bottomMargin / 2
}
landscape_queue?.let { landscape_queue ->
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin / 2
}
}
now_playing.animate()
.alpha(0.0f)
.setDuration(400)
@ -196,76 +332,7 @@ class MainActivity : AppCompatActivity() {
}
}
is Event.TrackPlayed -> {
message.track?.let { track ->
if (now_playing.visibility == View.GONE) {
now_playing.visibility = View.VISIBLE
now_playing.alpha = 0f
now_playing.animate()
.alpha(1.0f)
.setDuration(400)
.setListener(null)
.start()
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2
}
}
now_playing_title.text = track.title
now_playing_album.text = track.artist.name
now_playing_toggle.icon = getDrawable(R.drawable.pause)
now_playing_progress.progress = 0
now_playing_details_title.text = track.title
now_playing_details_artist.text = track.artist.name
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
now_playing_details_progress.progress = 0
Picasso.get()
.load(normalizeUrl(track.album.cover.original))
.fit()
.centerCrop()
.into(now_playing_cover)
Picasso.get()
.load(normalizeUrl(track.album.cover.original))
.fit()
.centerCrop()
.into(now_playing_details_cover)
favoriteRepository.fetch().untilNetwork(IO) { favorites ->
GlobalScope.launch(Main) {
val favorites = favorites.map { it.track.id }
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)
}
}
}
is Event.TrackFinished -> incrementListenCount(message.track)
is Event.StateChanged -> {
when (message.playing) {
@ -280,12 +347,49 @@ class MainActivity : AppCompatActivity() {
}
}
}
is Event.QueueChanged -> {
findViewById<View>(R.id.nav_queue)?.let { view ->
ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).let {
it.duration = 500
it.interpolator = AccelerateDecelerateInterpolator()
it.start()
}
}
}
}
}
}
GlobalScope.launch(Main) {
for ((current, duration, percent) in ProgressBus.asChannel()) {
lifecycleScope.launch(Main) {
CommandBus.get().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(this@MainActivity, lifecycleScope, command.tracks)
}
}
}
}
lifecycleScope.launch(Main) {
ProgressBus.get().collect { (current, duration, percent) ->
now_playing_progress.progress = percent
now_playing_details_progress.progress = percent
@ -300,4 +404,192 @@ class MainActivity : AppCompatActivity() {
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
if (now_playing.visibility == View.GONE) {
now_playing.visibility = View.VISIBLE
now_playing.alpha = 0f
now_playing.animate()
.alpha(1.0f)
.setDuration(400)
.setListener(null)
.start()
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2
}
landscape_queue?.let { landscape_queue ->
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2
}
}
}
now_playing_title.text = track.title
now_playing_album.text = track.artist.name
now_playing_toggle.icon = getDrawable(R.drawable.pause)
now_playing_details_title.text = track.title
now_playing_details_artist.text = track.artist.name
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover?.urls?.original))
.fit()
.centerCrop()
.into(now_playing_cover)
now_playing_details_cover?.let { now_playing_details_cover ->
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(now_playing_details_cover)
}
if (now_playing_details_cover == 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) {
now_playing_details.background = backgroundCover
}
}
}
now_playing_details_repeat?.let { now_playing_details_repeat ->
changeRepeatMode(Cache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0)
now_playing_details_repeat.setOnClickListener {
val current = Cache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0
changeRepeatMode((current + 1) % 3)
}
}
now_playing_details_info?.let { now_playing_details_info ->
now_playing_details_info.setOnClickListener {
PopupMenu(this@MainActivity, now_playing_details_info, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
inflate(R.menu.track_info)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_info_artist -> ArtistsFragment.openAlbums(this@MainActivity, track.artist, art = track.album?.cover())
R.id.track_info_album -> AlbumsFragment.openTracks(this@MainActivity, track.album)
R.id.track_info_details -> TrackInfoDetailsFragment.new(track).show(supportFragmentManager, "dialog")
}
now_playing.close()
true
}
show()
}
}
}
now_playing_details_favorite?.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)
}
now_playing_details_add_to_playlist.setOnClickListener {
CommandBus.send(Command.AddToPlaylist(listOf(track)))
}
}
}
}
private fun changeRepeatMode(index: Int) {
when (index) {
// From no repeat to repeat all
0 -> {
Cache.set(this@MainActivity, "repeat", "0".toByteArray())
now_playing_details_repeat?.setImageResource(R.drawable.repeat)
now_playing_details_repeat?.setColorFilter(ContextCompat.getColor(this, R.color.controlForeground))
now_playing_details_repeat?.alpha = 0.2f
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_OFF))
}
// From repeat all to repeat one
1 -> {
Cache.set(this@MainActivity, "repeat", "1".toByteArray())
now_playing_details_repeat?.setImageResource(R.drawable.repeat)
now_playing_details_repeat?.setColorFilter(ContextCompat.getColor(this, R.color.controlForeground))
now_playing_details_repeat?.alpha = 1.0f
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ALL))
}
// From repeat one to no repeat
2 -> {
Cache.set(this@MainActivity, "repeat", "2".toByteArray())
now_playing_details_repeat?.setImageResource(R.drawable.repeat_one)
now_playing_details_repeat?.setColorFilter(ContextCompat.getColor(this, R.color.controlForeground))
now_playing_details_repeat?.alpha = 1.0f
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ONE))
}
}
}
private fun incrementListenCount(track: Track?) {
track?.let {
lifecycleScope.launch(IO) {
try {
Fuel
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
.authorize()
.header("Content-Type", "application/json")
.body(Gson().toJson(mapOf("track" to track.id)))
.awaitStringResponse()
} catch (_: Exception) {
}
}
}
}
}

View File

@ -3,57 +3,116 @@ package com.github.apognu.otter.activities
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.repositories.SearchRepository
import com.github.apognu.otter.utils.untilNetwork
import com.github.apognu.otter.adapters.SearchAdapter
import com.github.apognu.otter.fragments.AddToPlaylistDialog
import com.github.apognu.otter.fragments.AlbumsFragment
import com.github.apognu.otter.fragments.ArtistsFragment
import com.github.apognu.otter.repositories.*
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.activity_search.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import java.util.*
class SearchActivity : AppCompatActivity() {
private lateinit var adapter: TracksAdapter
private lateinit var adapter: SearchAdapter
lateinit var repository: SearchRepository
lateinit var artistsRepository: ArtistsSearchRepository
lateinit var albumsRepository: AlbumsSearchRepository
lateinit var tracksRepository: TracksSearchRepository
lateinit var favoritesRepository: FavoritesRepository
var done = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
adapter = TracksAdapter(this).also {
adapter = SearchAdapter(this, SearchResultClickListener(), FavoriteListener()).also {
results.layoutManager = LinearLayoutManager(this)
results.adapter = it
}
search.requestFocus()
}
override fun onResume() {
super.onResume()
search.requestFocus()
lifecycleScope.launch(Dispatchers.Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
AddToPlaylistDialog.show(this@SearchActivity, lifecycleScope, command.tracks)
}
}
}
}
lifecycleScope.launch(Dispatchers.IO) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
artistsRepository = ArtistsSearchRepository(this@SearchActivity, "")
albumsRepository = AlbumsSearchRepository(this@SearchActivity, "")
tracksRepository = TracksSearchRepository(this@SearchActivity, "")
favoritesRepository = FavoritesRepository(this@SearchActivity)
search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
query?.let {
repository = SearchRepository(this@SearchActivity, it.toLowerCase(Locale.ROOT))
override fun onQueryTextSubmit(rawQuery: String?): Boolean {
search.clearFocus()
rawQuery?.let {
done = 0
val query = URLEncoder.encode(it, "UTF-8")
artistsRepository.query = query.toLowerCase(Locale.ROOT)
albumsRepository.query = query.toLowerCase(Locale.ROOT)
tracksRepository.query = query.toLowerCase(Locale.ROOT)
search_spinner.visibility = View.VISIBLE
search_empty.visibility = View.GONE
search_no_results.visibility = View.GONE
adapter.data.clear()
adapter.artists.clear()
adapter.albums.clear()
adapter.tracks.clear()
adapter.notifyDataSetChanged()
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks ->
search_spinner.visibility = View.GONE
search_empty.visibility = View.GONE
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { artists, _, _, _ ->
done++
when (tracks.isEmpty()) {
true -> search_no_results.visibility = View.VISIBLE
false -> adapter.data = tracks.toMutableList()
}
adapter.artists.addAll(artists)
refresh()
}
adapter.notifyDataSetChanged()
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()
}
}
@ -61,7 +120,52 @@ class SearchActivity : AppCompatActivity() {
}
override fun onQueryTextChange(newText: String?) = true
})
}
private fun refresh() {
adapter.notifyDataSetChanged()
if (adapter.artists.size + adapter.albums.size + adapter.tracks.size == 0) {
search_no_results.visibility = View.VISIBLE
} else {
search_no_results.visibility = View.GONE
}
if (done == 3) {
search_spinner.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)
}
}
inner class FavoriteListener : SearchAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {
true -> favoritesRepository.addFavorite(id)
false -> favoritesRepository.deleteFavorite(id)
}
}
}
}

View File

@ -1,8 +1,8 @@
package com.github.apognu.otter.activities
import android.content.Intent
import android.content.SharedPreferences
import android.content.*
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
@ -10,9 +10,12 @@ import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SeekBarPreference
import com.github.apognu.otter.BuildConfig
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.AppContext
import com.preference.PowerPreference
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -48,20 +51,41 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
when (preference?.key) {
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java))
"experiments" -> {
context?.let { context ->
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.settings_experiments_restart_title))
.setMessage(context.getString(R.string.settings_experiments_restart_content))
.setPositiveButton(android.R.string.yes) { _, _ -> }
.show()
}
}
"crash" -> {
activity?.let { activity ->
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
Cache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
clip.setPrimaryClip(ClipData.newPlainText("Otter logs", it))
Toast.makeText(activity, activity.getString(R.string.settings_crash_report_copied), Toast.LENGTH_SHORT).show()
}
}
}
}
"logout" -> {
context?.let { context ->
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.logout_title))
.setMessage(context.getString(R.string.logout_content))
.setPositiveButton(android.R.string.yes) { _, _ ->
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
CommandBus.send(Command.ClearQueue)
Intent(context, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
Otter.get().deleteAllData()
startActivity(this)
activity?.finish()
}
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
activity?.finish()
}
.setNegativeButton(android.R.string.no, null)
.show()
@ -88,6 +112,14 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
}
}
preferenceManager.findPreference<ListPreference>("play_order")?.let {
it.summary = when (it.value) {
"shuffle" -> activity.getString(R.string.settings_play_order_shuffle_summary)
"in_order" -> activity.getString(R.string.settings_play_order_in_order_summary)
else -> activity.getString(R.string.settings_play_order_shuffle_summary)
}
}
preferenceManager.findPreference<ListPreference>("night_mode")?.let {
when (it.value) {
"on" -> {
@ -116,6 +148,10 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let {
it.summary = getString(R.string.settings_media_cache_size_summary, it.value)
}
preferenceManager.findPreference<Preference>("version")?.let {
it.summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
}
}
}
}

View File

@ -0,0 +1,33 @@
package com.github.apognu.otter.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Settings
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
when (Settings.hasAccessToken() || Settings.isAnonymous()) {
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
}
false -> Intent(this@SplashActivity, LoginActivity::class.java).apply {
Otter.get().deleteAllData()
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
}
}
}
}
}

View File

@ -6,19 +6,22 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.normalizeUrl
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album.view.*
import kotlinx.android.synthetic.main.row_artist.view.art
class AlbumsAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsAdapter.ViewHolder>() {
class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickListener) : OtterAdapter<Album, AlbumsAdapter.ViewHolder>() {
interface OnAlbumClickListener {
fun onClick(view: View?, album: Album)
}
override fun getItemId(position: Int): Long = data[position].id.toLong()
override fun getItemCount() = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@ -33,20 +36,28 @@ class AlbumsAdapter(val context: Context?, val listener: OnAlbumClickListener) :
val album = data[position]
Picasso.get()
.load(normalizeUrl(album.cover.original))
.maybeLoad(maybeNormalizeUrl(album.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)
holder.title.text = album.title
holder.artist.text = album.artist.name
holder.release_date.visibility = View.GONE
album.release_date?.split('-')?.getOrNull(0)?.let { year ->
if (year.isNotEmpty()) {
holder.release_date.visibility = View.VISIBLE
holder.release_date.text = year
}
}
}
inner class ViewHolder(view: View, private val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val art = view.art
val title = view.title
val artist = view.artist
val release_date = view.release_date
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])

View File

@ -6,18 +6,21 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.normalizeUrl
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album_grid.view.*
class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsGridAdapter.ViewHolder>() {
class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClickListener) : OtterAdapter<Album, AlbumsGridAdapter.ViewHolder>() {
interface OnAlbumClickListener {
fun onClick(view: View?, album: Album)
}
override fun getItemId(position: Int): Long = data[position].id.toLong()
override fun getItemCount() = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@ -32,10 +35,10 @@ class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClic
val album = data[position]
Picasso.get()
.load(normalizeUrl(album.cover.original))
.maybeLoad(maybeNormalizeUrl(album.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(24, 0))
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = album.title

View File

@ -6,21 +6,40 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.normalizeUrl
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_artist.view.*
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : FunkwhaleAdapter<Artist, ArtistsAdapter.ViewHolder>() {
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : OtterAdapter<Artist, ArtistsAdapter.ViewHolder>() {
private var active: List<Artist> = mutableListOf()
interface OnArtistClickListener {
fun onClick(holder: View?, artist: Artist)
}
override fun getItemCount() = data.size
init {
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
active = data.filter { it.albums?.isNotEmpty() ?: false }
override fun getItemId(position: Int) = data[position].id.toLong()
super.onChanged()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
active = data.filter { it.albums?.isNotEmpty() ?: false }
super.onItemRangeInserted(positionStart, itemCount)
}
})
}
override fun getItemCount() = active.size
override fun getItemId(position: Int) = active[position].id.toLong()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_artist, parent, false)
@ -31,15 +50,14 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val artist = data[position]
val artist = active[position]
artist.albums?.let { albums ->
if (albums.isNotEmpty()) {
Picasso.get()
.load(normalizeUrl(albums[0].cover.original))
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)
}
}
@ -59,7 +77,7 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
val albums = view.albums
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
listener.onClick(view, active[layoutPosition])
}
}
}

View File

@ -4,15 +4,12 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.AlbumsGridFragment
import com.github.apognu.otter.fragments.ArtistsFragment
import com.github.apognu.otter.fragments.FavoritesFragment
import com.github.apognu.otter.fragments.PlaylistsFragment
import com.github.apognu.otter.fragments.*
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
var tabs = mutableListOf<Fragment>()
override fun getCount() = 4
override fun getCount() = 5
override fun getItem(position: Int): Fragment {
tabs.getOrNull(position)?.let {
@ -23,7 +20,8 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : Fragm
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> FavoritesFragment()
3 -> RadiosFragment()
4 -> FavoritesFragment()
else -> ArtistsFragment()
}
@ -37,7 +35,8 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : Fragm
0 -> context.getString(R.string.artists)
1 -> context.getString(R.string.albums)
2 -> context.getString(R.string.playlists)
3 -> context.getString(R.string.favorites)
3 -> context.getString(R.string.radios)
4 -> context.getString(R.string.favorites)
else -> ""
}
}

View File

@ -0,0 +1,105 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.graphics.drawable.Icon
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.playback.PinService
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadService
import kotlinx.android.synthetic.main.row_download.view.*
class DownloadsAdapter(private val context: Context, private val listener: OnDownloadChangedListener) : RecyclerView.Adapter<DownloadsAdapter.ViewHolder>() {
interface OnDownloadChangedListener {
fun onItemRemoved(index: Int)
}
var downloads: MutableList<DownloadInfo> = mutableListOf()
override fun getItemCount() = downloads.size
override fun getItemId(position: Int) = downloads[position].id.toLong()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_download, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val download = downloads[position]
holder.title.text = download.title
holder.artist.text = download.artist
download.download?.let { state ->
when (state.isTerminalState) {
true -> {
holder.progress.visibility = View.INVISIBLE
when (state.state) {
Download.STATE_FAILED -> {
holder.toggle.setImageDrawable(context.getDrawable(R.drawable.retry))
holder.progress.visibility = View.INVISIBLE
}
else -> holder.toggle.visibility = View.GONE
}
}
false -> {
holder.progress.visibility = View.VISIBLE
holder.toggle.visibility = View.VISIBLE
holder.progress.isIndeterminate = false
holder.progress.progress = state.percentDownloaded.toInt()
when (state.state) {
Download.STATE_QUEUED -> {
holder.progress.isIndeterminate = true
}
Download.STATE_REMOVING -> {
holder.progress.visibility = View.GONE
holder.toggle.visibility = View.GONE
}
Download.STATE_STOPPED -> holder.toggle.setImageIcon(Icon.createWithResource(context, R.drawable.play))
else -> holder.toggle.setImageIcon(Icon.createWithResource(context, R.drawable.pause))
}
}
}
holder.toggle.setOnClickListener {
when (state.state) {
Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, 1, false)
Download.STATE_FAILED -> {
Track.fromDownload(download).also {
PinService.download(context, it)
}
}
else -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, Download.STOP_REASON_NONE, false)
}
}
holder.delete.setOnClickListener {
listener.onItemRemoved(position)
DownloadService.sendRemoveDownload(context, PinService::class.java, download.contentId, false)
}
}
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title = view.title
val artist = view.artist
val progress = view.progress
val toggle = view.toggle
val delete = view.delete
}
}

View File

@ -2,8 +2,8 @@ package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Typeface
import android.os.Build
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@ -11,14 +11,14 @@ import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
import java.util.*
class FavoritesAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener, val fromQueue: Boolean = false) : FunkwhaleAdapter<Favorite, FavoritesAdapter.ViewHolder>() {
class FavoritesAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener, val fromQueue: Boolean = false) : OtterAdapter<Track, FavoritesAdapter.ViewHolder>() {
interface OnFavoriteListener {
fun onToggleFavorite(id: Int, state: Boolean)
}
@ -28,7 +28,7 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
override fun getItemCount() = data.size
override fun getItemId(position: Int): Long {
return data[position].track.id.toLong()
return data[position].id.toLong()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@ -44,39 +44,50 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
val favorite = data[position]
Picasso.get()
.load(normalizeUrl(favorite.track.album.cover.original))
.maybeLoad(maybeNormalizeUrl(favorite.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = favorite.track.title
holder.artist.text = favorite.track.artist.name
holder.title.text = favorite.title
holder.artist.text = favorite.artist.name
Build.VERSION_CODES.P.onApi(
{
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
},
{
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
})
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.ripple)
}
if (favorite.track == currentTrack || favorite.track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
if (favorite.id == currentTrack?.id) {
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.current)
}
}
context?.let {
when (favorite.track.favorite) {
when (favorite.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
}
when (favorite.cached || favorite.downloaded) {
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
if (favorite.cached && !favorite.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
}
}
if (favorite.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
}
}
holder.favorite.setOnClickListener {
favoriteListener.onToggleFavorite(favorite.track.id, !favorite.track.favorite)
favoriteListener.onToggleFavorite(favorite.id, !favorite.favorite)
data.remove(favorite)
notifyItemRemoved(holder.adapterPosition)
@ -90,9 +101,10 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite.track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite.track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite.track))
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite))
R.id.track_pin -> CommandBus.send(Command.PinTrack(favorite))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite))
}
true
@ -132,7 +144,7 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
false -> {
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
CommandBus.send(Command.ReplaceQueue(this.map { it.track }))
CommandBus.send(Command.ReplaceQueue(this))
context.toast("All tracks were added to your queue")
}

View File

@ -3,26 +3,30 @@ package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
import java.util.*
class PlaylistTracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : FunkwhaleAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
class PlaylistTracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, private val playlistListener: OnPlaylistListener? = null) : OtterAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
interface OnFavoriteListener {
fun onToggleFavorite(id: Int, state: Boolean)
}
interface OnPlaylistListener {
fun onMoveTrack(from: Int, to: Int)
fun onRemoveTrackFromPlaylist(track: Track, index: Int)
}
private lateinit var touchHelper: ItemTouchHelper
var currentTrack: Track? = null
@ -36,10 +40,8 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
if (fromQueue) {
touchHelper = ItemTouchHelper(TouchHelperCallback()).also {
it.attachToRecyclerView(recyclerView)
}
touchHelper = ItemTouchHelper(TouchHelperCallback()).also {
it.attachToRecyclerView(recyclerView)
}
}
@ -56,7 +58,7 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
val track = data[position]
Picasso.get()
.load(normalizeUrl(track.track.album.cover.original))
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
@ -65,20 +67,14 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
holder.title.text = track.track.title
holder.artist.text = track.track.artist.name
Build.VERSION_CODES.P.onApi(
{
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
},
{
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
})
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
}
if (track.track == currentTrack || track.track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
}
}
context?.let {
@ -100,13 +96,16 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
holder.actions.setOnClickListener {
context?.let { context ->
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track)
inflate(R.menu.row_track)
menu.findItem(R.id.track_remove_from_playlist).isVisible = true
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_remove_from_playlist -> playlistListener?.onRemoveTrackFromPlaylist(track.track, position)
}
true
@ -117,16 +116,14 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
}
}
if (fromQueue) {
holder.handle.visibility = View.VISIBLE
holder.handle.visibility = View.VISIBLE
holder.handle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(holder)
}
true
holder.handle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(holder)
}
true
}
}
@ -136,13 +133,12 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
Collections.swap(data, i, i + 1)
}
} else {
for (i in newPosition.downTo(oldPosition)) {
for (i in oldPosition.downTo(newPosition + 1)) {
Collections.swap(data, i, i - 1)
}
}
notifyItemMoved(oldPosition, newPosition)
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
}
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
@ -155,20 +151,18 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
val actions = view.actions
override fun onClick(view: View?) {
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.map { it.track }))
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
CommandBus.send(Command.ReplaceQueue(this.map { it.track }))
context.toast("All tracks were added to your queue")
}
}
context.toast("All tracks were added to your queue")
}
}
}
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
var from = -1
var to = -1
override fun isLongPressDragEnabled() = false
override fun isItemViewSwipeEnabled() = false
@ -177,6 +171,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
if (from == -1) from = viewHolder.adapterPosition
to = target.adapterPosition
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
return true
@ -186,7 +183,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
viewHolder?.itemView?.background = ColorDrawable(Color.argb(255, 100, 100, 100))
context?.let {
viewHolder?.itemView?.background = ColorDrawable(context.getColor(R.color.colorSelected))
}
}
super.onSelectedChanged(viewHolder, actionState)
@ -195,6 +194,13 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
if (from != -1 && to != -1 && from != to) {
playlistListener?.onMoveTrack(from, to)
from = -1
to = -1
}
super.clearView(recyclerView, viewHolder)
}
}

View File

@ -4,15 +4,17 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Playlist
import com.github.apognu.otter.utils.toDurationString
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_playlist.view.*
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : FunkwhaleAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : OtterAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
interface OnPlaylistClickListener {
fun onClick(holder: View?, playlist: Playlist)
}
@ -33,7 +35,16 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
val playlist = data[position]
holder.name.text = playlist.name
holder.summary.text = context?.getString(R.string.playlist_description, playlist.tracks_count, toDurationString(playlist.duration.toLong())) ?: ""
holder.summary.text = context?.resources?.getQuantityString(R.plurals.playlist_description, playlist.tracks_count, playlist.tracks_count, toDurationString(playlist.duration.toLong())) ?: ""
context?.let {
ContextCompat.getDrawable(context, R.drawable.cover).let {
holder.cover_top_left.setImageDrawable(it)
holder.cover_top_right.setImageDrawable(it)
holder.cover_bottom_left.setImageDrawable(it)
holder.cover_bottom_right.setImageDrawable(it)
}
}
playlist.album_covers.shuffled().take(4).forEachIndexed { index, url ->
val imageView = when (index) {
@ -44,8 +55,17 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
else -> holder.cover_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
}
Picasso.get()
.load(url)
.transform(RoundedCornersTransformation(32, 0, corner))
.into(imageView)
}
}

View File

@ -0,0 +1,163 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.views.LoadingImageView
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.row_radio.view.*
import kotlinx.android.synthetic.main.row_radio_header.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private val listener: OnRadioClickListener) : OtterAdapter<Radio, RadiosAdapter.ViewHolder>() {
interface OnRadioClickListener {
fun onClick(holder: ViewHolder, radio: Radio)
}
enum class RowType {
Header,
InstanceRadio,
UserRadio
}
private val instanceRadios: List<Radio> by lazy {
context?.let {
return@lazy when (val username = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")) {
"" -> listOf(
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description))
)
else -> listOf(
Radio(0, "actor_content", context.getString(R.string.radio_your_content_title), context.getString(R.string.radio_your_content_description), username),
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)),
Radio(0, "favorites", context.getString(R.string.favorites), context.getString(R.string.radio_favorites_description)),
Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description))
)
}
}
listOf<Radio>()
}
private fun getRadioAt(position: Int): Radio {
return when (getItemViewType(position)) {
RowType.InstanceRadio.ordinal -> instanceRadios[position - 1]
else -> data[position - instanceRadios.size - 2]
}
}
override fun getItemId(position: Int) = when (getItemViewType(position)) {
RowType.InstanceRadio.ordinal -> (-position - 1).toLong()
RowType.Header.ordinal -> Long.MIN_VALUE
else -> getRadioAt(position).id.toLong()
}
override fun getItemCount() = instanceRadios.size + data.size + 2
override fun getItemViewType(position: Int): Int {
return when {
position == 0 || position == instanceRadios.size + 1 -> RowType.Header.ordinal
position <= instanceRadios.size -> RowType.InstanceRadio.ordinal
else -> RowType.UserRadio.ordinal
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder {
return when (viewType) {
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
ViewHolder(view, listener).also {
view.setOnClickListener(it)
}
}
else -> ViewHolder(LayoutInflater.from(context).inflate(R.layout.row_radio_header, parent, false), null)
}
}
override fun onBindViewHolder(holder: RadiosAdapter.ViewHolder, position: Int) {
when (getItemViewType(position)) {
RowType.Header.ordinal -> {
context?.let {
when (position) {
0 -> holder.label.text = context.getString(R.string.radio_instance_radios)
instanceRadios.size + 1 -> holder.label.text = context.getString(R.string.radio_user_radios)
}
}
}
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val radio = getRadioAt(position)
holder.art.visibility = View.VISIBLE
holder.name.text = radio.name
holder.description.text = radio.description
context?.let { context ->
val icon = when (radio.radio_type) {
"actor_content" -> R.drawable.library
"favorites" -> R.drawable.favorite
"random" -> R.drawable.shuffle
"less-listened" -> R.drawable.sad
else -> null
}
icon?.let {
holder.native = true
holder.art.setImageDrawable(context.getDrawable(icon))
holder.art.alpha = 0.7f
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
}
}
}
}
}
inner class ViewHolder(view: View, private val listener: OnRadioClickListener?) : RecyclerView.ViewHolder(view), View.OnClickListener {
val label = view.label
val art = view.art
val name = view.name
val description = view.description
var native = false
override fun onClick(view: View?) {
listener?.onClick(this, getRadioAt(layoutPosition))
}
fun spin() {
context?.let {
val originalDrawable = art.drawable
val originalColorFilter = art.colorFilter
val imageAnimator = LoadingImageView.start(context, art)
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)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,280 @@
package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PorterDuff
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.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
class SearchAdapter(private val context: Context?, private val listener: OnSearchResultClickListener? = null, private val favoriteListener: OnFavoriteListener? = null) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
interface OnSearchResultClickListener {
fun onArtistClick(holder: View?, artist: Artist)
fun onAlbumClick(holder: View?, album: Album)
}
interface OnFavoriteListener {
fun onToggleFavorite(id: Int, state: Boolean)
}
enum class ResultType {
Header,
Artist,
Album,
Track
}
val SECTION_COUNT = 3
var artists: MutableList<Artist> = mutableListOf()
var albums: MutableList<Album> = mutableListOf()
var tracks: MutableList<Track> = mutableListOf()
var currentTrack: Track? = null
override fun getItemCount() = SECTION_COUNT + artists.size + albums.size + tracks.size
override fun getItemId(position: Int): Long {
return when (getItemViewType(position)) {
ResultType.Header.ordinal -> {
if (position == 0) return -1
if (position == (artists.size + 1)) return -2
return -3
}
ResultType.Artist.ordinal -> artists[position].id.toLong()
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong()
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT].id.toLong()
else -> 0
}
}
override fun getItemViewType(position: Int): Int {
if (position == 0) return ResultType.Header.ordinal // Artists header
if (position == (artists.size + 1)) return ResultType.Header.ordinal // Albums header
if (position == (artists.size + albums.size + 2)) return ResultType.Header.ordinal // Tracks header
if (position <= artists.size) return ResultType.Artist.ordinal
if (position <= artists.size + albums.size + 2) return ResultType.Album.ordinal
return ResultType.Track.ordinal
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = when (viewType) {
ResultType.Header.ordinal -> LayoutInflater.from(context).inflate(R.layout.row_search_header, parent, false)
else -> LayoutInflater.from(context).inflate(R.layout.row_track, parent, false)
}
return ViewHolder(view, context).also {
view.setOnClickListener(it)
}
}
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val resultType = getItemViewType(position)
if (resultType == ResultType.Header.ordinal) {
context?.let { context ->
if (position == 0) {
holder.title.text = context.getString(R.string.artists)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
if (artists.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
}
if (position == (artists.size + 1)) {
holder.title.text = context.getString(R.string.albums)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
if (albums.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
}
if (position == (artists.size + albums.size + 2)) {
holder.title.text = context.getString(R.string.tracks)
holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
if (tracks.isEmpty()) {
holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
}
}
return
}
val item = when (resultType) {
ResultType.Artist.ordinal -> {
holder.actions.visibility = View.GONE
holder.favorite.visibility = View.GONE
artists[position - 1]
}
ResultType.Album.ordinal -> {
holder.actions.visibility = View.GONE
holder.favorite.visibility = View.GONE
albums[position - artists.size - 2]
}
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT]
else -> tracks[position]
}
Picasso.get()
.maybeLoad(maybeNormalizeUrl(item.cover()))
.fit()
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = item.title()
holder.artist.text = item.subtitle()
Build.VERSION_CODES.P.onApi(
{
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
},
{
holder.title.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL)
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
})
holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
if (resultType == ResultType.Track.ordinal) {
(item as? Track)?.let { track ->
context?.let { context ->
if (track == currentTrack || track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
}
when (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.id, !track.favorite)
tracks[position - artists.size - albums.size - SECTION_COUNT].favorite = !track.favorite
notifyItemChanged(position)
}
}
when (track.cached || track.downloaded) {
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
if (track.cached && !track.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
}
}
if (track.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
}
}
holder.actions.setOnClickListener {
PopupMenu(context, holder.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
}
show()
}
}
}
}
}
}
fun getPositionOf(type: ResultType, position: Int): Int {
return when (type) {
ResultType.Artist -> position + 1
ResultType.Album -> position + artists.size + 2
ResultType.Track -> artists.size + albums.size + SECTION_COUNT + position
else -> 0
}
}
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
val handle = view.handle
val cover = view.cover
val title = view.title
val artist = view.artist
val favorite = view.favorite
val actions = view.actions
override fun onClick(view: View?) {
when (getItemViewType(layoutPosition)) {
ResultType.Artist.ordinal -> {
val position = layoutPosition - 1
listener?.onArtistClick(view, artists[position])
}
ResultType.Album.ordinal -> {
val position = layoutPosition - artists.size - 2
listener?.onAlbumClick(view, albums[position])
}
ResultType.Track.ordinal -> {
val position = layoutPosition - artists.size - albums.size - SECTION_COUNT
tracks.subList(position, tracks.size).plus(tracks.subList(0, position)).apply {
CommandBus.send(Command.ReplaceQueue(this))
context.toast("All tracks were added to your queue")
}
}
else -> {
}
}
}
}
}

View File

@ -2,23 +2,22 @@ package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.*
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
import java.util.*
class TracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : FunkwhaleAdapter<Track, TracksAdapter.ViewHolder>() {
class TracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : OtterAdapter<Track, TracksAdapter.ViewHolder>() {
interface OnFavoriteListener {
fun onToggleFavorite(id: Int, state: Boolean)
}
@ -27,11 +26,9 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
var currentTrack: Track? = null
override fun getItemCount() = data.size
override fun getItemId(position: Int): Long = data[position].id.toLong()
override fun getItemId(position: Int): Long {
return data[position].id.toLong()
}
override fun getItemCount() = data.size
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
@ -56,45 +53,56 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
val track = data[position]
Picasso.get()
.load(normalizeUrl(track.album.cover.original))
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.transform(RoundedCornersTransformation(8, 0))
.into(holder.cover)
holder.title.text = track.title
holder.artist.text = track.artist.name
Build.VERSION_CODES.P.onApi(
{
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
},
{
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
})
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
}
if (track == currentTrack || track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
}
}
context?.let {
when (track.favorite) {
true -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorSelected))
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.id, !track.favorite)
track.favorite = !track.favorite
data[position].favorite = !track.favorite
notifyItemChanged(position)
}
}
when (track.cached || track.downloaded) {
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
if (track.cached && !track.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
}
}
if (track.downloaded) {
holder.title.compoundDrawables.forEach {
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
}
}
}
holder.actions.setOnClickListener {
@ -106,6 +114,8 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
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))
}
@ -136,13 +146,12 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
Collections.swap(data, i, i + 1)
}
} else {
for (i in newPosition.downTo(oldPosition)) {
for (i in oldPosition.downTo(newPosition + 1)) {
Collections.swap(data, i, i - 1)
}
}
notifyItemMoved(oldPosition, newPosition)
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
}
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
@ -169,6 +178,9 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
}
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
var from = -1
var to = -1
override fun isLongPressDragEnabled() = false
override fun isItemViewSwipeEnabled() = false
@ -177,7 +189,9 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
to = target.adapterPosition
onItemMove(viewHolder.adapterPosition, to)
return true
}
@ -187,7 +201,10 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
context?.let {
viewHolder?.itemView?.background = ColorDrawable(context.getColor(R.color.colorSelected))
viewHolder?.let {
from = viewHolder.adapterPosition
viewHolder.itemView.background = ColorDrawable(context.getColor(R.color.colorSelected))
}
}
}
@ -195,6 +212,13 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (from != -1 && to != -1) {
CommandBus.send(Command.MoveFromQueue(from, to))
from = -1
to = -1
}
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
super.clearView(recyclerView, viewHolder)

View File

@ -0,0 +1,105 @@
package com.github.apognu.otter.fragments
import android.app.Activity
import android.app.AlertDialog
import android.view.View
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.PlaylistsAdapter
import com.github.apognu.otter.repositories.ManagementPlaylistsRepository
import com.github.apognu.otter.utils.*
import com.google.gson.Gson
import kotlinx.android.synthetic.main.dialog_add_to_playlist.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object AddToPlaylistDialog {
fun show(activity: Activity, lifecycleScope: CoroutineScope, tracks: List<Track>) {
val dialog = AlertDialog.Builder(activity).run {
setTitle(activity.getString(R.string.playlist_add_to))
setView(activity.layoutInflater.inflate(R.layout.dialog_add_to_playlist, null))
create()
}
dialog.show()
val repository = ManagementPlaylistsRepository(activity)
dialog.name.editText?.addTextChangedListener {
dialog.create.isEnabled = !(dialog.name.editText?.text?.trim()?.isBlank() ?: true)
}
dialog.create.setOnClickListener {
val name = dialog.name.editText?.text?.toString()?.trim() ?: ""
if (name.isEmpty()) return@setOnClickListener
lifecycleScope.launch(IO) {
repository.new(name)?.let { id ->
repository.add(id, tracks)
withContext(Main) {
Toast.makeText(activity, activity.getString(R.string.playlist_added_to, name), Toast.LENGTH_SHORT).show()
}
dialog.dismiss()
}
}
}
val adapter = PlaylistsAdapter(activity, object : PlaylistsAdapter.OnPlaylistClickListener {
override fun onClick(holder: View?, playlist: Playlist) {
repository.add(playlist.id, tracks)
Toast.makeText(activity, activity.getString(R.string.playlist_added_to, playlist.name), Toast.LENGTH_SHORT).show()
dialog.dismiss()
}
})
dialog.playlists.layoutManager = LinearLayoutManager(activity)
dialog.playlists.adapter = adapter
repository.apply {
var first = true
fetch().untilNetwork(lifecycleScope) { data, isCache, _, hasMore ->
if (isCache) {
adapter.data = data.toMutableList()
adapter.notifyDataSetChanged()
return@untilNetwork
}
if (first) {
adapter.data.clear()
first = false
}
adapter.data.addAll(data)
lifecycleScope.launch(IO) {
try {
Cache.set(
context,
cacheId,
Gson().toJson(cache(adapter.data)).toByteArray()
)
} catch (e: ConcurrentModificationException) {
}
}
if (!hasMore) {
adapter.notifyDataSetChanged()
first = false
}
}
}
}
}

View File

@ -1,40 +1,94 @@
package com.github.apognu.otter.fragments
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.transition.Fade
import androidx.transition.Slide
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.adapters.AlbumsAdapter
import com.github.apognu.otter.repositories.AlbumsRepository
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.repositories.ArtistTracksRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_albums.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
class AlbumsFragment : OtterFragment<Album, AlbumsAdapter>() {
override val viewRes = R.layout.fragment_albums
override val recycler: RecyclerView get() = albums
override val alwaysRefresh = false
private lateinit var artistTracksRepository: ArtistTracksRepository
var artistId = 0
var artistName = ""
var artistArt = ""
companion object {
fun new(artist: Artist): AlbumsFragment {
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 artist.albums!![0].cover.original
"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?) {
@ -48,46 +102,74 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
adapter = AlbumsAdapter(context, OnAlbumClickListener())
repository = AlbumsRepository(context, artistId)
artistTracksRepository = ArtistTracksRepository(context, artistId)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Picasso.get()
.load(artistArt)
.noFade()
.fit()
.centerCrop()
.into(cover)
cover?.let { cover ->
Picasso.get()
.maybeLoad(maybeNormalizeUrl(artistArt))
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(cover)
}
artist.text = artistName
play.setOnClickListener {
val loader = CircularProgressDrawable(requireContext()).apply {
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
strokeWidth = 4f
}
loader.start()
play.icon = loader
play.isClickable = false
lifecycleScope.launch(IO) {
artistTracksRepository.fetch(Repository.Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
.shuffled()
.also {
CommandBus.send(Command.ReplaceQueue(it))
withContext(Main) {
play.icon = requireContext().getDrawable(R.drawable.play)
play.isClickable = true
}
}
}
}
}
override fun onResume() {
super.onResume()
var coverHeight: Float? = null
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
if (coverHeight == null) {
coverHeight = cover.measuredHeight.toFloat()
}
cover.translationY = (scrollY / 2).toFloat()
coverHeight?.let { height ->
cover.alpha = (height - scrollY.toFloat()) / height
}
}
}
inner class OnAlbumClickListener : AlbumsAdapter.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()
}
openTracks(context, album, fragment = this@AlbumsFragment)
}
}
}

View File

@ -13,13 +13,13 @@ import com.github.apognu.otter.adapters.AlbumsGridAdapter
import com.github.apognu.otter.repositories.AlbumsRepository
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.onViewPager
import kotlinx.android.synthetic.main.fragment_albums_grid.*
class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
class AlbumsGridFragment : OtterFragment<Album, AlbumsGridAdapter>() {
override val viewRes = R.layout.fragment_albums_grid
override val recycler: RecyclerView get() = albums
override val layoutManager get() = GridLayoutManager(context, 3)
override val alwaysRefresh = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -31,14 +31,12 @@ class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: Album) {
(context as? MainActivity)?.let { activity ->
onViewPager {
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
}
view?.let {
addTarget(it)
}
}

View File

@ -1,8 +1,11 @@
package com.github.apognu.otter.fragments
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.Fade
import androidx.transition.Slide
@ -15,9 +18,44 @@ import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.onViewPager
import kotlinx.android.synthetic.main.fragment_artists.*
class ArtistsFragment : FunkwhaleFragment<Artist, ArtistsAdapter>() {
class ArtistsFragment : OtterFragment<Artist, ArtistsAdapter>() {
override val viewRes = R.layout.fragment_artists
override val recycler: RecyclerView get() = artists
override val alwaysRefresh = false
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()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -28,31 +66,7 @@ class ArtistsFragment : FunkwhaleFragment<Artist, ArtistsAdapter>() {
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
override fun onClick(holder: View?, artist: Artist) {
(context as? MainActivity)?.let { activity ->
onViewPager {
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
}
}
}
val fragment = AlbumsFragment.new(artist).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
}
}
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, fragment)
.addToBackStack(null)
.commit()
}
openAlbums(context, artist, fragment = this@ArtistsFragment)
}
}
}

View File

@ -24,7 +24,7 @@ class BrowseFragment : Fragment() {
tabs.getTabAt(0)?.select()
pager.adapter = adapter
pager.offscreenPageLimit = 4
pager.offscreenPageLimit = 3
}
}

View File

@ -1,28 +1,31 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.FavoritesAdapter
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksRepository
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.fragment_favorites.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FavoritesFragment : FunkwhaleFragment<Favorite, FavoritesAdapter>() {
class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
override val viewRes = R.layout.fragment_favorites
override val recycler: RecyclerView get() = favorites
lateinit var favoritesRepository: FavoritesRepository
override val alwaysRefresh = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = FavoritesAdapter(context, FavoriteListener())
repository = FavoritesRepository(context)
favoritesRepository = FavoritesRepository(context)
watchEventBus()
}
@ -30,42 +33,84 @@ class FavoritesFragment : FunkwhaleFragment<Favorite, FavoritesAdapter>() {
override fun onResume() {
super.onResume()
GlobalScope.launch(Main) {
lifecycleScope.launch(IO) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
withContext(Main) {
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
refreshDownloadedTracks()
}
play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled().map { it.track }))
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
}
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
for (message in EventBus.asChannel<Event>()) {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private suspend fun refreshDownloadedTracks() {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
withContext(Main) {
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.notifyDataSetChanged()
}
}
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
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.notifyItemChanged(match.second)
}
}
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack?.current = false
adapter.currentTrack = track.apply {
current = true
}
adapter.notifyDataSetChanged()
}
}
inner class FavoriteListener : FavoritesAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {
true -> favoritesRepository.addFavorite(id)
false -> favoritesRepository.deleteFavorite(id)
(repository as? FavoritesRepository)?.let { repository ->
when (state) {
true -> repository.addFavorite(id)
false -> repository.deleteFavorite(id)
}
}
}
}
}

View File

@ -1,80 +0,0 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.untilNetwork
import kotlinx.android.synthetic.main.fragment_artists.*
abstract class FunkwhaleAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
var data: MutableList<D> = mutableListOf()
}
abstract class FunkwhaleFragment<D : Any, A : FunkwhaleAdapter<D, *>> : Fragment() {
abstract val viewRes: Int
abstract val recycler: RecyclerView
open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context)
lateinit var repository: Repository<D, *>
lateinit var adapter: A
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(viewRes, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recycler.layoutManager = layoutManager
recycler.adapter = adapter
scroller?.setOnScrollChangeListener { _: NestedScrollView?, _: Int, _: Int, _: Int, _: Int ->
if (!scroller.canScrollVertically(1)) {
repository.fetch(Repository.Origin.Network.origin, adapter.data).untilNetwork {
swiper?.isRefreshing = false
onDataFetched(it)
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
}
}
}
swiper?.isRefreshing = true
repository.fetch().untilNetwork {
swiper?.isRefreshing = false
onDataFetched(it)
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
}
}
override fun onResume() {
super.onResume()
recycler.adapter = adapter
swiper?.setOnRefreshListener {
repository.fetch(Repository.Origin.Network.origin, listOf()).untilNetwork {
swiper?.isRefreshing = false
onDataFetched(it)
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
}
}
}
open fun onDataFetched(data: List<D>) {}
}

View File

@ -0,0 +1,96 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.utils.*
import kotlinx.android.synthetic.main.partial_queue.*
import kotlinx.android.synthetic.main.partial_queue.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class LandscapeQueueFragment : Fragment() {
private var adapter: TracksAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
watchEventBus()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.partial_queue, container, false).apply {
adapter = TracksAdapter(context, fromQueue = true).also {
queue.layoutManager = LinearLayoutManager(context)
queue.adapter = it
}
}
}
override fun onResume() {
super.onResume()
queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
queue_shuffle.setOnClickListener {
CommandBus.send(Command.ShuffleQueue)
}
queue_save.setOnClickListener {
adapter?.data?.let {
CommandBus.send(Command.AddToPlaylist(it))
}
}
queue_clear.setOnClickListener {
CommandBus.send(Command.ClearQueue)
}
refresh()
}
private fun refresh() {
activity?.lifecycleScope?.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
adapter?.let {
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
}
}
}
}
}
private fun watchEventBus() {
activity?.lifecycleScope?.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.QueueChanged -> refresh()
}
}
}
activity?.lifecycleScope?.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refresh()
}
}
}
}
}

View File

@ -0,0 +1,203 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.google.gson.Gson
import kotlinx.android.synthetic.main.fragment_artists.*
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 OtterAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
var data: MutableList<D> = mutableListOf()
init {
super.setHasStableIds(true)
}
abstract override fun getItemId(position: Int): Long
}
abstract class OtterFragment<D : Any, A : OtterAdapter<D, *>> : Fragment() {
companion object {
const val OFFSCREEN_PAGES = 20
}
abstract val viewRes: Int
abstract val recycler: RecyclerView
open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context)
open val alwaysRefresh = true
lateinit var repository: Repository<D, *>
lateinit var adapter: A
private var moreLoading = false
private var listener: Job? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(viewRes, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recycler.layoutManager = layoutManager
(recycler.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
recycler.adapter = adapter
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
recycler.setOnScrollChangeListener { _, _, _, _, _ ->
val offset = recycler.computeVerticalScrollOffset()
if (!moreLoading && offset > 0 && needsMoreOffscreenPages()) {
moreLoading = true
fetch(Repository.Origin.Network.origin, adapter.data.size)
}
}
}
}
if (listener == null) {
listener = lifecycleScope.launch(IO) {
EventBus.get().collect { event ->
if (event is Event.ListingsChanged) {
withContext(Main) {
swiper?.isRefreshing = true
fetch(Repository.Origin.Network.origin)
}
}
}
}
}
fetch(Repository.Origin.Cache.origin)
if (alwaysRefresh && adapter.data.isEmpty()) {
fetch(Repository.Origin.Network.origin)
}
}
override fun onResume() {
super.onResume()
swiper?.setOnRefreshListener {
fetch(Repository.Origin.Network.origin)
}
}
fun update() {
swiper?.isRefreshing = true
fetch(Repository.Origin.Network.origin)
}
open fun onDataFetched(data: List<D>) {}
private fun fetch(upstreams: Int = Repository.Origin.Network.origin, size: Int = 0) {
var first = size == 0
if (!moreLoading && upstreams == Repository.Origin.Network.origin) {
lifecycleScope.launch(Main) {
swiper?.isRefreshing = true
}
}
moreLoading = true
repository.fetch(upstreams, size).untilNetwork(lifecycleScope, IO) { data, isCache, _, hasMore ->
if (isCache && data.isEmpty()) {
moreLoading = false
return@untilNetwork fetch(Repository.Origin.Network.origin)
}
lifecycleScope.launch(Main) {
if (isCache) {
moreLoading = false
adapter.data = data.toMutableList()
adapter.notifyDataSetChanged()
return@launch
}
if (first) {
adapter.data.clear()
}
onDataFetched(data)
adapter.data.addAll(data)
withContext(IO) {
try {
repository.cacheId?.let { cacheId ->
Cache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.data)).toByteArray()
)
}
} catch (e: ConcurrentModificationException) {
}
}
if (hasMore) {
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (!isCache && upstream.behavior == HttpUpstream.Behavior.Progressive) {
if (first || needsMoreOffscreenPages()) {
fetch(Repository.Origin.Network.origin, adapter.data.size)
} else {
moreLoading = false
}
} else {
moreLoading = false
}
}
}
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
when (upstream.behavior) {
HttpUpstream.Behavior.Progressive -> if (!hasMore || !moreLoading) swiper?.isRefreshing = false
HttpUpstream.Behavior.AtOnce -> if (!hasMore) swiper?.isRefreshing = false
HttpUpstream.Behavior.Single -> if (!hasMore) swiper?.isRefreshing = false
}
}
when (first) {
true -> {
adapter.notifyDataSetChanged()
first = false
}
false -> adapter.notifyItemRangeInserted(adapter.data.size, data.size)
}
}
}
}
private fun needsMoreOffscreenPages(): Boolean {
view?.let {
val offset = recycler.computeVerticalScrollOffset()
val left = recycler.computeVerticalScrollRange() - recycler.height - offset
return left < (recycler.height * OFFSCREEN_PAGES)
}
return false
}
}

View File

@ -1,25 +1,32 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.PlaylistTracksAdapter
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.ManagementPlaylistsRepository
import com.github.apognu.otter.repositories.PlaylistTracksRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_tracks.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAdapter>() {
class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapter>() {
override val viewRes = R.layout.fragment_tracks
override val recycler: RecyclerView get() = tracks
lateinit var favoritesRepository: FavoritesRepository
lateinit var playlistsRepository: ManagementPlaylistsRepository
var albumId = 0
var albumArtist = ""
@ -49,9 +56,10 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
albumCover = getString("albumCover") ?: ""
}
adapter = PlaylistTracksAdapter(context, FavoriteListener())
adapter = PlaylistTracksAdapter(context, FavoriteListener(), PlaylistListener())
repository = PlaylistTracksRepository(context, albumId)
favoritesRepository = FavoritesRepository(context)
playlistsRepository = ManagementPlaylistsRepository(context)
watchEventBus()
}
@ -69,28 +77,60 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
override fun onResume() {
super.onResume()
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
var coverHeight: Float? = null
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
if (coverHeight == null) {
coverHeight = covers.measuredHeight.toFloat()
}
covers.translationY = (scrollY / 2).toFloat()
coverHeight?.let { height ->
covers.alpha = (height - scrollY.toFloat()) / height
}
}
play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
context.toast("All tracks were added to your queue")
}
queue.setOnClickListener {
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
context?.let { context ->
actions.setOnClickListener {
PopupMenu(context, actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
inflate(R.menu.album)
context.toast("All tracks were added to your queue")
setOnMenuItemClickListener {
when (it.itemId) {
R.id.add_to_queue -> {
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
context.toast("All tracks were added to your queue")
}
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data.map { it.track }))
}
true
}
show()
}
}
}
}
override fun onDataFetched(data: List<PlaylistTrack>) {
data.map { it.track.album }.toSet().map { it.cover.original }.take(4).forEachIndexed { index, url ->
data.map { it.track.album }.toSet().map { it?.cover() }.take(4).forEachIndexed { index, url ->
val imageView = when (index) {
0 -> cover_top_left
1 -> cover_top_right
@ -99,29 +139,44 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
else -> cover_top_left
}
Picasso.get()
.load(normalizeUrl(url))
.into(imageView)
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
}
imageView?.let { view ->
lifecycleScope.launch(Main) {
Picasso.get()
.maybeLoad(maybeNormalizeUrl(url))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0, corner))
.into(view)
}
}
}
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
for (message in EventBus.asChannel<Event>()) {
when (message) {
is Event.TrackPlayed -> {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack = track
adapter.notifyDataSetChanged()
}
}
inner class FavoriteListener : PlaylistTracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {
@ -130,4 +185,17 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
}
}
}
inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener {
override fun onMoveTrack(from: Int, to: Int) {
playlistsRepository.move(albumId, from, to)
}
override fun onRemoveTrackFromPlaylist(track: Track, index: Int) {
lifecycleScope.launch(Main) {
playlistsRepository.remove(albumId, track, index)
update()
}
}
}
}

View File

@ -14,9 +14,10 @@ import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Playlist
import kotlinx.android.synthetic.main.fragment_playlists.*
class PlaylistsFragment : FunkwhaleFragment<Playlist, PlaylistsAdapter>() {
class PlaylistsFragment : OtterFragment<Playlist, PlaylistsAdapter>() {
override val viewRes = R.layout.fragment_playlists
override val recycler: RecyclerView get() = playlists
override val alwaysRefresh = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -6,24 +6,32 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.utils.*
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.android.synthetic.main.fragment_queue.*
import kotlinx.android.synthetic.main.fragment_queue.view.*
import kotlinx.android.synthetic.main.partial_queue.*
import kotlinx.android.synthetic.main.partial_queue.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class QueueFragment : BottomSheetDialogFragment() {
private var adapter: TracksAdapter? = null
lateinit var favoritesRepository: FavoritesRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
favoritesRepository = FavoritesRepository(context)
setStyle(DialogFragment.STYLE_NORMAL, R.style.AppTheme_FloatingBottomSheet)
watchEventBus()
@ -41,9 +49,9 @@ class QueueFragment : BottomSheetDialogFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_queue, container, false).apply {
adapter = TracksAdapter(context, fromQueue = true).also {
queue.layoutManager = LinearLayoutManager(context)
queue.adapter = it
adapter = TracksAdapter(context, FavoriteListener(), fromQueue = true).also {
included.queue.layoutManager = LinearLayoutManager(context)
included.queue.adapter = it
}
}
}
@ -51,25 +59,41 @@ class QueueFragment : BottomSheetDialogFragment() {
override fun onResume() {
super.onResume()
queue?.visibility = View.GONE
included.queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
queue_shuffle.setOnClickListener {
CommandBus.send(Command.ShuffleQueue)
}
queue_save.setOnClickListener {
adapter?.data?.let {
CommandBus.send(Command.AddToPlaylist(it))
}
}
queue_clear.setOnClickListener {
CommandBus.send(Command.ClearQueue)
}
refresh()
}
private fun refresh() {
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
adapter?.let {
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
included?.let { included ->
adapter?.let {
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
if (it.data.isEmpty()) {
included.queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
included.queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
}
}
}
}
@ -77,13 +101,29 @@ class QueueFragment : BottomSheetDialogFragment() {
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
for (message in EventBus.asChannel<Event>()) {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refresh()
is Event.QueueChanged -> refresh()
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refresh()
}
}
}
}
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {
true -> favoritesRepository.addFavorite(id)
false -> favoritesRepository.deleteFavorite(id)
}
}
}
}

View File

@ -0,0 +1,53 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import androidx.core.view.forEach
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.RadiosAdapter
import com.github.apognu.otter.repositories.RadiosRepository
import com.github.apognu.otter.utils.*
import kotlinx.android.synthetic.main.fragment_radios.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosFragment : OtterFragment<Radio, RadiosAdapter>() {
override val viewRes = R.layout.fragment_radios
override val recycler: RecyclerView get() = radios
override val alwaysRefresh = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = RadiosAdapter(context, lifecycleScope, RadioClickListener())
repository = RadiosRepository(context)
}
inner class RadioClickListener : RadiosAdapter.OnRadioClickListener {
override fun onClick(holder: RadiosAdapter.ViewHolder, radio: Radio) {
holder.spin()
recycler.forEach {
it.isEnabled = false
it.isClickable = false
}
CommandBus.send(Command.PlayRadio(radio))
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted ->
if (radios != null) {
recycler.forEach {
it.isEnabled = true
it.isClickable = true
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,85 @@
package com.github.apognu.otter.fragments
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.mustNormalizeUrl
import com.github.apognu.otter.utils.toDurationString
import kotlinx.android.synthetic.main.fragment_track_info_details.*
class TrackInfoDetailsFragment : DialogFragment() {
companion object {
fun new(track: Track): TrackInfoDetailsFragment {
return TrackInfoDetailsFragment().apply {
arguments = bundleOf(
"artistName" to track.artist.name,
"albumTitle" to track.album?.title,
"trackTitle" to track.title,
"trackCopyright" to track.copyright,
"trackLicense" to track.license,
"trackPosition" to track.position,
"trackDuration" to track.bestUpload()?.duration?.toLong()?.let { toDurationString(it, showSeconds = true) },
"trackBitrate" to track.bestUpload()?.bitrate?.let { "${it / 1000} Kbps" },
"trackInstance" to track.bestUpload()?.listen_url?.let { Uri.parse(mustNormalizeUrl(it)).authority }
)
}
}
}
var properties: MutableList<Pair<Int, String?>> = mutableListOf()
override fun onStart() {
super.onStart()
dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
properties.add(Pair(R.string.track_info_details_artist, getString("artistName")))
properties.add(Pair(R.string.track_info_details_album, getString("albumTitle")))
properties.add(Pair(R.string.track_info_details_track_title, getString("trackTitle")))
properties.add(Pair(R.string.track_info_details_track_copyright, getString("trackCopyright")))
properties.add(Pair(R.string.track_info_details_track_license, getString("trackLicense")))
properties.add(Pair(R.string.track_info_details_track_duration, getString("trackDuration")))
properties.add(Pair(R.string.track_info_details_track_position, getInt("trackPosition").toString()))
properties.add(Pair(R.string.track_info_details_track_bitrate, getString("trackBitrate")))
properties.add(Pair(R.string.track_info_details_track_instance, getString("trackInstance")))
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_track_info_details, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
properties.forEach { (label, value) ->
val labelTextView = TextView(context).apply {
text = getString(label)
setTextAppearance(R.style.AppTheme_TrackDetailsLabel)
}
val valueTextView = TextView(context).apply {
text = value ?: "N/A"
setTextAppearance(R.style.AppTheme_TrackDetailsValue)
setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f, resources.displayMetrics).toInt())
}
infos.addView(labelTextView)
infos.addView(valueTextView)
}
}
}

View File

@ -1,25 +1,36 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksRepository
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_tracks.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
class TracksFragment : OtterFragment<Track, TracksAdapter>() {
override val viewRes = R.layout.fragment_tracks
override val recycler: RecyclerView get() = tracks
lateinit var favoritesRepository: FavoritesRepository
lateinit var favoritedRepository: FavoritedRepository
private var albumId = 0
private var albumArtist = ""
@ -33,7 +44,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
"albumId" to album.id,
"albumArtist" to album.artist.name,
"albumTitle" to album.title,
"albumCover" to album.cover.original
"albumCover" to album.cover()
)
}
}
@ -52,6 +63,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
adapter = TracksAdapter(context, FavoriteListener())
repository = TracksRepository(context, albumId)
favoritesRepository = FavoritesRepository(context)
favoritedRepository = FavoritedRepository(context)
watchEventBus()
}
@ -60,10 +72,11 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
super.onViewCreated(view, savedInstanceState)
Picasso.get()
.load(albumCover)
.maybeLoad(maybeNormalizeUrl(albumCover))
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(cover)
artist.text = albumArtist
@ -73,43 +86,138 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
override fun onResume() {
super.onResume()
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
refreshDownloadedTracks()
}
var coverHeight: Float? = null
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
if (coverHeight == null) {
coverHeight = cover.measuredHeight.toFloat()
}
cover.translationY = (scrollY / 2).toFloat()
coverHeight?.let { height ->
cover.alpha = (height - scrollY.toFloat()) / height
}
}
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> play.text = getString(R.string.playback_play)
else -> play.text = getString(R.string.playback_shuffle)
}
play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
when (PowerPreference.getDefaultFile().getString("play_order")) {
"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")
}
queue.setOnClickListener {
CommandBus.send(Command.AddToQueue(adapter.data))
context?.let { context ->
actions.setOnClickListener {
PopupMenu(context, actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
inflate(R.menu.album)
context.toast("All tracks were added to your queue")
menu.findItem(R.id.play_secondary)?.let { item ->
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> item.title = getString(R.string.playback_shuffle)
else -> item.title = getString(R.string.playback_play)
}
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.play_secondary -> when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
else -> CommandBus.send(Command.ReplaceQueue(adapter.data))
}
R.id.add_to_queue -> {
when (PowerPreference.getDefaultFile().getString("play_order")) {
"in_order" -> CommandBus.send(Command.AddToQueue(adapter.data))
else -> CommandBus.send(Command.AddToQueue(adapter.data.shuffled()))
}
context.toast("All tracks were added to your queue")
}
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data))
}
true
}
show()
}
}
}
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
for (message in EventBus.asChannel<Event>()) {
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private suspend fun refreshDownloadedTracks() {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
withContext(Main) {
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.notifyDataSetChanged()
}
}
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
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.notifyItemChanged(match.second)
}
}
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack?.current = false
adapter.currentTrack = track.apply {
current = true
}
adapter.notifyDataSetChanged()
}
}
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {

View File

@ -3,29 +3,27 @@ package com.github.apognu.otter.playback
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.MediaMetadata
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media.session.MediaButtonReceiver
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.squareup.picasso.Picasso
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.launch
class MediaControlsManager(val context: Service, 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
const val NOTIFICATION_ACTION_PREVIOUS = 1
const val NOTIFICATION_ACTION_TOGGLE = 2
const val NOTIFICATION_ACTION_NEXT = 3
}
private var notification: Notification? = null
@ -39,28 +37,36 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
false -> R.drawable.play
}
GlobalScope.launch(IO) {
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)
mediaSession.setMetadata(MediaMetadataCompat.Builder().apply {
putString(MediaMetadata.METADATA_KEY_ARTIST, track.artist.name)
putString(MediaMetadata.METADATA_KEY_TITLE, track.title)
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
}.build())
val coverUrl = maybeNormalizeUrl(track.album?.cover())
notification = NotificationCompat.Builder(
context,
AppContext.NOTIFICATION_CHANNEL_MEDIA_CONTROL
)
.setShowWhen(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(
MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 2)
)
.setSmallIcon(R.drawable.ottericon)
.setLargeIcon(Picasso.get().load(normalizeUrl(track.album.cover.original)).get())
.setSmallIcon(R.drawable.ottershape)
.run {
coverUrl?.let {
try {
setLargeIcon(Picasso.get().load(coverUrl).get())
} catch (_: Exception) {
}
return@run this
}
this
}
.setContentTitle(track.title)
.setContentText(track.artist.name)
.setContentIntent(openPendingIntent)
@ -68,58 +74,43 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
.addAction(
action(
R.drawable.previous, context.getString(R.string.control_previous),
NOTIFICATION_ACTION_PREVIOUS
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
.addAction(
action(
stateIcon, context.getString(R.string.control_toggle),
NOTIFICATION_ACTION_TOGGLE
PlaybackStateCompat.ACTION_PLAY_PAUSE
)
)
.addAction(
action(
R.drawable.next, context.getString(R.string.control_next),
NOTIFICATION_ACTION_NEXT
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
.build()
notification?.let {
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
if (playing) {
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
} else {
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
}
}
if (playing) tick()
Otter.get().mediaSession.connector.invalidateMediaSessionMetadata()
}
}
}
fun tick() {
notification?.let {
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
}
fun remove() {
NotificationManagerCompat.from(context).cancel(AppContext.NOTIFICATION_MEDIA_CONTROL)
}
private fun action(icon: Int, title: String, id: Int): NotificationCompat.Action {
val intent = Intent(context, MediaControlActionReceiver::class.java).apply { action = id.toString() }
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, 0)
return NotificationCompat.Action.Builder(icon, title, pendingIntent).build()
}
}
class MediaControlActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> CommandBus.send(
Command.PreviousTrack
)
MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> CommandBus.send(
Command.ToggleState
)
MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> CommandBus.send(
Command.NextTrack
)
private fun action(icon: Int, title: String, id: Long): NotificationCompat.Action {
return MediaButtonReceiver.buildMediaButtonPendingIntent(context, id).run {
NotificationCompat.Action.Builder(icon, title, this).build()
}
}
}

View File

@ -0,0 +1,88 @@
package com.github.apognu.otter.playback
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.google.android.exoplayer2.ControlDispatcher
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
class MediaSession(private val context: Context) {
var active = false
private val playbackStateBuilder = PlaybackStateCompat.Builder().apply {
setActions(
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
)
}
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
active = true
}
}
val connector: MediaSessionConnector by lazy {
MediaSessionConnector(session).also {
it.setQueueNavigator(OtterQueueNavigator())
it.setMediaButtonEventHandler { _, _, intent ->
if (!active) {
context.startService(Intent(context, PlayerService::class.java).apply {
action = intent.action
intent.extras?.let { extras -> putExtras(extras) }
})
return@setMediaButtonEventHandler true
}
false
}
}
}
}
class OtterQueueNavigator : MediaSessionConnector.QueueNavigator {
override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, 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 getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
}
override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {
CommandBus.send(Command.NextTrack)
}
override fun getActiveQueueItemId(player: Player?) = player?.currentWindowIndex?.toLong() ?: 0
override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {
CommandBus.send(Command.PreviousTrack)
}
override fun onTimelineChanged(player: Player) {}
}

View File

@ -0,0 +1,83 @@
package com.github.apognu.otter.playback
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloadRequest
import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.exoplayer2.scheduler.Scheduler
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.*
class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
private val scope: CoroutineScope = CoroutineScope(Job() + Main)
companion object {
fun download(context: Context, track: Track) {
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
val data = Gson().toJson(
DownloadInfo(
track.id,
url,
track.title,
track.artist.name,
null
)
).toByteArray()
DownloadRequest(url, DownloadRequest.TYPE_PROGRESSIVE, Uri.parse(url), Collections.emptyList(), null, data).also {
sendAddDownload(context, PinService::class.java, it, false)
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
buildResumeDownloadsIntent(this, PinService::class.java, true)
scope.launch(Main) {
RequestBus.get().collect { request ->
when (request) {
is Request.GetDownloads -> request.channel?.offer(Response.Downloads(getDownloads()))
}
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun getDownloadManager() = Otter.get().exoDownloadManager.apply {
addListener(DownloadListener())
}
override fun getScheduler(): Scheduler? = null
override fun getForegroundNotification(downloads: MutableList<Download>): Notification {
val description = resources.getQuantityString(R.plurals.downloads_description, downloads.size, downloads.size)
return DownloadNotificationHelper(this, AppContext.NOTIFICATION_CHANNEL_DOWNLOADS).buildProgressNotification(R.drawable.downloads, null, description, downloads)
}
private fun getDownloads() = downloadManager.downloadIndex.getDownloads()
inner class DownloadListener : DownloadManager.Listener {
override fun onDownloadChanged(downloadManager: DownloadManager, download: Download) {
super.onDownloadChanged(downloadManager, download)
EventBus.send(Event.DownloadChanged(download))
}
}
}

View File

@ -8,54 +8,88 @@ import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaMetadata
import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.MediaMetadataCompat
import android.view.KeyEvent
import androidx.core.app.NotificationManagerCompat
import androidx.media.session.MediaButtonReceiver
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlaybackException
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 kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
class PlayerService : Service() {
private lateinit var queue: QueueManager
private val jobs = mutableListOf<Job>()
companion object {
const val INITIAL_COMMAND_KEY = "start_command"
}
private var started = false
private val scope: CoroutineScope = CoroutineScope(Job() + Main)
private lateinit var audioManager: AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private val audioFocusChangeListener = AudioFocusChange()
private var stateWhenLostFocus = false
private lateinit var queue: QueueManager
private lateinit var mediaControlsManager: MediaControlsManager
private lateinit var mediaSession: MediaSessionCompat
private lateinit var player: SimpleExoPlayer
private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
private lateinit var playerEventListener: PlayerEventListener
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
private var progressCache = Triple(0, 0, 0)
private lateinit var radioPlayer: RadioPlayer
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
watchEventBus()
intent?.action?.let {
if (it == Intent.ACTION_MEDIA_BUTTON) {
intent.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
when (key.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
if (hasAudioFocus(true)) MediaButtonReceiver.handleIntent(Otter.get().mediaSession.session, intent)
Unit
}
else -> MediaButtonReceiver.handleIntent(Otter.get().mediaSession.session, intent)
}
}
}
}
if (!started) {
watchEventBus()
}
started = true
return START_STICKY
}
@SuppressLint("NewApi")
override fun onCreate() {
super.onCreate()
queue = QueueManager(this)
radioPlayer = RadioPlayer(this, scope)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.VERSION_CODES.O.onApi {
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
setAudioAttributes(AudioAttributes.Builder().run {
setUsage(AudioAttributes.USAGE_MEDIA)
@ -71,155 +105,140 @@ class PlayerService : Service() {
}
}
mediaSession = MediaSessionCompat(this, applicationContext.packageName).apply {
isActive = true
}
mediaControlsManager = MediaControlsManager(this, scope, Otter.get().mediaSession.session)
mediaControlsManager = MediaControlsManager(this, mediaSession)
player = ExoPlayerFactory.newSimpleInstance(this).apply {
player = SimpleExoPlayer.Builder(this).build().apply {
playWhenReady = false
playerEventListener = PlayerEventListener().also {
addListener(it)
}
}
MediaSessionConnector(mediaSession).also {
it.setPlayer(this)
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
mediaButtonEvent?.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
if (key.action == KeyEvent.ACTION_UP) {
when (key.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY -> state(true)
KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false)
KeyEvent.KEYCODE_MEDIA_NEXT -> player?.next()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack()
}
}
}
Otter.get().mediaSession.active = true
true
}
Otter.get().mediaSession.connector.apply {
setPlayer(player)
setMediaMetadataProvider {
buildTrackMetadata(queue.current())
}
}
if (queue.current > -1) {
player.prepare(queue.datasources, true, true)
player.seekTo(queue.current, 0)
player.prepare(queue.datasources)
Cache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong())
val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
}
}
registerReceiver(headphonesUnpluggedReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
}
private fun watchEventBus() {
jobs.add(GlobalScope.launch(Main) {
for (message in CommandBus.asChannel()) {
when (message) {
scope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshService -> {
EventBus.send(Event.QueueChanged)
if (queue.metadata.isNotEmpty()) {
EventBus.send(
Event.TrackPlayed(
queue.current(),
player.playWhenReady
)
)
EventBus.send(
Event.StateChanged(
player.playWhenReady
)
)
CommandBus.send(Command.RefreshTrack(queue.current()))
EventBus.send(Event.StateChanged(player.playWhenReady))
}
}
is Command.ReplaceQueue -> {
queue.replace(message.queue)
if (!command.fromRadio) radioPlayer.stop()
queue.replace(command.queue)
player.prepare(queue.datasources, true, true)
state(true)
setPlaybackState(true)
EventBus.send(
Event.TrackPlayed(
queue.current(),
true
)
)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
is Command.AddToQueue -> queue.append(message.tracks)
is Command.PlayNext -> queue.insertNext(message.track)
is Command.RemoveFromQueue -> queue.remove(message.track)
is Command.MoveFromQueue -> queue.move(message.oldPosition, message.newPosition)
is Command.AddToQueue -> queue.append(command.tracks)
is Command.PlayNext -> queue.insertNext(command.track)
is Command.RemoveFromQueue -> queue.remove(command.track)
is Command.MoveFromQueue -> queue.move(command.oldPosition, command.newPosition)
is Command.PlayTrack -> {
queue.current = message.index
player.seekTo(message.index, C.TIME_UNSET)
queue.current = command.index
player.seekTo(command.index, C.TIME_UNSET)
state(true)
setPlaybackState(true)
EventBus.send(
Event.TrackPlayed(
queue.current(),
true
)
)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
is Command.ToggleState -> toggle()
is Command.SetState -> state(message.state)
is Command.ToggleState -> togglePlayback()
is Command.SetState -> setPlaybackState(command.state)
is Command.NextTrack -> player.next()
is Command.PreviousTrack -> previousTrack()
is Command.Seek -> progress(message.progress)
}
is Command.NextTrack -> skipToNextTrack()
is Command.PreviousTrack -> skipToPreviousTrack()
is Command.Seek -> seek(command.progress)
if (player.playWhenReady) {
mediaControlsManager.tick()
is Command.ClearQueue -> {
queue.clear()
player.stop()
}
is Command.ShuffleQueue -> queue.shuffle()
is Command.PlayRadio -> {
queue.clear()
radioPlayer.play(command.radio)
}
is Command.SetRepeatMode -> player.repeatMode = command.mode
is Command.PinTrack -> PinService.download(this@PlayerService, command.track)
is Command.PinTracks -> command.tracks.forEach { PinService.download(this@PlayerService, it) }
}
}
})
}
jobs.add(GlobalScope.launch(Main) {
for (request in RequestBus.asChannel<Request>()) {
scope.launch(Main) {
RequestBus.get().collect { request ->
when (request) {
is Request.GetCurrentTrack -> request.channel?.offer(
Response.CurrentTrack(
queue.current()
)
)
is Request.GetState -> request.channel?.offer(
Response.State(
player.playWhenReady
)
)
is Request.GetQueue -> request.channel?.offer(
Response.Queue(
queue.get()
)
)
is Request.GetCurrentTrack -> request.channel?.offer(Response.CurrentTrack(queue.current()))
is Request.GetState -> request.channel?.offer(Response.State(player.playWhenReady))
is Request.GetQueue -> request.channel?.offer(Response.Queue(queue.get()))
}
}
})
}
jobs.add(GlobalScope.launch(Main) {
scope.launch(Main) {
while (true) {
delay(1000)
val (current, duration, percent) = progress()
val (current, duration, percent) = getProgress()
if (player.playWhenReady) {
ProgressBus.send(current, duration, percent)
}
}
})
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (!player.playWhenReady) {
NotificationManagerCompat.from(this).cancelAll()
stopSelf()
}
}
@SuppressLint("NewApi")
override fun onDestroy() {
jobs.forEach { it.cancel() }
scope.cancel()
try {
unregisterReceiver(headphonesUnpluggedReceiver)
@ -237,27 +256,96 @@ class PlayerService : Service() {
audioManager.abandonAudioFocus(audioFocusChangeListener)
})
mediaSession.isActive = false
mediaSession.release()
player.removeListener(playerEventListener)
state(false)
setPlaybackState(false)
player.release()
queue.cache.release()
stopForeground(true)
stopSelf()
Otter.get().mediaSession.active = false
super.onDestroy()
}
@SuppressLint("NewApi")
private fun state(state: Boolean) {
private fun setPlaybackState(state: Boolean) {
if (!state) {
val (progress, _, _) = getProgress()
Cache.set(this@PlayerService, "progress", progress.toString().toByteArray())
}
if (state && player.playbackState == Player.STATE_IDLE) {
player.prepare(queue.datasources)
}
if (hasAudioFocus(state)) {
player.playWhenReady = state
EventBus.send(Event.StateChanged(state))
}
}
private fun togglePlayback() {
setPlaybackState(!player.playWhenReady)
}
private fun skipToPreviousTrack() {
if (player.currentPosition > 5000) {
return player.seekTo(0)
}
player.previous()
}
private fun skipToNextTrack() {
player.next()
Cache.set(this@PlayerService, "progress", "0".toByteArray())
ProgressBus.send(0, 0, 0)
}
private fun getProgress(force: Boolean = false): Triple<Int, Int, Int> {
if (!player.playWhenReady && !force) return progressCache
return queue.current()?.bestUpload()?.let { upload ->
val current = player.currentPosition
val duration = upload.duration.toFloat()
val percent = ((current / (duration * 1000)) * 100).toInt()
progressCache = Triple(current.toInt(), duration.toInt(), percent)
progressCache
} ?: Triple(0, 0, 0)
}
private fun seek(value: Int) {
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
player.seekTo(duration.toLong())
}
private fun buildTrackMetadata(track: Track?): MediaMetadataCompat {
track?.let {
val coverUrl = maybeNormalizeUrl(track.album?.cover())
return mediaMetadataBuilder.apply {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name)
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
try {
runBlocking(IO) {
this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get())
}
} catch (e: Exception) {
}
}.build()
}
return mediaMetadataBuilder.build()
}
@SuppressLint("NewApi")
private fun hasAudioFocus(state: Boolean): Boolean {
var allowed = !state
if (!allowed) {
@ -283,133 +371,100 @@ class PlayerService : Service() {
)
}
if (allowed) {
player.playWhenReady = state
EventBus.send(Event.StateChanged(state))
}
}
private fun toggle() {
state(!player.playWhenReady)
}
private fun previousTrack() {
if (player.currentPosition > 5000) {
return player.seekTo(0)
}
player.previous()
}
private fun progress(): Triple<Int, Int, Int> {
if (!player.playWhenReady) return progressCache
return queue.current()?.bestUpload()?.let { upload ->
val current = player.currentPosition
val duration = upload.duration.toFloat()
val percent = ((current / (duration * 1000)) * 100).toInt()
progressCache = Triple(current.toInt(), duration.toInt(), percent)
progressCache
} ?: Triple(0, 0, 0)
}
private fun progress(value: Int) {
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
player.seekTo(duration.toLong())
return allowed
}
@SuppressLint("NewApi")
inner class PlayerEventListener : Player.EventListener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
super.onPlayerStateChanged(playWhenReady, playbackState)
EventBus.send(
Event.StateChanged(
playWhenReady
)
)
EventBus.send(Event.StateChanged(playWhenReady))
if (queue.current == -1) {
EventBus.send(
Event.TrackPlayed(
queue.current(),
playWhenReady
)
)
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_IDLE -> state(false)
Player.STATE_ENDED -> EventBus.send(Event.PlaybackStopped)
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
Player.STATE_ENDED -> {
setPlaybackState(false)
queue.current = 0
player.seekTo(0, C.TIME_UNSET)
ProgressBus.send(0, 0, 0)
}
Player.STATE_IDLE -> {
setPlaybackState(false)
return EventBus.send(Event.PlaybackStopped)
}
}
if (playbackState != Player.STATE_BUFFERING) EventBus.send(
Event.Buffering(
false
)
)
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
}
false -> {
EventBus.send(
Event.StateChanged(
false
)
)
EventBus.send(
Event.Buffering(
false
)
EventBus.send(Event.Buffering(false))
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
{ stopForeground(false) }
)
if (playbackState == Player.STATE_READY) {
mediaControlsManager.updateNotification(queue.current(), false)
stopForeground(false)
when (playbackState) {
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
Player.STATE_IDLE -> mediaControlsManager.remove()
}
}
}
}
override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {
override fun onTracksChanged(trackGroups: TrackGroupArray, trackSelections: TrackSelectionArray) {
super.onTracksChanged(trackGroups, trackSelections)
queue.current = player.currentWindowIndex
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
if (queue.current != player.currentWindowIndex) {
queue.current = player.currentWindowIndex
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
}
Cache.set(
this@PlayerService,
"current",
queue.current.toString().toByteArray()
)
if (queue.get().isNotEmpty() && queue.current() == queue.get().last() && radioPlayer.isActive()) {
scope.launch(IO) {
if (radioPlayer.lock.tryAcquire()) {
radioPlayer.prepareNextTrack()
radioPlayer.lock.release()
}
}
}
EventBus.send(
Event.TrackPlayed(
queue.current(),
true
)
)
Cache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
CommandBus.send(Command.RefreshTrack(queue.current()))
}
override fun onPlayerError(error: ExoPlaybackException?) {
EventBus.send(
Event.PlaybackError(
getString(R.string.error_playback)
)
)
override fun onPositionDiscontinuity(reason: Int) {
super.onPositionDiscontinuity(reason)
player.next()
if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) {
EventBus.send(Event.TrackFinished(queue.current()))
}
}
override fun onPlayerError(error: ExoPlaybackException) {
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
if (player.playWhenReady) {
queue.current++
player.prepare(queue.datasources, true, true)
player.seekTo(queue.current, 0)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
}
}
@ -419,18 +474,18 @@ class PlayerService : Service() {
AudioManager.AUDIOFOCUS_GAIN -> {
player.volume = 1f
state(stateWhenLostFocus)
setPlaybackState(stateWhenLostFocus)
stateWhenLostFocus = false
}
AudioManager.AUDIOFOCUS_LOSS -> {
stateWhenLostFocus = false
state(false)
setPlaybackState(false)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
stateWhenLostFocus = player.playWhenReady
state(false)
setPlaybackState(false)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
@ -440,4 +495,4 @@ class PlayerService : Service() {
}
}
}
}
}

View File

@ -2,42 +2,56 @@ package com.github.apognu.otter.playback
import android.content.Context
import android.net.Uri
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
import com.google.android.exoplayer2.upstream.FileDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.google.android.exoplayer2.util.Util
import com.google.gson.Gson
import com.preference.PowerPreference
class QueueManager(val context: Context) {
var cache: SimpleCache
var metadata: MutableList<Track> = mutableListOf()
val datasources = ConcatenatingMediaSource()
var current = -1
init {
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().also {
cache = SimpleCache(
context.cacheDir.resolve("media"),
LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024)
companion object {
fun factory(context: Context): CacheDataSourceFactory {
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
defaultRequestProperties.apply {
if (!Settings.isAnonymous()) {
set("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
}
val playbackCache = CacheDataSourceFactory(Otter.get().exoCache, http)
return CacheDataSourceFactory(
Otter.get().exoDownloadCache,
playbackCache,
FileDataSource.Factory(),
null,
CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR,
null
)
}
}
init {
Cache.get(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
metadata = cache.data.toMutableList()
val factory = factory()
val factory = factory(context)
datasources.addMediaSources(metadata.map { track ->
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
})
@ -57,23 +71,11 @@ class QueueManager(val context: Context) {
)
}
private fun factory(): CacheDataSourceFactory {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
defaultRequestProperties.apply {
set("Authorization", "Bearer $token")
}
}
return CacheDataSourceFactory(cache, http)
}
fun replace(tracks: List<Track>) {
val factory = factory()
val factory = factory(context)
val sources = tracks.map { track ->
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
}
@ -88,11 +90,11 @@ class QueueManager(val context: Context) {
}
fun append(tracks: List<Track>) {
val factory = factory()
val tracks = tracks.filter { metadata.indexOf(it) == -1 }
val factory = factory(context)
val missingTracks = tracks.filter { metadata.indexOf(it) == -1 }
val sources = tracks.map { track ->
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
val sources = missingTracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
}
@ -106,8 +108,8 @@ class QueueManager(val context: Context) {
}
fun insertNext(track: Track) {
val factory = factory()
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
val factory = factory(context)
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
if (metadata.indexOf(track) == -1) {
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url)).let {
@ -125,8 +127,24 @@ class QueueManager(val context: Context) {
fun remove(track: Track) {
metadata.indexOf(track).let {
if (it < 0) {
return
}
datasources.removeMediaSource(it)
metadata.removeAt(it)
if (it == current) {
CommandBus.send(Command.NextTrack)
}
if (it < current) {
current--
}
}
if (metadata.isEmpty()) {
current = -1
}
persist()
@ -155,4 +173,39 @@ class QueueManager(val context: Context) {
return metadata.getOrNull(current)
}
fun clear() {
metadata = mutableListOf()
datasources.clear()
current = -1
persist()
}
fun shuffle() {
if (metadata.size < 2) return
if (current == -1) {
replace(metadata.shuffled())
} else {
move(current, 0)
current = 0
val shuffled =
metadata
.drop(1)
.shuffled()
while (metadata.size > 1) {
datasources.removeMediaSource(metadata.size - 1)
metadata.removeAt(metadata.size - 1)
}
append(shuffled)
}
persist()
EventBus.send(Event.QueueChanged)
}
}

View File

@ -0,0 +1,147 @@
package com.github.apognu.otter.playback
import android.content.Context
import com.github.apognu.otter.R
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null, var related_object_id: String? = null)
data class RadioSession(val id: Int)
data class RadioTrackBody(val session: Int)
data class RadioTrack(val position: Int, val track: RadioTrackID)
data class RadioTrackID(val id: Int)
class RadioPlayer(val context: Context, val scope: CoroutineScope) {
val lock = Semaphore(1)
private var currentRadio: Radio? = null
private var session: Int? = null
private var cookie: String? = null
private val favoritedRepository = FavoritedRepository(context)
init {
Cache.get(context, "radio_type")?.readLine()?.let { radio_type ->
Cache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id ->
Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
val cachedCookie = Cache.get(context, "radio_cookie")?.readLine()
currentRadio = Radio(radio_id, radio_type, "", "")
session = radio_session
cookie = cachedCookie
}
}
}
}
fun play(radio: Radio) {
currentRadio = radio
session = null
scope.launch(IO) {
createSession()
}
}
fun stop() {
currentRadio = null
session = null
Cache.delete(context, "radio_type")
Cache.delete(context, "radio_id")
Cache.delete(context, "radio_session")
Cache.delete(context, "radio_cookie")
}
fun isActive() = currentRadio != null && session != null
private suspend fun createSession() {
currentRadio?.let { radio ->
try {
val request = RadioSessionBody(radio.radio_type, related_object_id = radio.related_object_id).apply {
if (radio_type == "custom") {
custom_radio = radio.id
}
}
val body = Gson().toJson(request)
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
.authorize()
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
session = result.get().id
cookie = response.header("set-cookie").joinToString(";")
Cache.set(context, "radio_type", radio.radio_type.toByteArray())
Cache.set(context, "radio_id", radio.id.toString().toByteArray())
Cache.set(context, "radio_session", session.toString().toByteArray())
Cache.set(context, "radio_cookie", cookie.toString().toByteArray())
prepareNextTrack(true)
} catch (e: Exception) {
withContext(Main) {
context.toast(context.getString(R.string.radio_playback_error))
}
}
}
}
suspend fun prepareNextTrack(first: Boolean = false) {
session?.let { session ->
try {
val body = Gson().toJson(RadioTrackBody(session))
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
.authorize()
.header("Content-Type", "application/json")
.apply {
cookie?.let {
header("cookie", it)
}
}
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
.authorize()
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
val track = trackResponse.get().apply {
favorite = favorites.contains(id)
}
if (first) {
CommandBus.send(Command.ReplaceQueue(listOf(track), true))
} else {
CommandBus.send(Command.AddToQueue(listOf(track)))
}
} catch (e: Exception) {
withContext(Main) {
context.toast(context.getString(R.string.radio_playback_error))
}
} finally {
EventBus.send(Event.RadioStarted)
}
}
}
}

View File

@ -4,7 +4,7 @@ import android.content.Context
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.AlbumsCache
import com.github.apognu.otter.utils.AlbumsResponse
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.OtterResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
@ -17,10 +17,10 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
override val upstream: Upstream<Album> by lazy {
val url =
if (artistId == null) "/api/v1/albums?playable=true"
else "/api/v1/albums?playable=true&artist=$artistId"
if (artistId == null) "/api/v1/albums/?playable=true&ordering=title"
else "/api/v1/albums/?playable=true&artist=$artistId&ordering=release_date"
HttpUpstream<Album, FunkwhaleResponse<Album>>(
HttpUpstream<Album, OtterResponse<Album>>(
HttpUpstream.Behavior.Progressive,
url,
object : TypeToken<AlbumsResponse>() {}.type

View File

@ -0,0 +1,18 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.OtterResponse
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.TracksCache
import com.github.apognu.otter.utils.TracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<Track, TracksCache>() {
override val cacheId = "tracks-artist-$artistId"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
}

View File

@ -4,14 +4,14 @@ import android.content.Context
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.ArtistsCache
import com.github.apognu.otter.utils.ArtistsResponse
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.OtterResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
override val cacheId = "artists"
override val upstream = HttpUpstream<Artist, FunkwhaleResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists?playable=true", object : TypeToken<ArtistsResponse>() {}.type)
override val upstream = HttpUpstream<Artist, OtterResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=name", object : TypeToken<ArtistsResponse>() {}.type)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)

View File

@ -1,55 +1,94 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.preference.PowerPreference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Favorite, FavoritesCache>() {
override val cacheId = "favorites"
override val upstream = HttpUpstream<Favorite, FunkwhaleResponse<Favorite>>(HttpUpstream.Behavior.AtOnce, "/api/v1/favorites/tracks?playable=true", object : TypeToken<FavoritesResponse>() {}.type)
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true&ordering=title", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Favorite>) = FavoritesCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritesCache::class.java).deserialize(reader)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Favorite>) = data.map {
it.apply {
it.track.favorite = true
private val favoritedRepository = FavoritedRepository(context)
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
data.map { track ->
track.favorite = true
track.downloaded = downloaded.contains(track.id)
track.bestUpload()?.let { upload ->
maybeNormalizeUrl(upload.listen_url)?.let { url ->
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
}
}
track
}
}
fun addFavorite(id: Int) {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
val body = mapOf("track" to id)
runBlocking(IO) {
Fuel
.post(normalizeUrl("/api/v1/favorites/tracks"))
.header("Authorization", "Bearer $token")
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
}
}
fun deleteFavorite(id: Int) {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
val body = mapOf("track" to id)
runBlocking(IO) {
Fuel
.post(normalizeUrl("/api/v1/favorites/tracks/remove/"))
.header("Authorization", "Bearer $token")
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
if (!Settings.isAnonymous()) {
request.header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
}
}
}
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
override val cacheId = "favorited"
override val upstream = HttpUpstream<Int, OtterResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
override fun cache(data: List<Int>) = FavoritedCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
fun update(context: Context?, scope: CoroutineScope) {
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
Cache.set(context, cacheId, Gson().toJson(cache(favorites)).toByteArray())
}
}
}

View File

@ -9,96 +9,99 @@ import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import com.preference.PowerPreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.io.Reader
import java.lang.reflect.Type
import kotlin.math.ceil
class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
enum class Behavior {
Single, AtOnce, Progressive
}
private var _channel: Channel<Repository.Response<D>>? = null
private val channel: Channel<Repository.Response<D>>
get() {
if (_channel?.isClosedForSend ?: true) {
_channel = Channel()
}
override fun fetch(size: Int): Flow<Repository.Response<D>> = flow {
if (behavior == Behavior.Single && size != 0) return@flow
return _channel!!
}
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
override fun fetch(data: List<D>): Channel<Repository.Response<D>>? {
if (behavior == Behavior.Single && data.isNotEmpty()) return null
val url =
Uri.parse(url)
.buildUpon()
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
.appendQueryParameter("page", page.toString())
.appendQueryParameter("scope", Settings.getScopes().joinToString(","))
.build()
.toString()
val page = ceil(data.size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
get(url).fold(
{ response ->
val data = response.getData()
GlobalScope.launch(Dispatchers.IO) {
val offsetUrl =
Uri.parse(url)
.buildUpon()
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
.appendQueryParameter("page", page.toString())
.build()
.toString()
when (behavior) {
Behavior.Single -> emit(Repository.Response(Repository.Origin.Network, data, page, false))
Behavior.Progressive -> emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
get(offsetUrl).fold(
{ response ->
val data = data.plus(response.getData())
else -> {
emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
if (behavior == Behavior.Progressive || response.next == null) {
channel.offer(Repository.Response(Repository.Origin.Network, data))
} else {
fetch(data)
}
},
{ error ->
when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut)
if (response.next != null) fetch(size + data.size).collect { emit(it) }
}
}
)
}
},
{ error ->
when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut)
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), page, false))
}
}
)
}.flowOn(IO)
return channel
}
class GenericDeserializer<T : FunkwhaleResponse<*>>(val type: Type) : ResponseDeserializable<T> {
class GenericDeserializer<T : OtterResponse<*>>(val type: Type) : ResponseDeserializable<T> {
override fun deserialize(reader: Reader): T? {
return Gson().fromJson(reader, type)
}
}
suspend fun get(url: String): Result<R, FuelError> {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
return try {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
val (_, response, result) = Fuel
.get(normalizeUrl(url))
.header("Authorization", "Bearer $token")
.awaitObjectResponseResult(GenericDeserializer<R>(type))
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
if (response.statusCode == 401) {
return retryGet(url)
if (response.statusCode == 401) {
return retryGet(url)
}
result
} catch (e: Exception) {
Result.error(FuelError.wrap(e))
}
return result
}
private suspend fun retryGet(url: String): Result<R, FuelError> {
return if (HTTP.refresh()) {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
return try {
return if (HTTP.refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
Fuel
.get(normalizeUrl(url))
.header("Authorization", "Bearer $token")
.awaitObjectResult(GenericDeserializer(type))
} else {
Result.Failure(FuelError.wrap(RefreshError))
request.awaitObjectResult(GenericDeserializer(type))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}
} catch (e: Exception) {
Result.error(FuelError.wrap(e))
}
}
}

View File

@ -1,31 +1,32 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.utils.OtterResponse
import com.github.apognu.otter.utils.PlaylistTrack
import com.github.apognu.otter.utils.PlaylistTracksCache
import com.github.apognu.otter.utils.PlaylistTracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() {
override val cacheId = "tracks-playlist-$playlistId"
override val upstream = HttpUpstream<PlaylistTrack, FunkwhaleResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
override val upstream = HttpUpstream<PlaylistTrack, OtterResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
val favorites = FavoritesRepository(context).fetch(Origin.Network.origin).receive().data
log(favorites.toString())
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
data.map { track ->
val favorite = favorites.find { it.track.id == track.track.id }
if (favorite != null) {
track.track.favorite = true
}
track.track.favorite = favorites.contains(track.track.id)
track
}
}

View File

@ -1,18 +1,99 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Playlist
import com.github.apognu.otter.utils.PlaylistsCache
import com.github.apognu.otter.utils.PlaylistsResponse
import com.github.apognu.otter.utils.*
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
import kotlinx.coroutines.launch
import java.io.BufferedReader
data class PlaylistAdd(val tracks: List<Int>, val allow_duplicates: Boolean)
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
override val cacheId = "tracks-playlists"
override val upstream = HttpUpstream<Playlist, FunkwhaleResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists?playable=true", object : TypeToken<PlaylistsResponse>() {}.type)
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists/?playable=true&ordering=name", object : TypeToken<PlaylistsResponse>() {}.type)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
}
class ManagementPlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
override val cacheId = "tracks-playlists-management"
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(HttpUpstream.Behavior.AtOnce, "/api/v1/playlists/?scope=me&ordering=name", object : TypeToken<PlaylistsResponse>() {}.type)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
suspend fun new(name: String): Int? {
val body = mapOf("name" to name, "privacy_level" to "me")
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
val (_, response, result) = request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitObjectResponseResult(gsonDeserializerOf(Playlist::class.java))
if (response.statusCode != 201) return null
return result.get().id
}
fun add(id: Int, tracks: List<Track>) {
val body = PlaylistAdd(tracks.map { it.id }, false)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/${id}/add/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
scope.launch(Dispatchers.IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
}
suspend fun remove(id: Int, track: Track, index: Int) {
val body = mapOf("index" to index)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/${id}/remove/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
fun move(id: Int, from: Int, to: Int) {
val body = mapOf("from" to from, "to" to to)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/${id}/move/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
scope.launch(Dispatchers.IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
}
}

View File

@ -0,0 +1,24 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.OtterResponse
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.utils.RadiosCache
import com.github.apognu.otter.utils.RadiosResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
override val cacheId = "radios"
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?ordering=name", object : TypeToken<RadiosResponse>() {}.type)
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Radio>): List<Radio> {
return data
.map { radio -> radio.apply { radio_type = "custom" } }
.toMutableList()
}
}

View File

@ -1,74 +1,59 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.CacheItem
import com.github.apognu.otter.utils.untilNetwork
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import java.io.BufferedReader
import kotlin.math.ceil
interface Upstream<D> {
fun fetch(data: List<D> = listOf()): Channel<Repository.Response<D>>?
fun fetch(size: Int = 0): Flow<Repository.Response<D>>
}
abstract class Repository<D : Any, C : CacheItem<D>> {
protected val scope: CoroutineScope = CoroutineScope(Job() + IO)
enum class Origin(val origin: Int) {
Cache(0b01),
Network(0b10)
}
data class Response<D>(val origin: Origin, val data: List<D>)
data class Response<D>(val origin: Origin, val data: List<D>, val page: Int, val hasMore: Boolean)
abstract val context: Context?
abstract val cacheId: String?
abstract val upstream: Upstream<D>
private var _channel: Channel<Response<D>>? = null
private val channel: Channel<Response<D>>
get() {
if (_channel?.isClosedForSend ?: true) {
_channel = Channel(10)
}
return _channel!!
}
protected open fun cache(data: List<D>): C? = null
open fun cache(data: List<D>): C? = null
protected open fun uncache(reader: BufferedReader): C? = null
fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, from: List<D> = listOf()): Channel<Response<D>> {
if (Origin.Cache.origin and upstreams == upstreams) fromCache()
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(from)
return channel
fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, size: Int = 0): Flow<Response<D>> = flow {
if (Origin.Cache.origin and upstreams == upstreams) fromCache().collect { emit(it) }
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(size).collect { emit(it) }
}
private fun fromCache() {
private fun fromCache() = flow {
cacheId?.let { cacheId ->
Cache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
channel.offer(Response(Origin.Cache, cache.data))
return@flow emit(Response(Origin.Cache, cache.data, ceil(cache.data.size / AppContext.PAGE_SIZE.toDouble()).toInt(), false))
}
}
return@flow emit(Response(Origin.Cache, listOf(), 1, false))
}
}
}.flowOn(IO)
private fun fromNetwork(from: List<D>) {
upstream.fetch(data = from)?.untilNetwork(IO) {
val data = onDataFetched(it)
cacheId?.let { cacheId ->
Cache.set(
context,
cacheId,
Gson().toJson(cache(data)).toByteArray()
)
}
channel.offer(Response(Origin.Network, data))
}
private fun fromNetwork(size: Int) = flow {
upstream
.fetch(size)
.map { response -> Response(Origin.Network, onDataFetched(response.data), response.page, response.hasMore) }
.collect { response -> emit(Response(Origin.Network, response.data, response.page, response.hasMore)) }
}
protected open fun onDataFetched(data: List<D>) = data

View File

@ -1,33 +1,60 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.TracksCache
import com.github.apognu.otter.utils.TracksResponse
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class SearchRepository(override val context: Context?, query: String) : Repository<Track, TracksCache>() {
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<Track, TracksCache>() {
override val cacheId: String? = null
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
override val upstream: Upstream<Track>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritesRepository(context).fetch(Origin.Network.origin).receive().data
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
data.map { track ->
val favorite = favorites.find { it.track.id == track.id }
track.favorite = favorites.contains(track.id)
track.downloaded = downloaded.contains(track.id)
if (favorite != null) {
track.favorite = true
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
}
track
}
}
}
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<Artist, ArtistsCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Artist>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
}
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<Album, AlbumsCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Album>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
}

View File

@ -1,33 +1,61 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.TracksCache
import com.github.apognu.otter.utils.TracksResponse
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.offline.Download
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() {
override val cacheId = "tracks-album-$albumId"
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&album=$albumId", object : TypeToken<TracksResponse>() {}.type)
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
companion object {
fun getDownloadedIds(): List<Int>? {
val cursor = Otter.get().exoDownloadManager.downloadIndex.getDownloads()
val ids: MutableList<Int> = mutableListOf()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let {
if (download.state == Download.STATE_COMPLETED) {
ids.add(it.id)
}
}
}
return ids
}
}
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritesRepository(context).fetch(Origin.Network.origin).receive().data
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
val downloaded = getDownloadedIds() ?: listOf()
data.map { track ->
val favorite = favorites.find { it.track.id == track.id }
track.favorite = favorites.contains(track.id)
track.downloaded = downloaded.contains(track.id)
if (favorite != null) {
track.favorite = true
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
}
track
}
}.sortedWith(compareBy({ it.disc_number }, { it.position }))
}
}

View File

@ -7,7 +7,6 @@ import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Build
import com.github.apognu.otter.R
import com.github.kittinunf.fuel.core.FuelManager
@ -17,7 +16,9 @@ object AppContext {
const val PREFS_CREDENTIALS = "credentials"
const val NOTIFICATION_MEDIA_CONTROL = 1
const val NOTIFICATION_DOWNLOADS = 2
const val NOTIFICATION_CHANNEL_MEDIA_CONTROL = "mediacontrols"
const val NOTIFICATION_CHANNEL_DOWNLOADS = "downloads"
const val PAGE_SIZE = 50
const val TRANSITION_DURATION = 300L
@ -25,8 +26,6 @@ object AppContext {
fun init(context: Activity) {
setupNotificationChannels(context)
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
// CastContext.getSharedInstance(context)
FuelManager.instance.addResponseInterceptor { next ->
@ -65,6 +64,24 @@ object AppContext {
}
}
}
Build.VERSION_CODES.O.onApi {
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).let { manager ->
NotificationChannel(
NOTIFICATION_CHANNEL_DOWNLOADS,
"Downloads",
NotificationManager.IMPORTANCE_LOW
).run {
description = "Downloads"
enableLights(false)
enableVibration(false)
setSound(null, null)
manager.createNotificationChannel(this)
}
}
}
}
}

View File

@ -1,11 +1,17 @@
package com.github.apognu.otter.utils
import kotlinx.coroutines.Dispatchers.Main
import com.github.apognu.otter.Otter
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadCursor
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
sealed class Command {
class StartService(val command: Command) : Command()
object RefreshService : Command()
object ToggleState : Command()
@ -16,12 +22,22 @@ sealed class Command {
class Seek(val progress: Int) : Command()
class AddToQueue(val tracks: List<Track>) : Command()
class AddToPlaylist(val tracks: List<Track>) : Command()
class PlayNext(val track: Track) : Command()
class ReplaceQueue(val queue: List<Track>) : Command()
class ReplaceQueue(val queue: List<Track>, val fromRadio: Boolean = false) : Command()
class RemoveFromQueue(val track: Track) : Command()
class MoveFromQueue(val oldPosition: Int, val newPosition: Int) : Command()
object ClearQueue : Command()
object ShuffleQueue : Command()
class PlayRadio(val radio: Radio) : Command()
class SetRepeatMode(val mode: Int) : Command()
class PlayTrack(val index: Int) : Command()
class PinTrack(val track: Track) : Command()
class PinTracks(val tracks: List<Track>) : Command()
class RefreshTrack(val track: Track?) : Command()
}
sealed class Event {
@ -30,83 +46,70 @@ sealed class Event {
class PlaybackError(val message: String) : Event()
object PlaybackStopped : Event()
class Buffering(val value: Boolean) : Event()
class TrackPlayed(val track: Track?, val play: Boolean) : Event()
class TrackFinished(val track: Track?) : Event()
class StateChanged(val playing: Boolean) : Event()
object QueueChanged : Event()
object RadioStarted : Event()
object ListingsChanged : Event()
class DownloadChanged(val download: Download) : Event()
}
sealed class Request(var channel: Channel<Response>? = null) {
object GetState : Request()
object GetQueue : Request()
object GetCurrentTrack : Request()
object GetDownloads : Request()
}
sealed class Response {
class State(val playing: Boolean) : Response()
class Queue(val queue: List<Track>) : Response()
class CurrentTrack(val track: Track?) : Response()
class Downloads(val cursor: DownloadCursor) : Response()
}
object EventBus {
private var bus: BroadcastChannel<Event> = BroadcastChannel(10)
fun send(event: Event) {
GlobalScope.launch {
bus.offer(event)
GlobalScope.launch(IO) {
Otter.get().eventBus.offer(event)
}
}
fun get() = bus
inline fun <reified T : Event> asChannel(): ReceiveChannel<T> {
return get().openSubscription().filter { it is T }.map { it as T }
}
fun get() = Otter.get().eventBus.asFlow()
}
object CommandBus {
private var bus: Channel<Command> = Channel(10)
fun send(command: Command) {
GlobalScope.launch {
bus.offer(command)
GlobalScope.launch(IO) {
Otter.get().commandBus.offer(command)
}
}
fun asChannel() = bus
fun get() = Otter.get().commandBus.asFlow()
}
object RequestBus {
private var bus: BroadcastChannel<Request> = BroadcastChannel(10)
fun send(request: Request): Channel<Response> {
return Channel<Response>().also {
GlobalScope.launch(Main) {
GlobalScope.launch(IO) {
request.channel = it
bus.offer(request)
Otter.get().requestBus.offer(request)
}
}
}
fun get() = bus
inline fun <reified T> asChannel(): ReceiveChannel<T> {
return get().openSubscription().filter { it is T }.map { it as T }
}
fun get() = Otter.get().requestBus.asFlow()
}
object ProgressBus {
private val bus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
fun send(current: Int, duration: Int, percent: Int) {
GlobalScope.launch {
bus.send(Triple(current, duration, percent))
GlobalScope.launch(IO) {
Otter.get().progressBus.send(Triple(current, duration, percent))
}
}
fun asChannel(): ReceiveChannel<Triple<Int, Int, Int>> {
return bus.openSubscription()
}
fun get() = Otter.get().progressBus.asFlow().conflate()
}
suspend inline fun <reified T> Channel<Response>.wait(): T? {

View File

@ -23,7 +23,7 @@ object HTTP {
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("password")
).toList()
val result = Fuel.post(normalizeUrl("/api/v1/token"), body).awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
val result = Fuel.post(mustNormalizeUrl("/api/v1/token"), body).awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
return result.fold(
{ data ->
@ -36,12 +36,13 @@ object HTTP {
}
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
val (_, response, result) = Fuel
.get(normalizeUrl(url))
.header("Authorization", "Bearer $token")
.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
if (response.statusCode == 401) {
return retryGet(url)
@ -52,12 +53,13 @@ object HTTP {
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
return if (refresh()) {
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
Fuel
.get(normalizeUrl(url))
.header("Authorization", "Bearer $token")
.awaitObjectResult(gsonDeserializerOf(T::class.java))
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}
@ -87,4 +89,10 @@ object Cache {
return null
}
}
fun delete(context: Context?, key: String) = context?.let {
with(File(it.cacheDir, key(key))) {
delete()
}
}
}

View File

@ -1,38 +1,26 @@
package com.github.apognu.otter.utils
import android.content.Context
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.repositories.Repository
import com.github.kittinunf.fuel.core.Request
import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
fun Context.getColor(colorRes: Int): Int {
return ContextCompat.getColor(this, colorRes)
}
inline fun <D> Channel<Repository.Response<D>>.await(context: CoroutineContext = Main, crossinline callback: (data: List<D>) -> Unit) {
GlobalScope.launch(context) {
this@await.receive().also {
callback(it.data)
close()
}
}
}
inline fun <D> Channel<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>) -> Unit) {
GlobalScope.launch(context) {
for (data in this@untilNetwork) {
callback(data.data)
if (data.origin == Repository.Origin.Network) {
close()
}
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit) {
scope.launch(context) {
collect { data ->
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
}
}
}
@ -73,4 +61,19 @@ fun <T> T.applyOnApi(api: Int, block: T.() -> T): T {
} else {
this
}
}
}
fun Picasso.maybeLoad(url: String?): RequestCreator {
return if (url == null) load(R.drawable.cover)
else load(url)
}
fun Request.authorize(): Request {
return this.apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
}
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)

View File

@ -1,80 +1,126 @@
package com.github.apognu.otter.utils
import com.google.android.exoplayer2.offline.Download
import com.preference.PowerPreference
data class User(
val full_username: String
)
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 PlaylistsCache(data: List<Playlist>) : CacheItem<Playlist>(data)
class PlaylistTracksCache(data: List<PlaylistTrack>) : CacheItem<PlaylistTrack>(data)
class FavoritesCache(data: List<Favorite>) : CacheItem<Favorite>(data)
class RadiosCache(data: List<Radio>) : CacheItem<Radio>(data)
class FavoritedCache(data: List<Int>) : CacheItem<Int>(data)
class QueueCache(data: List<Track>) : CacheItem<Track>(data)
abstract class FunkwhaleResponse<D : Any> {
abstract class OtterResponse<D : Any> {
abstract val count: Int
abstract val next: String?
abstract fun getData(): List<D>
}
data class ArtistsResponse(override val count: Int, override val next: String?, val results: List<Artist>) : FunkwhaleResponse<Artist>() {
data class UserResponse(override val count: Int, override val next: String?, val results: List<Artist>) : OtterResponse<Artist>() {
override fun getData() = results
}
data class AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : FunkwhaleResponse<Album>() {
data class ArtistsResponse(override val count: Int, override val next: String?, val results: List<Artist>) : OtterResponse<Artist>() {
override fun getData() = results
}
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : FunkwhaleResponse<Track>() {
data class AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : OtterResponse<Album>() {
override fun getData() = results
}
data class FavoritesResponse(override val count: Int, override val next: String?, val results: List<Favorite>) : FunkwhaleResponse<Favorite>() {
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : OtterResponse<Track>() {
override fun getData() = results
}
data class PlaylistsResponse(override val count: Int, override val next: String?, val results: List<Playlist>) : FunkwhaleResponse<Playlist>() {
data class FavoritedResponse(override val count: Int, override val next: String?, val results: List<Favorited>) : OtterResponse<Int>() {
override fun getData() = results.map { it.track }
}
data class PlaylistsResponse(override val count: Int, override val next: String?, val results: List<Playlist>) : OtterResponse<Playlist>() {
override fun getData() = results
}
data class PlaylistTracksResponse(override val count: Int, override val next: String?, val results: List<PlaylistTrack>) : FunkwhaleResponse<PlaylistTrack>() {
data class PlaylistTracksResponse(override val count: Int, override val next: String?, val results: List<PlaylistTrack>) : OtterResponse<PlaylistTrack>() {
override fun getData() = results
}
data class Covers(val original: String)
data class RadiosResponse(override val count: Int, override val next: String?, val results: List<Radio>) : OtterResponse<Radio>() {
override fun getData() = results
}
data class Covers(val urls: CoverUrls)
data class CoverUrls(val original: String)
typealias AlbumList = List<Album>
interface SearchResult {
fun cover(): String?
fun title(): String
fun subtitle(): String
}
data class Album(
val id: Int,
val artist: Artist,
val title: String,
val cover: Covers
) {
val cover: Covers?,
val release_date: String?
) : SearchResult {
data class Artist(val name: String)
override fun cover() = cover?.urls?.original
override fun title() = title
override fun subtitle() = artist.name
}
data class Artist(
val id: Int,
val name: String,
val albums: List<Album>?
) {
) : SearchResult {
data class Album(
val title: String,
val cover: Covers
val cover: Covers?
)
override fun cover(): String? = albums?.getOrNull(0)?.cover?.urls?.original
override fun title() = name
override fun subtitle() = "Artist"
}
data class Track(
val id: Int,
val id: Int = 0,
val title: String,
val artist: Artist,
val album: Album,
val uploads: List<Upload>
) {
val album: Album?,
val disc_number: Int = 0,
val position: Int = 0,
val uploads: List<Upload> = listOf(),
val copyright: String? = null,
val license: String? = null
) : SearchResult {
var current: Boolean = false
var favorite: Boolean = false
var cached: Boolean = false
var downloaded: Boolean = false
companion object {
fun fromDownload(download: DownloadInfo): Track = Track(
id = download.id,
title = download.title,
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,
@ -98,9 +144,13 @@ data class Track(
else -> uploads.maxBy { it.bitrate } ?: uploads[0]
}
}
override fun cover() = album?.cover?.urls?.original
override fun title() = title
override fun subtitle() = artist.name
}
data class Favorite(val id: Int, val track: Track)
data class Favorited(val track: Int)
data class Playlist(
val id: Int,
@ -110,4 +160,20 @@ data class Playlist(
val duration: Int
)
data class PlaylistTrack(val track: Track)
data class PlaylistTrack(val track: Track)
data class Radio(
val id: Int,
var radio_type: String,
val name: String,
val description: String,
var related_object_id: String? = null
)
data class DownloadInfo(
val id: Int,
val contentId: String,
val title: String,
val artist: String,
var download: Download?
)

View File

@ -0,0 +1,34 @@
package com.github.apognu.otter.utils
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.preference.PowerPreference
object Userinfo {
suspend fun get(): User? {
try {
val hostname = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/")
.authorize()
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
return when (result) {
is Result.Success -> {
val user = result.get()
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("actor_username", user.full_username)
}
user
}
else -> null
}
} catch (e: Exception) {
return null
}
}
}

View File

@ -12,29 +12,69 @@ fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
}
}
fun Any.log(message: String) {
Log.d("FUNKWHALE", "${this.javaClass.simpleName}: $message")
private fun logClassName(): String {
val known = setOf(
"dalvik.system.VMStack",
"java.lang.Thread",
"com.github.apognu.otter.utils.UtilKt"
)
Thread.currentThread().stackTrace.forEach {
if (!known.contains(it.className)) {
val className = it.className.split('.').last()
val line = it.lineNumber
return "$className:$line"
}
}
return "UNKNOWN"
}
fun normalizeUrl(url: String): String {
fun Any?.log(prefix: String? = null) {
prefix?.let {
Log.d("OTTER", "${logClassName()} - $prefix: $this")
} ?: Log.d("OTTER", "${logClassName()} - $this")
}
fun maybeNormalizeUrl(rawUrl: String?): String? {
try {
if (rawUrl == null || rawUrl.isEmpty()) return null
return mustNormalizeUrl(rawUrl)
} catch (e: Exception) {
return null
}
}
fun mustNormalizeUrl(rawUrl: String): String {
val fallbackHost = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val uri = URI(url).takeIf { it.host != null } ?: URI("$fallbackHost$url")
val uri = URI(rawUrl).takeIf { it.host != null } ?: URI("$fallbackHost$rawUrl")
return uri.run {
URI("https", host, path, query, null)
}.toString()
return uri.toString()
}
fun toDurationString(seconds: Long): String {
val days = (seconds / 86400)
val hours = (seconds % 86400) / 3600
val minutes = (seconds % 86400 % 3600) / 60
fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
val days = (duration / 86400)
val hours = (duration % 86400) / 3600
val minutes = (duration % 86400 % 3600) / 60
val seconds = duration % 86400 % 3600 % 60
val ret = StringBuilder()
if (days > 0) ret.append("${days}d")
if (hours > 0) ret.append(" ${hours}h")
if (minutes > 0) ret.append(" ${minutes}m")
if (days > 0) ret.append("${days}d ")
if (hours > 0) ret.append("${hours}h ")
if (minutes > 0) ret.append("${minutes}m ")
if (showSeconds && seconds > 0) ret.append("${seconds}s")
return ret.toString()
}
}
object Settings {
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
fun getScopes() = PowerPreference.getDefaultFile().getString("scope", "all").split(",")
}

View File

@ -0,0 +1,26 @@
package com.github.apognu.otter.views
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
class DisableableFrameLayout : FrameLayout {
var callback: ((MotionEvent?) -> Boolean)? = null
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 onInterceptTouchEvent(event: MotionEvent?): Boolean {
callback?.let {
return !it(event)
}
return false
}
fun setShouldRegisterTouch(callback: (event: MotionEvent?) -> Boolean) {
this.callback = callback
}
}

View File

@ -1,79 +0,0 @@
package com.github.apognu.otter.views
import android.animation.Animator
import android.animation.ObjectAnimator
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.transition.TransitionValues
import androidx.transition.Visibility
class ExplodeReveal : Visibility() {
private val SCREEN_BOUNDS = "screenBounds"
private val locations = IntArray(2)
override fun captureStartValues(transitionValues: TransitionValues) {
super.captureStartValues(transitionValues)
capture(transitionValues)
}
override fun captureEndValues(transitionValues: TransitionValues) {
super.captureEndValues(transitionValues)
capture(transitionValues)
}
override fun onAppear(sceneRoot: ViewGroup, view: View, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (endValues == null) return null
val bounds = endValues.values[SCREEN_BOUNDS] as Rect
val endY = view.translationY
val distance = calculateDistance(sceneRoot, bounds)
val startY = endY + distance
return ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, startY, endY)
}
override fun onDisappear(sceneRoot: ViewGroup, view: View, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (startValues == null) return null
val bounds = startValues.values[SCREEN_BOUNDS] as Rect
val startY = view.translationY
val distance = calculateDistance(sceneRoot, bounds)
val endY = startY + distance
return ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, startY, endY)
}
private fun capture(transitionValues: TransitionValues) {
transitionValues.view.also {
it.getLocationOnScreen(locations)
val left = locations[0]
val top = locations[1]
val right = left + it.width
val bottom = top + it.height
transitionValues.values[SCREEN_BOUNDS] = Rect(left, top, right, bottom)
}
}
private fun calculateDistance(sceneRoot: View, viewBounds: Rect): Int {
sceneRoot.getLocationOnScreen(locations)
val sceneRootY = locations[1]
return when (epicenter) {
is Rect -> return when {
viewBounds.top <= (epicenter as Rect).top -> sceneRootY - (epicenter as Rect).top
else -> sceneRootY + sceneRoot.height - (epicenter as Rect).bottom
}
else -> -sceneRoot.height
}
}
}

View File

@ -0,0 +1,35 @@
package com.github.apognu.otter.views
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.ImageView
import com.github.apognu.otter.R
object LoadingImageView {
fun start(context: Context?, image: ImageView): ObjectAnimator? {
context?.let {
image.isEnabled = false
image.setImageDrawable(context.getDrawable(R.drawable.fab_spinner))
return ObjectAnimator.ofFloat(image, View.ROTATION, 0f, 360f).apply {
duration = 500
repeatCount = ObjectAnimator.INFINITE
start()
}
}
return null
}
fun stop(context: Context?, original: Drawable, image: ImageView, animator: ObjectAnimator?) {
context?.let {
animator?.cancel()
image.isEnabled = true
image.setImageDrawable(original)
image.rotation = 0.0f
}
}
}

View File

@ -52,7 +52,7 @@ class NowPlayingView : MaterialCardView {
if (motionEvent.actionMasked == MotionEvent.ACTION_UP) {
if (gestureDetectorCallback?.isScrolling == true) {
gestureDetectorCallback?.onUp(motionEvent)
gestureDetectorCallback?.onUp()
}
}
@ -72,7 +72,7 @@ class NowPlayingView : MaterialCardView {
}
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
var maxHeight = 0
private var maxHeight = 0
private var minHeight = 0
private var maxMargin = 0
@ -100,12 +100,10 @@ class NowPlayingView : MaterialCardView {
initialTouchY = e.rawY
lastTouchY = e.rawY
flingAnimator?.cancel()
return true
}
fun onUp(event: MotionEvent): Boolean {
fun onUp(): Boolean {
isScrolling = false
layoutParams.let {

View File

@ -0,0 +1 @@
otter@support.popineau.eu

View File

@ -0,0 +1 @@
https://github.com/apognu/otter

View File

@ -0,0 +1 @@
en-US

View File

@ -0,0 +1,9 @@
Otter is a simple music player that allows you to stream the audio content of your self-hosted Funkwhale pod.
This app requires an account on a Funkwhale instance to work.
You can get support or take a part in Otter's development by visiting our GitHub project or join us on Matrix.
Source code : https://github.com/apognu/otter
Matrix room: https://matrix.to/#/#otter:matrix.org
Funkwhale : https://funkwhale.audio

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

View File

@ -0,0 +1 @@
Music player for Funkwhale

View File

@ -0,0 +1 @@
Otter for Funkwhale

View File

@ -0,0 +1,8 @@
Otter est un lecteur de musique basique qui vous permet de profiter du contenu audio de votre instance Funkwhale.
Cette application nécessite un compte sur une instance Funkwhale pour fonctionner.
Vous pouvez obtenir de l'aide ou participer au développement d'Otter en vous rendant sur notre projet GitHub ou nous rejoindre sur Matrix.
Code source : https://github.com/apognu/otter
Funkwhale : https://funkwhale.audio

View File

@ -0,0 +1 @@
Lecteur de musique pour Funkwhale

View File

@ -0,0 +1 @@
Otter pour Funkwhale

View File

@ -0,0 +1 @@
../../../../../../fastlane/metadata/android/en-US/changelogs/1000021.txt

View File

@ -0,0 +1 @@
../../../../../../fastlane/metadata/android/fr-FR/changelogs/1000021.txt

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

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