Compare commits

...

183 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
191 changed files with 4958 additions and 1291 deletions

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.

View File

@ -17,7 +17,6 @@ jobs:
- name: Build with Gradle
run: |
mkdir -p /home/runner/.android && touch /home/runner/.android/repositories.cfg
echo y | sudo ${ANDROID_HOME}/tools/bin/sdkmanager ndk-bundle 'ndk;21.3.6528147'
./gradlew assembleDebug
- name: Create release
uses: eine/tip@gha-tip

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

View File

@ -1,44 +0,0 @@
language: android
dist: trusty
if: tag != 'tip'
android:
components:
- platform-tools
- tools
- build-tools-29.0.3
- android-29
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
cache:
directories:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
- "$HOME/.android/build-cache"
before_install:
- yes | sdkmanager ndk-bundle 'ndk;21.3.6528147'
- openssl aes-256-cbc -K $encrypted_532b6bc7108c_key -iv $encrypted_532b6bc7108c_iv -in dist/apognu.jks.enc -out dist/apognu.jks -d
- echo -e "signing.store=/home/travis/build/apognu/otter/dist/apognu.jks\nsigning.key_passphrase=${keystore_passphrase}\nsigning.alias=release\nsigning.store_passphrase=${keystore_passphrase}" > local.properties
script:
- "./gradlew app:assembleRelease"
before_deploy:
- RELEASE_MESSAGE="$(git tag -ln --format '%(subject)' $TRAVIS_TAG)"
deploy:
- provider: releases
name: $TRAVIS_TAG
body: $RELEASE_MESSAGE
prerelease: false
file: app/build/outputs/apk/release/app-release.apk
overwrite: true
skip_cleanup: true
on:
tags: true
api_key:
secure: CIgVCinr1VDsMAAjcU8rxVq5SA0kDK3yTmtZW8Hc5RoOhVlKH24VWzdIjWdPZyW3vEDSPlBehAoSCDFI7oZ8xH/SgeXIdFMFbpCUi7QXp+ZdK8MdduHsXXVsEvVlIKi4R8ZhpFF/oR/yKwUo3zDT3SNv5zKZQvae+OzWIt9hm95gm9A+HexgdG4NjjtaNNp+wmhWEO8BvyZV6ZN05o+Z/qQz5pHc8n/v4sLcaQltnErZBaW4wBKKIvwZ54TQrWewW2y2m2lARKt8IGcgWW1jwQD7rzP1hzz/UGLF0eMbFCHNm+r8go0YoB7UDXKfmsZuDsFhjqpQuP3rOlavtXdBB1wAfsbJB3u1mso54w9/M9r1hRK2oClmhLOKU4wYzHLhwwa658qcbTDCJ2+Zwf81/D2YADj3hAkCdpoc0hJoEKQXRdhnQ0yiqYy4p/3RYh7WqSsU7iwUCqyDVHVprv89Qh5iPdbxezYpXQVWyeLpy4+4cYWqjggyxBy0wz7LUu9uClg2M2uaFZ/Ud1FOqtHeDP+Q8hW0DcArYPHXVAySyiuNXAhS/SWFnmpuCtzCs5NmnYA9lmYr33u3+rDJ9+LHArFXTVdfxU2xaSsG0kyjNVxKa3UXEoz0tqBJpeihkWGtjq04sK2AeEEw8lAt6wafFY6jMra23qPQRTCBNPuUsqk=

View File

@ -1,14 +1,16 @@
# Otter for Funkwhale
![](https://img.shields.io/github/license/apognu/otter?style=flat-square)
[![](https://img.shields.io/travis/apognu/otter/master?style=flat-square)](https://travis-ci.org/apognu/otter)
[![](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).
![Otter graphic](https://github.com/apognu/otter/raw/master/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png)
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
@ -22,11 +24,16 @@ Otter's features, as of this writing, are the following:
* 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.

View File

@ -1,5 +1,3 @@
import org.gradle.kotlin.dsl.*
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
import org.jetbrains.kotlin.konan.properties.hasProperty
import java.io.FileInputStream
import java.util.*
@ -15,7 +13,10 @@ plugins {
}
val props = Properties().apply {
try { load(FileInputStream(rootProject.file("local.properties"))) } catch (e: Exception) {}
try {
load(FileInputStream(rootProject.file("local.properties")))
} catch (e: Exception) {
}
}
androidGitVersion {
@ -30,9 +31,7 @@ android {
}
kotlinOptions {
(this as KotlinJvmOptions).apply {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildToolsVersion = "29.0.3"
@ -64,17 +63,36 @@ android {
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")
}
isMinifyEnabled = false
resValue("string", "debug.hostname", "")
resValue("string", "debug.username", "")
resValue("string", "debug.password", "")
proguardFile(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFile("proguard-rules.pro")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
@ -95,33 +113,38 @@ play {
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.61")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2")
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.1.0")
implementation("androidx.core:core-ktx:1.4.0-alpha01")
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.0.0")
implementation("com.google.android.material:material:1.2.0-alpha06")
implementation("com.android.support.constraint:constraint-layout:1.1.3")
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:2.10.5")
implementation("com.google.android.exoplayer:extension-mediasession:2.10.6")
// implementation("com.google.android.exoplayer:extension-cast:2.10.6")
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:2.10.5") {
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.5")
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

@ -0,0 +1 @@
-keep class com.github.apognu.otter.** { *; }

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.github.apognu.otter">
<uses-permission android:name="android.permission.INTERNET" />
@ -11,41 +12,70 @@
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:theme="@style/AppTheme">
<!-- <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider"/> -->
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name="com.github.apognu.otter.activities.SplashActivity"
android:name=".activities.SplashActivity"
android:launchMode="singleInstance"
android:noHistory="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.github.apognu.otter.activities.LoginActivity"
android:name=".activities.LoginActivity"
android:configChanges="screenSize|orientation"
android:launchMode="singleInstance" />
<activity android:name="com.github.apognu.otter.activities.MainActivity" />
<activity android:name=".activities.MainActivity" />
<activity
android:name="com.github.apognu.otter.activities.SearchActivity"
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
<activity android:name="com.github.apognu.otter.activities.SettingsActivity" />
<activity android:name="com.github.apognu.otter.activities.LicencesActivity" />
<service android:name="com.github.apognu.otter.playback.PlayerService" />
<activity android:name=".activities.DownloadsActivity" />
<receiver android:name="com.github.apognu.otter.playback.MediaControlActionReceiver" />
<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

View File

@ -2,13 +2,19 @@ package com.github.apognu.otter
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.Request
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.Channel
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import java.text.SimpleDateFormat
import java.util.*
@ -23,10 +29,40 @@ class Otter : Application() {
var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
val eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
val commandBus: Channel<Command> = Channel(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()
@ -43,6 +79,16 @@ class Otter : Application() {
}
}
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))

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

@ -1,13 +1,18 @@
package com.github.apognu.otter.activities
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.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
@ -16,7 +21,6 @@ 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, val non_field_errors: List<String>?)
@ -26,6 +30,8 @@ class LoginActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
limitContainerWidth()
}
override fun onResume() {
@ -50,11 +56,23 @@ class LoginActivity : AppCompatActivity() {
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
Uri.parse(hostname).apply {
if (scheme == "http") {
if (!cleartext.isChecked && scheme == "http") {
throw Exception(getString(R.string.login_error_hostname_https))
}
if (scheme == null) hostname = "https://$hostname"
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 =
@ -63,16 +81,15 @@ class LoginActivity : AppCompatActivity() {
hostname_field.error = message
}
hostname_field.error = ""
when (anonymous.isChecked) {
false -> authedLogin(hostname, username, password)
true -> anonymousLogin(hostname)
}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
limitContainerWidth()
}
private fun authedLogin(hostname: String, username: String, password: String) {
val body = mapOf(
"username" to username,
@ -83,7 +100,7 @@ class LoginActivity : AppCompatActivity() {
show(supportFragmentManager, "LoginDialog")
}
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
try {
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
@ -98,9 +115,14 @@ class LoginActivity : AppCompatActivity() {
setString("access_token", result.get().token)
}
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
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 -> {
@ -135,7 +157,7 @@ class LoginActivity : AppCompatActivity() {
show(supportFragmentManager, "LoginDialog")
}
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
try {
val (_, _, result) = Fuel.get("$hostname/api/v1/tracks/")
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
@ -169,4 +191,16 @@ class LoginActivity : AppCompatActivity() {
}
}
}
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

@ -5,8 +5,8 @@ import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.*
@ -19,25 +19,32 @@ 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.*
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
@ -48,7 +55,8 @@ class MainActivity : AppCompatActivity() {
}
private val favoriteRepository = FavoritesRepository(this)
private val favoriteCheckRepository = FavoritedRepository(this)
private val favoritedRepository = FavoritedRepository(this)
private var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -68,14 +76,31 @@ class MainActivity : AppCompatActivity() {
.commit()
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)
@ -123,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
}
@ -147,6 +184,63 @@ class MainActivity : AppCompatActivity() {
R.id.nav_queue -> launchDialog(QueueFragment())
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> {
menu?.let { menu ->
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
item.actionView = View(this)
item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?) = false
override fun onMenuItemActionCollapse(item: MenuItem?) = false
})
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)
}
@ -158,18 +252,17 @@ class MainActivity : AppCompatActivity() {
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()
}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
}
private fun launchFragment(fragment: Fragment) {
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
oldFragment.enterTransition = null
@ -193,11 +286,11 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("NewApi")
private fun watchEventBus() {
GlobalScope.launch(Main) {
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
@ -239,8 +332,6 @@ class MainActivity : AppCompatActivity() {
}
}
is Event.TrackPlayed -> refreshCurrentTrack(message.track)
is Event.RefreshTrack -> refreshCurrentTrack(message.track)
is Event.TrackFinished -> incrementListenCount(message.track)
is Event.StateChanged -> {
@ -270,7 +361,34 @@ class MainActivity : AppCompatActivity() {
}
}
GlobalScope.launch(Main) {
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
@ -319,27 +437,28 @@ class MainActivity : AppCompatActivity() {
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
.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.original))
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(now_playing_details_cover)
}
if (now_playing_details_cover == null) {
GlobalScope.launch(IO) {
lifecycleScope.launch(Default) {
val width = DisplayMetrics().apply {
windowManager.defaultDisplay.getMetrics(this)
}.widthPixels
val backgroundCover = Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.get()
.run { Bitmap.createScaledBitmap(this, width, width, false).toDrawable(resources) }
.apply {
@ -370,7 +489,7 @@ class MainActivity : AppCompatActivity() {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_info_artist -> ArtistsFragment.openAlbums(this@MainActivity, track.artist, art = track.album.cover.original)
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")
}
@ -386,8 +505,8 @@ class MainActivity : AppCompatActivity() {
}
now_playing_details_favorite?.let { now_playing_details_favorite ->
favoriteCheckRepository.fetch().untilNetwork(IO) { favorites, _, _ ->
GlobalScope.launch(Main) {
favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ ->
lifecycleScope.launch(Main) {
track.favorite = favorites.contains(track.id)
when (track.favorite) {
@ -414,6 +533,10 @@ class MainActivity : AppCompatActivity() {
favoriteRepository.fetch(Repository.Origin.Network.origin)
}
now_playing_details_add_to_playlist.setOnClickListener {
CommandBus.send(Command.AddToPlaylist(listOf(track)))
}
}
}
}
@ -456,13 +579,16 @@ class MainActivity : AppCompatActivity() {
private fun incrementListenCount(track: Track?) {
track?.let {
GlobalScope.launch(IO) {
Fuel
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
.authorize()
.header("Content-Type", "application/json")
.body(Gson().toJson(mapOf("track" to track.id)))
.awaitStringResponse()
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,16 +3,22 @@ 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.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.Album
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.untilNetwork
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.*
@ -25,6 +31,8 @@ class SearchActivity : AppCompatActivity() {
lateinit var favoritesRepository: FavoritesRepository
var done = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -34,26 +42,51 @@ class SearchActivity : AppCompatActivity() {
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(rawQuery: String?): Boolean {
search.clearFocus()
rawQuery?.let {
done = 0
val query = URLEncoder.encode(it, "UTF-8")
tracksRepository = TracksSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT))
albumsRepository = AlbumsSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT))
artistsRepository = ArtistsSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT))
favoritesRepository = FavoritesRepository(this@SearchActivity)
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.artists.clear()
@ -61,34 +94,25 @@ class SearchActivity : AppCompatActivity() {
adapter.tracks.clear()
adapter.notifyDataSetChanged()
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork { artists, _, _ ->
when (artists.isEmpty()) {
true -> search_no_results.visibility = View.VISIBLE
false -> adapter.artists.addAll(artists)
}
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { artists, _, _, _ ->
done++
adapter.notifyDataSetChanged()
adapter.artists.addAll(artists)
refresh()
}
albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork { albums, _, _ ->
when (albums.isEmpty()) {
true -> search_no_results.visibility = View.VISIBLE
false -> adapter.albums.addAll(albums)
}
albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { albums, _, _, _ ->
done++
adapter.notifyDataSetChanged()
adapter.albums.addAll(albums)
refresh()
}
tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _, _ ->
search_spinner.visibility = View.GONE
search_empty.visibility = View.GONE
tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { tracks, _, _, _ ->
done++
when (tracks.isEmpty()) {
true -> search_no_results.visibility = View.VISIBLE
false -> adapter.tracks.addAll(tracks)
}
adapter.notifyDataSetChanged()
adapter.tracks.addAll(tracks)
refresh()
}
}
@ -99,6 +123,33 @@ class SearchActivity : AppCompatActivity() {
})
}
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)

View File

@ -11,9 +11,11 @@ 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.*
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?) {
@ -78,12 +80,10 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
.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()
context.cacheDir.deleteRecursively()
CommandBus.send(Command.ClearQueue)
Otter.get().deleteAllData()
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
activity?.finish()
}
@ -112,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" -> {

View File

@ -4,6 +4,7 @@ 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
@ -20,6 +21,8 @@ class SplashActivity : AppCompatActivity() {
}
false -> Intent(this@SplashActivity, LoginActivity::class.java).apply {
Otter.get().deleteAllData()
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)

View File

@ -6,7 +6,7 @@ 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.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
@ -15,11 +15,13 @@ 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?, private 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 {
@ -34,19 +36,28 @@ class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickLis
val album = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(album.cover.original))
.maybeLoad(maybeNormalizeUrl(album.cover()))
.fit()
.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,7 +6,7 @@ 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.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
@ -14,11 +14,13 @@ 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 {
@ -33,7 +35,7 @@ class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClic
val album = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(album.cover.original))
.maybeLoad(maybeNormalizeUrl(album.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))

View File

@ -6,7 +6,7 @@ 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.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
@ -14,14 +14,32 @@ 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)
@ -32,12 +50,12 @@ 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()
.maybeLoad(maybeNormalizeUrl(albums[0].cover.original))
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
.fit()
.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

@ -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<Track, 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)
}
@ -44,7 +44,7 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
val favorite = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(favorite.album.cover.original))
.maybeLoad(maybeNormalizeUrl(favorite.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
@ -53,19 +53,14 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
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.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL)
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
})
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.ripple)
}
if (favorite.id == currentTrack?.id) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.current)
}
}
context?.let {
@ -74,6 +69,23 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
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.id, !favorite.favorite)
@ -91,6 +103,7 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite))
R.id.track_pin -> CommandBus.send(Command.PinTrack(favorite))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite))
}

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()
.maybeLoad(maybeNormalizeUrl(track.track.album.cover.original))
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
@ -65,19 +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) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
if (track.track == currentTrack || track.track.current) {
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
}
}
context?.let {
@ -99,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
@ -116,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
}
}
@ -135,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 {
@ -154,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
@ -176,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
@ -185,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)
@ -194,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)
}
@ -35,6 +37,15 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
holder.name.text = playlist.name
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) {
0 -> holder.cover_top_left
@ -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

@ -6,59 +6,128 @@ 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.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.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosAdapter(val context: Context?, private val listener: OnRadioClickListener) : FunkwhaleAdapter<Radio, RadiosAdapter.ViewHolder>() {
class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private val listener: OnRadioClickListener) : OtterAdapter<Radio, RadiosAdapter.ViewHolder>() {
interface OnRadioClickListener {
fun onClick(holder: ViewHolder, radio: Radio)
}
override fun getItemCount() = data.size
enum class RowType {
Header,
InstanceRadio,
UserRadio
}
override fun getItemId(position: Int) = data[position].id.toLong()
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 {
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
return when (viewType) {
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
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) {
val radio = data[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) {
"random" -> R.drawable.shuffle
"less-listened" -> R.drawable.sad
else -> null
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)
}
}
}
icon?.let {
holder.native = true
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val radio = getRadioAt(position)
holder.art.setImageDrawable(context.getDrawable(icon))
holder.art.alpha = 0.7f
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
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 {
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
@ -66,7 +135,7 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
var native = false
override fun onClick(view: View?) {
listener.onClick(this, data[layoutPosition])
listener?.onClick(this, getRadioAt(layoutPosition))
}
fun spin() {
@ -77,7 +146,7 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
art.setColorFilter(context.getColor(R.color.controlForeground))
GlobalScope.launch(Main) {
scope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted -> {

View File

@ -2,6 +2,8 @@ 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
@ -69,10 +71,6 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
return ResultType.Track.ordinal
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
}
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)
@ -93,27 +91,33 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
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)
}
}
}
@ -160,6 +164,8 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
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 ->
@ -183,6 +189,23 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
}
}
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)
@ -191,6 +214,8 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
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))
}
@ -205,6 +230,15 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
}
}
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

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,7 +53,7 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
val track = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.cover)
@ -64,19 +61,14 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
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.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL)
holder.artist.typeface = Typeface.create(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 {
@ -94,6 +86,23 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
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 {
@ -105,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))
}
@ -135,14 +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,17 +1,16 @@
package com.github.apognu.otter.fragments
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.Gravity
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.drawable.toDrawable
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
@ -21,23 +20,22 @@ import com.github.apognu.otter.repositories.AlbumsRepository
import com.github.apognu.otter.repositories.ArtistTracksRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.views.LoadingFlotingActionButton
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_albums.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
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
lateinit var artistTracksRepository: ArtistTracksRepository
private lateinit var artistTracksRepository: ArtistTracksRepository
var artistId = 0
var artistName = ""
@ -45,7 +43,7 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
companion object {
fun new(artist: Artist, _art: String? = null): AlbumsFragment {
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.albums[0].cover.original else ""
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.cover() else ""
return AlbumsFragment().apply {
arguments = bundleOf(
@ -56,7 +54,11 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
}
}
fun openTracks(context: Context?, album: Album, fragment: Fragment? = null) {
fun openTracks(context: Context?, album: Album?, fragment: Fragment? = null) {
if (album == null) {
return
}
(context as? MainActivity)?.let {
fragment?.let { fragment ->
fragment.onViewPager {
@ -112,39 +114,24 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(cover)
}
cover_background?.let { background ->
activity?.let { activity ->
GlobalScope.launch(IO) {
val width = DisplayMetrics().apply {
activity.windowManager.defaultDisplay.getMetrics(this)
}.widthPixels
val backgroundCover = Picasso.get()
.maybeLoad(maybeNormalizeUrl(artistArt))
.get()
.run { Bitmap.createScaledBitmap(this, width, width, false) }
.run { Bitmap.createBitmap(this, 0, 0, width, background.height).toDrawable(resources) }
.apply {
alpha = 20
gravity = Gravity.CENTER
}
withContext(Dispatchers.Main) {
background.background = backgroundCover
}
}
}
}
artist.text = artistName
play.setOnClickListener {
val loaderAnimation = LoadingFlotingActionButton.start(play)
val loader = CircularProgressDrawable(requireContext()).apply {
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
strokeWidth = 4f
}
GlobalScope.launch(IO) {
loader.start()
play.icon = loader
play.isClickable = false
lifecycleScope.launch(IO) {
artistTracksRepository.fetch(Repository.Origin.Network.origin)
.map { it.data }
.toList()
@ -154,13 +141,32 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
CommandBus.send(Command.ReplaceQueue(it))
withContext(Main) {
LoadingFlotingActionButton.stop(play, loaderAnimation)
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) {
openTracks(context, album, fragment = this@AlbumsFragment)

View File

@ -15,10 +15,11 @@ import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.AppContext
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)

View File

@ -18,9 +18,10 @@ 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) {

View File

@ -1,20 +1,25 @@
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<Track, FavoritesAdapter>() {
class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
override val viewRes = R.layout.fragment_favorites
override val recycler: RecyclerView get() = favorites
override val alwaysRefresh = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -28,11 +33,15 @@ class FavoritesFragment : FunkwhaleFragment<Track, 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 {
@ -41,25 +50,59 @@ class FavoritesFragment : FunkwhaleFragment<Track, FavoritesAdapter>() {
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refreshCurrentTrack()
is Event.RefreshTrack -> refreshCurrentTrack()
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private fun refreshCurrentTrack() {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
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) {
(repository as? FavoritesRepository)?.let { repository ->

View File

@ -1,126 +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.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.untilNetwork
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.GlobalScope
import kotlinx.coroutines.launch
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
private var initialFetched = false
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
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
recycler.setOnScrollChangeListener { _, _, _, _, _ ->
if (recycler.computeVerticalScrollOffset() > 0 && !recycler.canScrollVertically(1)) {
fetch(Repository.Origin.Network.origin, adapter.data.size)
}
}
}
}
fetch(Repository.Origin.Cache.origin)
if (adapter.data.isEmpty()) {
fetch(Repository.Origin.Network.origin)
}
}
override fun onResume() {
super.onResume()
swiper?.setOnRefreshListener {
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 (upstreams == Repository.Origin.Network.origin) {
swiper?.isRefreshing = true
}
repository.fetch(upstreams, size).untilNetwork(IO) { data, isCache, hasMore ->
GlobalScope.launch(Main) {
if (isCache) {
adapter.data = data.toMutableList()
adapter.notifyDataSetChanged()
return@launch
}
if (first && data.isNotEmpty()) {
adapter.data.clear()
}
onDataFetched(data)
adapter.data.addAll(data)
if (!hasMore) {
swiper?.isRefreshing = false
GlobalScope.launch(IO) {
if (adapter.data.isNotEmpty()) {
try {
repository.cacheId?.let { cacheId ->
Cache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.data)).toByteArray()
)
}
} catch (e: ConcurrentModificationException) {
}
}
}
}
when (first) {
true -> {
adapter.notifyDataSetChanged()
first = false
}
false -> adapter.notifyItemRangeInserted(adapter.data.size, data.size)
}
}
}
}
}

View File

@ -5,6 +5,7 @@ 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
@ -12,7 +13,6 @@ 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.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@ -40,11 +40,25 @@ class LandscapeQueueFragment : Fragment() {
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) {
activity?.lifecycleScope?.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
adapter?.let {
it.data = response.queue.toMutableList()
@ -63,14 +77,20 @@ class LandscapeQueueFragment : Fragment() {
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
activity?.lifecycleScope?.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refresh()
is Event.RefreshTrack -> refresh()
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,26 +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 = ""
@ -50,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()
}
@ -70,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
@ -100,10 +139,21 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
else -> 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
}
imageView?.let { view ->
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
Picasso.get()
.maybeLoad(maybeNormalizeUrl(url))
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0, corner))
.into(view)
}
}
@ -111,22 +161,19 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refreshCurrentTrack()
is Event.RefreshTrack -> refreshCurrentTrack()
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private fun refreshCurrentTrack() {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack = track
adapter.notifyDataSetChanged()
}
}
@ -138,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,6 +6,7 @@ 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
@ -18,7 +19,6 @@ 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
@ -62,11 +62,25 @@ class QueueFragment : BottomSheetDialogFragment() {
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 ->
included?.let { included ->
adapter?.let {
@ -87,15 +101,21 @@ class QueueFragment : BottomSheetDialogFragment() {
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refresh()
is Event.RefreshTrack -> refresh()
is Event.QueueChanged -> refresh()
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refresh()
}
}
}
}
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {

View File

@ -2,6 +2,7 @@ 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
@ -9,18 +10,18 @@ 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.GlobalScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
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, RadioClickListener())
adapter = RadiosAdapter(context, lifecycleScope, RadioClickListener())
repository = RadiosRepository(context)
}
@ -34,15 +35,16 @@ class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
CommandBus.send(Command.PlayRadio(radio))
GlobalScope.launch(Main) {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.RadioStarted ->
if (radios != null) { recycler.forEach {
it.isEnabled = true
it.isClickable = true
if (radios != null) {
recycler.forEach {
it.isEnabled = true
it.isClickable = true
}
}
}
}
}
}

View File

@ -22,8 +22,10 @@ class TrackInfoDetailsFragment : DialogFragment() {
return TrackInfoDetailsFragment().apply {
arguments = bundleOf(
"artistName" to track.artist.name,
"albumTitle" to track.album.title,
"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" },
@ -48,8 +50,10 @@ class TrackInfoDetailsFragment : DialogFragment() {
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, getString("trackPosition")))
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")))
}

View File

@ -1,26 +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 = ""
@ -34,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()
)
}
}
@ -53,6 +63,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
adapter = TracksAdapter(context, FavoriteListener())
repository = TracksRepository(context, albumId)
favoritesRepository = FavoritesRepository(context)
favoritedRepository = FavoritedRepository(context)
watchEventBus()
}
@ -65,6 +76,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(cover)
artist.text = albumArtist
@ -74,44 +86,136 @@ 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)
}
}
private fun watchEventBus() {
GlobalScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.TrackPlayed -> refreshCurrentTrack()
is Event.RefreshTrack -> refreshCurrentTrack()
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 refreshCurrentTrack() {
GlobalScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
private fun watchEventBus() {
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
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 {

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,22 +37,11 @@ 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)
val coverUrl = maybeNormalizeUrl(track.album.cover.original)
val cover = coverUrl?.run { Picasso.get().load(coverUrl) }
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)
cover?.let {
try { putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it.get()) } catch (_: Exception) {}
}
}.build())
val coverUrl = maybeNormalizeUrl(track.album?.cover())
notification = NotificationCompat.Builder(
context,
@ -67,13 +54,18 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 2)
)
.setSmallIcon(R.drawable.ottericon)
.setSmallIcon(R.drawable.ottershape)
.run {
if (cover != null) {
try { setLargeIcon(cover.get()) } catch (_: Exception) {}
coverUrl?.let {
try {
setLargeIcon(Picasso.get().load(coverUrl).get())
} catch (_: Exception) {
}
this
} else this
return@run this
}
this
}
.setContentTitle(track.title)
.setContentText(track.artist.name)
@ -82,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,37 +8,47 @@ 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.flow.collect
import kotlinx.coroutines.launch
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()
@ -47,20 +57,39 @@ class PlayerService : Service() {
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)
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)
@ -76,45 +105,33 @@ 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.prepare(queue.datasources)
Cache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong())
val (current, duration, percent) = progress(true)
val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
}
@ -124,77 +141,68 @@ class PlayerService : Service() {
}
private fun watchEventBus() {
jobs.add(GlobalScope.launch(Main) {
for (message in CommandBus.get()) {
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.RefreshTrack(queue.current(), player.playWhenReady))
CommandBus.send(Command.RefreshTrack(queue.current()))
EventBus.send(Event.StateChanged(player.playWhenReady))
}
}
is Command.ReplaceQueue -> {
if (!message.fromRadio) radioPlayer.stop()
if (!command.fromRadio) radioPlayer.stop()
queue.replace(message.queue)
queue.replace(command.queue)
player.prepare(queue.datasources, true, true)
state(true)
setPlaybackState(true)
EventBus.send(
Event.RefreshTrack(
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.RefreshTrack(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.NextTrack -> skipToNextTrack()
is Command.PreviousTrack -> skipToPreviousTrack()
is Command.Seek -> seek(command.progress)
Cache.set(this@PlayerService, "progress", "0".toByteArray())
ProgressBus.send(0, 0, 0)
is Command.ClearQueue -> {
queue.clear()
player.stop()
}
is Command.PreviousTrack -> previousTrack()
is Command.Seek -> progress(message.progress)
is Command.ClearQueue -> queue.clear()
is Command.ShuffleQueue -> queue.shuffle()
is Command.PlayRadio -> {
queue.clear()
radioPlayer.play(message.radio)
radioPlayer.play(command.radio)
}
is Command.SetRepeatMode -> player.repeatMode = message.mode
}
is Command.SetRepeatMode -> player.repeatMode = command.mode
if (player.playWhenReady) {
mediaControlsManager.tick()
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) {
scope.launch(Main) {
RequestBus.get().collect { request ->
when (request) {
is Request.GetCurrentTrack -> request.channel?.offer(Response.CurrentTrack(queue.current()))
@ -202,26 +210,35 @@ class PlayerService : Service() {
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)
@ -239,25 +256,18 @@ 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, _, _) = progress()
val (progress, _, _) = getProgress()
Cache.set(this@PlayerService, "progress", progress.toString().toByteArray())
}
@ -266,6 +276,76 @@ class PlayerService : Service() {
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) {
@ -291,46 +371,10 @@ 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(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 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)
@ -338,7 +382,7 @@ class PlayerService : Service() {
EventBus.send(Event.StateChanged(playWhenReady))
if (queue.current == -1) {
EventBus.send(Event.TrackPlayed(queue.current(), playWhenReady))
CommandBus.send(Command.RefreshTrack(queue.current()))
}
when (playWhenReady) {
@ -346,33 +390,51 @@ class PlayerService : Service() {
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_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))
}
false -> {
EventBus.send(Event.StateChanged(false))
EventBus.send(Event.Buffering(false))
if (playbackState == Player.STATE_READY) {
mediaControlsManager.updateNotification(queue.current(), false)
stopForeground(false)
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
{ 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)
}
if (queue.get().isNotEmpty() && queue.current() == queue.get().last() && radioPlayer.isActive()) {
GlobalScope.launch(IO) {
scope.launch(IO) {
if (radioPlayer.lock.tryAcquire()) {
radioPlayer.prepareNextTrack()
radioPlayer.lock.release()
@ -382,7 +444,7 @@ class PlayerService : Service() {
Cache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
EventBus.send(Event.RefreshTrack(queue.current(), true))
CommandBus.send(Command.RefreshTrack(queue.current()))
}
override fun onPositionDiscontinuity(reason: Int) {
@ -393,12 +455,15 @@ class PlayerService : Service() {
}
}
override fun onPlayerError(error: ExoPlaybackException?) {
override fun onPlayerError(error: ExoPlaybackException) {
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
queue.current()?.let {
queue.remove(it)
player.prepare(queue.datasources)
if (player.playWhenReady) {
queue.current++
player.prepare(queue.datasources, true, true)
player.seekTo(queue.current, 0)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
}
}
@ -409,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 -> {
@ -430,4 +495,4 @@ class PlayerService : Service() {
}
}
}
}
}

View File

@ -2,38 +2,53 @@ 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.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 = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
@ -56,20 +71,8 @@ class QueueManager(val context: Context) {
)
}
private fun factory(): CacheDataSourceFactory {
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
defaultRequestProperties.apply {
if (!Settings.isAnonymous()) {
set("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
}
return CacheDataSourceFactory(cache, http)
}
fun replace(tracks: List<Track>) {
val factory = factory()
val factory = factory(context)
val sources = tracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
@ -87,7 +90,7 @@ class QueueManager(val context: Context) {
}
fun append(tracks: List<Track>) {
val factory = factory()
val factory = factory(context)
val missingTracks = tracks.filter { metadata.indexOf(it) == -1 }
val sources = missingTracks.map { track ->
@ -105,7 +108,7 @@ class QueueManager(val context: Context) {
}
fun insertNext(track: Track) {
val factory = factory()
val factory = factory(context)
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
if (metadata.indexOf(track) == -1) {
@ -124,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()
@ -159,5 +178,34 @@ class QueueManager(val context: Context) {
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

@ -6,29 +6,31 @@ 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.GlobalScope
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)
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) {
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)
@ -36,8 +38,11 @@ class RadioPlayer(val context: Context) {
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
}
}
}
@ -47,7 +52,7 @@ class RadioPlayer(val context: Context) {
currentRadio = radio
session = null
GlobalScope.launch(IO) {
scope.launch(IO) {
createSession()
}
}
@ -59,6 +64,7 @@ class RadioPlayer(val context: Context) {
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
@ -66,24 +72,26 @@ class RadioPlayer(val context: Context) {
private suspend fun createSession() {
currentRadio?.let { radio ->
try {
val request = RadioSessionBody(radio.radio_type).apply {
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 result = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
.authorize()
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioSession::class.java))
.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) {
@ -101,6 +109,11 @@ class RadioPlayer(val context: Context) {
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))
@ -108,7 +121,7 @@ class RadioPlayer(val context: Context) {
.authorize()
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
val favorites = favoritedRepository.fetch(Repository.Origin.Network.origin)
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()

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

@ -1,7 +1,7 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
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
@ -11,7 +11,7 @@ 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, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
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,27 +1,43 @@
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true", object : TypeToken<TracksResponse>() {}.type)
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<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Track>) = data.map {
it.favorite = true
it
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) {
@ -33,11 +49,13 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
}
}
GlobalScope.launch(IO) {
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
}
}
@ -50,19 +68,27 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
}
}
GlobalScope.launch(IO) {
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, FunkwhaleResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
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

@ -18,7 +18,7 @@ import java.io.Reader
import java.lang.reflect.Type
import kotlin.math.ceil
class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(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
}
@ -28,68 +28,80 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, pr
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
val offsetUrl =
val url =
Uri.parse(url)
.buildUpon()
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
.appendQueryParameter("page", page.toString())
.appendQueryParameter("scope", Settings.getScopes().joinToString(","))
.build()
.toString()
get(offsetUrl).fold(
get(url).fold(
{ response ->
val data = response.getData()
if (behavior == Behavior.Progressive || response.next == null) {
emit(Repository.Response(Repository.Origin.Network, data, false))
} else {
emit(Repository.Response(Repository.Origin.Network, data, true))
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))
fetch(size + data.size).collect { emit(it) }
else -> {
emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
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(), false))
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), page, false))
}
}
)
}.flowOn(IO)
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 request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
if (response.statusCode == 401) {
return retryGet(url)
}
return result
}
private suspend fun retryGet(url: String): Result<R, FuelError> {
return if (HTTP.refresh()) {
return try {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
request.awaitObjectResult(GenericDeserializer(type))
} else {
Result.Failure(FuelError.wrap(RefreshError))
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
if (response.statusCode == 401) {
return retryGet(url)
}
result
} catch (e: Exception) {
Result.error(FuelError.wrap(e))
}
}
private suspend fun retryGet(url: String): Result<R, FuelError> {
return try {
return if (HTTP.refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
request.awaitObjectResult(GenericDeserializer(type))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}
} catch (e: Exception) {
Result.error(FuelError.wrap(e))
}
}
}

View File

@ -1,7 +1,7 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
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
@ -14,7 +14,7 @@ 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)

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

@ -1,8 +1,7 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.FunkwhaleResponse
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
@ -12,22 +11,14 @@ import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
override val cacheId = "radios"
override val upstream = HttpUpstream<Radio, FunkwhaleResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/", object : TypeToken<RadiosResponse>() {}.type)
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" }
}
.map { radio -> radio.apply { radio_type = "custom" } }
.toMutableList()
.apply {
context?.let { context ->
add(0, Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)))
add(1, Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description)))
}
}
}
}

View File

@ -1,23 +1,29 @@
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import java.io.BufferedReader
import kotlin.math.ceil
interface Upstream<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>, val hasMore: Boolean)
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?
@ -35,17 +41,19 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
cacheId?.let { cacheId ->
Cache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
emit(Response(Origin.Cache, cache.data, false))
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(size: Int) = flow {
upstream
.fetch(size)
.map { response -> Response(Origin.Network, onDataFetched(response.data), response.hasMore) }
.collect { response -> emit(Response(Origin.Network, response.data, response.hasMore)) }
.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,6 +1,7 @@
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.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
@ -9,37 +10,50 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class TracksSearchRepository(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 = FavoritedRepository(context).fetch(Origin.Network.origin)
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
data.map { track ->
track.favorite = favorites.contains(track.id)
track.downloaded = downloaded.contains(track.id)
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?, query: String) : Repository<Artist, ArtistsCache>() {
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<Artist, ArtistsCache>() {
override val cacheId: String? = null
override val upstream = HttpUpstream<Artist, FunkwhaleResponse<Artist>>(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
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?, query: String) : Repository<Album, AlbumsCache>() {
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<Album, AlbumsCache>() {
override val cacheId: String? = null
override val upstream = HttpUpstream<Album, FunkwhaleResponse<Album>>(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
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,11 +1,10 @@
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
@ -14,20 +13,49 @@ 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 = FavoritedRepository(context).fetch(Origin.Network.origin)
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
val downloaded = getDownloadedIds() ?: listOf()
data.map { track ->
track.favorite = favorites.contains(track.id)
track.downloaded = downloaded.contains(track.id)
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
}
track
}.sortedBy { it.position }
}.sortedWith(compareBy({ it.disc_number }, { it.position }))
}
}

View File

@ -16,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
@ -62,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,7 +1,9 @@
package com.github.apognu.otter.utils
import com.github.apognu.otter.Otter
import kotlinx.coroutines.Dispatchers.Main
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.Channel
import kotlinx.coroutines.flow.asFlow
@ -9,6 +11,7 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
sealed class Command {
class StartService(val command: Command) : Command()
object RefreshService : Command()
object ToggleState : Command()
@ -19,16 +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>, 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 {
@ -37,30 +46,32 @@ 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 RefreshTrack(val track: Track?, val play: Boolean) : 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 {
fun send(event: Event) {
GlobalScope.launch {
Otter.get().eventBus.send(event)
GlobalScope.launch(IO) {
Otter.get().eventBus.offer(event)
}
}
@ -69,18 +80,18 @@ object EventBus {
object CommandBus {
fun send(command: Command) {
GlobalScope.launch {
get().offer(command)
GlobalScope.launch(IO) {
Otter.get().commandBus.offer(command)
}
}
fun get() = Otter.get().commandBus
fun get() = Otter.get().commandBus.asFlow()
}
object RequestBus {
fun send(request: Request): Channel<Response> {
return Channel<Response>().also {
GlobalScope.launch(Main) {
GlobalScope.launch(IO) {
request.channel = it
Otter.get().requestBus.offer(request)
@ -93,7 +104,7 @@ object RequestBus {
object ProgressBus {
fun send(current: Int, duration: Int, percent: Int) {
GlobalScope.launch {
GlobalScope.launch(IO) {
Otter.get().progressBus.send(Triple(current, duration, percent))
}
}

View File

@ -6,19 +6,21 @@ 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.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, hasMore: Boolean) -> Unit) {
GlobalScope.launch(context) {
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.hasMore)
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
}
}
}
@ -62,8 +64,8 @@ fun <T> T.applyOnApi(api: Int, block: T.() -> T): T {
}
fun Picasso.maybeLoad(url: String?): RequestCreator {
if (url == null) return load(R.drawable.cover)
else return load(url)
return if (url == null) load(R.drawable.cover)
else load(url)
}
fun Request.authorize(): Request {
@ -73,3 +75,5 @@ fun Request.authorize(): Request {
}
}
}
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)

View File

@ -1,7 +1,12 @@
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)
@ -12,46 +17,47 @@ 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 UserResponse(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 ArtistsResponse(override val count: Int, override val next: String?, val results: List<Artist>) : FunkwhaleResponse<Artist>() {
data class ArtistsResponse(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 AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : OtterResponse<Album>() {
override fun getData() = results
}
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : FunkwhaleResponse<Track>() {
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : OtterResponse<Track>() {
override fun getData() = results
}
data class FavoritedResponse(override val count: Int, override val next: String?, val results: List<Favorited>) : FunkwhaleResponse<Int>() {
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>) : FunkwhaleResponse<Playlist>() {
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 RadiosResponse(override val count: Int, override val next: String?, val results: List<Radio>) : FunkwhaleResponse<Radio>() {
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 original: String)
data class Covers(val urls: CoverUrls)
data class CoverUrls(val original: String)
typealias AlbumList = List<Album>
@ -65,11 +71,12 @@ 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.original
override fun cover() = cover?.urls?.original
override fun title() = title
override fun subtitle() = artist.name
}
@ -81,24 +88,39 @@ data class Artist(
) : SearchResult {
data class Album(
val title: String,
val cover: Covers
val cover: Covers?
)
override fun cover() = albums?.getOrNull(0)?.cover?.original
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 position: Int,
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,
@ -123,7 +145,7 @@ data class Track(
}
}
override fun cover() = album.cover.original
override fun cover() = album?.cover?.urls?.original
override fun title() = title
override fun subtitle() = artist.name
}
@ -144,5 +166,14 @@ data class Radio(
val id: Int,
var radio_type: String,
val name: String,
val description: 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

@ -5,7 +5,6 @@ import android.widget.Toast
import com.google.android.exoplayer2.util.Log
import com.preference.PowerPreference
import java.net.URI
import java.net.URL
fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
if (this != null) {
@ -13,27 +12,46 @@ 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 Any.log() {
Log.d("FUNKWHALE", this.toString())
fun Any?.log(prefix: String? = null) {
prefix?.let {
Log.d("OTTER", "${logClassName()} - $prefix: $this")
} ?: Log.d("OTTER", "${logClassName()} - $this")
}
fun maybeNormalizeUrl(rawUrl: String?): String? {
if (rawUrl == null || rawUrl.isEmpty()) return null
try {
if (rawUrl == null || rawUrl.isEmpty()) return null
return mustNormalizeUrl(rawUrl)
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(rawUrl).takeIf { it.host != null } ?: URI("$fallbackHost$rawUrl")
return uri.toURL().run {
URL("https", host, file)
}.toString()
return uri.toString()
}
fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
@ -57,4 +75,6 @@ object Settings {
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

@ -6,30 +6,6 @@ import android.graphics.drawable.Drawable
import android.view.View
import android.widget.ImageView
import com.github.apognu.otter.R
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
object LoadingFlotingActionButton {
fun start(button: ExtendedFloatingActionButton): ObjectAnimator {
button.isEnabled = false
button.setIconResource(R.drawable.fab_spinner)
button.shrink()
return ObjectAnimator.ofFloat(button, View.ROTATION, 0f, 360f).apply {
duration = 500
repeatCount = ObjectAnimator.INFINITE
start()
}
}
fun stop(button: ExtendedFloatingActionButton, animator: ObjectAnimator) {
animator.cancel()
button.isEnabled = true
button.setIconResource(R.drawable.play)
button.rotation = 0.0f
button.extend()
}
}
object LoadingImageView {
fun start(context: Context?, image: ImageView): ObjectAnimator? {

View File

@ -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,8 +100,6 @@ class NowPlayingView : MaterialCardView {
initialTouchY = e.rawY
lastTouchY = e.rawY
flingAnimator?.cancel()
return true
}

View File

@ -1 +1 @@
apognu@gmail.com
otter@support.popineau.eu

View File

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

View File

@ -2,5 +2,8 @@ Otter is a simple music player that allows you to stream the audio content of yo
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.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 KiB

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

@ -1,6 +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 un instance Funkwhale pour fonctionner.
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

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

View File

@ -1 +1 @@
../../../../../../fastlane/metadata/android/fr-FR/changelogs/1000018.txt
../../../../../../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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M14,10L2,10v2h12v-2zM14,6L2,6v2h12L14,6zM18,14v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2,16h8v-2L2,14v2z"/>
</vector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/elevatedSurface" />
<padding android:top="5dp" android:left="5dp" android:right="5dp" android:bottom="5dp" />
</shape>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/current" />
<corners android:radius="4dp" />
</shape>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View File

@ -0,0 +1,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="16dp"
android:height="16dp"
android:drawable="@drawable/downloads" />
</layer-list>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
</vector>

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